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