Files
deepseek-harness/apps/web/tests/queue-actions.e2e.ts
T
Yichen Jiang 882b9dad5b test(web): wait for the Host to accept each queued prompt before the next
The composer stays editable while a prompt is sending, so waiting for an
editable composer no longer waits for the round trip. Consecutive sends in
the queue layout and queued image scenarios now wait until the composer
clears, which happens only after the Host accepted the previous prompt.
2026-10-19 22:53:14 +08:00

503 lines
25 KiB
TypeScript

// Keyless browser coverage for pending queue actions through the shipped Web
// composition and real HTTP/SSE wire. Replay overrides park consecutive turns
// so the page can edit and remove exact occurrences, then stop the active turn
// while proving the preserved Queue advances in FIFO order.
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queue-actions', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const SENDING_EXPECTED = join(SNAPSHOT_DIR, 'sending.expected.md')
const WRITER_HELD_EXPECTED = join(SNAPSHOT_DIR, 'writer-held.expected.md')
const FAILED_EXPECTED = join(SNAPSHOT_DIR, 'failed.expected.md')
const GOAL_STOP_EXPECTED = join(SNAPSHOT_DIR, 'goal-stop.expected.md')
const MODE = webSnapshotMode()
const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.'
const REMOVE = 'Queue item to remove'
const EDIT = 'Queue item to edit'
// The edited value is deliberately multi-line: the inline editor must keep the
// line breaks through the queue mutation and into the model-bound user message.
const EDITED_CONTENT = 'Edited\nqueue item'
// The dock preview flattens the queued text, so row locators match this form.
const EDITED = 'Edited queue item'
const TAIL = 'Queue item preserved after stop'
const WAKE = 'Wake the preserved queue'
const FAILED = 'Queue submission to retry'
/** Durable turn-end classifications observed by the scenario. */
function turnEndReasons(events: readonly SessionEvent[]): string[] {
return events.flatMap(event => event.type === 'turn/end' ? [event.data.reason.kind] : [])
}
describe('web e2e: queue row actions', () => {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
let page: Page
let overrideDir: string | undefined
afterEach(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
browser = undefined
const closing = scaffold
scaffold = undefined
await closing?.close().catch((error: unknown) => failures.push(error))
if (overrideDir !== undefined) {
await rm(overrideDir, { recursive: true, force: true })
.catch((error: unknown) => failures.push(error))
}
overrideDir = undefined
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed')
})
/** Wait for the exact queue mutation response before observing its unlocked actions. */
async function settleQueueAction(action: () => Promise<void>, remainingText: string): Promise<void> {
const response = page.waitForResponse('**/api/session/updateQueue')
await action()
expect((await response).ok()).toBe(true)
const row = page.locator('[data-queue-dock] li', { hasText: remainingText })
await expect.poll(() => row.getByRole('button', { name: 'Edit queued message' }).isEnabled()).toBe(true)
await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
}
it.skipIf(MODE === 'record').each(['button', 'keyboard'] as const)('edits and removes exact occurrences and preserves Queue across %s stop', async (method) => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
const readyFile = join(overrideDir, '.hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
expect(recorded).toHaveLength(1)
const replay: ReplayEntry[] = [
{ kind: 'hang', readyFile },
recorded[0]!,
recorded[0]!,
recorded[0]!,
]
await writeFile(overridePath, JSON.stringify(replay))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions'))
const input = page.locator('[data-composer-input]').first()
const firstSettled = scaffold.whenTurnSettled()
await input.fill(ACTIVE_PROMPT)
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
const admitted = page.waitForResponse('**/api/session/prompt')
const received = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
await page.route('**/api/session/prompt', async (route) => {
received.resolve(undefined)
await release.promise
await route.continue()
}, { times: 1 })
try {
await input.fill(REMOVE)
await input.press('Enter')
await received.promise
// The composer keeps the message, still editable, until the Host accepts it; Stop stays
// beside the sending control for the running Turn, and the queue shows nothing yet.
const sendingButton = page.getByRole('button', { name: 'Sending message', exact: true })
await sendingButton.waitFor()
expect(await sendingButton.getAttribute('aria-busy')).toBe('true')
expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).isEnabled()).toBe(true)
expect(await input.textContent()).toBe(REMOVE)
expect(await input.getAttribute('contenteditable')).toBe('true')
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
const sending = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SENDING_EXPECTED, sending, MODE)
} finally {
release.resolve(undefined)
}
expect((await admitted).ok()).toBe(true)
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
expect(await page.locator('[data-queue-dock]').getByRole('status').count()).toBe(0)
await input.fill(EDIT)
await input.press('Enter')
// The Host queue row can arrive before the prompt reply that clears the composer.
await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
await expect.poll(() => queueHeader.getByRole('status').count(), { timeout: 10_000 }).toBe(0)
const collapsedSnapshot = await captureStableAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE)
await queueHeader.click()
await expect.poll(
() => page.getByRole('button', { name: 'Remove queued message', disabled: false }).count(),
{ timeout: 10_000 },
).toBe(2)
await page.setViewportSize({ width: 640, height: 1000 })
await page.locator('[data-sidebar-collapsed="true"]').waitFor()
// The responsive sidebar and composer settle independently; sample both
// rectangles in one browser task so the comparison uses one layout.
await vi.waitFor(async () => {
const metrics = await page.evaluate(() => {
const queue = document.querySelector('[data-queue-dock]')
const composer = document.querySelector('[data-composer-card]')
if (queue === null || composer === null) return undefined
const queueBox = queue.getBoundingClientRect()
const composerBox = composer.getBoundingClientRect()
return {
leftInset: queueBox.left - composerBox.left,
rightInset: composerBox.right - queueBox.right,
dockInset: Number.parseFloat(getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset')),
}
})
expect(metrics).toBeDefined()
expect(metrics!.leftInset).toBeGreaterThanOrEqual(0)
expect(metrics!.rightInset).toBeGreaterThanOrEqual(0)
expect(metrics!.leftInset).toBeCloseTo(metrics!.dockInset, 1)
expect(metrics!.rightInset).toBeCloseTo(metrics!.dockInset, 1)
}, { timeout: 10_000 })
await page.setViewportSize({ width: 1680, height: 1000 })
const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT })
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
await editor.fill(EDITED_CONTENT)
const save = page.getByRole('button', { name: 'Save queued message' })
await save.hover()
const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true })
await saveTooltip.waitFor()
// The dock tucks under the input card, which paints later; the bubble must
// escape the panel's stacking context instead of landing behind it.
await expectTooltipOnTop(saveTooltip)
const tooltipGeometry = await page.evaluate(() => {
const element = document.querySelector<HTMLElement>('[role="tooltip"]')
if (element === null) return null
const tooltip = element.getBoundingClientRect()
return {
declaredLeft: Number.parseFloat(element.style.left),
declaredTop: Number.parseFloat(element.style.top),
tooltipCenter: tooltip.left + tooltip.width / 2,
tooltipTop: tooltip.top,
}
})
expect(tooltipGeometry).not.toBeNull()
expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2)
expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2)
const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
await settleQueueAction(() => save.click(), EDITED)
await page.getByText(EDITED, { exact: true }).waitFor()
const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE })
await settleQueueAction(() => removeRow.getByRole('button', { name: 'Remove queued message' }).click(), EDITED)
await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
// The queue stream can remove the row before the mutation reply clears busy.
const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true })
await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true)
await remainingEdit.hover()
const editTooltip = page.getByRole('tooltip', { name: 'Edit queued message', exact: true })
await editTooltip.waitFor()
await expectTooltipOnTop(editTooltip)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
await page.route('**/api/session/prompt', async (route) => {
const envelope = route.request().postDataJSON() as { rpcId: string }
await route.fulfill({
status: 200,
contentType: 'application/json',
json: {
type: 'server-response', rpcId: envelope.rpcId,
result: {
ok: false,
error: { code: 'session/agent-busy', message: 'Queue submission failed', details: { reason: 'admission refused' } },
},
},
})
}, { times: 1 })
await input.fill(FAILED)
await input.press('Enter')
const failure = page.getByRole('alert').filter({ hasText: 'Queue submission failed' })
await failure.waitFor()
await expect.poll(() => input.textContent()).toBe(FAILED)
expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0)
const failed = [
await failure.ariaSnapshot(),
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
].join('\n')
await compareOrRefreshGolden(FAILED_EXPECTED, failed, MODE)
await page.route('**/api/session/prompt', async (route) => {
const envelope = route.request().postDataJSON() as { rpcId: string }
await route.fulfill({
status: 200,
contentType: 'application/json',
json: {
type: 'server-response', rpcId: envelope.rpcId,
result: {
ok: false,
error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } },
},
},
})
}, { times: 1 })
await input.press('Enter')
const writerHeld = page.getByRole('alert').filter({ hasText: 'This session is already in use' })
await writerHeld.waitFor()
await expect.poll(() => input.textContent()).toBe(FAILED)
await compareOrRefreshGolden(WRITER_HELD_EXPECTED, [
await writerHeld.ariaSnapshot(),
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd),
].join('\n'), MODE)
await input.fill(TAIL)
await input.press('Enter')
await expect.poll(
() => page.getByRole('button', { name: 'Remove queued message', disabled: false }).count(),
{ timeout: 10_000 },
).toBe(2)
const stopButton = page.getByRole('button', { name: 'Stop generating' })
await stopButton.hover()
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
if (method === 'button') await stopButton.click()
else {
await input.focus()
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
}
await firstSettled
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
.toBe(0)
await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count())
.toBe(2)
// The disabled Send button must dismiss the active Stop tooltip without mouseleave.
await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
const expanded = await captureExpandedTurnProcessAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(PRESERVED_EXPANDED_EXPECTED, expanded, MODE)
const settled = scaffold.whenTurnSettled()
await input.fill(WAKE)
await input.press('Enter')
await settled
await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 })
.toEqual(['aborted', 'completed', 'completed', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([ACTIVE_PROMPT, EDITED_CONTENT, TAIL, WAKE])
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
const readyFile = join(overrideDir, '.hang-ready')
const overridePath = join(overrideDir, 'replay.override.json')
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
const input = page.locator('[data-composer-input]').first()
const settled = scaffold.whenTurnSettled()
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
await input.fill('/goal Keep the composer context panels aligned')
await input.press('Enter')
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
const sessions = scaffold.ctx.sessions.list()
expect(sessions).toHaveLength(1)
sessions[0]!.append('todo/write', {
todos: [
{ content: 'Confirm the panel order', status: 'completed' },
{ content: 'Align the panel widths', status: 'in_progress' },
],
})
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
for (const text of ['Layout queue first', 'Layout queue second']) {
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
await input.fill(text)
await input.press('Enter')
// The composer keeps a sent message, still editable, until the Host accepts it.
await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
}
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
await expect.poll(() => queueHeader.getByRole('status').count(), { timeout: 10_000 }).toBe(0)
const layoutSnapshot = await captureStableAria(
page,
'[class*="centerCol"]',
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
const expectAlignedContextPanels = async () => {
// Sample one layout: the responsive grid can move between separate browser round trips.
const [queuePanelBox, todoBox, goalBox] = await page.evaluate(() => [
'[data-queue-dock] > div', '[data-testid="todo-panel"]', '[data-goal-bar] > div',
].map((selector) => {
const box = document.querySelector(selector)?.getBoundingClientRect()
return box === undefined ? null : { x: box.x, y: box.y, width: box.width }
}))
expect(queuePanelBox).not.toBeNull()
expect(todoBox).not.toBeNull()
expect(goalBox).not.toBeNull()
expect(todoBox!.y).toBeLessThan(goalBox!.y)
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
}
await expectAlignedContextPanels()
await page.setViewportSize({ width: 640, height: 1000 })
await expectAlignedContextPanels()
await page.setViewportSize({ width: 1680, height: 1000 })
await queueHeader.click()
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
await removeButtons.first().click()
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
await removeButtons.first().click()
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Clear goal' }).click()
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('runs a prompt sent after Stop when a Goal round was queued behind stopped work', async () => {
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-goal-stop-'))
const roundReady = join(overrideDir, '.round-ready')
const humanReady = join(overrideDir, '.human-ready')
const overridePath = join(overrideDir, 'replay.override.json')
const recorded = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
await writeFile(overridePath, JSON.stringify([
{ kind: 'hang', readyFile: roundReady },
{ kind: 'hang', readyFile: humanReady },
recorded[0]!,
] satisfies ReplayEntry[]))
const sessionEvents: SessionEvent[] = []
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath, compareReplaySession: false })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-stop'))
const input = page.locator('[data-composer-input]').first()
const editable = page.locator('[data-composer-input][contenteditable="true"]').first()
const stop = page.getByRole('button', { name: 'Stop generating', exact: true })
await editable.waitFor({ timeout: 10_000 })
await input.fill('/goal Keep working after Stop')
await input.press('Enter')
await expect.poll(() => existsSync(roundReady), { timeout: 15_000 }).toBe(true)
// Human work queued behind the running round survives the first Stop.
await editable.waitFor({ timeout: 10_000 })
await input.fill(REMOVE)
await input.press('Enter')
await page.getByRole('button', { name: 'Remove queued message', disabled: false }).waitFor({ timeout: 10_000 })
await stop.click()
await page.getByRole('button', { name: 'Resume goal' }).waitFor({ timeout: 10_000 })
// Resuming queues the next round behind that human prompt, which runs first.
await page.getByRole('button', { name: 'Resume goal' }).click()
await expect.poll(() => existsSync(humanReady), { timeout: 15_000 }).toBe(true)
await expect.poll(() => page.locator('[data-queue-dock] li', { hasText: '<goal_round>' }).count(), { timeout: 10_000 })
.toBe(1)
await stop.click()
await expect.poll(() => stop.count(), { timeout: 10_000 }).toBe(0)
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
const settled = scaffold.whenTurnSettled()
await editable.waitFor({ timeout: 10_000 })
await input.fill(WAKE)
await input.press('Enter')
await settled
await expect.poll(() => turnEndReasons(sessionEvents), { timeout: 15_000 })
.toEqual(['aborted', 'aborted', 'completed'])
expect(sessionEvents.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'user'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])).toEqual([REMOVE, WAKE])
expect(sessionEvents.some(event => event.type === 'agent/inbox/spliced' && event.data.target === 'next-turn'
&& event.data.outcome === 'canceled')).toBe(true)
await expect.poll(() => stop.count(), { timeout: 10_000 }).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(GOAL_STOP_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(
SNAPSHOT_DIR,
[
'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
'sending.expected.md', 'failed.expected.md', 'writer-held.expected.md',
'goal-stop.expected.md',
],
)
})
})