diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 4d920402fa..e48e24302b 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2 -README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f +README.md: 2744c40c7ff6d90e2d1d6cefadfcf6b55acb7e1b +README.zh.md: e7dd1a23b96e5b3eff055e49deec8c86d8e91727 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 8b9c58ce9e..2744c40c7f 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -10,6 +10,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). +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 They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 199ae3b561..e7dd1a23b9 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -10,6 +10,8 @@ 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 +显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。 + ## 这些是 Host 面的测试 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index af48f48e2c..a28b0b3525 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -14,7 +14,7 @@ import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documen import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' -import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url)) @@ -396,13 +396,13 @@ else process.exit(1); const markdownImages: string[] = [] for (const alt of ['relative image', 'absolute image', 'reference image']) { const image = preview.getByRole('img', { name: alt, exact: true }) - await image.scrollIntoViewIfNeeded() + await scrollIntoView(image) await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true) const source = new URL(await image.getAttribute('src') ?? '') expect(source.pathname).toBe('/api/file') markdownImages.push(alt) } - await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded() + await scrollIntoView(preview.getByRole('heading', { name: heading, exact: true })) await successShot(page, 'markdown') const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) }) const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab') @@ -567,7 +567,7 @@ else process.exit(1); expect(firstColor).toBe('red') const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name))) expect(workerNames).toContain('dsh-pdf') - await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded() + await scrollIntoView(preview.locator('[data-pdf-page="2"]')) const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true }) await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') @@ -580,7 +580,7 @@ else process.exit(1); await filesTab.click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await pdfTab.click() - await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded() + await scrollIntoView(preview.locator('[data-pdf-page="2"]')) await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') const restoredColor = await canvasColor(secondPage) diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 69676f37d2..37797460c2 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -11,7 +11,7 @@ import { acknowledgeReloadConnectionLoss, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' // Borrowed read-only: this scenario needs any settled assistant message to // address, not a new recording (message-actions / sidebar-scrollbar pattern). @@ -67,7 +67,7 @@ describe('web e2e: durable per-message feedback', () => { await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) const like = page.getByRole('button', { name: 'Good response' }).first() await like.waitFor({ timeout: 30_000 }) - await like.scrollIntoViewIfNeeded() + await scrollIntoView(like) await like.hover() await like.click() const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) @@ -111,12 +111,12 @@ describe('web e2e: durable per-message feedback', () => { // the unrated control is what triggers the authoritative re-read. const cold = page.getByRole('button', { name: 'Good response' }).first() await cold.waitFor({ timeout: 30_000 }) - await cold.scrollIntoViewIfNeeded() + await scrollIntoView(cold) await cold.hover() const restored = page.getByRole('button', { name: 'Remove rating' }).first() await restored.waitFor({ timeout: 30_000 }) - await restored.scrollIntoViewIfNeeded() + await scrollIntoView(restored) await restored.hover() await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true') // The retract label sits on the Dislike side: the Like stays unpressed. diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 146ff77b85..aaf4c1f7d5 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -14,7 +14,7 @@ import { compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage } from './support.ts' +import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage, scrollIntoView } from './support.ts' const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url)) const FIXTURE = join(DIR, 'session.v3.jsonl') @@ -272,7 +272,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') - await row.scrollIntoViewIfNeeded() + await scrollIntoView(row) for (const card of await row.getByRole('button').all()) { const bounds = await card.boundingBox() expect(bounds).not.toBeNull() diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9945da66a4..9faa3d8f76 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' +import { expect } from 'vitest' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) @@ -55,6 +56,18 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise

