test(web): stabilize idle submission handoff waits

This commit is contained in:
Tianyi Cui committed 2026-10-07 02:47:10 +08:00
1 parent 41ede2f572
commit 8c24036e20
4 files changed
+39 -28

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@
en: 3e8ecd3509fa944f
zh: 3fd3ff9fcd1e1e90
/apps-web-browser-e2e/completion-observations:
en: 263a34ba5c63e316
zh: ac1b79f39dde721f
en: ae500691f64d1e43
zh: ef8d84b9907ab67d
/apps-web-browser-e2e/these-are-host-face-tests:
en: 8c406d423c8e2137
zh: a66a144fd146f56c
+2
View File
@@ -18,6 +18,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. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules.
[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.
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
+2
View File
@@ -18,6 +18,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。
[空闲提交交接](idle-submission-handoff.e2e.ts)对 Host 帧和浏览器位置的等待使用 scaffold 的 30 秒 Turn 完成预算。Vitest 负责 scaffold 清理,使 replay 消耗检查错误不会覆盖场景中失败的断言。
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
## 这些是 Host 面的测试
+33 -26
View File
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Page } from 'playwright'
import { expect, it } from 'vitest'
import { expect, it, onTestFinished } from 'vitest'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
@@ -89,6 +89,11 @@ class HistoryDeliveryGate {
}
}
/** Match the scaffold's turn-completion budget for Host frames and browser rendering. */
function poll<T>(read: () => T) {
return expect.poll(read, { timeout: 30_000 })
}
async function placement(page: Page, input = TEXT) {
return page.evaluate((text) => {
const matching = (selector: string) => [...document.querySelectorAll<HTMLElement>(selector)]
@@ -109,6 +114,7 @@ async function placement(page: Page, input = TEXT) {
it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first'] as const)(
'keeps an idle submission in Chat under CPU throttling (%s)', async (order) => {
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
onTestFinished(() => scaffold.close())
const releasePrompt = Promise.withResolvers<undefined>()
const promptBlocked = Promise.withResolvers<undefined>()
const gate = new HistoryDeliveryGate()
@@ -138,42 +144,42 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
await input.fill(TEXT)
await input.press('Enter')
await promptBlocked.promise
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
await poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'optimistic', ...await placement(page) })
releasePrompt.resolve(undefined)
if (order === 'transcript-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
await poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await page.locator('[data-chat-flow-kind="turn-process"]').last().waitFor({ state: 'attached' })
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
await poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
await expect.poll(() => gate.admissionDelivery()).toBeDefined()
await poll(() => gate.admissionDelivery()).toBeDefined()
gate.releaseThrough(gate.admissionDelivery()!)
await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
await poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
trace.push({ phase: 'durable-before-inbox', ...await placement(page) })
}
await expect.poll(() => gate.inboxDelivery(true)).toBeDefined()
await poll(() => gate.inboxDelivery(true)).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(true)!)
await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
await poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
trace.push({ phase: 'inbox', ...await placement(page) })
await expect.poll(() => gate.inboxDelivery(false)).toBeDefined()
await poll(() => gate.inboxDelivery(false)).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(false)!)
await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
await poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 })
trace.push({ phase: 'claim-projection', ...await placement(page) })
if (order === 'inbox-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
await poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await page.locator('[data-chat-flow-kind="turn-process"]').last().waitFor({ state: 'attached' })
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
await poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
}
gate.releaseAll()
await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
await poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 })
trace.push({ phase: 'durable', ...await placement(page) })
for (const entry of trace) {
expect(entry.messageTop, entry.phase).toBe(trace[0]?.messageTop)
@@ -186,7 +192,7 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
} finally {
releasePrompt.resolve(undefined)
gate.releaseAll()
try { await browser?.close() } finally { await scaffold.close() }
await browser?.close()
}
},
)
@@ -199,6 +205,9 @@ it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB
const gate = new HistoryDeliveryGate()
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
let scaffold: Awaited<ReturnType<typeof launchWebScaffold>> | undefined
onTestFinished(async () => {
try { await scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) }
})
try {
const fixture = await readFile(FIXTURE, 'utf8')
const recorded = deriveReplayScript(parseSessionLog(fixture))
@@ -230,30 +239,30 @@ it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB
for (const id of releases.keys()) {
await input.fill(text(id))
await input.press('Enter')
await expect.poll(() => blocked.has(id)).toBe(true)
await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 1, dock: 0, durable: 0 })
await poll(() => blocked.has(id)).toBe(true)
await poll(() => placement(page, text(id))).toMatchObject({ echo: 1, dock: 0, durable: 0 })
}
const entered: string[] = []
for (const id of hostOrder) {
const settled = scaffold.whenTurnSettled()
releases.get(id)!.resolve(undefined)
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
await poll(() => gate.turnStartDelivery()).toBeDefined()
gate.releaseThrough(gate.turnStartDelivery()!)
await expect.poll(() => gate.admissionDelivery(text(id))).toBeDefined()
await poll(() => gate.admissionDelivery(text(id))).toBeDefined()
gate.releaseThrough(gate.admissionDelivery(text(id))!)
entered.push(id)
for (const candidate of releases.keys()) {
const admitted = entered.includes(candidate)
await expect.poll(() => placement(page, text(candidate))).toMatchObject({
await poll(() => placement(page, text(candidate))).toMatchObject({
echo: admitted ? 0 : 1, dock: 0, durable: admitted ? 1 : 0,
})
}
await expect.poll(() => gate.inboxDelivery(true, text(id))).toBeDefined()
await poll(() => gate.inboxDelivery(true, text(id))).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(true, text(id))!)
await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 0, dock: 0, durable: 1 })
await expect.poll(() => gate.inboxDelivery(false, text(id))).toBeDefined()
await poll(() => placement(page, text(id))).toMatchObject({ echo: 0, dock: 0, durable: 1 })
await poll(() => gate.inboxDelivery(false, text(id))).toBeDefined()
gate.releaseThrough(gate.inboxDelivery(false, text(id))!)
await expect.poll(() => gate.turnEndDelivery()).toBeDefined()
await poll(() => gate.turnEndDelivery()).toBeDefined()
gate.releaseThrough(gate.turnEndDelivery()!)
await settled
}
@@ -267,9 +276,7 @@ it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB
} finally {
for (const release of releases.values()) release.resolve(undefined)
gate.releaseAll()
try { await browser?.close() } finally {
try { await scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) }
}
await browser?.close()
}
},
)