diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 20302b4f08..524edaeb31 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -6,8 +6,8 @@ en: 3e8ecd3509fa944f zh: 3fd3ff9fcd1e1e90 /apps-web-browser-e2e/completion-observations: - en: ae500691f64d1e43 - zh: 2bfc53b52d210b24 + en: 4a06ba5463b53bb3 + zh: 70863c70e15c6746 /apps-web-browser-e2e/these-are-host-face-tests: en: 8c406d423c8e2137 zh: a66a144fd146f56c diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 4c34c39314..0076a3b809 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -20,6 +20,8 @@ State-sensitive cases use Workspace, admission, attachment, and model-stream bar [Idle-submission handoff](idle-submission-handoff.e2e.ts) waits up to the scaffold's 30-second turn-completion budget for Host frames and browser placement. Vitest owns scaffold teardown so a replay-consumption error does not replace the failed scenario assertion. +Plugin-manager switches wait up to `PLUGIN_TOGGLE_SETTLE_MS` (10 seconds) for the Host's profile write and the store's follow-up inventory read, the two steps that republish `aria-checked`; that constant's JSDoc records the measured chain. + Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling. ## These are Host-face tests diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index a13aae71b9..d1968d5080 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -20,6 +20,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps [空闲提交交接](idle-submission-handoff.e2e.ts)对 Host 帧和浏览器中的消息归位的等待使用 scaffold 的 30 秒 Turn 完成预算。Vitest 负责 scaffold 清理,使 replay 消耗检查错误不会覆盖场景中失败的断言。 +插件管理器的开关为 Host 的 profile 写入与 store 随后的 inventory 读取预留 `PLUGIN_TOGGLE_SETTLE_MS`(10 秒),这两步完成后才会重新发布 `aria-checked`;链路的实测值记录在该常量的 JSDoc 中。 + 显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。 ## 这些是 Host 面的测试 diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 16cc5b2f2f..235db04364 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -15,7 +15,9 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspaceZh, expandOwningTurnProcess, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' +import { + connectFreshWorkspaceZh, expandOwningTurnProcess, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, ZH_BROWSER_LOCALE, +} from './support.ts' const BUNDLE_NAME = '@deepseek-ai/dsh-experimental-cot-translation-bundle' const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/cot-translation-bundle', import.meta.url)) @@ -168,7 +170,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr const panel = settings.locator('[data-plugin-panel]'), card = panel.locator(`[data-plugin-package="${BUNDLE_NAME}"]`) const toggle = card.getByRole('switch'); await toggle.waitFor() expect(await toggle.getAttribute('aria-checked')).toBe('false') - await toggle.click(); await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') await card.getByRole('button').first().click() const provider = panel.getByLabel('翻译服务', { exact: true }) await provider.waitFor(); expect(await provider.inputValue()).toBe('bing') diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts index 23517e510e..beb296eeda 100644 --- a/apps/web/tests/cot-translation.e2e.ts +++ b/apps/web/tests/cot-translation.e2e.ts @@ -12,7 +12,9 @@ import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspaceZh, expandOwningTurnProcess, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts' +import { + connectFreshWorkspaceZh, expandOwningTurnProcess, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, ZH_BROWSER_LOCALE, +} from './support.ts' const BUNDLE_NAME = '@deepseek-ai/dsh-experimental-cot-translation-bundle' const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/cot-translation-bundle', import.meta.url)) @@ -198,7 +200,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await toggle.waitFor() expect(await toggle.getAttribute('aria-checked')).toBe('false') await toggle.click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') await card.getByRole('button').first().click() const provider = panel.getByLabel('翻译服务', { exact: true }) const target = panel.getByLabel('目标语言', { exact: true }) @@ -315,7 +317,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await panel.getByRole('button', { name: '返回插件列表', exact: true }).click() await toggle.click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') await body.waitFor({ state: 'detached' }) await reasoning.getByText(ORIGINAL, { exact: true }).waitFor() await reasoning.locator('[data-disclosure-row]').first().click() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index c260f9889d..b0fa589d55 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -16,7 +16,9 @@ import { SCAFFOLD_DEFAULTS_BUNDLE, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettings, saveFailureShot } from './support.ts' +import { + ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, openSettings, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot, +} from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/plugin-manager', import.meta.url)) const MANAGER_EXPECTED = join(SNAPSHOT_DIR, 'manager.expected.md') @@ -643,7 +645,7 @@ describe('web e2e: plugin manager', () => { await toggle.click() try { await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3) - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') await action.waitFor({ timeout: 20_000 }) await action.getByRole('button', { name: '智能体团队', exact: true }).click() const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true }) @@ -897,10 +899,10 @@ describe('web e2e: plugin manager', () => { const bundles = async () => (JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { dsh: { profile: { bundles: string[] } } }).dsh.profile.bundles - await expect.poll(bundles, { timeout: 10_000 }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) + await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) // A live profile: the row mounts once the whole tree recomposed, the switch is on, and nothing waits for a restart. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2) - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('true') + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') expect(await panel.getByText(/下次启动生效/).count()).toBe(0) const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(LIVE_EXPECTED, snapshot, MODE) @@ -910,8 +912,8 @@ describe('web e2e: plugin manager', () => { await rowSwitch.waitFor({ timeout: 10_000 }) expect(await rowSwitch.getAttribute('aria-checked')).toBe('true') await rowSwitch.click() - await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'cordis.patch.yml')).includes('fixture-row'), { timeout: 10_000 }).toBe(true) - await expect.poll(() => rowSwitch.getAttribute('aria-checked'), { timeout: 10_000 }).toBe('false') + await expect.poll(async () => (await homeFile('profiles', 'scaffold', 'cordis.patch.yml')).includes('fixture-row'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true) + await expect.poll(() => rowSwitch.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') // A disabled entry keeps its disposed fiber; only an active one counts as mounted. await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) await rowSwitch.click() @@ -920,7 +922,7 @@ describe('web e2e: plugin manager', () => { await toggle.click() await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2) - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(tripwire.pageErrors).toEqual([]) }, 90_000) @@ -959,8 +961,8 @@ describe('web e2e: startup-applied plugin management', () => { expect(mounted()?.fiber?.state).toBeUndefined() await toggle.click() // The selection is saved and the switch turns on, but nothing mounts before the next start; a toast says so. - await expect.poll(bundles).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual([...SCAFFOLD_BUNDLES, '@fixture/bundle']) + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') await page.getByText('更改将在下次启动生效', { exact: true }).waitFor({ timeout: 10_000 }) expect(mounted()?.fiber?.state).toBeUndefined() // The pack's page lists its rows from their declarations, with no live entry to switch. @@ -970,8 +972,8 @@ describe('web e2e: startup-applied plugin management', () => { await panel.getByRole('button', { name: '返回插件列表' }).click() await toggle.click() - await expect.poll(bundles).toEqual(SCAFFOLD_BUNDLES) - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual(SCAFFOLD_BUNDLES) + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(tripwire.pageErrors).toEqual([]) } finally { await browser?.close() diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index b9f37c1382..021b10e569 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -41,6 +41,15 @@ export const ZH_BROWSER_LOCALE = 'zh-CN' /** Same-day anchor for seeded event times and the Asia/Shanghai browser clock. */ export const WEB_FIXTURE_TIME = Date.parse('2026-01-15T12:00:00+08:00') +/** + * Budget for a plugin-manager switch to republish its state after a click: the + * click starts a Host profile write, and the store's follow-up `load()` re-reads + * its inventory before the switch shows the new value. That chain measured + * 0.5-0.8 s on an idle host and 2.0-4.8 s across 32 samples in a 16-worker lane, + * so Vitest's 1-second `expect.poll` default expires first. + */ +export const PLUGIN_TOGGLE_SETTLE_MS = 10_000 + /** How often a pinned browser clock advances, in milliseconds. */ const PINNED_CLOCK_STEP_MS = 250 diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts index 0cdd5bc73d..c2f2e186a8 100644 --- a/apps/web/tests/worktree-opt-in.e2e.ts +++ b/apps/web/tests/worktree-opt-in.e2e.ts @@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-experimental-worktree' import { chromium } from 'playwright' import { expect, it, onTestFailed, onTestFinished } from 'vitest' import { launchWebScaffold, watchConsole } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts' const BUNDLE = '@deepseek-ai/dsh-experimental-tool-worktree' const exec = promisify(execFile) @@ -80,8 +80,8 @@ it('persists the Git Worktrees switch and removes its capability while retaining expect(tools()).not.toContain('create_worktree') await toggle.click() - await expect.poll(() => rows().length).toBe(2) - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') + await expect.poll(() => rows().length, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(2) + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') expect(await selectedBundles()).toContain(BUNDLE) expect(tools()).toContain('create_worktree') const created = await scaffold.ctx.worktrees.create(agent, { name: 'gui-opt-in' }) @@ -95,8 +95,8 @@ it('persists the Git Worktrees switch and removes its capability while retaining await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(tools()).toContain('create_worktree') await toggle.click() - await expect.poll(() => rows().length).toBe(0) - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + await expect.poll(() => rows().length, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(0) + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(scaffold.ctx.get('worktrees')).toBeUndefined() expect(tools()).not.toContain('create_worktree')