mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Six findings. Each terminal-semantics case was checked in a real terminal (tmux, reading back the painted screen) before changing anything: `100%\r\x1b[KOK` shows `OK`. Modelling the `\r` without its erase left the previous frame's tail standing — a regression against the old truncate, since `\r\x1b[K` is the single idiom every spinner and progress bar writes. Erase is now part of the same replay, in all three parameter forms. `a\tb\rXY` shows `XY b`. Counting a tab as one column produced `XYb` and destroyed the alignment this card exists to hold, so the cursor now advances by terminal columns: tabs reach the next 8-column stop and a wide character takes two cells. `\x1b[31mabc\rX\nnext` paints BOTH lines red. A newline does not reset the graphic state, so state threads from one replayed line to the next instead of closing at each line end. Only `m` accumulates into a cell's style now. Folding cursor and erase sequences in grew the state string per redraw and emitted boundaries anser had to discard. The empty check reads the parsed lines the card renders rather than the raw text: output that is only escapes or control bytes survives `trim()` yet parses to nothing, and drew blank rows plus a copy control for invisible bytes instead of the placeholder. The gutter is the card's own left padding rather than a margin. Every render site rewrites `margin` wholesale for its own indent, which silently cancelled the reservation and let a container clip the dot. The fixture sample no longer carries an `[exit code: 1]` line: the real bash presenter consumes that marker precisely because the card shows the exit as its own pill, so the built-bundle snapshot had pinned a frame showing it twice — one the product path cannot produce.
266 lines
16 KiB
TypeScript
266 lines
16 KiB
TypeScript
// Web e2e scenarios: navigation & panes — the view tabs (Trajectory /
|
||
// Waterfall) and sidebar search, all over ONE rich two-turn seeded fixture
|
||
// rendered purely from the log (the seeded-history pattern: zero model calls
|
||
// in replay, so every surface here is the client fold + host history RPC,
|
||
// not replay binding). The seed is recorded live under the standard
|
||
// discipline: turn 1 produces a bash call plus two parallel reads in one
|
||
// assistant message (tool-call density for the trajectory/waterfall lanes),
|
||
// turn 2 a markdown-rich reply (a second turn so the waterfall has two lanes).
|
||
import { mkdir, readFile, writeFile } 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 { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||
import {
|
||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||
} from './scaffold.ts'
|
||
import { saveFailureShot } from './support.ts'
|
||
|
||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
|
||
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
|
||
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
|
||
const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
|
||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||
const MODE = webSnapshotMode()
|
||
const SEED_ID = 'navigation-panes-web-e2e'
|
||
|
||
// Turn 1 leads with a distinctive word: the session-title fallback takes the
|
||
// first words of the first message, so the sidebar-search scenario has a
|
||
// known-matching query ('navscenario') without depending on a live title call.
|
||
const PROMPT_TURN1 = 'NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.'
|
||
const PROMPT_TURN2 = 'Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop.'
|
||
|
||
describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||
let scaffold: WebScaffold
|
||
let browser: Browser
|
||
let page: Page
|
||
let tripwire: ReturnType<typeof watchConsole>
|
||
|
||
beforeAll(async () => {
|
||
scaffold = await launchWebScaffold({})
|
||
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
|
||
// the read targets must live in that session cwd (pre-creation is safe:
|
||
// create-by-name adopts an existing directory).
|
||
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
|
||
await mkdir(sessionCwd, { recursive: true })
|
||
await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n')
|
||
await writeFile(join(sessionCwd, 'nav-b.md'), '# beta nav\n')
|
||
if (MODE !== 'record') {
|
||
const raw = await readFile(SEED, 'utf8')
|
||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the two drive prompts')
|
||
.toEqual([PROMPT_TURN1, PROMPT_TURN2])
|
||
await seedSession(scaffold, raw, SEED_ID)
|
||
}
|
||
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.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-record'))
|
||
const input = page.locator('textarea').first()
|
||
await input.waitFor({ timeout: 10_000 })
|
||
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
|
||
for (const prompt of [PROMPT_TURN1, PROMPT_TURN2]) {
|
||
const settled = scaffold.whenTurnSettled()
|
||
// Turn 2 types into the same composer once turn 1 unlocks it.
|
||
await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
|
||
await input.fill(prompt)
|
||
await input.press('Enter')
|
||
sessionId = await settled
|
||
}
|
||
await recordFixture(scaffold, sessionId!, SEED)
|
||
// Fixture honesty: the recording must carry the shape the replay
|
||
// scenarios assert on — three calls in turn 1 and two closed turns.
|
||
const recorded = parseSessionLog(await readFile(SEED, 'utf8'))
|
||
expect(recorded.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||
const calls = recorded.filter((e): e is SessionEvent & { data: { name: string } } => e.type === 'tool/call')
|
||
expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
|
||
}, 400_000)
|
||
|
||
it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
|
||
// Expand the collapsed group row, then open the revealed session row.
|
||
const groupRow = page.locator('[role="treeitem"]').first()
|
||
await groupRow.waitFor({ timeout: 15_000 })
|
||
await groupRow.click()
|
||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||
await sessionRow.waitFor({ timeout: 10_000 })
|
||
await sessionRow.click()
|
||
await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
|
||
}, 90_000)
|
||
|
||
it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||
// Runs after the session is open: a cold summary carries no title (the
|
||
// sidebar shows the cwd basename), and the durable title lands with the
|
||
// attach subscription's baseline — which is itself worth pinning: search
|
||
// matches the title the user sees, not a hidden cold field.
|
||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||
await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||
// Negative: a garbage query empties the tree (group rows hide too).
|
||
await search.fill('zzzqx-no-such-session')
|
||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
|
||
// Positive: a title word narrows to the matched session + its group,
|
||
// force-expanded by search mode (case-insensitive client-side filter).
|
||
await search.fill('navscenario')
|
||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||
// Clear restores the unfiltered tree.
|
||
await page.getByRole('button', { name: 'Clear search' }).click()
|
||
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
|
||
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('renders the trajectory tab with turn sections and step cells', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||
// Two sticky turn sections; turn 1's step group summarizes its tool mix
|
||
// (bash + the two parallel reads collapse to 'bash read×2').
|
||
await expect.poll(() => page.getByText('Turn 1', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||
await expect.poll(() => page.getByText('Turn 2', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||
await expect.poll(() => page.getByText('bash read×2', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
|
||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||
.split(SEED_ID).join('{{seededId}}')
|
||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('renders the waterfall tab with span stats and one lane per span', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-waterfall'))
|
||
await page.getByRole('tab', { name: 'Waterfall' }).click()
|
||
// The stats header rides the waterfall body. The span fold counts THREE
|
||
// spans for this two-turn log: only assistant/steering nodes carry a turn
|
||
// number, so the first user message lands in a turn-0 prologue span (a
|
||
// P-I placeholder shape — pinned as-is; real spans are deferred to
|
||
// P-III per the view's deviation ledger). Calls: bash + two reads.
|
||
await expect.poll(() => page.getByText(/3 turns · \d+ steps · 3 tool calls/).count(), { timeout: 15_000 }).toBe(1)
|
||
// One lane per span, tagged by turn number, prologue included.
|
||
for (const tag of ['turn 0', 'turn 1', 'turn 2']) {
|
||
await expect.poll(() => page.getByText(tag, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||
}
|
||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||
.split(SEED_ID).join('{{seededId}}')
|
||
await compareOrRefreshGolden(WATERFALL_EXPECTED, snapshot, MODE)
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('bash and file-path rows leave the details column collapsed', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
|
||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||
const bashRow = page.locator('[data-sample="bash-global"]').first()
|
||
await bashRow.waitFor({ timeout: 15_000 })
|
||
const frame = page.locator('[data-details-collapsed], [class*="frame"]').first()
|
||
expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
|
||
await bashRow.click()
|
||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||
// The card's own controls are outside the summary row and must not open
|
||
// details either — the terminal card is read in place.
|
||
await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
|
||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||
// Read summaries are host-open file links; they also must not open details.
|
||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||
await fileLink.waitFor({ timeout: 10_000 })
|
||
await fileLink.click()
|
||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
|
||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
|
||
await page.getByRole('tab', { name: 'Chat' }).click()
|
||
// The card is resident in the keyed bash row (no expand gesture): the
|
||
// recorded command's own output sits in the message flow, derived from the
|
||
// logged call/result presentations alone.
|
||
const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
|
||
await card.waitFor({ timeout: 15_000 })
|
||
// Real layout, not jsdom's stub (which computes no geometry at all):
|
||
// squeeze the output pane below its content width and the line must keep
|
||
// its single row and overflow sideways instead of folding. Soft-wrapping
|
||
// here is what shredded the column alignment this card exists to hold.
|
||
const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
|
||
const pane = node as HTMLElement
|
||
const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
|
||
if (row === null) throw new Error('output pane has no line')
|
||
const before = row.offsetHeight
|
||
const restore = pane.style.width
|
||
pane.style.width = '8px'
|
||
const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
|
||
pane.style.width = restore
|
||
return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
|
||
})
|
||
expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
|
||
// The run-state dot's color is the whole point of it and is the one thing
|
||
// jsdom cannot report: --dsw-* tokens resolve only against the real theme
|
||
// stylesheet. This command settled cleanly, so the dot must be the green
|
||
// success token — a red one here would read as a failed command.
|
||
const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
|
||
// The token lives on body, so the probe must sit in the same cascade.
|
||
const probe = document.createElement('span')
|
||
probe.style.color = 'var(--dsw-alias-state-success-primary)'
|
||
document.body.appendChild(probe)
|
||
const success = getComputedStyle(probe).color
|
||
probe.remove()
|
||
return {
|
||
state: node.getAttribute('data-state'),
|
||
color: getComputedStyle(node as HTMLElement).color,
|
||
success,
|
||
// One label per card (the state is the call's), so it hangs off the
|
||
// prompt column rather than the row the dot sits in.
|
||
label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
|
||
// The dot precedes the prompt label in document order, which is what
|
||
// puts it to the left of the `$`.
|
||
beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
|
||
=== Node.DOCUMENT_POSITION_FOLLOWING,
|
||
// The dot lives in the card's OWN left padding, so it sits inside the
|
||
// card box yet left of the prompt text. Owning the reservation as padding
|
||
// rather than margin is what keeps a consumer's own margin from
|
||
// cancelling it and letting a container clip the dot — geometry jsdom
|
||
// cannot compute.
|
||
insideCard: (node as HTMLElement).getBoundingClientRect().left
|
||
>= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
|
||
leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
|
||
<= (node.closest('[class*="_promptLine_"]')
|
||
?.querySelector('[class*="_cwd_"]')
|
||
?.getBoundingClientRect().left ?? -Infinity),
|
||
}
|
||
})
|
||
expect(dot.state).toBe('done')
|
||
expect(dot.label).toBe('已完成')
|
||
expect(dot.beforePrompt).toBe(true)
|
||
expect(dot.insideCard).toBe(true)
|
||
expect(dot.leftOfPrompt).toBe(true)
|
||
// Resolved through the theme token, not a literal hex in the component.
|
||
expect(dot.success).toMatch(/^rgb/)
|
||
expect(dot.color).toBe(dot.success)
|
||
// Golden of the card at rest — captured before the copy click, whose
|
||
// confirmation label self-reverts on a timer and would not hold still.
|
||
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
|
||
.split(SEED_ID).join('{{seededId}}')
|
||
await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
|
||
// Copy writes the raw output through the browser's own clipboard, which in
|
||
// a real page is the async Clipboard API rather than the jsdom fallback.
|
||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||
await card.locator('[class*="_copyButton_"]').first().click()
|
||
await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
|
||
.toBe('复制成功')
|
||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
|
||
}, 60_000)
|
||
|
||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||
expect(tripwire.pageErrors).toEqual([])
|
||
expect(tripwire.warnings).toEqual([])
|
||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||
'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'terminal-card.expected.md',
|
||
])
|
||
})
|
||
})
|