mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
test(web): stabilize resize and scroll fixtures
This commit is contained in:
1 parent
838eff14eb
commit
fad9e27706
13 files changed
+100
-21
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 服务访问都无法编译。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user