Merge pull request #5286 from deepseek-harness/worktree/plugin-highlight-ci-flake-20260927

test(web): keep plugin install routes active until page closes
This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-09-28 23:11:52 +08:00
commit c89b65aa45
7 files changed
+123 -26

No files matched your search

@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-27-plugin-install-interceptor-lifetime.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md
/agent-note-keep-plugin-install-interception-for-the-page-lifetime:
en: 2f732d10bfaf9f5b
zh: fff9f6cf8bcd86a7
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/problem:
en: 71b72bb5b13530bb
zh: b78170e3ac09cccf
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/decision:
en: 45d689dced9dad9f
zh: cb89fbbb7937ca8e
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/alternatives-considered:
en: 1027b59d76cd3ff7
zh: c4c60fb406acb1db
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/verification:
en: 0e7fbf7fbb47b07c
zh: 9b52991ad8b355ca
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/consequences:
en: 3583e26c8ec33e03
zh: 6080722751cf15e6
@@ -0,0 +1,35 @@
# Agent Note: Keep plugin-install interception for the page lifetime
Status: implemented
English | [中文](2026-09-27-plugin-install-interceptor-lifetime.zh.md)
## Problem
The [plugin-install scenario](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) can leave a directory read pending after cancelling an installation. The Host emits invalidations that trigger background reads while the scenario removes its Playwright routes between installation phases. A stalled read keeps the installed package out of the list, so enabling it closes the dialog but never exposes the highlighted card.
## Decision
The scenario registers its `installBundle` and `cancelInstall` interceptors before navigation and retains them until the browser closes. Each interceptor calls a typed handler that the scenario replaces when it changes phases. The delivery and cancellation barriers retain their ordering, and `activeReplySettled` joins the lost-response handler before the next installation starts. Ordinary requests still reach the real Host.
The [input-state decision](2026-09-27-web-lane-assertions-name-their-input-state.md) owns the separate wait for the highlighted card's status to become `running`. Keeping interception active lets the directory read complete; polling its result still establishes the state the golden records.
## Alternatives considered
**Remove routes with `unrouteAll({ behavior: 'wait' })`.** Waiting for matched handlers does not settle unrelated browser requests. A captured Chromium protocol trace shows `Fetch.disable` immediately followed by a `pluginManager/listPlugins` request with no response or failure before teardown. The Host's inventory and plugin methods still answer direct calls.
**Advance the browser clock or extend the locator timeout.** Advancing the paused clock does not release the stalled request. The 2.4-second highlight expiry is not responsible for a package that never reaches the directory.
**Add a general route-switching helper.** Two endpoint handlers in one scenario do not need another fixture API. Their local delegates preserve the existing cancellation and lost-response cases without changing the browser's interception configuration.
## Verification
The unchanged scenario passes alone and fails at the same highlighted-card locator in one of four concurrent independent processes. Instrumented failures place the unanswered directory request inside the second `unrouteAll` interval; advancing the page clock leaves it unanswered, while direct Host reads complete. The protocol trace identifies the interception transition independently of the DOM assertion.
`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` passes in four concurrent independent processes (1/1 each). One protocol capture observes one `Fetch.enable`, no `Fetch.disable`, and responses for all 33 directory requests. The existing goldens cover cancellation before delivery, cancellation of the running child, manifest and lockfile restoration, retry, enabled-card status, both themes and motion preferences, highlight expiry, and lost-result recovery.
## Consequences
The browser retains two narrowly matched interceptors for its lifetime. The scenario controls their behavior without interrupting concurrent directory reads, and browser closure owns their cleanup. Product code, goldens, test deadlines, and CI worker counts are unchanged.
Live-page `unrouteAll` calls also remain in [sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts), [queue-image](../../../../apps/web/tests/queue-image.e2e.ts), [subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts), and [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts). These are candidates for separate overlap investigations; this scenario's reproduction does not establish the cause of failures in those tests.
@@ -0,0 +1,35 @@
# Agent Note: 让插件安装拦截器持续到页面关闭
Status: implemented
[English](2026-09-27-plugin-install-interceptor-lifetime.md) | 中文
## Problem
[插件安装场景](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在取消安装后,可能留下一次未结束的目录读取。场景在安装阶段之间移除 Playwright 路由时,Host 发出的失效通知仍会触发后台读取。停滞的读取使已安装的包无法进入列表,因此启用操作虽然关闭了对话框,却始终不显示高亮卡片。
## Decision
场景在导航前注册 `installBundle` 和 `cancelInstall` 拦截器,并保留到浏览器关闭。每个拦截器调用一个带类型的处理函数,场景切换阶段时替换该函数。请求交付和取消屏障保持原有顺序,`activeReplySettled` 在下一次安装开始前等待丢失响应的处理函数结束。普通请求仍然到达真实 Host。
[输入状态决策](2026-09-27-web-lane-assertions-name-their-input-state.zh.md) 负责另一项等待:高亮卡片的状态变为 `running`。持续启用拦截使目录读取能够完成;轮询其结果仍负责确立预期输出记录的状态。
## Alternatives considered
**用 `unrouteAll({ behavior: 'wait' })` 移除路由。** 等待匹配的处理函数结束并不意味着其他浏览器请求已结束。捕获的 Chromium 协议记录显示,`Fetch.disable` 后紧接着发出 `pluginManager/listPlugins` 请求,直到清理前都没有响应或失败事件。直接调用 Host 的目录和插件方法仍能得到答复。
**推进浏览器时钟或延长 locator 超时。** 推进已暂停的时钟不能释放停滞的请求。包始终未进入目录,不是 2.4 秒的高亮到期计时器造成的。
**增加通用路由切换辅助函数。** 一个场景中的两个端点处理函数不需要另一层 fixture(测试前置数据)API。局部委托函数保留了现有取消和丢失响应场景,也不改变浏览器的拦截配置。
## Verification
未修改的场景单独运行通过,四个并发独立进程中有一个在同一高亮卡片 locator 处失败。插桩失败将未获响应的目录请求定位到第二次 `unrouteAll` 的执行区间内;推进页面时钟后请求仍未获响应,而直接读取 Host 可以完成。协议记录独立于 DOM 断言确认了拦截状态的切换。
`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` 在四个并发独立进程中全部通过,每个进程均为 1/1。一次协议记录观察到一次 `Fetch.enable`、零次 `Fetch.disable`,且全部 33 次目录请求均收到响应。现有预期输出覆盖取消先于请求交付、取消运行中的子进程、恢复 manifest(元数据清单)与 lockfile、重试、已启用卡片状态、两种主题与动画偏好、高亮到期,以及丢失结果恢复。
## Consequences
浏览器在整个生命周期内保留两个精确匹配的拦截器。场景控制其行为时不会中断并发目录读取,浏览器关闭负责清理它们。产品代码、预期输出、测试超时和 CI worker 数保持不变。
[sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts)、[queue-image](../../../../apps/web/tests/queue-image.e2e.ts)、[subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts) 和 [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts) 中也仍有页面存续期间的 `unrouteAll` 调用。这些位置可分别调查请求重叠;本场景的复现并不能确定那些测试失败的原因。
@@ -13,10 +13,10 @@
zh: 4fe1f6abd6fd4c1e
/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
en: 6cd5940f55500c3d
zh: ded68b931ed81e10
zh: 4a2e13717e87b783
/agent-note-web-lane-assertions-name-their-input-state/verification:
en: 6d11abb7a60f81ae
zh: 42bc43777a3b02b7
en: 9a35f9dbc4285674
zh: 0995a8b137912b6b
/agent-note-web-lane-assertions-name-their-input-state/consequences:
en: ad3d8ec4b171b04a
zh: e254cc9515b33e0e
@@ -40,7 +40,7 @@ Two negative controls reproduce the recorded signatures with the shipped asserti
Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
The install-cancel scenario also recorded a separate 30 s highlighted-card wait after the enable click closed the dialog: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command. The page clock was paused, excluding highlight expiry. The [interceptor-lifetime decision](2026-09-27-plugin-install-interceptor-lifetime.md) owns the diagnosed directory-request stall and its fixture fix. The status poll still covers the read after the card appears.
The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
@@ -32,7 +32,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此
**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture(测试前置数据)前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
## Verification
@@ -40,7 +40,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此
本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
install-cancel 场景还记录过独立的 30 s 高亮卡片等待:启用点击关闭对话框后,`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都未能找到卡片。当时页面时钟已暂停,因此可排除高亮到期。[拦截器生命周期决策](2026-09-27-plugin-install-interceptor-lifetime.zh.md) 负责已定位的目录请求停滞及其 fixture 修复。状态轮询仍覆盖卡片出现之后的读取。
串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
+25 -20
View File
@@ -4,7 +4,7 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Locator, type Page } from 'playwright'
import { chromium, type Locator, type Page, type Route } from 'playwright'
import { expect, it } from 'vitest'
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
@@ -87,6 +87,24 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
`)
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE })
const tripwire = watchConsole(page)
// Hold request delivery and the real Host's cancellation reply independently.
// Cancellation reaches the Host before the install it names.
const cancellationArrived = Promise.withResolvers<undefined>()
let installBundleRoute = async (route: Route): Promise<void> => {
await delivery.promise
await route.continue()
}
let cancelInstallRoute = async (route: Route): Promise<void> => {
const response = await route.fetch()
cancellationArrived.resolve(undefined)
await cancellationReply.promise
await route.fulfill({ response })
}
// Host invalidations keep reading the directory between install phases.
// Interception stays enabled from before navigation until page closure,
// so phase changes cannot strand those requests.
await page.route('**/api/pluginManager/installBundle', route => installBundleRoute(route))
await page.route('**/api/pluginManager/cancelInstall', route => cancelInstallRoute(route))
await page.clock.install()
await page.goto(scaffold.authenticatedUrl)
await page.waitForSelector('[class*="frame"]')
@@ -120,19 +138,6 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)),
`${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode())
await packageName.fill('slow-package')
// Hold request delivery and the real Host's cancellation reply independently.
// Cancellation reaches the Host before the install it names.
const cancellationArrived = Promise.withResolvers<undefined>()
await page.route('**/api/pluginManager/installBundle', async (route) => {
await delivery.promise
await route.continue()
})
await page.route('**/api/pluginManager/cancelInstall', async (route) => {
const response = await route.fetch()
cancellationArrived.resolve(undefined)
await cancellationReply.promise
await route.fulfill({ response })
})
await dialog.getByRole('button', { name: '安装', exact: true }).click()
await dialog.getByText('正在准备安装…', { exact: true }).waitFor()
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/preparing.expected.md', import.meta.url)),
@@ -156,10 +161,10 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor()
expect(await readFile(manifestPath, 'utf8')).toBe(manifest)
expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
await page.unrouteAll({ behavior: 'wait' })
cancelInstallRoute = route => route.continue()
// Drop the browser's response while the real Host still owns the child process.
const activeReplySettled = Promise.withResolvers<undefined>()
await page.route('**/api/pluginManager/installBundle', async (route) => {
installBundleRoute = async (route) => {
const response = route.fetch().then(() => undefined, (error: unknown) => error)
try {
await loseActiveReply.promise
@@ -168,7 +173,7 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
} finally {
activeReplySettled.resolve(undefined)
}
})
}
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
await dialog.getByRole('textbox').fill('slow-package')
await dialog.getByRole('button', { name: '安装', exact: true }).click()
@@ -186,7 +191,7 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n')
expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package')
await activeReplySettled.promise
await page.unrouteAll({ behavior: 'wait' })
installBundleRoute = route => route.continue()
const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
.split(process.execPath).join('{{node}}')
.split(scaffold.harnessHome).join('{{harnessHome}}')
@@ -257,10 +262,10 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha
}
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
await dialog.getByRole('textbox').fill('recovered-package')
await page.route('**/api/pluginManager/installBundle', async (route) => {
installBundleRoute = async (route) => {
await route.fetch()
await route.abort('failed')
})
}
await dialog.getByRole('button', { name: '安装', exact: true }).click()
await dialog.getByText('未能获取安装结果', { exact: true }).waitFor()
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/unknown.expected.md', import.meta.url)),