mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Every spec now commits at least one golden and the interactive ones one per distinct end-state (nine new .expected.md): - live-interactions: cancel.expected.md (frozen partial + 已停止 marker), error-auth.expected.md (the prompt bubble alone — the committed artifact of the web-error-surface gap, the diff that flips when error rendering lands), retry.expected.md (indistinguishable from a clean completion — retries are deliberately invisible in the transcript). - question-composer: answered.expected.md (the question resolved into its tool round trip plus the final reply, takeover gone) beside the existing waiting-state golden. - steering: mid-steer.expected.md pins the accepted-but-INVISIBLE state (the loop drains steering only at the step boundary, so no interjection bubble exists while the question still blocks — if the client ever renders pending steers eagerly, this golden flips first) and settled.expected.md the badged bubble plus obeying reply. - navigation-panes: waterfall.expected.md and details-open.expected.md (tool-name header, Input args, Output result) beside the trajectory one. - lifecycle-chrome: reloaded.expected.md — rendering the same settled transcript from persistence alone IS the recovery claim. Fixture inventories extended to the new closed sets; the Agent Note's expected-outputs policy updated in both languages (per-end-state goldens for interactive scenarios), pairing re-recorded.
107 lines
5.5 KiB
TypeScript
107 lines
5.5 KiB
TypeScript
// Web e2e scenario: the resident question composer. The shipped composition
|
|
// already exposes ask_user_question (the ui-question row's node half mounts
|
|
// the tool), so a recorded turn where the model asks blocks mid-turn on the
|
|
// real userInteraction seam: the composer renders in the browser, the test
|
|
// answers through it, and the turn completes with the answer in the log.
|
|
// Replay is fully deterministic — the question content arrives from replayed
|
|
// chunks, the composer wait is real, and the answer click is the test's own
|
|
// gesture (the ONE place a drive step legitimately reacts to model content:
|
|
// the turn cannot complete without it, in record and replay alike).
|
|
import { readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join } from 'node:path'
|
|
import type { Browser, Page } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import {
|
|
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
|
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
|
} from './scaffold.ts'
|
|
import { saveFailureShot } from './support.ts'
|
|
|
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url))
|
|
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
|
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
|
// Second golden: the answered transcript — the question resolved into its
|
|
// tool round trip and the final reply, the state the waiting golden cannot see.
|
|
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
|
|
const MODE = webSnapshotMode()
|
|
|
|
const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and options labeled "Blue" and "Green". After I answer, reply with the single word DONE and stop.'
|
|
|
|
describe('web e2e: resident question composer round trip', () => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
const sessionEvents: SessionEvent[] = []
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
|
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
|
browser = await chromium.launch()
|
|
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
await scaffold?.close()
|
|
})
|
|
|
|
it('asks through the composer, answers, and completes with the answer logged', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-question'))
|
|
if (MODE !== 'record') {
|
|
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
|
}
|
|
const input = page.locator('textarea').first()
|
|
await input.waitFor({ timeout: 10_000 })
|
|
const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
|
|
await input.fill(PROMPT)
|
|
await input.press('Enter')
|
|
|
|
// The composer takes over the input area while the tool blocks. Its
|
|
// presence is a STABLE waiting state (not a transient): it stays until
|
|
// answered, so a plain waitFor is race-free.
|
|
const composer = page.locator('[data-question-key]')
|
|
await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
|
|
await expect.poll(() => composer.getByText('Which color do you prefer?').count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
|
|
|
if (MODE !== 'record') {
|
|
// Golden of the composer's waiting state (the transcript region golden
|
|
// is #612's job; this pins the question surface).
|
|
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
|
}
|
|
|
|
await composer.getByRole('radio', { name: 'Blue' }).click()
|
|
// Submit: Enter on the focused option (the composer's documented submit).
|
|
await composer.getByRole('radio', { name: 'Blue' }).press('Enter')
|
|
|
|
const sessionId = await settled
|
|
if (MODE === 'record') {
|
|
await recordFixture(scaffold, sessionId, FIXTURE)
|
|
return
|
|
}
|
|
// World state: the tool result carries the chosen answer, and DONE lands.
|
|
const results = sessionEvents.filter(e => e.type === 'tool/result')
|
|
expect(JSON.stringify(results.at(-1))).toContain('Blue')
|
|
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
|
// Composer gone; regular input restored.
|
|
expect(await page.locator('[data-question-key]').count()).toBe(0)
|
|
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
|
// Golden of the answered transcript: the ask_user_question round trip
|
|
// rendered as history (question tool row + DONE), composer takeover gone.
|
|
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
|
await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
}, 200_000)
|
|
|
|
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
|
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
|
|
})
|
|
})
|