{ + await expect.poll(() => target.evaluate((element) => { + if (!element.isConnected) return false + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }) + return true + }), { timeout: 10_000 }).toBe(true) +} + /** * Expand every eligible Turn process and secondary group so a Tool-focused * scenario can exercise the original row contract beneath product-default diff --git a/apps/web/tests/thinking-markdown.e2e.ts b/apps/web/tests/thinking-markdown.e2e.ts index ea2a6efb76..4a8ac6b132 100644 --- a/apps/web/tests/thinking-markdown.e2e.ts +++ b/apps/web/tests/thinking-markdown.e2e.ts @@ -16,7 +16,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts' +import { expandTurnProcesses, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url)) @@ -159,7 +159,7 @@ describe('web e2e: secondary Thinking Markdown', () => { expect(await markdown.locator('hr').count()).toBe(1) expect(await markdown.locator('table').count()).toBe(1) expect(await markdown.locator('.katex').count()).toBe(7) - await markdown.locator('pre').scrollIntoViewIfNeeded() + await scrollIntoView(markdown.locator('pre')) await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1) const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd)) .split(LONG_TOKEN).join('{{longToken}}') @@ -233,7 +233,7 @@ describe('web e2e: secondary Thinking Markdown', () => { expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize)) await page.setViewportSize({ width: 1680, height: 1000 }) const groupScroll = thinking.locator('xpath=ancestor::*[@data-step-process-body][1]') - await groupScroll.scrollIntoViewIfNeeded() + await scrollIntoView(groupScroll) await groupScroll.evaluate((host) => { const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)') if (row === null) throw new Error('tall Thinking table row missing') diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts index a1a84780e9..4dcaa0f759 100644 --- a/apps/web/tests/tool-details.e2e.ts +++ b/apps/web/tests/tool-details.e2e.ts @@ -8,7 +8,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url)) @@ -73,7 +73,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { const card = page.locator('[data-tool="schedule_create"]') expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) const reminders = page.locator('[data-tool="schedule_list"]') - await reminders.scrollIntoViewIfNeeded() + await scrollIntoView(reminders) const list = reminders.getByRole('list').first() await list.evaluate((element) => { const second = element.children[1]! diff --git a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts index 5fb8092161..e4ebaeaaeb 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client.client.spec.ts @@ -32,6 +32,9 @@ async function started(plan: AssemblyPlan, options?: TestClientOptions): Promise describe('TestClient (jsdom)', () => { it('boots the whole web-app roster, connects, mounts, and disposes with nothing unmatched', async () => { + // Exercise missing-API cleanup independently of the suite's browser defaults. + vi.stubGlobal('ResizeObserver', undefined) + onTestFinished(() => { vi.unstubAllGlobals() }) const mock = RemoteMock.create().load(remoteDefaultResponses) const client = await TestClient.start({ roster: webApp }, mock, { mount: true }) expect(client.connection.state.getSnapshot()).toBe('connected') diff --git a/scripts/test-dom-environment.ts b/scripts/test-dom-environment.ts new file mode 100644 index 0000000000..12c8895735 --- /dev/null +++ b/scripts/test-dom-environment.ts @@ -0,0 +1,41 @@ +/** Initial resize delivery from jsdom's modeled offsets; geometry/timing suites supply their own observers. */ +import { afterAll, beforeEach } from 'vitest' + +const original = Object.getOwnPropertyDescriptor(globalThis, 'ResizeObserver') +let installed = false + +class TestResizeObserver implements ResizeObserver { + private readonly targets = new Set() + + constructor(private readonly callback: ResizeObserverCallback) {} + + observe(target: Element): void { + if (this.targets.has(target)) return + this.targets.add(target) + const width = target instanceof HTMLElement ? target.offsetWidth : 0 + const height = target instanceof HTMLElement ? target.offsetHeight : 0 + const size = [{ inlineSize: width, blockSize: height }] + this.callback([{ + target, contentRect: new DOMRectReadOnly(0, 0, width, height), + borderBoxSize: size, contentBoxSize: size, devicePixelContentBoxSize: size, + }], this) + } + + unobserve(target: Element): void { this.targets.delete(target) } + + disconnect(): void { this.targets.clear() } +} + +beforeEach(() => { + if (typeof document !== 'undefined' && typeof ResizeObserver === 'undefined') { + // Establish the per-environment default, so vi.unstubAllGlobals restores it. + Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, writable: true, value: TestResizeObserver }) + installed = true + } +}) + +afterAll(() => { + if (!installed) return + if (original === undefined) Reflect.deleteProperty(globalThis, 'ResizeObserver') + else Object.defineProperty(globalThis, 'ResizeObserver', original) +}) diff --git a/scripts/vitest-environment.compat.spec.ts b/scripts/vitest-environment.compat.spec.ts index fae1fc29fd..8146d58463 100644 --- a/scripts/vitest-environment.compat.spec.ts +++ b/scripts/vitest-environment.compat.spec.ts @@ -2,6 +2,24 @@ import { describe, expect, it } from 'vitest' describe('Vitest jsdom compatibility', () => { + it('provides initial ResizeObserver delivery and supports disconnecting targets', () => { + const target = document.createElement('div') + target.getBoundingClientRect = () => { throw new Error('the jsdom resize fallback must not measure viewport geometry') } + const deliveries: ResizeObserverEntry[][] = [] + const observer = new ResizeObserver(entries => deliveries.push(entries)) + observer.observe(target, { box: 'border-box' }) + observer.observe(target) + expect(deliveries).toHaveLength(1) + expect(deliveries[0]?.[0]).toMatchObject({ target, borderBoxSize: [{ inlineSize: 0, blockSize: 0 }] }) + observer.unobserve(target) + observer.observe(target) + expect(deliveries).toHaveLength(2) + observer.disconnect() + observer.observe(target) + expect(deliveries).toHaveLength(3) + observer.disconnect() + }) + it('provides isolated browser storage instead of Node process storage', () => { if (process.allowedNodeEnvironmentFlags.has('--webstorage')) { expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1) diff --git a/vitest.config.ts b/vitest.config.ts index 5de17ca3c6..6c0a46e4f3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -161,7 +161,7 @@ const processBoundTests = [ export default defineConfig({ plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { - setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, exclude: platformUnsupportedTests, @@ -177,7 +177,7 @@ export default defineConfig({ // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, // Linux, and Windows. Forked workers avoid that shared thread path. pool: 'forks', - setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: testIncludes, exclude: [ ...platformUnsupportedTests, @@ -192,7 +192,7 @@ export default defineConfig({ name: 'process-bound', execArgv: vitestExecArgv, pool: 'forks', - setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts', './scripts/test-dom-environment.ts'], include: processBoundTests, exclude: [ ...platformUnsupportedTests,