test(web): stabilize resize and scroll fixtures

This commit is contained in:
imccyu committed 2026-09-22 21:44:39 +08:00
1 parent 838eff14eb
commit fad9e27706
13 files changed
+100 -21

No files matched your search

+2 -2
View File
@@ -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
+2
View File
@@ -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.
+2
View File
@@ -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 服务访问都无法编译。
+5 -5
View File
@@ -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)
+4 -4
View File
@@ -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.
+2 -2
View File
@@ -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()
+13
View File
@@ -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<P
return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
}
/**
* Scroll a locator whose rendered element can be replaced during layout.
* @param target - locator resolved again when its previous element detached.
*/
export async function scrollIntoView(target: Locator): Promise<void> {
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
+3 -3
View File
@@ -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')
+2 -2
View File
@@ -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]!
@@ -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')
+41
View File
@@ -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<Element>()
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)
})
+18
View File
@@ -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)
+3 -3
View File
@@ -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,