Merge origin/master into task/command-feedback-master
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>DeepSeek Harness</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"./dist/*": "./dist/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
@@ -24,6 +27,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
|
||||
3
apps/web/public/favicon.svg
Normal file
3
apps/web/public/favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="50.000000" height="50.000000" viewBox="0 0 50 50" fill="none">
|
||||
<path id="path" d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000" fill-opacity="1.000000" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
153
apps/web/stress-tests/reasoning-chunks.stress.ts
Normal file
153
apps/web/stress-tests/reasoning-chunks.stress.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Opt-in browser stress reproduction for reasoning-stream renderer stalls.
|
||||
* The fixture emits 100,000 individual chunks through the normal async
|
||||
* carrier; the test measures event-loop and scheduled-interaction delay while
|
||||
* the assembled React surface keeps a collapsed Think row live.
|
||||
*/
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFailed } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
|
||||
|
||||
const CHUNK_COUNT = 100_000
|
||||
const CHUNKS_PER_INTERVAL = 128
|
||||
const CHUNK_INTERVAL_MS = 16
|
||||
const MAIN_THREAD_DELAY_BUDGET_MS = 250
|
||||
|
||||
interface ReasoningChunkStormState {
|
||||
sessionId: string
|
||||
chunkCount: number
|
||||
chunksPerInterval: number
|
||||
intervalMs: number
|
||||
emitted: number
|
||||
marker: string
|
||||
emitting: boolean
|
||||
}
|
||||
|
||||
interface StressProbe {
|
||||
intervalId: number
|
||||
intervalMs: number
|
||||
lastTickAt: number
|
||||
maxDelayMs: number
|
||||
samples: number
|
||||
interactionDueAt: number
|
||||
interactionHandledAt: number | null
|
||||
}
|
||||
|
||||
interface StressWindow extends Window {
|
||||
__fxTiming?: {
|
||||
startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
|
||||
reasoningChunkStormState(): ReasoningChunkStormState | null
|
||||
}
|
||||
__reasoningStressProbe?: StressProbe
|
||||
}
|
||||
|
||||
it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
let page: Page | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
|
||||
page = await newEnglishPage(browser)
|
||||
const activePage = page
|
||||
await activePage.addInitScript(() => {
|
||||
localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
|
||||
})
|
||||
const tripwire = watchConsole(activePage)
|
||||
onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
|
||||
await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
||||
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fixture settings deliberately reject writes, so its welcome notice
|
||||
// cannot acknowledge. Hide only that test overlay; the assembled chat
|
||||
// tree beneath it remains mounted and exercises the production renderer.
|
||||
await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
|
||||
await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
|
||||
|
||||
await activePage.evaluate(() => {
|
||||
const intervalMs = 50
|
||||
const now = performance.now()
|
||||
const probe: StressProbe = {
|
||||
intervalId: 0,
|
||||
intervalMs,
|
||||
lastTickAt: now,
|
||||
maxDelayMs: 0,
|
||||
samples: 0,
|
||||
interactionDueAt: now + 1_000,
|
||||
interactionHandledAt: null,
|
||||
}
|
||||
probe.intervalId = window.setInterval(() => {
|
||||
const tickAt = performance.now()
|
||||
probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
|
||||
probe.lastTickAt = tickAt
|
||||
probe.samples++
|
||||
}, intervalMs)
|
||||
document.body.addEventListener('reasoning-stress-interaction', () => {
|
||||
probe.interactionHandledAt = performance.now()
|
||||
}, { once: true })
|
||||
window.setTimeout(() => {
|
||||
document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
|
||||
}, 1_000)
|
||||
;(window as StressWindow).__reasoningStressProbe = probe
|
||||
})
|
||||
|
||||
const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
|
||||
const hooks = (window as StressWindow).__fxTiming
|
||||
if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
|
||||
return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
|
||||
}, {
|
||||
chunkCount: CHUNK_COUNT,
|
||||
chunksPerInterval: CHUNKS_PER_INTERVAL,
|
||||
intervalMs: CHUNK_INTERVAL_MS,
|
||||
})
|
||||
|
||||
const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
|
||||
await liveThink.waitFor({ timeout: 60_000 })
|
||||
await expect.poll(async () => await activePage.evaluate(() => {
|
||||
const hooks = (window as StressWindow).__fxTiming
|
||||
return hooks?.reasoningChunkStormState()?.emitted ?? 0
|
||||
}), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
|
||||
await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
|
||||
|
||||
const report = await activePage.evaluate(() => {
|
||||
const win = window as StressWindow
|
||||
const probe = win.__reasoningStressProbe
|
||||
const state = win.__fxTiming?.reasoningChunkStormState()
|
||||
if (probe === undefined || state === undefined || state === null) {
|
||||
throw new Error('reasoning stress metrics unavailable')
|
||||
}
|
||||
window.clearInterval(probe.intervalId)
|
||||
const interactionDelayMs = probe.interactionHandledAt === null
|
||||
? null
|
||||
: probe.interactionHandledAt - probe.interactionDueAt
|
||||
return {
|
||||
chunkCount: state.chunkCount,
|
||||
chunksPerInterval: state.chunksPerInterval,
|
||||
intervalMs: state.intervalMs,
|
||||
emitted: state.emitted,
|
||||
maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
|
||||
interactionDelayMs,
|
||||
heartbeatSamples: probe.samples,
|
||||
}
|
||||
})
|
||||
process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
|
||||
|
||||
expect(report).toMatchObject({
|
||||
chunkCount: CHUNK_COUNT,
|
||||
chunksPerInterval: CHUNKS_PER_INTERVAL,
|
||||
intervalMs: CHUNK_INTERVAL_MS,
|
||||
emitted: CHUNK_COUNT,
|
||||
})
|
||||
expect(report.heartbeatSamples).toBeGreaterThan(0)
|
||||
const interactionDelayMs = report.interactionDelayMs
|
||||
if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
|
||||
expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
||||
expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
}
|
||||
}, 600_000)
|
||||
80
apps/web/tests/access-confirmation.e2e.ts
Normal file
80
apps/web/tests/access-confirmation.e2e.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Web e2e scenario: every visible permission picker gates Full access behind
|
||||
// the same locale-aware, in-page risk confirmation. Zero model calls: the
|
||||
// scenario boots the shipped Web composition and exercises the real
|
||||
// permission projection, client command path, HTTP RPC, and pushed update.
|
||||
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 {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: Full access confirmation', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// CI uses Playwright's pinned browser. A developer may point this one
|
||||
// scenario at an installed Chromium when the matching browser download
|
||||
// is temporarily unavailable.
|
||||
const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
|
||||
browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
|
||||
// Keep the product default Chinese locale: the golden pins the actual
|
||||
// registered dictionary rather than a test-local translation callback.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('requires acknowledgement before the composer picker can enable Full access', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
|
||||
const access = page.locator('button[aria-label^="访问模式"]').first()
|
||||
await access.waitFor({ timeout: 10_000 })
|
||||
|
||||
expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
|
||||
|
||||
await access.click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const enable = dialog.getByRole('button', { name: '启用 Full access' })
|
||||
expect(await enable.isDisabled()).toBe(true)
|
||||
|
||||
// The modal is in this page's body (not a native/new window) and escapes
|
||||
// the sticky composer's stacking context.
|
||||
expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
|
||||
expect(await enable.isEnabled()).toBe(true)
|
||||
await enable.click()
|
||||
await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('访问模式,当前:Full access')
|
||||
expect(await dialog.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
183
apps/web/tests/approval-composer.e2e.ts
Normal file
183
apps/web/tests/approval-composer.e2e.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// Web e2e scenario: the composer-takeover approval panel under a long
|
||||
// command. The shipped composition confines bash through the sandbox policy
|
||||
// and routes its escalation through the approval seam, so a read-only session
|
||||
// asked to write a file produces a REAL pending approval — the panel renders
|
||||
// in the browser, the test measures its geometry, answers through it, and the
|
||||
// escalated command then runs. Replay is deterministic: the denial, the
|
||||
// escalation retry and its command text arrive from replayed chunks, and the
|
||||
// answer click is the test's own gesture (the same sanctioned reaction to
|
||||
// model content as the question composer: the turn cannot complete without it).
|
||||
//
|
||||
// Geometry is the point of the scenario. The command is unbounded model text,
|
||||
// and before the cap a long one grew the card until the refuse/allow buttons
|
||||
// left the viewport — an approval the user could see and not answer.
|
||||
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'
|
||||
// Empty type import: carries the approval package's session-event merge, so
|
||||
// the decided-outcome assertion below type-checks against the real union.
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/approval-composer', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// The scenario's one golden: the waiting panel. Everything the answered state
|
||||
// proves is asserted directly — see the world-state block at the end.
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// Irreducible payload: the command has to be long enough to pass the card's
|
||||
// height cap, which is the only shape that reproduces an action row pushed off
|
||||
// screen. Unrelated tokens, not a repeated word — a repeated word is what the
|
||||
// model compressed into `printf 'alpha %.0s' {1..400}` while recording, and a
|
||||
// short command proves nothing here. The formula keeps the source small; the
|
||||
// model receives the expanded literal it has to put in the command.
|
||||
const TOKENS = Array.from({ length: 220 }, (_, index) => `tok${((index + 1) * 7919 % 99991).toString(36)}`).join(' ')
|
||||
const PROMPT = `Write a file named notes.txt in the workspace containing exactly this text on one line: ${TOKENS}. Use one bash command with the literal text inline. Then reply with the single word DONE and stop.`
|
||||
|
||||
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
|
||||
const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
|
||||
|
||||
describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
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 newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('caps the long command, answers through the panel, and runs the escalated command', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-approval'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
|
||||
// The composer's own text cap, measured on the live draft scrollport before
|
||||
// the takeover replaces it — the box that carries the cap, while the
|
||||
// textarea inside it is as tall as the whole draft. The panel's scroll
|
||||
// region must stop at the same height (the designer's requirement: one cap
|
||||
// for the composer seat), and measuring it here keeps the assertion free of
|
||||
// the px value itself.
|
||||
await input.fill(CAP_PROBE)
|
||||
const composerCap = await input.evaluate(el => el.closest('[data-input-scroll]')?.clientHeight ?? 0)
|
||||
expect(composerCap).toBeGreaterThan(0)
|
||||
await input.fill('')
|
||||
|
||||
// Read-only: the mode whose denial the model escalates from. Switched
|
||||
// through the shipped access-mode chip, not a test-only seam.
|
||||
await page.locator('[aria-label^="Access mode"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await expect.poll(
|
||||
() => page.locator('[aria-label="Access mode, current: Read Only"]').count(),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(1)
|
||||
|
||||
const settled = scaffold.whenTurnSettled(MODE === 'record' ? 240_000 : 60_000)
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
|
||||
// The panel takes over the input area while the tool blocks. Its presence
|
||||
// is a STABLE waiting state (it stays until answered), so waitFor is
|
||||
// race-free.
|
||||
const panel = page.locator('[data-approval-key]')
|
||||
await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 })
|
||||
const scroll = panel.locator('[data-approval-scroll]')
|
||||
await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0)
|
||||
|
||||
if (MODE !== 'record') {
|
||||
// This golden owns the stable waiting surface; the answered golden below
|
||||
// owns the resulting transcript.
|
||||
const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// The regression this scenario exists for: an uncapped card grew with
|
||||
// the command until the action row left the viewport. Measured at the
|
||||
// lane baseline and at a short viewport, on the live panel.
|
||||
const original = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
for (const height of [1000, 700]) {
|
||||
await page.setViewportSize({ width: 900, height })
|
||||
const geometry = await panel.evaluate((root) => {
|
||||
const region = root.querySelector<HTMLElement>('[data-approval-scroll]')
|
||||
const card = region?.parentElement ?? null
|
||||
// Role/text, not the CSS-module class names: the built client hashes those.
|
||||
const buttons = [...root.querySelectorAll<HTMLElement>('button')]
|
||||
const rows = buttons.map(button => button.getBoundingClientRect())
|
||||
return {
|
||||
buttons: buttons.length,
|
||||
capped: region === null ? 0 : region.clientHeight,
|
||||
// A scrolling region proves the cap is genuinely engaged; without
|
||||
// it every assertion below would hold vacuously.
|
||||
scrolls: region === null ? false : region.scrollHeight > region.clientHeight,
|
||||
cardBottom: card === null ? Number.NaN : card.getBoundingClientRect().bottom,
|
||||
actionsTop: Math.min(...rows.map(rect => rect.top)),
|
||||
actionsBottom: Math.max(...rows.map(rect => rect.bottom)),
|
||||
viewport: window.innerHeight,
|
||||
}
|
||||
})
|
||||
expect(geometry.buttons).toBe(2)
|
||||
expect(geometry.scrolls).toBe(true)
|
||||
// One cap for the seat: the panel's text region stops where the
|
||||
// composer draft does (sub-pixel tolerance for the shared padding).
|
||||
expect(Math.abs(geometry.capped - composerCap)).toBeLessThan(1)
|
||||
// Both buttons stay inside the card AND inside the viewport — the
|
||||
// answerable state the cap exists to guarantee.
|
||||
expect(geometry.actionsTop).toBeGreaterThan(0)
|
||||
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.viewport)
|
||||
expect(geometry.actionsBottom).toBeLessThanOrEqual(geometry.cardBottom)
|
||||
}
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
|
||||
await panel.getByRole('button', { name: 'Allow once' }).click()
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
return
|
||||
}
|
||||
// World state: the granted escalation is what let the command run, and the
|
||||
// panel leaves with the regular composer restored. Asserted on the world
|
||||
// and the DOM rather than through a transcript golden — the denied first
|
||||
// attempt renders the OS's own refusal ("Operation not permitted" on
|
||||
// macOS, "Read-only file system" on Linux), so the answered transcript is
|
||||
// not a platform-neutral golden surface.
|
||||
expect(JSON.stringify(sessionEvents.filter(e => e.type === 'approval/decided').at(-1)))
|
||||
.toContain('allowed-once')
|
||||
const written = await readFile(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'), 'utf8')
|
||||
expect(written).toContain(TOKENS.slice(0, 64))
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(1)
|
||||
expect(await page.locator('[data-approval-key]').count()).toBe(0)
|
||||
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 300_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
82
apps/web/tests/bash-abort-row.e2e.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Web e2e scenario: a cancelled Bash call can settle without terminal-card
|
||||
// material. Borrow the real cancellation fixture and prove the keyed Bash row
|
||||
// still exposes the recorded command and full error without any model call.
|
||||
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 {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/bash-abort-row', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'bash-abort-row-web-e2e'
|
||||
const PROMPT = 'Run two shell commands: wait for cancellation, then write skipped.txt.'
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: cancelled Bash row disclosure', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(FIXTURE, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture)).toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
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 page.locator('[data-sample="bash"]').nth(1).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('expands the aborted row to its command and full error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-bash-abort-row'))
|
||||
const row = page.locator('[data-sample="bash"]').first()
|
||||
const call = row.locator('xpath=..')
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(1)
|
||||
await row.click()
|
||||
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
await call.getByText('IN', { exact: true }).waitFor()
|
||||
await call.getByText('OUT', { exact: true }).waitFor()
|
||||
await call.getByText('Wait until cancellation', { exact: false }).waitFor()
|
||||
await call.getByText('setInterval(() => {}, 1000)', { exact: false }).waitFor()
|
||||
await expect.poll(() => call.getByText('Error: tool call aborted', { exact: true }).count()).toBe(2)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
// The borrowed fixture's UTC date is still the previous day in PDT;
|
||||
// the disclosure golden must not depend on the runner timezone.
|
||||
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
183
apps/web/tests/built-boot.snapshot.ts
Normal file
183
apps/web/tests/built-boot.snapshot.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
// The built-bundle boot smoke: the ONE assembled-jsdom test that loads the
|
||||
// real `packages/client/*/lib/client.js` artifacts through AppWebEntry's
|
||||
// ModuleLoader path (loadBundle) and proves the boot graph
|
||||
// assembles — staged activation across the immediately tier and the inject
|
||||
// layers, per-plugin CSS injection, and a rendered journey reaching chat
|
||||
// content from the keyless FixtureApiClient transport.
|
||||
//
|
||||
// Component behavior remains owned by per-package suites (SlotTestRuntime
|
||||
// benches over src). This smoke additionally pins the resident interaction
|
||||
// fixture's cross-plugin projection because only the built connection/runtime/
|
||||
// workspace graph can prove that transport-to-row path end to end.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// English pinned before boot: role/text locators stay deterministic across
|
||||
// localized component migrations (the newEnglishPage e2e convention).
|
||||
localStorage.setItem('dsh.locale', 'en')
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('boots the built plugin graph and renders a fixture session end to end', async () => {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
loadBundle: async (url) => {
|
||||
const code = bundles.get(url)
|
||||
if (code === undefined) throw new Error(`missing built bundle ${url}`)
|
||||
;(0, eval)(code)
|
||||
},
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
|
||||
// The sidebar renders from the boot graph: every inject layer activated.
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
await within(tree).findByText('4 sessions')
|
||||
|
||||
// The resident fixture has both a question and an approval; composer routing
|
||||
// exposes the question first, and the assembled workspace plugin mirrors that
|
||||
// actionable wait instead of the underlying running state.
|
||||
const waitingTitle = await within(tree).findByText('Fixture 历史会话')
|
||||
const waitingRow = waitingTitle.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (waitingRow === null) throw new Error('fixture Session title must belong to a tree row')
|
||||
expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull()
|
||||
expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
within(waitingRow).getByText('Waiting for answer')
|
||||
|
||||
// Opening a session reaches chat content through the fixture transport.
|
||||
fireEvent.click(waitingTitle)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Resolve the resident approval so the ordinary composer bar (which owns
|
||||
// ContextMeter) resumes without replacing the session shell. This minimal
|
||||
// boot graph intentionally does not mount the separate question UI plugin.
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Allow once' }))
|
||||
|
||||
// The fixture mirrors all three token-meter projections, so the assembled
|
||||
// ContextMeter reaches its composition panel instead of only the occupancy
|
||||
// fallback path.
|
||||
const contextTrigger = await screen.findByRole('button', { name: /of context used/ })
|
||||
fireEvent.click(contextTrigger)
|
||||
const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
|
||||
within(contextPanel).getByText('System prompt')
|
||||
within(contextPanel).getByText('Tools')
|
||||
within(contextPanel).getByText('Messages')
|
||||
|
||||
// The write/edit turns render a real diff card through the assembled graph
|
||||
// (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the
|
||||
// fixture's raw text. The card is collapsed by default, so expand each edit/
|
||||
// write row first. The write turn's `hello fixture\n` proves the terminator
|
||||
// rule end to end: a trailing newline terminates its line, so the footer reads
|
||||
// `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS
|
||||
// ::before, so it is absent from textContent — assert on the line body and the
|
||||
// footer.
|
||||
const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')]
|
||||
expect(mutationRows.length).toBeGreaterThan(0)
|
||||
for (const row of mutationRows) {
|
||||
const toggle = row.querySelector('[data-expandable]')
|
||||
if (toggle !== null) act(() => { fireEvent.click(toggle) })
|
||||
}
|
||||
const diffCards = [...document.querySelectorAll('[data-diff]')]
|
||||
expect(diffCards.length).toBeGreaterThan(0)
|
||||
const footers = diffCards.map(card => card.textContent ?? '')
|
||||
expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
|
||||
|
||||
// The web render intent reaches the assembled boot graph: the fixture's
|
||||
// web_search / web_fetch turns render their keyed WebRow cards, proving the
|
||||
// registration, wire projection, and card rendering survive the real bundle
|
||||
// path (not just the per-package src benches). WebRow composes ToolRow, so the
|
||||
// card is collapsed behind the row; the keyed row is pinned by its `data-tool`
|
||||
// (ToolRow sets it from the wire tool name).
|
||||
const webSearchRow = await waitFor(() => {
|
||||
const row = document.querySelector('[data-tool="web_search"]')
|
||||
expect(row).not.toBeNull()
|
||||
expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull()
|
||||
return row!
|
||||
}, { timeout: 10_000 })
|
||||
// Expand the web_search row to prove its WebBlock card renders end to end.
|
||||
const webToggle = webSearchRow.querySelector('[data-expandable]')
|
||||
if (webToggle !== null) act(() => { fireEvent.click(webToggle) })
|
||||
await waitFor(() => {
|
||||
expect(webSearchRow.querySelector('[data-web]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
|
||||
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
|
||||
.map(style => style.getAttribute('data-plugin'))
|
||||
for (const plugin of ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-sidebar', '@deepseek-ai/dsh-client-ui-conversation']) {
|
||||
expect(styleOwners).toContain(plugin)
|
||||
}
|
||||
})
|
||||
328
apps/web/tests/chat-continuous-conversation.e2e.ts
Normal file
328
apps/web/tests/chat-continuous-conversation.e2e.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
// Web e2e contract for a conversation grown through the real composer rather
|
||||
// than pre-seeded history. Twelve deterministic replay turns exercise repeated
|
||||
// send/settle/render cycles, including two real bash executions and one long,
|
||||
// multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
|
||||
// mounted-row cardinality is treated as a correctness contract.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const TURN_COUNT = 12
|
||||
const TOOL_TURNS = [4, 9] as const
|
||||
const STREAM_PACE_MS = 10
|
||||
|
||||
interface TurnSpec {
|
||||
readonly index: number
|
||||
readonly prompt: string
|
||||
readonly userMarker: string
|
||||
readonly firstMarker: string
|
||||
readonly doneMarker: string
|
||||
readonly deltas: readonly string[]
|
||||
readonly callId?: ReturnType<typeof CallId>
|
||||
readonly toolResultMarker?: string
|
||||
}
|
||||
|
||||
function suffix(index: number): string {
|
||||
return String(index).padStart(3, '0')
|
||||
}
|
||||
|
||||
function longFinalPrompt(userMarker: string): string {
|
||||
return [
|
||||
`${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
|
||||
...Array.from(
|
||||
{ length: 36 },
|
||||
(_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
|
||||
),
|
||||
'Return one continuous response and finish with the requested completion marker.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function turnSpec(index: number): TurnSpec {
|
||||
const id = suffix(index)
|
||||
const userMarker = `CONTINUOUS_CHAT_USER_${id}`
|
||||
const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
|
||||
const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
|
||||
const deltaCount = index === TURN_COUNT ? 36 : 8
|
||||
const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
|
||||
if (chunkIndex === 0) return `${firstMarker} `
|
||||
if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
|
||||
return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
|
||||
})
|
||||
if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
|
||||
return {
|
||||
index,
|
||||
prompt: index === TURN_COUNT
|
||||
? longFinalPrompt(userMarker)
|
||||
: `${userMarker} Continue this same conversation through turn ${String(index)}.`,
|
||||
userMarker,
|
||||
firstMarker,
|
||||
doneMarker,
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
|
||||
userMarker,
|
||||
firstMarker,
|
||||
doneMarker,
|
||||
deltas,
|
||||
callId: CallId(`continuous-chat-tool-${id}`),
|
||||
toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
|
||||
}
|
||||
}
|
||||
|
||||
function textStream(spec: TurnSpec): StreamChunk[] {
|
||||
const response = spec.deltas.join('')
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
||||
{
|
||||
type: 'usage',
|
||||
usage: {
|
||||
inputTokens: Math.ceil(spec.prompt.length / 4),
|
||||
outputTokens: Math.ceil(response.length / 4),
|
||||
},
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function toolStream(spec: TurnSpec): StreamChunk[] {
|
||||
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
|
||||
throw new Error(`turn ${String(spec.index)} has no tool identity`)
|
||||
}
|
||||
const args = JSON.stringify({
|
||||
command: `printf '${spec.toolResultMarker}\\n'`,
|
||||
description: spec.toolResultMarker,
|
||||
})
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id: spec.callId,
|
||||
name: 'bash',
|
||||
argumentsDelta: args,
|
||||
},
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
|
||||
return specs.flatMap((spec): ReplayEntry[] => {
|
||||
const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
|
||||
return spec.callId === undefined
|
||||
? [final]
|
||||
: [{ kind: 'chunks', chunks: toolStream(spec) }, final]
|
||||
})
|
||||
}
|
||||
|
||||
function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
|
||||
return event.data.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
|
||||
return event.data.message.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
|
||||
return event.data.message.content[0].content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('web e2e: continuous conversation grown through the composer', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let scaffold: WebScaffold
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const consoleWarnings: string[] = []
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
replayContextWindow: 10_000_000,
|
||||
paceMs: STREAM_PACE_MS,
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
|
||||
sessionEvents.push(event)
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'warning') consoleWarnings.push(message.text())
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.waitFor({ timeout: 15_000 })
|
||||
let sessionId: SessionId | undefined
|
||||
|
||||
for (const spec of specs) {
|
||||
const eventStart = sessionEvents.length
|
||||
expect(await composer.inputValue()).toBe('')
|
||||
expect(await composer.isEnabled()).toBe(true)
|
||||
await composer.fill(spec.prompt)
|
||||
expect(await composer.inputValue()).toBe(spec.prompt)
|
||||
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
const echoedUser = sessionEvents.slice(eventStart).find(
|
||||
(event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'user'
|
||||
&& userText(event).includes(spec.userMarker)
|
||||
),
|
||||
)
|
||||
if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
|
||||
await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await userRow.textContent()).toContain(spec.userMarker)
|
||||
await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
const settledSessionId = await settled
|
||||
if (sessionId === undefined) {
|
||||
sessionId = settledSessionId
|
||||
} else {
|
||||
expect(settledSessionId).toBe(sessionId)
|
||||
}
|
||||
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
|
||||
await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
const turnEvents = sessionEvents.slice(eventStart)
|
||||
const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
|
||||
event.type === 'turn/start'
|
||||
))
|
||||
const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
|
||||
event.type === 'user/message' && event.data.source.kind === 'user'
|
||||
))
|
||||
const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
|
||||
event.type === 'assistant/message'
|
||||
))
|
||||
const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
|
||||
const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
|
||||
event.type === 'turn/end'
|
||||
))
|
||||
const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
|
||||
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
expect(turnStarts[0]?.data.turn).toBe(spec.index)
|
||||
expect(users).toHaveLength(1)
|
||||
expect(users[0]?.seq).toBe(echoedUser.seq)
|
||||
expect(userText(users[0]!)).toBe(spec.prompt)
|
||||
expect(finalAssistants).toHaveLength(1)
|
||||
expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
|
||||
expect(turnEnds).toHaveLength(1)
|
||||
expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
|
||||
expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
|
||||
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
|
||||
await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await assistantRow.textContent()).toContain(spec.doneMarker)
|
||||
|
||||
const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
|
||||
const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
|
||||
if (spec.callId === undefined || spec.toolResultMarker === undefined) {
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(results).toHaveLength(0)
|
||||
continue
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(results).toHaveLength(1)
|
||||
expect(calls[0]?.data).toMatchObject({
|
||||
turn: spec.index,
|
||||
callId: spec.callId,
|
||||
name: 'bash',
|
||||
})
|
||||
expect(results[0]?.data.turn).toBe(spec.index)
|
||||
expect(results[0]?.data.message.source.callId).toBe(spec.callId)
|
||||
expect(results[0]?.data.message.content[0].isError).toBe(false)
|
||||
expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
|
||||
|
||||
const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
|
||||
await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
|
||||
const disclosure = toolRow.locator('[data-sample="bash"]')
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
// The collapsed summary deliberately repeats the result marker; the
|
||||
// last exact match is the expanded terminal output owned by this call.
|
||||
await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
|
||||
}
|
||||
|
||||
if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
|
||||
expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
|
||||
event.type === 'turn/end' && event.data.reason.kind === 'completed'
|
||||
))).toHaveLength(TURN_COUNT)
|
||||
expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
|
||||
expect(sessionEvents.filter(event => (
|
||||
event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
|
||||
)).length).toBeGreaterThan(30)
|
||||
expect(consoleWarnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 180_000)
|
||||
})
|
||||
273
apps/web/tests/chat-long-interactions.e2e.ts
Normal file
273
apps/web/tests/chat-long-interactions.e2e.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
// Long-history Chat behavior contract for a future virtualized renderer. Wheel
|
||||
// input only navigates to the semantic target; assertions pin content identity
|
||||
// and interaction routing rather than scroll geometry or mounted row counts.
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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 { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const SESSION_ID = 'chat-long-interactions-e2e'
|
||||
const FIXTURE_TURNS = 88
|
||||
const TOOL_TURN = FIXTURE_TURNS
|
||||
const BRANCH_TURN = 80
|
||||
const TARGET_CALL_1 = 'chat-scroll-088-1'
|
||||
const TARGET_CALL_2 = 'chat-scroll-088-2'
|
||||
const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
|
||||
const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
|
||||
const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'INTERACTION',
|
||||
title: 'CHAT_INTERACTION long semantic identity session',
|
||||
turns: FIXTURE_TURNS,
|
||||
})
|
||||
|
||||
function continuationChunks(): StreamChunk[] {
|
||||
const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
|
||||
{ type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
||||
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
|
||||
return { kind: 'chunks', chunks }
|
||||
}
|
||||
|
||||
function carries(event: SessionEvent, marker: string): boolean {
|
||||
return JSON.stringify(event).includes(marker)
|
||||
}
|
||||
|
||||
function textContent(content: readonly unknown[]): string {
|
||||
return content.flatMap((block) => {
|
||||
if (typeof block !== 'object' || block === null) return []
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
return candidate.type === 'text' && typeof candidate.text === 'string'
|
||||
? [candidate.text]
|
||||
: []
|
||||
}).join('')
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { resolve() })
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
async function openSeed(page: Page): Promise<void> {
|
||||
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await results.first().waitFor({ timeout: 60_000 })
|
||||
const resultCount = await results.count()
|
||||
if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
|
||||
await results.click()
|
||||
await results.click()
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
|
||||
.last().waitFor({ timeout: 30_000 })
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
const scrollport = page.locator('[data-conversation-scroll]')
|
||||
const box = await scrollport.boundingBox()
|
||||
if (box === null) throw new Error('conversation scrollport has no layout box')
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (await page.locator(selector).count() > 0) return
|
||||
await page.mouse.wheel(0, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
throw new Error(`semantic Chat target did not mount: ${selector}`)
|
||||
}
|
||||
|
||||
function requiredEvent<T extends SessionEvent['type']>(
|
||||
events: readonly SessionEvent[],
|
||||
type: T,
|
||||
marker: string,
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
|
||||
candidate.type === type && carries(candidate, marker)
|
||||
))
|
||||
if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
|
||||
return event
|
||||
}
|
||||
|
||||
describe('web e2e: long Chat interaction contract', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let scaffold: WebScaffold
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
|
||||
await writeFile(replayOverride, JSON.stringify(replay))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
replayContextWindow: 10_000_000,
|
||||
paceMs: 18,
|
||||
})
|
||||
await seedSession(scaffold, FIXTURE.log, SESSION_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser, 900)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSeed(page)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
|
||||
const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
|
||||
if (source === undefined) throw new Error('seeded long-history agent is not attached')
|
||||
|
||||
const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
|
||||
const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
|
||||
const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
|
||||
const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
|
||||
const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
|
||||
const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
|
||||
const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
|
||||
const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
|
||||
const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
|
||||
const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
|
||||
const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
|
||||
event.type === 'turn/end' && event.data.turn === BRANCH_TURN
|
||||
))
|
||||
if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
|
||||
const expectedUserText = textContent(branchUserEvent.data.content)
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
|
||||
const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
|
||||
const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
|
||||
const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
|
||||
const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
|
||||
|
||||
await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await call1.count()).toBe(1)
|
||||
expect(await call2.count()).toBe(1)
|
||||
expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
|
||||
expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
|
||||
expect(await toolUserRow.textContent()).toContain(toolUserMarker)
|
||||
expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
|
||||
expect(await call1.textContent()).toContain(toolMarker1)
|
||||
expect(await call2.textContent()).toContain(toolMarker2)
|
||||
|
||||
const expectedOrder = [
|
||||
`node:${String(toolUserEvent.seq)}`,
|
||||
`call:${TARGET_CALL_1}`,
|
||||
`call:${TARGET_CALL_2}`,
|
||||
`node:${String(toolAssistantEvent.seq)}`,
|
||||
]
|
||||
const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
|
||||
rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
|
||||
.filter((key): key is string => key !== undefined && keys.includes(key))
|
||||
), expectedOrder)
|
||||
expect(actualOrder).toEqual(expectedOrder)
|
||||
const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
||||
element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
|
||||
))))
|
||||
expect(groupKeys[0]).not.toBeNull()
|
||||
expect(groupKeys[1]).toBe(groupKeys[0])
|
||||
|
||||
const summary1 = call1.locator('[data-sample="bash"]')
|
||||
const summary2 = call2.locator('[data-sample="bash"]')
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await summary2.getAttribute('aria-expanded')).toBe('false')
|
||||
await summary2.focus()
|
||||
await summary2.press('Enter')
|
||||
await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
expect(await summary1.getAttribute('aria-expanded')).toBe('false')
|
||||
await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
|
||||
const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
|
||||
const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
|
||||
expect(await userRow.textContent()).toContain(branchUserMarker)
|
||||
expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
await userRow.hover()
|
||||
await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
|
||||
.toBe(expectedUserText)
|
||||
|
||||
await assistantRow.hover()
|
||||
await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
|
||||
{ timeout: 15_000 },
|
||||
).toBeDefined()
|
||||
const child = scaffold.ctx.agents.list()
|
||||
.find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
|
||||
if (child === undefined) throw new Error('message branch did not create a child session')
|
||||
expect(child.session.header.seedLength).toBe(boundary.seq + 1)
|
||||
expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
|
||||
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
|
||||
expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
|
||||
|
||||
const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByRole('button').last()
|
||||
await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
|
||||
.toBe(`${FIXTURE.title} (1)`)
|
||||
await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
const composer = page.locator('textarea:enabled').last()
|
||||
await composer.fill(CONTINUE_PROMPT)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await settled).toBe(child.session.id)
|
||||
await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
expect(await composer.inputValue()).toBe('')
|
||||
expect(await composer.isEnabled()).toBe(true)
|
||||
expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
|
||||
expect(child.session.events.filter(event => (
|
||||
event.type === 'user/message' && carries(event, CONTINUE_PROMPT)
|
||||
))).toHaveLength(1)
|
||||
const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
|
||||
event.type === 'turn/end'
|
||||
))
|
||||
expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 180_000)
|
||||
})
|
||||
686
apps/web/tests/chat-scroll-contract.e2e.ts
Normal file
686
apps/web/tests/chat-scroll-contract.e2e.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
// Browser geometry contracts for a long Chat transcript. These scenarios are
|
||||
// deliberately virtualizer-neutral: they assert semantic-row position,
|
||||
// bottom ownership, interaction state, and the real outer scroll host rather
|
||||
// than DOM cardinality or implementation-specific spacer markup.
|
||||
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { createChatScrollFixture, type ChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
|
||||
const TOOL_SESSION_ID = 'chat-scroll-tool-e2e'
|
||||
const RESTORE_SESSION_A_ID = 'chat-scroll-restore-a-e2e'
|
||||
const RESTORE_SESSION_B_ID = 'chat-scroll-restore-b-e2e'
|
||||
const REPLAY_CONTEXT_WINDOW = 10_000_000
|
||||
const STREAM_PACE_MS = 24
|
||||
const GEOMETRY_TOLERANCE = 2
|
||||
const LIVE_TEXT_PROMPT = 'CHAT_SCROLL_LIVE_USER Continue this long conversation while I inspect older history.'
|
||||
const LIVE_TEXT_FIRST = 'CHAT_SCROLL_LIVE_FIRST'
|
||||
const LIVE_TEXT_DONE = 'CHAT_SCROLL_LIVE_DONE'
|
||||
const LIVE_TOOL_PROMPT = 'CHAT_SCROLL_TOOL_USER Run the requested diagnostic and then summarize it.'
|
||||
const LIVE_TOOL_CALL_ID = CallId('chat-scroll-live-tool-call')
|
||||
const LIVE_TOOL_RESULT = 'CHAT_SCROLL_LIVE_TOOL_RESULT'
|
||||
const LIVE_TOOL_FIRST = 'CHAT_SCROLL_TOOL_STREAM_FIRST'
|
||||
const LIVE_TOOL_DONE = 'CHAT_SCROLL_TOOL_STREAM_DONE'
|
||||
const TOOL_READY_FILE = '.chat-scroll-tool-ready'
|
||||
const TOOL_RELEASE_FILE = '.chat-scroll-tool-release'
|
||||
|
||||
const HISTORY_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'HISTORY',
|
||||
title: 'CHAT_SCROLL_HISTORY long paging session',
|
||||
})
|
||||
const TOOL_FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TOOL',
|
||||
title: 'CHAT_SCROLL_TOOL live tool session',
|
||||
})
|
||||
const RESTORE_FIXTURE_A = createChatScrollFixture({
|
||||
markerPrefix: 'RESTORE_A',
|
||||
title: 'CHAT_SCROLL_RESTORE_A long session',
|
||||
})
|
||||
const RESTORE_FIXTURE_B = createChatScrollFixture({
|
||||
markerPrefix: 'RESTORE_B',
|
||||
title: 'CHAT_SCROLL_RESTORE_B comparison session',
|
||||
turns: 32,
|
||||
})
|
||||
|
||||
interface ScrollGeometry {
|
||||
readonly distanceFromBottom: number
|
||||
readonly scrollTop: number
|
||||
}
|
||||
|
||||
interface FlowAnchor {
|
||||
readonly key: string
|
||||
readonly top: number
|
||||
}
|
||||
|
||||
interface ScrollWorld {
|
||||
readonly events: SessionEvent[]
|
||||
readonly page: Page
|
||||
readonly replayDir?: string
|
||||
readonly scaffold: WebScaffold
|
||||
readonly tripwire: ReturnType<typeof watchConsole>
|
||||
}
|
||||
|
||||
interface ScrollWorldOptions {
|
||||
readonly failureShot: string
|
||||
readonly replay?: ReplayOverrideDoc
|
||||
readonly seeds: readonly { fixture: ChatScrollFixture; id: string }[]
|
||||
}
|
||||
|
||||
function textStream(first: string, done: string, deltaCount: number): StreamChunk[] {
|
||||
const deltas = Array.from({ length: deltaCount }, (_, index) => {
|
||||
if (index === 0) return `${first} `
|
||||
if (index === deltaCount - 1) return `${done}.`
|
||||
return `stream-chunk-${String(index).padStart(3, '0')} ${'incremental response '.repeat(3)}`
|
||||
})
|
||||
const response = deltas.join('')
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text: response } },
|
||||
{
|
||||
type: 'usage',
|
||||
usage: { inputTokens: 512, outputTokens: Math.ceil(response.length / 4) },
|
||||
},
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function toolStream(): StreamChunk[] {
|
||||
const command = [
|
||||
`: > ${TOOL_READY_FILE}`,
|
||||
`while [ ! -f ${TOOL_RELEASE_FILE} ]; do sleep 0.02; done`,
|
||||
'line=1',
|
||||
`while [ "$line" -le 64 ]; do printf '${LIVE_TOOL_RESULT} line %02d\\n' "$line"; line=$((line + 1)); done`,
|
||||
].join('; ')
|
||||
const args = JSON.stringify({ command, description: LIVE_TOOL_RESULT })
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{
|
||||
type: 'tool-call-delta',
|
||||
index: 0,
|
||||
id: LIVE_TOOL_CALL_ID,
|
||||
name: 'bash',
|
||||
argumentsDelta: args,
|
||||
},
|
||||
{
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'tool-call', id: LIVE_TOOL_CALL_ID, name: 'bash', arguments: args },
|
||||
},
|
||||
{ type: 'usage', usage: { inputTokens: 256, outputTokens: 48 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
function replayEntry(chunks: StreamChunk[]): ReplayEntry {
|
||||
return { kind: 'chunks', chunks }
|
||||
}
|
||||
|
||||
async function launchScrollWorld(options: ScrollWorldOptions): Promise<ScrollWorld> {
|
||||
let replayDir: string | undefined
|
||||
let scaffold: WebScaffold | undefined
|
||||
let page: Page | undefined
|
||||
try {
|
||||
if (options.replay !== undefined) {
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-scroll-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(options.replay))
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: join(replayDir, 'override-only.jsonl'),
|
||||
replayOverride,
|
||||
paceMs: STREAM_PACE_MS,
|
||||
replayContextWindow: REPLAY_CONTEXT_WINDOW,
|
||||
})
|
||||
} else {
|
||||
scaffold = await launchWebScaffold({})
|
||||
}
|
||||
for (const seed of options.seeds) await seedSession(scaffold, seed.fixture.log, seed.id)
|
||||
const events: SessionEvent[] = []
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
page = await newEnglishPage(browser, 900)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Session-list bootstrap can replace the controlled search state. Wait
|
||||
// for the seeded baseline before openSeed starts the lazy content query.
|
||||
await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
return {
|
||||
events,
|
||||
page,
|
||||
scaffold,
|
||||
tripwire,
|
||||
...(replayDir === undefined ? {} : { replayDir }),
|
||||
}
|
||||
} catch (error) {
|
||||
const failures: unknown[] = [error]
|
||||
if (page !== undefined) await page.context().close().catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
if (scaffold !== undefined) await scaffold.close().catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
if (replayDir !== undefined) {
|
||||
await rm(replayDir, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
}
|
||||
if (failures.length === 1) throw error
|
||||
throw new AggregateError(failures, 'chat-scroll browser world setup failed and cleanup was incomplete')
|
||||
}
|
||||
}
|
||||
|
||||
async function closeScrollWorld(world: ScrollWorld): Promise<void> {
|
||||
const failures: unknown[] = []
|
||||
// newEnglishPage/browser.newPage owns an isolated context. Close the whole
|
||||
// context so its SSE connection and cache cannot leak into the next world
|
||||
// in this file's shared Chromium process.
|
||||
await world.page.context().close().catch((error: unknown) => failures.push(error))
|
||||
await world.scaffold.close().catch((error: unknown) => failures.push(error))
|
||||
if (world.replayDir !== undefined) {
|
||||
await rm(world.replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'chat-scroll browser world cleanup failed')
|
||||
}
|
||||
|
||||
async function withScrollWorld(
|
||||
options: ScrollWorldOptions,
|
||||
run: (world: ScrollWorld) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const world = await launchScrollWorld(options)
|
||||
let runFailure: unknown
|
||||
try {
|
||||
await run(world)
|
||||
} catch (error) {
|
||||
runFailure = error
|
||||
try {
|
||||
await saveFailureShot(world.page, options.failureShot)
|
||||
} catch {
|
||||
// Best-effort evidence must never prevent cleanup of the owned world.
|
||||
}
|
||||
}
|
||||
let cleanupFailure: unknown
|
||||
try {
|
||||
await closeScrollWorld(world)
|
||||
} catch (error) {
|
||||
cleanupFailure = error
|
||||
}
|
||||
if (runFailure !== undefined && cleanupFailure !== undefined) {
|
||||
throw new AggregateError([runFailure, cleanupFailure], 'chat-scroll scenario and cleanup both failed')
|
||||
}
|
||||
if (runFailure !== undefined) throw runFailure
|
||||
if (cleanupFailure !== undefined) throw cleanupFailure
|
||||
}
|
||||
|
||||
async function nextPaint(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
await document.fonts.ready
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => { resolve() })
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
function scrollGeometry(page: Page): Promise<ScrollGeometry> {
|
||||
return page.locator('[data-conversation-scroll]').evaluate(host => ({
|
||||
distanceFromBottom: host.scrollHeight - host.clientHeight - host.scrollTop,
|
||||
scrollTop: host.scrollTop,
|
||||
}))
|
||||
}
|
||||
|
||||
async function conversationTurns(page: Page): Promise<number> {
|
||||
const stats = page.getByText(/\d+ turns · \d+ steps/, { exact: true }).last()
|
||||
await stats.waitFor({ timeout: 15_000 })
|
||||
const value = await stats.textContent()
|
||||
const match = value?.match(/^(\d+) turns · \d+ steps$/)
|
||||
if (match?.[1] === undefined) throw new Error(`unexpected conversation stats ${JSON.stringify(value)}`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
async function openSeed(page: Page, fixture: ChatScrollFixture, tailMarker?: string): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
// Cold summaries initially show the temporary workspace basename, so the
|
||||
// persisted first-message marker is the stable user-facing identity. The
|
||||
// query itself triggers lazy content-index reconciliation; no transient
|
||||
// empty-state paint is used as a barrier.
|
||||
await search.fill(fixture.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
||||
await results.click()
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
|
||||
if (tailMarker !== undefined) {
|
||||
await page.getByText(tailMarker, { exact: false }).last().waitFor({ timeout: 30_000 })
|
||||
}
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
const box = await page.locator('[data-conversation-scroll]').boundingBox()
|
||||
if (box === null) throw new Error('conversation scrollport has no layout box')
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
|
||||
await page.mouse.wheel(0, deltaY)
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelToHistoryStart(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
await wheelTranscript(page, -2_400)
|
||||
}
|
||||
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
if (await page.locator(selector).count() > 0) return
|
||||
await wheelTranscript(page, deltaY)
|
||||
}
|
||||
throw new Error(`selector did not mount during transcript wheel: ${selector}`)
|
||||
}
|
||||
|
||||
async function wheelUntilVisible(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
const target = page.locator(selector)
|
||||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||||
if (await target.count() > 0 && await target.evaluate((row) => {
|
||||
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (host === null) return false
|
||||
const viewport = host.getBoundingClientRect()
|
||||
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
|
||||
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < visibleBottom
|
||||
})) return
|
||||
await wheelTranscript(page, deltaY)
|
||||
}
|
||||
throw new Error(`selector did not become visible during transcript wheel: ${selector}`)
|
||||
}
|
||||
|
||||
function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
|
||||
return page.locator('[data-conversation-scroll]').evaluate((host) => {
|
||||
const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
||||
const viewport = host.getBoundingClientRect()
|
||||
const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
|
||||
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
|
||||
const visible = rows.filter((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect()
|
||||
return rect.bottom > viewport.top && rect.top < visibleBottom
|
||||
})
|
||||
const row = visible[0]
|
||||
if (row?.dataset.chatAnchorKey === undefined) {
|
||||
throw new Error(`no visible settled Chat row: ${JSON.stringify({
|
||||
composerTop: visibleBottom,
|
||||
host: { bottom: viewport.bottom, top: viewport.top },
|
||||
rows: rows.slice(0, 4).map(candidate => ({
|
||||
callId: candidate.dataset.chatCallId,
|
||||
key: candidate.dataset.chatAnchorKey,
|
||||
rect: {
|
||||
bottom: candidate.getBoundingClientRect().bottom,
|
||||
top: candidate.getBoundingClientRect().top,
|
||||
},
|
||||
})),
|
||||
totalRows: rows.length,
|
||||
})}`)
|
||||
}
|
||||
return {
|
||||
key: row.dataset.chatAnchorKey,
|
||||
top: row.getBoundingClientRect().top - viewport.top,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function flowTop(page: Page, key: string): Promise<number> {
|
||||
return page.locator('[data-chat-anchor-key]').evaluateAll((rows, anchorKey) => {
|
||||
const row = rows.find(candidate => (candidate as HTMLElement).dataset.chatAnchorKey === anchorKey)
|
||||
if (!(row instanceof HTMLElement)) throw new Error(`stable Chat anchor ${anchorKey} is not mounted`)
|
||||
const host = row.closest('[data-conversation-scroll]')
|
||||
if (!(host instanceof HTMLElement)) throw new Error('flow row has no conversation scrollport')
|
||||
return row.getBoundingClientRect().top - host.getBoundingClientRect().top
|
||||
}, key)
|
||||
}
|
||||
|
||||
async function expectSameFlowTop(page: Page, anchor: FlowAnchor): Promise<void> {
|
||||
await expect.poll(async () => Math.abs((await flowTop(page, anchor.key)) - anchor.top), {
|
||||
timeout: 10_000,
|
||||
message: `flow row ${anchor.key} moved relative to the transcript viewport`,
|
||||
}).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
|
||||
}
|
||||
|
||||
async function expectBottom(page: Page): Promise<void> {
|
||||
await expect.poll(async () => Math.abs((await scrollGeometry(page)).distanceFromBottom), {
|
||||
timeout: 10_000,
|
||||
}).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function expectMarkerAboveComposer(page: Page, marker: string): Promise<void> {
|
||||
const geometry = await page.getByText(marker, { exact: false }).last().evaluate((node) => {
|
||||
const row = node.closest('[data-chat-flow-key], [data-streaming]')
|
||||
const composer = node.closest('[data-conversation-scroll]')?.querySelector('[data-composer-seat]')
|
||||
if (!(row instanceof HTMLElement) || !(composer instanceof HTMLElement)) {
|
||||
throw new Error('latest marker or composer geometry is unavailable')
|
||||
}
|
||||
return {
|
||||
composerTop: composer.getBoundingClientRect().top,
|
||||
rowBottom: row.getBoundingClientRect().bottom,
|
||||
}
|
||||
})
|
||||
expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
|
||||
}
|
||||
|
||||
async function loadEarlierWithAnchor(page: Page): Promise<void> {
|
||||
await wheelToHistoryStart(page)
|
||||
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
|
||||
await older.waitFor({ timeout: 10_000 })
|
||||
const anchor = await visibleFlowAnchor(page)
|
||||
const before = await conversationTurns(page)
|
||||
await older.click()
|
||||
await expect.poll(() => conversationTurns(page), { timeout: 30_000 }).toBeGreaterThan(before)
|
||||
await nextPaint(page)
|
||||
await expectSameFlowTop(page, anchor)
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function eventCarries(event: SessionEvent, marker: string): boolean {
|
||||
return JSON.stringify(event).includes(marker)
|
||||
}
|
||||
|
||||
function assertClean(world: ScrollWorld): void {
|
||||
expect(world.tripwire.pageErrors).toEqual([])
|
||||
expect(world.tripwire.warnings).toEqual([])
|
||||
}
|
||||
|
||||
let browser: Browser
|
||||
|
||||
describe('web e2e: long Chat scroll contract', () => {
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-history-stream',
|
||||
replay: [replayEntry(textStream(LIVE_TEXT_FIRST, LIVE_TEXT_DONE, 120))],
|
||||
seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
await openSeed(
|
||||
world.page,
|
||||
HISTORY_FIXTURE,
|
||||
HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns),
|
||||
)
|
||||
await expectBottom(world.page)
|
||||
|
||||
let releaseHistory = (): void => {}
|
||||
let held = false
|
||||
let releaseGate: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
|
||||
releaseHistory = () => { releaseGate?.() }
|
||||
await world.page.route('**/api/session.history', async (route) => {
|
||||
const request = route.request().postDataJSON() as {
|
||||
method?: string
|
||||
payload?: { beforeSeq?: number }
|
||||
}
|
||||
if (!held && request.method === 'session.history' && request.payload?.beforeSeq !== undefined) {
|
||||
held = true
|
||||
await gate
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
await composer.fill(LIVE_TEXT_PROMPT)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await wheelToHistoryStart(world.page)
|
||||
const beforeTurns = await conversationTurns(world.page)
|
||||
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
|
||||
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await wheelTranscript(world.page, 420)
|
||||
const readerAnchor = await visibleFlowAnchor(world.page)
|
||||
const chunksAfterAnchor = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThan(chunksAfterAnchor + 5)
|
||||
|
||||
releaseHistory()
|
||||
await expect.poll(() => conversationTurns(world.page), { timeout: 30_000 }).toBeGreaterThan(beforeTurns)
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, readerAnchor)
|
||||
} finally {
|
||||
releaseHistory()
|
||||
}
|
||||
|
||||
await settled
|
||||
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await world.page.getByText(LIVE_TEXT_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await world.page.unroute('**/api/session.history')
|
||||
|
||||
let additionalPages = 0
|
||||
while (additionalPages < 8) {
|
||||
await wheelToHistoryStart(world.page)
|
||||
if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
additionalPages += 1
|
||||
}
|
||||
expect(additionalPages).toBeGreaterThan(0)
|
||||
expect(await conversationTurns(world.page)).toBe(HISTORY_FIXTURE.turns + 1)
|
||||
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps streaming ownership and tool disclosure state across a long scroll-away cycle', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-live-tool',
|
||||
replay: [
|
||||
replayEntry(toolStream()),
|
||||
replayEntry(textStream(LIVE_TOOL_FIRST, LIVE_TOOL_DONE, 84)),
|
||||
],
|
||||
seeds: [{ fixture: TOOL_FIXTURE, id: TOOL_SESSION_ID }],
|
||||
}, async (world) => {
|
||||
const readyPath = join(world.scaffold.workspaceCwd, TOOL_READY_FILE)
|
||||
const releasePath = join(world.scaffold.workspaceCwd, TOOL_RELEASE_FILE)
|
||||
await openSeed(world.page, TOOL_FIXTURE, TOOL_FIXTURE.markers.assistant(TOOL_FIXTURE.turns))
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
let released = false
|
||||
try {
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
await composer.fill(LIVE_TOOL_PROMPT)
|
||||
await world.page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
await expect.poll(() => fileExists(readyPath), { timeout: 15_000 }).toBe(true)
|
||||
const liveRow = world.page.locator(`[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`)
|
||||
await liveRow.waitFor({ timeout: 15_000 })
|
||||
expect(await liveRow.getAttribute('data-state')).toBe('running')
|
||||
await expectBottom(world.page)
|
||||
|
||||
await wheelTranscript(world.page, -1_200)
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })
|
||||
const awayAnchor = await visibleFlowAnchor(world.page)
|
||||
const chunksBeforeRelease = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await writeFile(releasePath, 'release\n')
|
||||
released = true
|
||||
await expect.poll(
|
||||
() => world.events.some(event => event.type === 'tool/result'),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => world.events.some(event => eventCarries(event, LIVE_TOOL_FIRST)),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(true)
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksBeforeRelease + 5)
|
||||
await expectSameFlowTop(world.page, awayAnchor)
|
||||
|
||||
const chunksAtRepin = world.events.filter(event => event.type === 'assistant/chunk').length
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
await expect.poll(
|
||||
() => world.events.filter(event => event.type === 'assistant/chunk').length,
|
||||
{ timeout: 15_000 },
|
||||
).toBeGreaterThan(chunksAtRepin + 5)
|
||||
await expectBottom(world.page)
|
||||
} finally {
|
||||
if (!released) await writeFile(releasePath, 'release\n').catch(() => {})
|
||||
}
|
||||
|
||||
await settled
|
||||
await expect.poll(() => world.page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
|
||||
await world.page.getByText(LIVE_TOOL_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await expectBottom(world.page)
|
||||
await expectMarkerAboveComposer(world.page, LIVE_TOOL_DONE)
|
||||
|
||||
const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
|
||||
const liveRow = world.page.locator(liveRowSelector)
|
||||
await wheelUntilVisible(world.page, liveRowSelector, -300)
|
||||
const toolAnchor = await liveRow.evaluate((row) => {
|
||||
const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
|
||||
const host = row.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (flow?.dataset.chatAnchorKey === undefined || host === null) {
|
||||
throw new Error('live tool row has no settled flow identity')
|
||||
}
|
||||
return {
|
||||
key: flow.dataset.chatAnchorKey,
|
||||
top: flow.getBoundingClientRect().top - host.getBoundingClientRect().top,
|
||||
}
|
||||
})
|
||||
await liveRow.click()
|
||||
await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
await expectSameFlowTop(world.page, toolAnchor)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
await wheelUntilMounted(world.page, liveRowSelector, -1_100)
|
||||
const restoredRow = world.page.locator(liveRowSelector)
|
||||
await restoredRow.waitFor({ timeout: 10_000 })
|
||||
expect(await restoredRow.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await world.page.getByText(LIVE_TOOL_RESULT, { exact: false }).count()).toBeGreaterThan(0)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('restores tab/session position and keeps composer resizing on the correct scroll owner', async () => {
|
||||
await withScrollWorld({
|
||||
failureShot: 'web-e2e-chat-scroll-restore-composer',
|
||||
seeds: [
|
||||
{ fixture: RESTORE_FIXTURE_A, id: RESTORE_SESSION_A_ID },
|
||||
{ fixture: RESTORE_FIXTURE_B, id: RESTORE_SESSION_B_ID },
|
||||
],
|
||||
}, async (world) => {
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A,
|
||||
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
||||
)
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await wheelTranscript(world.page, 1_300)
|
||||
const sessionAnchor = await visibleFlowAnchor(world.page)
|
||||
|
||||
await world.page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
|
||||
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
await world.page.setViewportSize({ width: 700, height: 900 })
|
||||
// The narrow breakpoint auto-collapses the sidebar. Re-open it because
|
||||
// this scenario switches sessions while pinning the narrow Chat scroll owner.
|
||||
await world.page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
|
||||
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, sessionAnchor)
|
||||
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_B,
|
||||
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
||||
)
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A,
|
||||
)
|
||||
await expectSameFlowTop(world.page, sessionAnchor)
|
||||
|
||||
const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true })
|
||||
await backToBottom.evaluate((button) => {
|
||||
if (!(button instanceof HTMLElement)) throw new Error('Back-to-bottom control is not an HTML element')
|
||||
button.click()
|
||||
const trajectory = [...document.querySelectorAll<HTMLElement>('[role="tab"]')]
|
||||
.find(tab => tab.textContent?.trim() === 'Trajectory')
|
||||
if (!(trajectory instanceof HTMLElement)) {
|
||||
throw new Error('Trajectory tab is unavailable during pinned remount')
|
||||
}
|
||||
trajectory.click()
|
||||
})
|
||||
await world.page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
await world.page.getByRole('tab', { name: 'Chat', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_B,
|
||||
RESTORE_FIXTURE_B.markers.assistant(RESTORE_FIXTURE_B.turns),
|
||||
)
|
||||
await openSeed(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A,
|
||||
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
||||
)
|
||||
await expectBottom(world.page)
|
||||
const composer = world.page.locator('textarea:enabled').last()
|
||||
const longDraft = Array.from(
|
||||
{ length: 18 },
|
||||
(_, index) => `composer resize line ${String(index + 1).padStart(2, '0')}`,
|
||||
).join('\n')
|
||||
await composer.fill(longDraft)
|
||||
await nextPaint(world.page)
|
||||
await expectBottom(world.page)
|
||||
await expectMarkerAboveComposer(
|
||||
world.page,
|
||||
RESTORE_FIXTURE_A.markers.assistant(RESTORE_FIXTURE_A.turns),
|
||||
)
|
||||
|
||||
await composer.fill('short draft')
|
||||
await nextPaint(world.page)
|
||||
await wheelTranscript(world.page, -900)
|
||||
const resizeAnchor = await visibleFlowAnchor(world.page)
|
||||
await composer.fill(longDraft)
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, resizeAnchor)
|
||||
await composer.fill('short draft')
|
||||
await nextPaint(world.page)
|
||||
await expectSameFlowTop(world.page, resizeAnchor)
|
||||
|
||||
const beforeChain = await scrollGeometry(world.page)
|
||||
await composer.hover()
|
||||
await world.page.mouse.wheel(0, -320)
|
||||
await expect.poll(async () => (await scrollGeometry(world.page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeLessThan(beforeChain.scrollTop)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
})
|
||||
234
apps/web/tests/chat-scroll-fixture.ts
Normal file
234
apps/web/tests/chat-scroll-fixture.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// Synthetic long-chat history for browser behavior contracts. The fixture is
|
||||
// generated through Session so pagination exercises the same event shapes as
|
||||
// persisted conversations, while unique markers identify semantic rows
|
||||
// without depending on CSS-module names or virtualizer DOM positions.
|
||||
import {
|
||||
CallId,
|
||||
createAssistantMessage,
|
||||
createToolResultMessage,
|
||||
createUserMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
// Carries the session/title event declaration into this fixture builder.
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Options for one deterministic long-chat fixture. */
|
||||
export interface ChatScrollFixtureOptions {
|
||||
/** Marker namespace, used when two sessions share one browser world. */
|
||||
readonly markerPrefix: string
|
||||
/** Searchable title projected into the sidebar. */
|
||||
readonly title: string
|
||||
/** Number of closed turns to generate. */
|
||||
readonly turns?: number
|
||||
}
|
||||
|
||||
/** Semantic marker helpers returned with a generated fixture. */
|
||||
interface ChatScrollMarkers {
|
||||
/** Marker painted in the human message for a turn. */
|
||||
user(turn: number): string
|
||||
/** Marker painted in the final assistant message for a turn. */
|
||||
assistant(turn: number): string
|
||||
/** Marker painted in one seeded bash call and result. */
|
||||
tool(turn: number, index: number): string
|
||||
}
|
||||
|
||||
/** Generated JSONL plus the stable facts browser scenarios assert. */
|
||||
export interface ChatScrollFixture {
|
||||
readonly log: string
|
||||
readonly markers: ChatScrollMarkers
|
||||
readonly title: string
|
||||
readonly turns: number
|
||||
}
|
||||
|
||||
const DEFAULT_TURNS = 88
|
||||
const TOOL_INTERVAL = 8
|
||||
const CODE_INTERVAL = 11
|
||||
|
||||
function text(value: string): { type: 'text'; text: string }[] {
|
||||
return [{ type: 'text', text: value }]
|
||||
}
|
||||
|
||||
function suffix(turn: number): string {
|
||||
return String(turn).padStart(3, '0')
|
||||
}
|
||||
|
||||
function markerHelpers(prefix: string): ChatScrollMarkers {
|
||||
return {
|
||||
user: turn => `CHAT_SCROLL_${prefix}_USER_${suffix(turn)}`,
|
||||
assistant: turn => `CHAT_SCROLL_${prefix}_ASSISTANT_${suffix(turn)}`,
|
||||
tool: (turn, index) => `CHAT_SCROLL_${prefix}_TOOL_${suffix(turn)}_${String(index)}`,
|
||||
}
|
||||
}
|
||||
|
||||
function appendRequestHeader(session: Session, turn: number, step: number): void {
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
system: `Synthetic chat-scroll request for turn ${String(turn)}, step ${String(step)}.`,
|
||||
},
|
||||
reason: turn === 1 && step === 1 ? 'initial' : 'change',
|
||||
})
|
||||
}
|
||||
|
||||
function appendAssistant(session: Session, turn: number, step: number, body: string): void {
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
message: createAssistantMessage({
|
||||
content: text(body),
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: {
|
||||
inputTokens: 2_000 + turn * 7,
|
||||
outputTokens: 180 + step * 20,
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
function codeBlock(turn: number): string {
|
||||
if (turn % CODE_INTERVAL !== 0) return ''
|
||||
const lines = Array.from(
|
||||
{ length: 30 },
|
||||
(_, index) => `const scroll_case_${suffix(turn)}_${String(index).padStart(2, '0')} = ${String(turn + index)}`,
|
||||
)
|
||||
return `\n\n\`\`\`ts\n${lines.join('\n')}\n\`\`\``
|
||||
}
|
||||
|
||||
function appendToolStep(
|
||||
session: Session,
|
||||
markers: ChatScrollMarkers,
|
||||
turn: number,
|
||||
): void {
|
||||
const calls = [1, 2].map((index) => {
|
||||
const marker = markers.tool(turn, index)
|
||||
const callId = CallId(`chat-scroll-${suffix(turn)}-${String(index)}`)
|
||||
const args = JSON.stringify({
|
||||
command: `printf '${marker}\\n'`,
|
||||
description: marker,
|
||||
})
|
||||
return { args, callId, marker }
|
||||
})
|
||||
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createAssistantMessage({
|
||||
content: [
|
||||
{ type: 'reasoning', text: `Inspecting two scroll fixtures for turn ${String(turn)}.` },
|
||||
...calls.map(call => ({
|
||||
type: 'tool-call' as const,
|
||||
id: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})),
|
||||
],
|
||||
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
usage: { inputTokens: 2_000 + turn * 7, outputTokens: 240, reasoningTokens: 30 },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
for (const call of calls) {
|
||||
const source = session.append('tool/call', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId: call.callId,
|
||||
name: 'bash',
|
||||
arguments: call.args,
|
||||
})
|
||||
session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: call.callId,
|
||||
content: text(Array.from(
|
||||
{ length: 12 },
|
||||
(_, line) => `${call.marker} output line ${String(line + 1).padStart(2, '0')}`,
|
||||
).join('\n')),
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
|
||||
}
|
||||
}
|
||||
|
||||
function fixtureLog(session: Session): string {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: Date.now() - 60_000,
|
||||
cwd: '{{cwd}}',
|
||||
delegationDepth: 0,
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a multi-page conversation with prose, fenced code, and paired bash
|
||||
* calls/results. Every turn is closed, so cold resume cannot repair or mutate
|
||||
* the seed before the browser observes it.
|
||||
* @param options - Fixture identity and optional turn count.
|
||||
* @returns Canonical JSONL and semantic marker helpers.
|
||||
*/
|
||||
export function createChatScrollFixture(options: ChatScrollFixtureOptions): ChatScrollFixture {
|
||||
const turns = options.turns ?? DEFAULT_TURNS
|
||||
const markers = markerHelpers(options.markerPrefix)
|
||||
const session = Session.create(SessionId(`chat-scroll-${options.markerPrefix.toLowerCase()}-template`))
|
||||
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: text(
|
||||
`${markers.user(turn)} Review the long-running conversation state for turn ${String(turn)}. `
|
||||
+ 'Keep the visible message stable while history, tools, and new output change around it.',
|
||||
),
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
if (turn === 1) {
|
||||
session.append('session/title', {
|
||||
title: options.title,
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
}
|
||||
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
appendRequestHeader(session, turn, 1)
|
||||
if (turn % TOOL_INTERVAL === 0) {
|
||||
appendToolStep(session, markers, turn)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('step/start', { turn, step: 2 })
|
||||
appendRequestHeader(session, turn, 2)
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
2,
|
||||
`${markers.assistant(turn)} Both tool results are accounted for. `
|
||||
+ `This settled response keeps turn ${String(turn)} identifiable after paging.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 2 })
|
||||
} else {
|
||||
appendAssistant(
|
||||
session,
|
||||
turn,
|
||||
1,
|
||||
`${markers.assistant(turn)} The conversation remains readable after several paragraphs.\n\n`
|
||||
+ `Turn ${String(turn)} deliberately carries enough prose to wrap at narrower viewport widths. `
|
||||
+ 'The semantic marker stays near the start so geometry probes can find the same rendered row.\n\n'
|
||||
+ `The closing paragraph makes this a realistic assistant response rather than a one-line list item.${codeBlock(turn)}`,
|
||||
)
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
}
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
return { log: fixtureLog(session), markers, title: options.title, turns }
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Code Mode fixture snapshot over the BUILT client graph (the workspace-flow
|
||||
// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
|
||||
// Opens the fixture history session and pins the run_code turn's rendering:
|
||||
// the code-variant parent row titled by the model-authored description, its
|
||||
// three always-visible nested sub-rows (bash through the sample registration,
|
||||
// read through GenericToolCard, the failing read wearing the error state),
|
||||
// the expanded program body, inert bash / file-link sub-row gestures,
|
||||
// details-panel resolution of a sub-callId, and the Trajectory tab's sub-call
|
||||
// cells and timing overview.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against the populated fixture branch. */
|
||||
function boot(): void {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Open the fixture history session (the alpha log carrying the run_code turn) and scroll to its tail. */
|
||||
async function openFixtureSession(): Promise<void> {
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
const group = within(tree).getByText('4 sessions').closest<HTMLElement>('[role="treeitem"]')
|
||||
if (group === null) throw new Error('fixture Workspace group missing')
|
||||
if (group.getAttribute('aria-expanded') === 'false') {
|
||||
fireEvent.click(within(group).getByText('fixture'))
|
||||
await waitFor(() => {
|
||||
expect(within(tree).getByText('4 sessions').closest('[role="treeitem"]')?.getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
}
|
||||
const session = await within(tree).findByText('Fixture 历史会话')
|
||||
fireEvent.click(session)
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-variant="code"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
it('renders the fixture run_code turn: code parent row, nested sub-rows, error state', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
const codeRoot = document.querySelector('[data-variant="code"]')
|
||||
if (codeRoot === null) throw new Error('code-variant row missing')
|
||||
const nest = codeRoot.closest('[class*="callRow"]')?.querySelector('[data-subcalls]')
|
||||
if (nest === undefined || nest === null) throw new Error('sub-call nest missing under the code row')
|
||||
|
||||
expect({
|
||||
parentRow: visibleText(codeRoot),
|
||||
// The three sub-rows in dispatch order: bash rides the sample plugin's
|
||||
// keyed registration (the same one a native top-level bash row uses),
|
||||
// both reads ride GenericToolCard.
|
||||
bashSample: nest.querySelector('[data-sample="bash-global"]') !== null,
|
||||
subRows: [...nest.querySelectorAll(':scope > *')].map(visibleText),
|
||||
errorSubRow: nest.querySelector('[data-state="error"]') !== null,
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bashSample": true,
|
||||
"errorSubRow": true,
|
||||
"parentRow": "CodeRead the notes files and summarize",
|
||||
"subRows": [
|
||||
"BashList notes",
|
||||
"Readnotes/demo.txt",
|
||||
"Readnotes/missing.txt",
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('expands the code row into the program body; sub-row clicks do not open details', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
// Expand: the leading control reveals the program (shiki-tokenized: the
|
||||
// text splits into styled spans inside one <pre class="shiki"> tree).
|
||||
const codeRoot = document.querySelector('[data-variant="code"]')
|
||||
if (codeRoot === null) throw new Error('code-variant row missing')
|
||||
const toggle = codeRoot.querySelector('button[aria-expanded]')
|
||||
if (toggle === null) throw new Error('code row expand control missing')
|
||||
fireEvent.click(toggle)
|
||||
await waitFor(() => {
|
||||
// Scope to THIS row: the markdown fixture turn also renders shiki pres.
|
||||
const pre = codeRoot.querySelector('pre.shiki')
|
||||
if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
|
||||
throw new Error('highlighted program body missing under the code row')
|
||||
}
|
||||
})
|
||||
|
||||
// Tool rows no longer drive the details panel: bash is inert, file paths
|
||||
// are host-open links (fixture openPath is a no-op success).
|
||||
const nest = document.querySelector('[data-subcalls]')
|
||||
if (nest === null) throw new Error('sub-call nest missing')
|
||||
const bashRow = nest.querySelector('[data-sample="bash-global"]')
|
||||
if (bashRow === null) throw new Error('bash sample sub-row missing')
|
||||
const fileLink = nest.querySelector('button')
|
||||
if (fileLink === null) throw new Error('file-path summary link missing on a read sub-row')
|
||||
const frame = document.querySelector('[data-details-collapsed]')
|
||||
if (frame === null) throw new Error('app frame missing')
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
fireEvent.click(bashRow)
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
fireEvent.click(fileLink)
|
||||
expect(frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
expect({
|
||||
fileLink: visibleText(fileLink),
|
||||
detailsCollapsed: frame.getAttribute('data-details-collapsed'),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"detailsCollapsed": "true",
|
||||
"fileLink": "notes/demo.txt",
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('trajectory surfaces run_code sub-calls in the ledger and timing overview', async () => {
|
||||
boot()
|
||||
await openFixtureSession()
|
||||
|
||||
// Switch to the trajectory tab (same slot ring the chat view registers in).
|
||||
fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' }))
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const subCells = [...document.querySelectorAll('[data-kind="subtool"]')]
|
||||
expect({
|
||||
// Three Subtool cells nested under the run_code Tool cell in dispatch
|
||||
// order, each paired with its result preview.
|
||||
subCells: subCells.map(cell => visibleText(cell)),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"subCells": [
|
||||
"SUBTOOLbash{"command":"ls notes","description":"List notes"}→demo.txt new-demo.txt",
|
||||
"SUBTOOLread{"path":"notes/demo.txt"}→hello fixture",
|
||||
"SUBTOOLread{"path":"notes/missing.txt"}→error",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
const timelineSubCalls = [...document.querySelectorAll('[data-timeline-span="subtool"]')]
|
||||
expect({
|
||||
count: timelineSubCalls.length,
|
||||
measured: timelineSubCalls.map(span => span.getAttribute('title')?.endsWith(' · 800 ms')),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"count": 3,
|
||||
"measured": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
],
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
|
||||
@@ -44,12 +44,12 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -112,20 +112,20 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
|
||||
// the bash sub-call landed in the bash sample registration.
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
await nest.waitFor({ timeout: 10_000 })
|
||||
expect(await nest.locator('[data-sample="bash-global"]').count()).toBeGreaterThanOrEqual(1)
|
||||
expect(await nest.locator('[data-sample="bash"]').count()).toBeGreaterThanOrEqual(1)
|
||||
// The failing read sub-call wears the same error state a native failed
|
||||
// row wears (the recorded program tolerates a read of missing.txt).
|
||||
expect(await nest.locator('[data-state="error"]').count()).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel open', async () => {
|
||||
it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details'))
|
||||
const nest = page.locator('[data-subcalls]').first()
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
await nest.locator('[data-sample="bash-global"]').first().click()
|
||||
// Tool rows do not drive layout geometry; the Session's default panel stays open.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
await nest.locator('[data-sample="bash"]').first().click()
|
||||
// Tool rows do not drive layout geometry; the Session's default panel stays closed.
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
|
||||
|
||||
1432
apps/web/tests/complex-history.perf.ts
Normal file
1432
apps/web/tests/complex-history.perf.ts
Normal file
File diff suppressed because it is too large
Load Diff
476
apps/web/tests/composer-draft-scroll.e2e.ts
Normal file
476
apps/web/tests/composer-draft-scroll.e2e.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
// Web e2e scenario: a composer draft longer than the 14-line cap scrolls its
|
||||
// GLYPHS AND ITS CARET AS ONE.
|
||||
//
|
||||
// The composer paints its text in two stacked layers (see
|
||||
// packages/client/ui-conversation/src/client/skeleton/InputBar.module.css): the
|
||||
// `<textarea>` carries the value, the selection and the caret but renders its
|
||||
// own glyphs `color: transparent`, and every visible character is painted by the
|
||||
// `[data-input-backdrop]` div underneath it, which also carries the claim-token
|
||||
// highlight, the chips and the ghost hint.
|
||||
//
|
||||
// Two layers can only stay together by moving together. They now do: both sit
|
||||
// inside `[data-input-scroll]`, the composer's single scrolling box, and are as
|
||||
// tall as the whole draft — so one offset, applied by the browser, moves the
|
||||
// caret and the words in the same frame. Scrolling the textarea and assigning
|
||||
// its offset to the backdrop looks equivalent and is not: a wheel gesture is
|
||||
// composited off the main thread, so the assignment lands frames late and the
|
||||
// caret visibly flies ahead of the text it belongs to.
|
||||
//
|
||||
// That failure is what the same-task measurement below pins. Every metric here
|
||||
// is read through the caret's own coordinate frame — where the textarea puts
|
||||
// line n — against where the backdrop paints line n, because that difference is
|
||||
// the defect a user sees, and it is the one number a mirror between two boxes
|
||||
// cannot hold at zero.
|
||||
//
|
||||
// Only a real engine can show any of this. Scrolling is layout: jsdom reports
|
||||
// `scrollHeight === clientHeight` for every element and never scrolls one, so
|
||||
// the unit spec in packages/client/ui-conversation/tests/input-bar.spec.tsx can
|
||||
// only assert that one scrollport contains both layers.
|
||||
//
|
||||
// Zero model calls: a fresh workspace's blank session already carries a live
|
||||
// composer, and the scenario only types into it. A stray stream would fail loud
|
||||
// with NO_ADAPTER.
|
||||
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 {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-draft-scroll', import.meta.url))
|
||||
/**
|
||||
* Committed golden of the composer's two-layer scroll geometry. The change
|
||||
* alters no accessible name, so the aria goldens the other scenarios commit are
|
||||
* byte-identical with and without it; this records the relations instead, which
|
||||
* makes a shift in the cap or in the layer coupling a reviewable diff rather
|
||||
* than an assertion someone has to reconstruct.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Marks the first and last line so a Range can find them in the backdrop's text. */
|
||||
const FIRST_MARKER = 'FIRST-LINE-MARKER'
|
||||
const LAST_MARKER = 'LAST-LINE-MARKER'
|
||||
/** Comfortably past the 14-line cap, so the draft overflows however the lines wrap. */
|
||||
const DRAFT_LINES = 40
|
||||
const DRAFT = Array.from({ length: DRAFT_LINES }, (_unused, index) => {
|
||||
if (index === 0) return FIRST_MARKER
|
||||
if (index === DRAFT_LINES - 1) return LAST_MARKER
|
||||
return `draft line ${String(index + 1).padStart(2, '0')}`
|
||||
}).join('\n')
|
||||
|
||||
/**
|
||||
* A draft ending in a newline: the shape where the two layers reserve their
|
||||
* final line box on different terms. A textarea keeps one for the caret after a
|
||||
* final newline; `white-space: pre-wrap` collapses a text node's trailing
|
||||
* newline and generates none. The hidden auto-grow mirror carries the newline
|
||||
* and so decides the height for both, which is why the backdrop needs no
|
||||
* padding of its own — but only a draft of this shape can show it.
|
||||
*/
|
||||
const DRAFT_TRAILING_NEWLINE = `${DRAFT}\n`
|
||||
|
||||
/** The composer's text layers as the browser lays them out. */
|
||||
interface ComposerMetrics {
|
||||
/** True when the draft is taller than the capped box — the situation under test. */
|
||||
overflows: boolean
|
||||
/** Visible height of the scrollport's content box: the cap in pixels. */
|
||||
clientHeight: number
|
||||
/** Whole lines that fit in the visible box, at the composer's own line-height. */
|
||||
visibleLines: number
|
||||
/** The composer's one scroll offset, which the caret and the glyphs both follow. */
|
||||
scrollTop: number
|
||||
/** Furthest that offset can go. */
|
||||
scrollMax: number
|
||||
/**
|
||||
* Scrollable overflow the textarea holds on its own — 0, or a second offset
|
||||
* exists that nothing keeps equal to this one.
|
||||
*/
|
||||
inputScrollable: number
|
||||
/**
|
||||
* Distance between where the caret sits for a draft line and where the
|
||||
* backdrop paints that line, in pixels. A fixed value (the difference between
|
||||
* a line box's top and its glyph box's) is alignment; a value that CHANGES
|
||||
* with the scroll offset is the defect — the words trailing the caret.
|
||||
*/
|
||||
caretGlyphGap: number
|
||||
/**
|
||||
* How much that gap moves when the offset changes inside a single task: 0
|
||||
* here, because one box carries both layers. Assigning one box's offset to
|
||||
* another cannot be 0 — a scroll event is dispatched after the task that
|
||||
* moved the box, so between the two there is a frame with the caret at the
|
||||
* new offset and the glyphs at the old one.
|
||||
*/
|
||||
gapShiftOnScroll: number
|
||||
/**
|
||||
* Top of the LAST draft line relative to the visible box's top, in pixels: at
|
||||
* most `clientHeight` when that line is on screen.
|
||||
*/
|
||||
lastLineOffset: number
|
||||
/** Top of the FIRST draft line relative to the visible box's top: negative once it has scrolled out. */
|
||||
firstLineOffset: number
|
||||
/** Content width the textarea wraps at. */
|
||||
inputWrapWidth: number
|
||||
/** Content width the backdrop wraps at — equal, or the layers break lines in different places. */
|
||||
backdropWrapWidth: number
|
||||
/** Content width the hidden auto-grow mirror wraps at — it decides the box's height. */
|
||||
mirrorWrapWidth: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the composer's layers in the page, in the caret's coordinate frame.
|
||||
* @param page - the page under test.
|
||||
* @returns the offset, the caret-to-glyph gap, and where the draft's first and last lines sit.
|
||||
*/
|
||||
function measureComposer(page: Page): Promise<ComposerMetrics> {
|
||||
return page.evaluate(({ first, last }) => {
|
||||
const input = document.querySelector<HTMLTextAreaElement>('textarea:enabled')
|
||||
if (input === null) throw new Error('no live composer textarea in the DOM')
|
||||
const scroll = input.closest<HTMLElement>('[data-input-scroll]')
|
||||
if (scroll === null) throw new Error('the composer textarea is not inside a draft scrollport')
|
||||
const backdrop = input.parentElement?.querySelector<HTMLElement>('[data-input-backdrop]')
|
||||
if (backdrop === undefined || backdrop === null) throw new Error('no decoration backdrop beside the composer textarea')
|
||||
// The hidden auto-grow mirror: the textarea's next sibling, and the layer
|
||||
// that decides the box's height, so its wrap width matters as much as the
|
||||
// two that carry glyphs.
|
||||
const mirror = input.nextElementSibling
|
||||
if (!(mirror instanceof HTMLElement)) throw new Error('no auto-grow mirror after the composer textarea')
|
||||
// The draft carries no chips or claim token, so the decoration walk emits it
|
||||
// as a single text node, which is what the Range below needs.
|
||||
const text = backdrop.firstChild
|
||||
if (!(text instanceof Text)) throw new Error('backdrop does not open with a plain text node')
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(input).lineHeight)
|
||||
/** Where the backdrop paints the line holding `marker`, in viewport coordinates. */
|
||||
const glyphTop = (marker: string): number => {
|
||||
const at = text.data.indexOf(marker)
|
||||
if (at < 0) throw new Error(`marker ${marker} missing from the backdrop text`)
|
||||
const range = document.createRange()
|
||||
range.setStart(text, at)
|
||||
range.setEnd(text, at + marker.length)
|
||||
return range.getBoundingClientRect().top
|
||||
}
|
||||
const paddingTop = Number.parseFloat(getComputedStyle(input).paddingTop)
|
||||
// Where the CARET sits on the draft's first line: the textarea lays its own
|
||||
// (transparent) glyphs out from its border box, shifted by any offset it
|
||||
// holds itself. Reading the caret's frame this way rather than the
|
||||
// scrollport's is what makes the gap the user-visible quantity — it stays
|
||||
// honest if the textarea ever starts scrolling on its own again.
|
||||
const gap = (): number =>
|
||||
Math.round(input.getBoundingClientRect().top + paddingTop - input.scrollTop - glyphTop(first))
|
||||
// The same-task probe: move the offset and re-read the gap before the task
|
||||
// ends, which is before any scroll event could have run a listener.
|
||||
const before = gap()
|
||||
const restore = scroll.scrollTop
|
||||
scroll.scrollTop = restore === 0 ? 120 : 0
|
||||
const gapShiftOnScroll = Math.abs(gap() - before)
|
||||
scroll.scrollTop = restore
|
||||
const box = scroll.getBoundingClientRect()
|
||||
return {
|
||||
inputWrapWidth: input.clientWidth,
|
||||
backdropWrapWidth: backdrop.clientWidth,
|
||||
mirrorWrapWidth: mirror.clientWidth,
|
||||
overflows: scroll.scrollHeight > scroll.clientHeight,
|
||||
clientHeight: scroll.clientHeight,
|
||||
visibleLines: Math.floor(scroll.clientHeight / lineHeight),
|
||||
scrollTop: scroll.scrollTop,
|
||||
scrollMax: scroll.scrollHeight - scroll.clientHeight,
|
||||
inputScrollable: input.scrollHeight - input.clientHeight,
|
||||
caretGlyphGap: before,
|
||||
gapShiftOnScroll,
|
||||
lastLineOffset: glyphTop(last) - box.top,
|
||||
firstLineOffset: glyphTop(first) - box.top,
|
||||
}
|
||||
}, { first: FIRST_MARKER, last: LAST_MARKER })
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body.
|
||||
*
|
||||
* Absolute glyph coordinates are deliberately absent: they depend on font
|
||||
* metrics and would make the fixture fail on a machine that measures text
|
||||
* differently — a golden that needs re-recording per platform documents the
|
||||
* platform, not the change. What is recorded is the cap, the caret-to-glyph
|
||||
* relation, and which lines are on screen, each a comparison that survives any
|
||||
* layout keeping the coupling.
|
||||
* @param top - metrics with the draft scrolled to its start.
|
||||
* @param bottom - metrics with the draft scrolled to its end.
|
||||
* @param trailingNewline - metrics with the trailing-newline draft scrolled to its end.
|
||||
* @param pasted - metrics right after a long block was pasted at the draft's end.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(
|
||||
top: ComposerMetrics, bottom: ComposerMetrics, trailingNewline: ComposerMetrics, pasted: ComposerMetrics,
|
||||
): string {
|
||||
return [
|
||||
'# Composer draft scrolling (14-line cap, two text layers, one scrollport)',
|
||||
'',
|
||||
'## At the start of the draft',
|
||||
'',
|
||||
`- draft overflows the capped box: ${String(top.overflows)}`,
|
||||
`- visible lines: ${String(top.visibleLines)}`,
|
||||
`- the textarea holds no scroll offset of its own: ${String(top.inputScrollable === 0)}`,
|
||||
`- all three layers wrap at one width: ${String(
|
||||
top.inputWrapWidth === top.backdropWrapWidth && top.backdropWrapWidth === top.mirrorWrapWidth,
|
||||
)}`,
|
||||
`- scroll offset: ${String(top.scrollTop)}px`,
|
||||
`- caret and glyphs stay level when the offset changes: ${String(top.gapShiftOnScroll === 0)}`,
|
||||
`- first draft line is on screen: ${String(top.firstLineOffset >= 0 && top.firstLineOffset < top.clientHeight)}`,
|
||||
`- last draft line is on screen: ${String(top.lastLineOffset >= 0 && top.lastLineOffset < top.clientHeight)}`,
|
||||
'',
|
||||
'## Scrolled to the end of the draft',
|
||||
'',
|
||||
`- offset moved: ${String(bottom.scrollTop > 0)}`,
|
||||
`- caret sits on its own glyphs: ${String(bottom.caretGlyphGap === top.caretGlyphGap)}`,
|
||||
`- caret and glyphs stay level when the offset changes: ${String(bottom.gapShiftOnScroll === 0)}`,
|
||||
`- first draft line has scrolled out above: ${String(bottom.firstLineOffset < 0)}`,
|
||||
`- last draft line is on screen: ${String(bottom.lastLineOffset >= 0 && bottom.lastLineOffset < bottom.clientHeight)}`,
|
||||
'',
|
||||
'## Draft ending in a newline, scrolled to the end',
|
||||
'',
|
||||
`- caret sits on its own glyphs: ${String(trailingNewline.caretGlyphGap === top.caretGlyphGap)}`,
|
||||
`- the draft's own last line is on screen: ${String(
|
||||
trailingNewline.lastLineOffset >= 0 && trailingNewline.lastLineOffset < trailingNewline.clientHeight,
|
||||
)}`,
|
||||
'',
|
||||
'## Right after pasting a long block at the end',
|
||||
'',
|
||||
`- the composer scrolled to the caret it left: ${String(pasted.scrollTop > 0)}`,
|
||||
`- caret and glyphs stay level when the offset changes: ${String(pasted.gapShiftOnScroll === 0)}`,
|
||||
`- the pasted block's last line is on screen: ${String(
|
||||
pasted.lastLineOffset >= 0 && pasted.lastLineOffset < pasted.clientHeight,
|
||||
)}`,
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
describe('web e2e: composer draft scrolling', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll')
|
||||
await page.locator('textarea:enabled').first().fill(DRAFT)
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('caps the draft box and keeps both text layers at the start', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-top'))
|
||||
// Vacuity guard: without an overflowing draft there is nothing to scroll and
|
||||
// every assertion below holds trivially.
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
// Typing the draft left the caret — and the box — at its end, so reach the
|
||||
// start by the same gesture a user would, and leave it there for the wheel
|
||||
// case below.
|
||||
await page.locator('textarea:enabled').first().hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The cap is the composer seat's `--dsh-composer-text-max-height` (336px =
|
||||
// 14 x 24px lines). The count, not the pixels: it is the figma constant and
|
||||
// survives a device-pixel-ratio change.
|
||||
expect(metrics.visibleLines).toBe(14)
|
||||
// One scrolling box: the textarea is as tall as the draft, so there is no
|
||||
// second offset for the caret to hold while the glyphs hold another.
|
||||
expect(metrics.inputScrollable).toBe(0)
|
||||
expect(metrics.scrollTop).toBe(0)
|
||||
expect(metrics.firstLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.firstLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.lastLineOffset).toBeGreaterThan(metrics.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('lays out all three text layers at one wrap width', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wrap-width'))
|
||||
// A layer that breaks lines somewhere else puts the words under the wrong
|
||||
// caret, and an 8px difference is worth 2 to 5 lines on a wrap-sensitive
|
||||
// draft. The three now share a containing block — the scrollport — so a
|
||||
// scrollbar that consumes layout space costs them the same width; before,
|
||||
// only the textarea scrolled, and WebKit reserved gutter space for it alone
|
||||
// (768 against 776) while chromium and firefox did not.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.backdropWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
// The mirror decides the box height, so it belongs in the same equality —
|
||||
// were it alone to wrap wider, the box would be measured too short and
|
||||
// clip content before the 14-line cap, with every other assertion green.
|
||||
expect(metrics.mirrorWrapWidth).toBe(metrics.inputWrapWidth)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('the glyphs cannot lag the caret: one task moves both', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-lag'))
|
||||
// The reported symptom, isolated. A scroll offset changes and the caret's
|
||||
// distance to its own glyphs is re-read before the task ends — before any
|
||||
// `scroll` listener could have run. With the layers on one scrollport the
|
||||
// browser moved both, so the distance is unchanged; with the glyph layer
|
||||
// catching up in a listener it is off by the whole delta until a later
|
||||
// frame, which is a caret flying away from its text mid-gesture.
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.gapShiftOnScroll).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a wheel gesture over a long draft moves the words, not only the caret', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-wheel'))
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.hover()
|
||||
const resting = (await measureComposer(page)).caretGlyphGap
|
||||
// One delta past the whole draft: the box clamps at its own end, and the
|
||||
// wheel-chaining handler leaves it native because the box is not yet at its
|
||||
// edge when the gesture starts (the chaining itself is owned by the unit spec).
|
||||
await page.mouse.wheel(0, 2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The caret is still on its own glyphs after the gesture.
|
||||
expect(metrics.caretGlyphGap).toBe(resting)
|
||||
// The reported symptom, stated as what the user sees: the end of the draft
|
||||
// is on screen and its beginning is not.
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.firstLineOffset).toBeLessThan(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('typing at the end of a scrolled draft brings the caret back into view', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-edit'))
|
||||
// The other way the box moves, and the one that depends on the browser: the
|
||||
// textarea no longer scrolls, so revealing the caret after an edit is a
|
||||
// scroll-into-view that has to walk up to the scrollport. Scroll away from
|
||||
// the caret first, so the edit has somewhere to bring it back from.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.press('End')
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
||||
await input.pressSequentially(' tail')
|
||||
const metrics = await measureComposer(page)
|
||||
expect(metrics.scrollTop).toBeGreaterThan(0)
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('pasting a long block scrolls to the caret it leaves at the end', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-paste'))
|
||||
// The composer suppresses the native paste — the machine owns the draft and
|
||||
// the undo log — and restores the caret programmatically, which reveals
|
||||
// nothing on its own: measured in chromium and WebKit, the view stayed
|
||||
// where it was while the caret sat at the end of the pasted block. The
|
||||
// restore now scrolls it into view, and this is the case that proves it.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
// A real `paste` event carrying real clipboard data, dispatched at the
|
||||
// textarea: the same event a Cmd-V delivers, and it runs the same handler.
|
||||
await input.evaluate((el, text) => {
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/plain', text)
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
||||
// Ending in a newline is the shape the engines disagree on: the caret
|
||||
// lands on a line with nothing on it, where chromium reports no client
|
||||
// rects at all for the collapsed position.
|
||||
}, `\n${DRAFT}\n`)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
// The restore lands one frame after the machine commits the draft, so the
|
||||
// box overflows before it moves; waiting on the offset is waiting for the
|
||||
// behavior itself, and its absence fails this poll.
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
const metrics = await measureComposer(page)
|
||||
// The caret is at the end of what was pasted, so the draft's last line is
|
||||
// what has to be on screen.
|
||||
expect(metrics.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(metrics.lastLineOffset).toBeLessThan(metrics.clientHeight)
|
||||
expect(metrics.gapShiftOnScroll).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('a draft ending in a newline scrolls to its true end, not a line above it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-trailing-newline'))
|
||||
// The layers reserve a final line box on different terms, so this shape is
|
||||
// the one that separates a height every layer agrees on from a box measured
|
||||
// one line short of the caret's own last position.
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.fill(DRAFT_TRAILING_NEWLINE)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 4000)
|
||||
await expect.poll(async () => {
|
||||
const m = await measureComposer(page)
|
||||
return m.scrollTop === m.scrollMax
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
const bottom = await measureComposer(page)
|
||||
// At the very bottom the glyphs are level with the caret, and the draft's
|
||||
// own last line — the one before the empty final line — is on screen.
|
||||
expect(bottom.gapShiftOnScroll).toBe(0)
|
||||
expect(bottom.lastLineOffset).toBeGreaterThanOrEqual(0)
|
||||
expect(bottom.lastLineOffset).toBeLessThan(bottom.clientHeight)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed composer scroll geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-draft-scroll-golden'))
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
// Restore the pristine draft (the edit case appended to it) and return to
|
||||
// its start, both through ordinary gestures.
|
||||
await input.fill(DRAFT)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBe(0)
|
||||
const top = await measureComposer(page)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 2000)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0)
|
||||
const bottom = await measureComposer(page)
|
||||
await input.fill(DRAFT_TRAILING_NEWLINE)
|
||||
await input.hover()
|
||||
await page.mouse.wheel(0, 4000)
|
||||
await expect.poll(async () => {
|
||||
const m = await measureComposer(page)
|
||||
return m.scrollTop === m.scrollMax
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
const trailingNewline = await measureComposer(page)
|
||||
// The paste path, measured the way a user meets it: a short draft, the
|
||||
// caret at its end, one long block pasted in.
|
||||
await input.fill('one short line')
|
||||
await input.press('End')
|
||||
await input.evaluate((el, text) => {
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/plain', text)
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
|
||||
// The ordinary shape — not ending in a newline — so the collapsed branch
|
||||
// of the reveal keeps a real engine under it; the case above owns the
|
||||
// after-newline branch.
|
||||
}, `\n${DRAFT}`)
|
||||
await expect.poll(async () => (await measureComposer(page)).overflows, { timeout: 10_000 }).toBe(true)
|
||||
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
const pasted = await measureComposer(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(top, bottom, trailingNewline, pasted), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// Zero model calls, so the scenario records no session fixture: the geometry
|
||||
// golden is the whole inventory.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
420
apps/web/tests/composer-tab-geometry.e2e.ts
Normal file
420
apps/web/tests/composer-tab-geometry.e2e.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
// Web e2e scenario: the input card holds one horizontal position across the
|
||||
// Chat and Trajectory tabs.
|
||||
//
|
||||
// The composer seat is the same node in both tabs, but it measures itself
|
||||
// against a different edge in each (see
|
||||
// packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css).
|
||||
// In Chat it is a sticky CHILD of the column's scroller, so it rides that
|
||||
// scroller's content box — the box a space-consuming scrollbar shortens. A view
|
||||
// that opts into a composer overlay (`data-conversation-composer-overlay`, which
|
||||
// Trajectory declares and which moves the column's own scrolling into the view)
|
||||
// gets an absolutely positioned seat instead, laid out against the padding box,
|
||||
// which the scrollbar never reduces.
|
||||
//
|
||||
// So the two tabs disagreed by exactly the bar's width for as long as the
|
||||
// transcript overflowed: the card jumped sideways on every tab switch, and
|
||||
// inside Chat alone at the moment a growing transcript started to scroll. The
|
||||
// column now reserves the gutter unconditionally (`scrollbar-gutter: stable`)
|
||||
// and states the overlay branch as a scroll container on the same axes, so both
|
||||
// edges are the same edge.
|
||||
//
|
||||
// Only a real engine can show this. The seat's geometry is layout: jsdom gives
|
||||
// every element a zero-sized box and reports no scrollbar at all, so a unit spec
|
||||
// can assert the declarations exist but not that the two states land in the same
|
||||
// place. What is asserted here is the user-visible fact — the card does not move
|
||||
// — measured as the distance between the two tabs' card rectangles.
|
||||
//
|
||||
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
||||
// which is load-bearing rather than incidental. Under that argument a scroll
|
||||
// container's bar consumes no layout width at all, so the two tabs agree before
|
||||
// this change as much as after it and every comparison below holds vacuously —
|
||||
// measured: the pre-fix cascade leaves both tabs' bands at 0 there, against 8
|
||||
// and 0 with the argument dropped. Dropping it is also the faithful
|
||||
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
||||
// and a bar that occupies layout space is what the product actually draws.
|
||||
//
|
||||
// The scenario runs that pre-fix cascade in the page — `scrollbar-gutter: auto`
|
||||
// on the scroller, `overflow: hidden` on the overlay branch — and measures the
|
||||
// same two tabs through it, which is what keeps the equal rectangles above from
|
||||
// being explained by a tab switch that never reached the layout. It is the
|
||||
// reported symptom as a number: the card moves 4px, half the 8px band, on each
|
||||
// edge.
|
||||
//
|
||||
// Zero model calls: a seeded cold session renders from its log, and switching
|
||||
// tabs asks the host for nothing. A stray stream would fail loud with NO_ADAPTER.
|
||||
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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/composer-tab-geometry', import.meta.url))
|
||||
/**
|
||||
* Committed golden of where the input card sits in each tab, at a wide viewport
|
||||
* (card at its width cap) and a narrow one (card shrinking with the column).
|
||||
*
|
||||
* Absolute coordinates are deliberately absent: they depend on the sidebar's
|
||||
* laid-out width and on font metrics, so committing them would produce a fixture
|
||||
* that has to be re-recorded per platform. What is recorded is the distance
|
||||
* between the two tabs' rectangles, which is zero when the reservation holds and
|
||||
* the bar's width when it does not — including under the control, so the golden
|
||||
* carries the difference the fix removes rather than only its absence.
|
||||
*/
|
||||
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
/** Long enough that the transcript overflows the lane's 1000px viewport; the scenario asserts the overflow rather than trusting it. */
|
||||
const FIXTURE = createChatScrollFixture({
|
||||
markerPrefix: 'TAB_GEOMETRY',
|
||||
title: 'COMPOSER_TAB_GEOMETRY long session',
|
||||
turns: 24,
|
||||
})
|
||||
const SEED_ID = 'composer-tab-geometry-web-e2e'
|
||||
|
||||
/** Viewport widths the scenario measures at: the card capped, and the card shrinking with the column. */
|
||||
const WIDE_VIEWPORT = { width: 1680, height: 1000 }
|
||||
const NARROW_VIEWPORT = { width: 800, height: 1000 }
|
||||
|
||||
/**
|
||||
* Resize to one measurement viewport after the responsive sidebar and center
|
||||
* column finish their track transition.
|
||||
* @param page - the page under test.
|
||||
* @param viewport - the viewport dimensions to apply.
|
||||
* @param sidebarCollapsed - the sidebar state expected at this width.
|
||||
*/
|
||||
async function setMeasuredViewport(
|
||||
page: Page,
|
||||
viewport: { width: number; height: number },
|
||||
sidebarCollapsed: boolean,
|
||||
): Promise<void> {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.locator('[data-sidebar-collapsed="true"]').waitFor({
|
||||
state: sidebarCollapsed ? 'attached' : 'detached',
|
||||
timeout: 10_000,
|
||||
})
|
||||
await page.locator('[data-conversation-scroll]').evaluate(async (host) => {
|
||||
const deadline = performance.now() + 5_000
|
||||
let previous = host.getBoundingClientRect().width
|
||||
let stableFrames = 0
|
||||
while (performance.now() < deadline) {
|
||||
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
|
||||
const current = host.getBoundingClientRect().width
|
||||
stableFrames = Math.abs(current - previous) < 0.01 ? stableFrames + 1 : 0
|
||||
if (stableFrames >= 3) return
|
||||
previous = current
|
||||
}
|
||||
throw new Error('conversation width did not settle after the viewport changed')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-fix cascade, injected into the page: the reservation dropped and the
|
||||
* overlay branch back to a hidden box. `!important` beats the module rules
|
||||
* without a rebuild, and the id lets the control be lifted again in the same
|
||||
* session.
|
||||
*/
|
||||
const CONTROL_STYLE_ID = 'composer-tab-geometry-control'
|
||||
const CONTROL_CSS = `
|
||||
[data-conversation-scroll] { scrollbar-gutter: auto !important; }
|
||||
[data-conversation-scroll]:has([data-conversation-composer-overlay]) { overflow: hidden !important; }
|
||||
`
|
||||
|
||||
/** The column scroller and the input card as the browser lays them out, in one tab. */
|
||||
interface TabMetrics {
|
||||
/** Resolved `scrollbar-gutter` on the column's scroller. */
|
||||
gutter: string
|
||||
/** Resolved `overflow-x`: `hidden` in both states, so neither grows a horizontal bar. */
|
||||
overflowX: string
|
||||
/** Resolved `overflow-y`: `auto` in both states, which is the form WebKit honours the gutter on. */
|
||||
overflowY: string
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** True when the column's scroller actually scrolls — only Chat does. */
|
||||
scrolls: boolean
|
||||
/** Left edge of the input card in viewport coordinates. */
|
||||
cardLeft: number
|
||||
/** Right edge of the input card. */
|
||||
cardRight: number
|
||||
/** Width of the input card, capped at the composer card max width. */
|
||||
cardWidth: number
|
||||
}
|
||||
|
||||
/** One tab's metrics beside the other's, plus the distances between them. */
|
||||
interface TabComparison {
|
||||
chat: TabMetrics
|
||||
trajectory: TabMetrics
|
||||
/** Distance between the two tabs' card left edges: 0 when the card holds its position. */
|
||||
leftShift: number
|
||||
/** Distance between the two tabs' card right edges. */
|
||||
rightShift: number
|
||||
/** Difference between the two tabs' card widths. */
|
||||
widthShift: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the column scroller and the input card in the tab currently shown.
|
||||
* @param page - the page under test.
|
||||
* @returns the scroller's resolved overflow style and the card's rectangle.
|
||||
*/
|
||||
function measureTab(page: Page): Promise<TabMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
|
||||
if (host === null) throw new Error('conversation column scroller not in the DOM')
|
||||
const card = host.querySelector<HTMLElement>('[data-composer-seat] [data-composer-card]')
|
||||
if (card === null) throw new Error('no input card inside the composer seat')
|
||||
const style = getComputedStyle(host)
|
||||
const hostRect = host.getBoundingClientRect()
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
overflowX: style.overflowX,
|
||||
overflowY: style.overflowY,
|
||||
band: hostRect.width - host.clientWidth,
|
||||
scrolls: host.scrollHeight > host.clientHeight,
|
||||
cardLeft: cardRect.left,
|
||||
cardRight: cardRect.right,
|
||||
cardWidth: cardRect.width,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show one tab and wait for the view that owns it to be laid out.
|
||||
* @param page - the page under test.
|
||||
* @param tab - the tab to show.
|
||||
*/
|
||||
async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
|
||||
await page.getByRole('tab', { name: tab, exact: true }).click()
|
||||
if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
|
||||
else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
|
||||
// Both measurements are taken after a paint, so a rectangle read mid-transition
|
||||
// cannot be reported as a shift the cascade did not cause.
|
||||
await page.evaluate(() => new Promise<void>((settle) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { settle() }) })
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure both tabs and the distances between them, leaving Chat shown.
|
||||
* @param page - the page under test.
|
||||
* @returns each tab's metrics and the card's displacement between them.
|
||||
*/
|
||||
async function compareTabs(page: Page): Promise<TabComparison> {
|
||||
await showTab(page, 'Chat')
|
||||
const chat = await measureTab(page)
|
||||
await showTab(page, 'Trajectory')
|
||||
const trajectory = await measureTab(page)
|
||||
await showTab(page, 'Chat')
|
||||
return {
|
||||
chat,
|
||||
trajectory,
|
||||
leftShift: Math.abs(trajectory.cardLeft - chat.cardLeft),
|
||||
rightShift: Math.abs(trajectory.cardRight - chat.cardRight),
|
||||
widthShift: Math.abs(trajectory.cardWidth - chat.cardWidth),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the pre-fix cascade in the page for one measurement, then lift it.
|
||||
* @param page - the page under test.
|
||||
* @returns the comparison as the column laid out before this change.
|
||||
*/
|
||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
||||
await page.evaluate(({ id, css }) => {
|
||||
const style = document.createElement('style')
|
||||
style.id = id
|
||||
style.textContent = css
|
||||
document.head.append(style)
|
||||
}, { id: CONTROL_STYLE_ID, css: CONTROL_CSS })
|
||||
try {
|
||||
return await compareTabs(page)
|
||||
} finally {
|
||||
await page.evaluate((id) => { document.getElementById(id)?.remove() }, CONTROL_STYLE_ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the seeded session from the sidebar search.
|
||||
*
|
||||
* Cold summaries carry the temp workspace's basename, so the persisted first
|
||||
* message is the stable identity to search for, and the query itself drives the
|
||||
* lazy content-index reconciliation. Hand-rolled polling because `expect.poll`
|
||||
* is test-scoped and this runs in `beforeAll`.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
|
||||
await search.fill(FIXTURE.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
const deadline = Date.now() + 60_000
|
||||
for (;;) {
|
||||
if (await results.count() === 1) break
|
||||
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
await results.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body.
|
||||
* @param wide - comparison at the viewport where the card sits at its width cap.
|
||||
* @param narrow - comparison at the viewport where the card shrinks with the column.
|
||||
* @param control - comparison at the wide viewport with the reservation removed.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(wide: TabComparison, narrow: TabComparison, control: TabComparison): string {
|
||||
const section = (name: string, comparison: TabComparison): string[] => [
|
||||
`## ${name}`,
|
||||
'',
|
||||
`- Chat: scrollbar-gutter ${comparison.chat.gutter}, overflow ${comparison.chat.overflowX}/${comparison.chat.overflowY}`,
|
||||
`- Chat scroller scrolls: ${String(comparison.chat.scrolls)}`,
|
||||
`- Chat reserved band: ${String(comparison.chat.band)}px`,
|
||||
`- Trajectory: scrollbar-gutter ${comparison.trajectory.gutter}, overflow ${comparison.trajectory.overflowX}/${comparison.trajectory.overflowY}`,
|
||||
`- Trajectory scroller scrolls: ${String(comparison.trajectory.scrolls)}`,
|
||||
`- Trajectory reserved band: ${String(comparison.trajectory.band)}px`,
|
||||
`- input card left edge moves between tabs: ${String(comparison.leftShift)}px`,
|
||||
`- input card right edge moves between tabs: ${String(comparison.rightShift)}px`,
|
||||
`- input card width changes between tabs: ${String(comparison.widthShift)}px`,
|
||||
'',
|
||||
]
|
||||
return [
|
||||
'# Input card position across the Chat and Trajectory tabs',
|
||||
'',
|
||||
...section(`Wide viewport (${String(WIDE_VIEWPORT.width)}px, card at its cap)`, wide),
|
||||
...section(`Narrow viewport (${String(NARROW_VIEWPORT.width)}px, card shrinking with the column)`, narrow),
|
||||
...section('Wide viewport, reservation removed in the page (control)', control),
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
describe('web e2e: input card position across view tabs', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, FIXTURE.log, SEED_ID)
|
||||
// Scrollbars must take layout space here or the scenario proves nothing;
|
||||
// see the file header for the measurement behind dropping this argument.
|
||||
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
|
||||
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await openSeededSession(page)
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 30_000 })
|
||||
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('reserves the same gutter in both tabs while the transcript scrolls', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// Vacuity guard, in two parts. A transcript that does not overflow gives
|
||||
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
|
||||
// would make the tabs agree without the reservation doing anything.
|
||||
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
||||
const comparison = await compareTabs(page)
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
// The reservation reaches both states, which is the whole change: the same
|
||||
// band, on a box that scrolls and on one that only holds a view.
|
||||
expect(comparison.chat.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.gutter).toBe('stable')
|
||||
expect(comparison.trajectory.band).toBe(comparison.chat.band)
|
||||
// Declared as a scroll container on both axes rather than left to compute:
|
||||
// `overflow: hidden` would drop the reservation in WebKit, and a `visible`
|
||||
// horizontal axis computes to `auto` beside a scrolling one.
|
||||
expect(comparison.trajectory.overflowY).toBe('auto')
|
||||
expect(comparison.trajectory.overflowX).toBe('hidden')
|
||||
// Only Chat scrolls this box; the Trajectory view owns its own scrollers.
|
||||
expect(comparison.trajectory.scrolls).toBe(false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('holds the input card in place when the tab changes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-wide'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const comparison = await compareTabs(page)
|
||||
// The reported symptom as a number. At this viewport the card sits at its
|
||||
// width cap, so the pre-fix shift showed up as a centring difference — half
|
||||
// the band on each edge — rather than as a width change.
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('holds the input card in place at a viewport where it shrinks with the column', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-narrow'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const capped = await measureTab(page)
|
||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||
const comparison = await compareTabs(page)
|
||||
// The other geometry, and a different failure: below the cap the card takes
|
||||
// the column's width, so an unreserved gutter changed its WIDTH by the whole
|
||||
// band instead of shifting it by half. Asserted against the capped
|
||||
// measurement rather than against the cap's pixel value, which belongs to
|
||||
// the stylesheet.
|
||||
expect(comparison.chat.cardWidth).toBeLessThan(capped.cardWidth)
|
||||
expect(comparison.leftShift).toBe(0)
|
||||
expect(comparison.rightShift).toBe(0)
|
||||
expect(comparison.widthShift).toBe(0)
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('moves the card again once the reservation is removed in the page', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
// The control: without it, equal rectangles could also mean the tab switch
|
||||
// never reached the layout. Under the pre-fix cascade the Chat scroller keeps
|
||||
// its bar and the Trajectory branch goes back to a hidden box with none, and
|
||||
// the card moves by half the band on each edge.
|
||||
const comparison = await compareTabsWithoutReservation(page)
|
||||
expect(comparison.chat.gutter).toBe('auto')
|
||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||
expect(comparison.trajectory.band).toBe(0)
|
||||
expect(comparison.leftShift).toBe(comparison.chat.band / 2)
|
||||
expect(comparison.rightShift).toBe(comparison.chat.band / 2)
|
||||
// Restoring the sheet restores the fix, so the control cannot leak into the
|
||||
// remaining measurements.
|
||||
const restored = await compareTabs(page)
|
||||
expect(restored.leftShift).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('matches the committed tab geometry golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-golden'))
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const wide = await compareTabs(page)
|
||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||
const narrow = await compareTabs(page)
|
||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||
const control = await compareTabsWithoutReservation(page)
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('commits exactly the fixtures it reads', async () => {
|
||||
// The seeded session is generated in-process, so the geometry golden is the
|
||||
// whole inventory.
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/cordis-tool-round/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/cordis-tool-round/ui.expected.md', import.meta.url))
|
||||
@@ -29,9 +29,7 @@ function assertCompleteCordisLifecycle(events: readonly SessionEvent[]): void {
|
||||
(event): event is Extract<SessionEvent, { type: 'turn/end' }> => event.type === 'turn/end',
|
||||
)
|
||||
const reason = turnEnd?.data.reason
|
||||
const reasonSummary = reason?.kind === 'error'
|
||||
? { kind: reason.kind, code: reason.failure?.code, status: reason.failure?.status }
|
||||
: { kind: reason?.kind }
|
||||
const reasonSummary = { kind: reason?.kind }
|
||||
expect(reasonSummary).toEqual({ kind: 'completed' })
|
||||
|
||||
const calls = events.filter(
|
||||
@@ -62,11 +60,11 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -107,7 +105,8 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
|
||||
|
||||
const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
|
||||
await mountRow.waitFor({ timeout: 10_000 })
|
||||
await mountRow.locator('button[aria-expanded]').click()
|
||||
// The whole summary row is the expand toggle (unified tool-row interaction).
|
||||
await mountRow.locator('[aria-expanded]').first().click()
|
||||
await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
|
||||
.toContain(MOUNT_CODE)
|
||||
|
||||
|
||||
84
apps/web/tests/core-web-profile.snapshot.ts
Normal file
84
apps/web/tests/core-web-profile.snapshot.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url))
|
||||
|
||||
describe('core Web profile', () => {
|
||||
let scaffold: WebScaffold
|
||||
let agentHandle: AgentHandle
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: CORE_WEB_OVERLAY,
|
||||
toolsMode: 'native',
|
||||
})
|
||||
agentHandle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('core-web-profile-smoke'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
await agentHandle?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed')
|
||||
})
|
||||
|
||||
it('boots and executes both tools through the shipped Web composition', async () => {
|
||||
const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt')
|
||||
await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n')
|
||||
const signal = new AbortController().signal
|
||||
const bash = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-bash-smoke'),
|
||||
name: 'bash',
|
||||
arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
const editor = await scaffold.ctx.tools.execute({
|
||||
signal,
|
||||
callId: CallId('core-web-editor-smoke'),
|
||||
name: 'str_replace_editor',
|
||||
arguments: { command: 'view', path: seedPath },
|
||||
agent: agentHandle.agent,
|
||||
})
|
||||
|
||||
const text = (result: typeof bash): string => result.content
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join('')
|
||||
.replaceAll(scaffold.workspaceCwd, '{{cwd}}')
|
||||
.trimEnd()
|
||||
|
||||
expect({
|
||||
tools: scaffold.ctx.tools.schemas().map(tool => tool.name),
|
||||
bash: text(bash),
|
||||
editor: text(editor),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"bash": "CORE_WEB_BASH_OK",
|
||||
"editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines):
|
||||
1 CORE_WEB_EDITOR_OK
|
||||
2",
|
||||
"tools": [
|
||||
"bash",
|
||||
"str_replace_editor",
|
||||
],
|
||||
}
|
||||
`)
|
||||
|
||||
const entries = [...scaffold.ctx.loader.entries()]
|
||||
expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined()
|
||||
expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,21 @@
|
||||
// Keyless browser regression for the details column's Session ownership.
|
||||
// The shipped composition retains geometry through unselected states and closes it only when a different Session takes ownership.
|
||||
// Keyless browser regression for the details column's default visibility and Session ownership.
|
||||
// The shipped composition starts closed after selection and reload, retains an explicitly opened width through
|
||||
// unselected states, and closes it only when a different Session takes ownership.
|
||||
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 {
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, compareOrRefreshGolden,
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/details-session-lifecycle', import.meta.url))
|
||||
const HANDLES_EXPECTED = join(SNAPSHOT_DIR, 'handles.expected.md')
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/lifecycle-chrome/session.jsonl', import.meta.url))
|
||||
const SEED_FIXTURE = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
@@ -23,11 +29,41 @@ async function detailsTrack(page: Page): Promise<number> {
|
||||
})
|
||||
}
|
||||
|
||||
/** First AppFrame grid track in CSS pixels. */
|
||||
async function sidebarTrack(page: Page): Promise<number> {
|
||||
return await appFrame(page).evaluate((element) => {
|
||||
const tracks = getComputedStyle(element).gridTemplateColumns.split(' ')
|
||||
return Number.parseFloat(tracks[0] ?? 'NaN')
|
||||
})
|
||||
}
|
||||
|
||||
/** AppFrame is the only product element with an inline grid track template. */
|
||||
function appFrame(page: Page) {
|
||||
return page.locator('[style*="grid-template-columns"]').first()
|
||||
}
|
||||
|
||||
/** Render the two boundary affordances without platform-dependent coordinates. */
|
||||
async function handleSnapshot(page: Page): Promise<string> {
|
||||
const handles = await page.locator('[class*="handle"]').evaluateAll(elements =>
|
||||
elements.map(element => ({
|
||||
side: element.getAttribute('data-side'),
|
||||
cursor: getComputedStyle(element).cursor,
|
||||
pillGenerated: getComputedStyle(element, '::after').content !== 'none',
|
||||
})))
|
||||
return [
|
||||
'# AppFrame drag handles',
|
||||
'',
|
||||
...handles.flatMap(handle => [
|
||||
`## ${handle.side}`,
|
||||
'',
|
||||
'- hit strip present: true',
|
||||
`- cursor: ${handle.cursor}`,
|
||||
`- pill generated: ${String(handle.pillGenerated)}`,
|
||||
'',
|
||||
]),
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: details panel follows the current Session lifecycle', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -40,11 +76,11 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5 })
|
||||
await seedSession(scaffold, await readFile(SEED_FIXTURE, 'utf8'), 'details-session-lifecycle-seed')
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await appFrame(page).waitFor({ timeout: 30_000 })
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -52,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('retains geometry through hero and closes it for a different Session', async () => {
|
||||
it('starts and reloads closed, then stays closed across Session ownership changes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-details-session-lifecycle'))
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('textarea').first()
|
||||
@@ -61,19 +97,39 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
|
||||
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
|
||||
|
||||
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
|
||||
const sidebarBefore = await sidebarTrack(page)
|
||||
const sidebarHandle = page.locator('[data-side="sidebar"]')
|
||||
const sidebarBox = await sidebarHandle.boundingBox()
|
||||
expect(sidebarBox).not.toBeNull()
|
||||
const dragStartX = sidebarBox!.x + sidebarBox!.width / 2
|
||||
await page.mouse.move(dragStartX, sidebarBox!.y + 200)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(dragStartX + 70, sidebarBox!.y + 200, { steps: 6 })
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => sidebarTrack(page), { timeout: 5_000 }).toBe(sidebarBefore + 70)
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await appFrame(page).waitFor({ timeout: 30_000 })
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
|
||||
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
|
||||
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
|
||||
const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
|
||||
await original.click()
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(360)
|
||||
expect(await page.getByText('详情', { exact: true }).count()).toBe(1)
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
|
||||
|
||||
const ungrouped = page.getByText('Ungrouped', { exact: true })
|
||||
const ungroupedRow = ungrouped.locator('..').locator('..')
|
||||
@@ -91,5 +147,6 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['handles.expected.md'])
|
||||
}, 90_000)
|
||||
})
|
||||
|
||||
71
apps/web/tests/goal-bar.e2e.ts
Normal file
71
apps/web/tests/goal-bar.e2e.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Keyless assembled-browser coverage for the goal bar over the shipped Web
|
||||
// bundles and FixtureApiClient wire. The command creates a real projected
|
||||
// goal in the fixture session; the golden pins the active strip, while the
|
||||
// clear gesture proves the acknowledged tombstone leaves neither stale chrome
|
||||
// nor a duplicate-mutation error.
|
||||
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 {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-bar', import.meta.url))
|
||||
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: goal bar clear convergence', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders one active goal and clears it without exposing a stale error', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
|
||||
// Startup reuses the fixture workspace's blank session, keeping this
|
||||
// command independent of alpha's running replay and pending question.
|
||||
const input = page.getByPlaceholder('Describe what you want to build')
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await input.fill('/goal guard rapid clear clicks')
|
||||
await input.press('Enter')
|
||||
|
||||
const bar = page.locator('[data-goal-bar]')
|
||||
await bar.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACTIVE_EXPECTED, snapshot, MODE)
|
||||
|
||||
const clear = bar.getByRole('button', { name: 'Clear goal' })
|
||||
await clear.evaluate((button) => {
|
||||
const control = button as HTMLButtonElement
|
||||
control.click()
|
||||
control.click()
|
||||
})
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(await page.getByText(/no current goal/iu).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md'])
|
||||
})
|
||||
})
|
||||
5
apps/web/tests/goal-bar.overlay.yml
Normal file
5
apps/web/tests/goal-bar.overlay.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
# The client-side FixtureApiClient intentionally rejects settings writes, so
|
||||
# this goal-only scenario omits the durable welcome step that would otherwise
|
||||
# cover the page. Onboarding owns separate assembled-browser coverage.
|
||||
- id: ui-settings-general
|
||||
disabled: true
|
||||
132
apps/web/tests/hmr-live.e2e.ts
Normal file
132
apps/web/tests/hmr-live.e2e.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Fiber } from 'cordis'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import { REPO_ROOT } from './support.ts'
|
||||
|
||||
function spawnSpec(argv: readonly string[], cwd: string, env?: Record<string, string>): SubprocessSpawnSpec {
|
||||
return {
|
||||
argv,
|
||||
cwd,
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
||||
graceMs: 5_000,
|
||||
...env === undefined ? {} : { env },
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOutput(child: SubprocessHandle, pattern: RegExp, label: string): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
let output = ''
|
||||
let settled = false
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer)
|
||||
child.stdout?.off('data', onData)
|
||||
child.stderr?.off('data', onData)
|
||||
}
|
||||
const resolveOnce = (value: string): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolveReady(value)
|
||||
}
|
||||
const rejectOnce = (error: Error): void => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
reject(error)
|
||||
}
|
||||
const onData = (chunk: Buffer): void => {
|
||||
output += chunk.toString()
|
||||
const match = pattern.exec(output)
|
||||
if (match === null) return
|
||||
resolveOnce(match[1] ?? match[0])
|
||||
}
|
||||
const timer = setTimeout(() => { rejectOnce(new Error(`${label} not ready:\n${output}`)) }, 60_000)
|
||||
child.stdout?.on('data', onData)
|
||||
child.stderr?.on('data', onData)
|
||||
void child.done.then((outcome) => {
|
||||
rejectOnce(new Error(`${label} exited before ready (${JSON.stringify(outcome)}):\n${output}`))
|
||||
}, (error: unknown) => {
|
||||
rejectOnce(new Error(`${label} failed before ready:\n${output}`, { cause: error }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopTree(child: SubprocessHandle): Promise<void> {
|
||||
child.terminate()
|
||||
const stopped = await child.waitForExit(AbortSignal.timeout(15_000))
|
||||
if (!stopped) throw new Error(`process tree ${String(child.pid)} did not stop after termination escalation`)
|
||||
await child.done
|
||||
}
|
||||
|
||||
it('hot-reloads a real client-plugin source edit without refreshing the page', async () => {
|
||||
const world = await mkdtemp(join(tmpdir(), 'dsh-web-hmr-world-'))
|
||||
const sourcePath = join(REPO_ROOT, 'packages/client/ui-conversation/src/client/locales.ts')
|
||||
const bundlePath = join(REPO_ROOT, 'packages/client/ui-conversation/lib/client.js')
|
||||
const binPath = join(REPO_ROOT, 'apps/cli/lib/bin.js')
|
||||
if (!existsSync(binPath)) throw new Error('HMR browser test needs the built dsh bin; run pnpm run build first')
|
||||
const originalSource = await readFile(sourcePath)
|
||||
const originalBundle = await readFile(bundlePath)
|
||||
const oldText = "Let's start building"
|
||||
const sourceNeedle = "'hero.headline': 'Let\\'s start building'"
|
||||
const newText = `HMR UPDATED ${'x'.repeat(80)}`
|
||||
const updatedSource = originalSource.toString().replace(sourceNeedle, `'hero.headline': '${newText}'`)
|
||||
if (updatedSource === originalSource.toString()) throw new Error(`HMR source lacks ${JSON.stringify(sourceNeedle)}`)
|
||||
|
||||
const subprocessCtx = new Context()
|
||||
let subprocessFiber: Fiber | undefined
|
||||
let watcher: SubprocessHandle | undefined
|
||||
let host: SubprocessHandle | undefined
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
subprocessFiber = await subprocessCtx.plugin(LocalSubprocessService)
|
||||
watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
|
||||
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
|
||||
host = subprocessCtx.subprocess.spawn(spawnSpec(
|
||||
[process.execPath, binPath, 'web', '--dev', '--port', '0'],
|
||||
world,
|
||||
{
|
||||
DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
|
||||
DSH_HOME: join(world, '.dsh'),
|
||||
},
|
||||
))
|
||||
const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
const pageErrors: string[] = []
|
||||
page.on('pageerror', error => pageErrors.push(String(error)))
|
||||
await page.goto(baseUrl, { waitUntil: 'load' })
|
||||
await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const pageIdentity = await page.evaluate(() => {
|
||||
const identity = crypto.randomUUID()
|
||||
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
|
||||
return identity
|
||||
})
|
||||
|
||||
await writeFile(sourcePath, updatedSource)
|
||||
await page.getByText(newText, { exact: true }).waitFor({ timeout: 30_000 })
|
||||
expect(await page.evaluate(() => (window as Window & { __dshHmrPageIdentity?: string }).__dshHmrPageIdentity))
|
||||
.toBe(pageIdentity)
|
||||
expect(pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
} finally {
|
||||
await writeFile(sourcePath, originalSource).catch((error: unknown) => failures.push(error))
|
||||
if (watcher !== undefined) await stopTree(watcher).catch((error: unknown) => failures.push(error))
|
||||
await writeFile(bundlePath, originalBundle).catch((error: unknown) => failures.push(error))
|
||||
if (host !== undefined) await stopTree(host).catch((error: unknown) => failures.push(error))
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await subprocessFiber?.dispose().catch((error: unknown) => failures.push(error))
|
||||
await rm(world, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'HMR browser test or cleanup failed')
|
||||
}, 120_000)
|
||||
@@ -20,17 +20,20 @@ import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
|
||||
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
|
||||
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
|
||||
// Post-reload golden: the same settled conversation rebuilt purely from
|
||||
// persistence + history — byte-equal rendering is exactly the recovery claim.
|
||||
const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
const REPLAY_PACE_MS = 100
|
||||
|
||||
describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -40,15 +43,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -56,6 +59,92 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
|
||||
const launcher = page.getByRole('button', { name: 'Commands' })
|
||||
await launcher.click()
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
|
||||
expect(snapshot).toContain('text: Commands')
|
||||
expect(snapshot).not.toContain('text: Skills')
|
||||
expect(snapshot).not.toContain('text: Subagents')
|
||||
const launchedBox = await menu.boundingBox()
|
||||
await page.locator('textarea').first().press('Escape')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/')
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
const typedBox = await menu.boundingBox()
|
||||
expect(launchedBox).not.toBeNull()
|
||||
expect(typedBox).not.toBeNull()
|
||||
expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
|
||||
expect(Math.abs(
|
||||
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
|
||||
)).toBeLessThan(1)
|
||||
await input.fill('')
|
||||
await expect.poll(() => menu.count()).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
|
||||
const activeScaffold = await launchWebScaffold()
|
||||
const activePage = await newEnglishPage(browser)
|
||||
const activeTripwire = watchConsole(activePage)
|
||||
try {
|
||||
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
|
||||
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd)
|
||||
const input = activePage.locator('textarea').first()
|
||||
await activePage.getByRole('button', { name: 'Commands' }).click()
|
||||
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await menu.waitFor({ timeout: 10_000 })
|
||||
await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
|
||||
await expect.poll(() => input.inputValue()).toBe('/plan ')
|
||||
await input.press('Enter')
|
||||
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||
await planButton.waitFor({ timeout: 10_000 })
|
||||
// The golden encodes an empty composer, and the button arriving does not
|
||||
// mean the submitted text is gone yet: under load the capture caught a
|
||||
// textbox still holding `/plan`.
|
||||
await expect.poll(() => input.inputValue(), { timeout: 10_000 }).toBe('')
|
||||
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
|
||||
const planStyle = await planButton.evaluate((element) => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = 'var(--dsw-alias-state-warn-label)'
|
||||
probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
|
||||
document.body.append(probe)
|
||||
const actual = getComputedStyle(element)
|
||||
const reference = getComputedStyle(probe)
|
||||
const result = {
|
||||
color: actual.color,
|
||||
backgroundColor: actual.backgroundColor,
|
||||
borderRadius: actual.borderRadius,
|
||||
fontSize: actual.fontSize,
|
||||
referenceColor: reference.color,
|
||||
referenceBackgroundColor: reference.backgroundColor,
|
||||
}
|
||||
probe.remove()
|
||||
return result
|
||||
})
|
||||
expect(planStyle.color).toBe(planStyle.referenceColor)
|
||||
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
|
||||
expect(planStyle.borderRadius).toBe('999px')
|
||||
expect(planStyle.fontSize).toBe('13px')
|
||||
await planButton.click()
|
||||
await expect.poll(() => planButton.count()).toBe(0)
|
||||
expect(activeTripwire.pageErrors).toEqual([])
|
||||
expect(activeTripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
await activePage.close()
|
||||
await activeScaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('sends the first prompt from the empty-state hero (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
|
||||
if (MODE !== 'record') {
|
||||
@@ -74,8 +163,24 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
}
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
const observeTurn = async () => {
|
||||
const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
|
||||
if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 })
|
||||
try {
|
||||
await input.press('Enter')
|
||||
if (MODE !== 'record') {
|
||||
const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]')
|
||||
await expect.poll(async () => await liveTail.evaluate(element => (
|
||||
element.scrollWidth > element.clientWidth
|
||||
&& element.scrollLeft >= element.scrollWidth - element.clientWidth - 1
|
||||
)), { timeout: 10_000, interval: 10 }).toBe(true)
|
||||
}
|
||||
return await settled
|
||||
} finally {
|
||||
if (MODE !== 'record') await page.setViewportSize(originalViewport)
|
||||
}
|
||||
}
|
||||
const sessionId = await observeTurn()
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
@@ -88,10 +193,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Host: the session's durable header cwd is the workspace flow's
|
||||
// create-by-name target (<workspaceRoot>/workspace, the composer's
|
||||
// default draft name) — the proof the send went through workspace
|
||||
// materialization rather than a bare default-cwd session.
|
||||
// Host: the session's durable header cwd is the folder the workspace
|
||||
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
|
||||
// send went through workspace materialization rather than a bare
|
||||
// default-cwd session.
|
||||
const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd)
|
||||
expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')])
|
||||
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
|
||||
@@ -152,6 +257,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,18 +23,19 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// One golden per interactive end-state: what the user is left looking at
|
||||
// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface)
|
||||
// gap as a reviewable artifact: NO error copy in the tree), and after retry
|
||||
// recovery — three genuinely different terminal surfaces of one fixture.
|
||||
// One golden pins the stable mid-turn loading state; the other three capture
|
||||
// what the user is left looking at after cancel, after a non-retryable failure,
|
||||
// and after retry recovery.
|
||||
const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
|
||||
const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
|
||||
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
|
||||
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
|
||||
|
||||
// The recorded base: one text-only turn whose derived script the sidecars
|
||||
// patch. Kept deliberately tool-free so the derived script is exactly one
|
||||
@@ -90,12 +91,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,6 +134,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// The marker IS the synchronization: the stream is provably parked in the
|
||||
// hang (prefix chunks delivered to the loop) before the stop click.
|
||||
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
|
||||
await expect.poll(
|
||||
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(true)
|
||||
const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE)
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
||||
@@ -151,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('surfaces a non-retryable AUTH failure without retrying', async () => {
|
||||
await launch(() => ({
|
||||
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: 'invalid api key', code: 'AUTH' } }],
|
||||
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
|
||||
}))
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-error-auth'))
|
||||
const { settled } = await sendPrompt()
|
||||
@@ -159,18 +166,47 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('error')
|
||||
// AUTH is outside llm-retry's retryable set: no retry record.
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBe(0)
|
||||
// Product gap found by this lane, pinned as-is: the client consumes no
|
||||
// agent/error frames and a pre-chunk failure freezes no partial, so THIS
|
||||
// failure renders no error copy anywhere — the user sees the send simply
|
||||
// stop. FIXME(web-error-surface): assert visible error text here once the
|
||||
// web UI grows an error rendering; until then the pinned contract is
|
||||
// "no crash, composer recovers, turn logged as error".
|
||||
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(await page.locator('[data-streaming="true"]').count()).toBe(0)
|
||||
// Golden of the same gap: the prompt bubble alone, no error copy in the
|
||||
// tree — the diff that changes when web-error-surface lands.
|
||||
const errorStatus = page.getByRole('status').filter({ hasText: 'This turn failed' })
|
||||
await errorStatus.waitFor({ timeout: 10_000 })
|
||||
expect(await errorStatus.textContent()).toContain('API key is invalid')
|
||||
expect(await errorStatus.textContent()).toContain('AUTH')
|
||||
expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(ERROR_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
const requestMarker = page.locator('tr[data-request-only="true"]').last()
|
||||
.getByRole('button', { name: /Request #/ })
|
||||
await requestMarker.click()
|
||||
await page.getByText('API key is invalid', { exact: true }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('body').textContent()).not.toContain('sk-preview-secret')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps a terminal request marker inside the trajectory table', async () => {
|
||||
await launch(() => ({
|
||||
patches: [{ at: 0, entry: { kind: 'throw', chunks: [], message: AUTH_PROVIDER_MESSAGE, code: 'AUTH' } }],
|
||||
}))
|
||||
const { settled } = await sendPrompt()
|
||||
await settled
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
// The boundary marker row itself is a 0-height hairline except at the
|
||||
// table tail; the marker button is absolutely positioned and stays
|
||||
// visible, so wait on it directly.
|
||||
const tailRequest = page.locator('tr[data-request-only="true"]').last()
|
||||
const requestMarker = tailRequest.getByRole('button', { name: /Request #/ })
|
||||
await requestMarker.waitFor({ timeout: 10_000 })
|
||||
|
||||
const markerWithinTable = await requestMarker.evaluate((element) => {
|
||||
const marker = element.getBoundingClientRect()
|
||||
const table = element.closest('table')?.getBoundingClientRect()
|
||||
if (table === undefined) throw new Error('request marker has no table')
|
||||
return marker.bottom <= table.bottom
|
||||
})
|
||||
|
||||
expect(markerWithinTable).toBe(true)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
@@ -195,8 +231,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
// only on change, so attempt count is invisible there).
|
||||
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
// Golden of the recovered end-state: indistinguishable from a clean
|
||||
// completion — retries are deliberately invisible in the transcript.
|
||||
// Golden of the recovered end-state: the discarded partial stays absent,
|
||||
// while the settled retry row remains as durable recovery context.
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
|
||||
await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
@@ -205,7 +241,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md',
|
||||
'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
128
apps/web/tests/markdown-cjk-strong.e2e.ts
Normal file
128
apps/web/tests/markdown-cjk-strong.e2e.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-cjk-strong', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-cjk-strong/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'markdown-cjk-strong-web-e2e'
|
||||
const DONE = 'CJK_STRONG_DONE'
|
||||
const CASES = [
|
||||
['**注意:**内容', '注意:', '注意:内容'],
|
||||
['**Notice:**内容', 'Notice:', 'Notice:内容'],
|
||||
['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'],
|
||||
['**事件中间件(waterfall)**实现', '事件中间件(waterfall)', '事件中间件(waterfall)实现'],
|
||||
['**句号。**后续', '句号。', '句号。后续'],
|
||||
['**Period.**后续', 'Period.', 'Period.后续'],
|
||||
['**提醒!**继续', '提醒!', '提醒!继续'],
|
||||
['**Warning!**继续', 'Warning!', 'Warning!继续'],
|
||||
] as const
|
||||
|
||||
/** Build one settled assistant reply covering CJK-adjacent strong punctuation boundaries. */
|
||||
function markdownFixture(): string {
|
||||
const session = Session.create(SessionId('markdown-cjk-strong-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Render adjacent CJK strong emphasis.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'CJK strong emphasis',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'## CJK strong emphasis',
|
||||
'',
|
||||
...CASES.flatMap(([markdown]) => [markdown, '']),
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: CJK-adjacent Markdown strong emphasis', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, markdownFixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
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')('renders punctuation-terminated strong spans before adjacent CJK text', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-cjk-strong'))
|
||||
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(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
const strong = page.locator('[class*="markdown"] strong')
|
||||
await expect.poll(() => strong.count(), { timeout: 10_000 }).toBe(CASES.length)
|
||||
expect(await strong.allTextContents()).toEqual(CASES.map(([, expected]) => expected))
|
||||
for (const [, , paragraph] of CASES) {
|
||||
expect(await page.getByText(paragraph, { exact: true }).count()).toBe(1)
|
||||
}
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
202
apps/web/tests/markdown-images.e2e.ts
Normal file
202
apps/web/tests/markdown-images.e2e.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session
|
||||
// assembled through the Session API is seeded cold into the real web
|
||||
// composition, then a separate image origin proves that the browser receives
|
||||
// a real network image while local-path Markdown remains inert alt text.
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-images', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-images/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'markdown-images-web-e2e'
|
||||
const REMOTE_ALT = 'Remote test image'
|
||||
const LOCAL_ALT = 'Local test image'
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
)
|
||||
|
||||
interface ImageOrigin {
|
||||
server: Server
|
||||
url: string
|
||||
requests: Array<{ path: string | undefined; referer: string | undefined }>
|
||||
}
|
||||
|
||||
/** Start the deterministic remote image origin used by this browser scenario. */
|
||||
async function startImageOrigin(): Promise<ImageOrigin> {
|
||||
const requests: ImageOrigin['requests'] = []
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({ path: request.url, referer: request.headers.referer })
|
||||
response.writeHead(200, {
|
||||
'cache-control': 'no-store',
|
||||
'content-length': PNG.length,
|
||||
'content-type': 'image/png',
|
||||
})
|
||||
response.end(PNG)
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') {
|
||||
throw new Error('image origin did not expose an IP socket')
|
||||
}
|
||||
return {
|
||||
server,
|
||||
url: `http://127.0.0.1:${String(address.port)}/image.png`,
|
||||
requests,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop one image origin after the browser and host release their requests. */
|
||||
async function stopServer(server: Server): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
|
||||
function markdownImageFixture(remoteUrl: string): string {
|
||||
const session = Session.create(SessionId('markdown-image-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Markdown image policy',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'## Markdown images',
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
``,
|
||||
'',
|
||||
'REMOTE_IMAGE_DONE',
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const header = {
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}
|
||||
return [
|
||||
JSON.stringify(header),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: remote Markdown image rendering', () => {
|
||||
let scaffold: WebScaffold
|
||||
let imageOrigin: ImageOrigin
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
imageOrigin = await startImageOrigin()
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
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()
|
||||
await stopServer(imageOrigin.server)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
|
||||
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('REMOTE_IMAGE_DONE', { exact: true }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(1)
|
||||
|
||||
const image = page.getByRole('img', { name: REMOTE_ALT })
|
||||
await image.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), {
|
||||
timeout: 10_000,
|
||||
}).toBeGreaterThan(0)
|
||||
expect(await image.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
borderRadius: computed.borderRadius,
|
||||
decoding: element.getAttribute('decoding'),
|
||||
loading: element.getAttribute('loading'),
|
||||
maxWidth: computed.maxWidth,
|
||||
referrerPolicy: element.getAttribute('referrerpolicy'),
|
||||
}
|
||||
})).toEqual({
|
||||
borderRadius: '8px',
|
||||
decoding: 'async',
|
||||
loading: 'lazy',
|
||||
maxWidth: '100%',
|
||||
referrerPolicy: 'no-referrer',
|
||||
})
|
||||
expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
|
||||
expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
|
||||
expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
138
apps/web/tests/markdown-inline-code-links.e2e.ts
Normal file
138
apps/web/tests/markdown-inline-code-links.e2e.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-inline-code-links', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/markdown-inline-code-links/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'markdown-inline-code-links-web-e2e'
|
||||
const DONE = 'INLINE_CODE_LINK_DONE'
|
||||
|
||||
/** Build a settled assistant reply with linkable URL code and inert code controls. */
|
||||
function markdownFixture(linkUrl: string): string {
|
||||
const session = Session.create(SessionId('markdown-inline-code-links-source'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Show the local preview URL.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Inline code links',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'## Inline code links',
|
||||
'',
|
||||
`Preview: \`${linkUrl}\``,
|
||||
'',
|
||||
`Standard: [Open preview](${linkUrl})`,
|
||||
'',
|
||||
`Command: \`curl ${linkUrl}\``,
|
||||
'',
|
||||
'Unsafe: `javascript:alert(1)`',
|
||||
'',
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: Markdown inline-code links', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let linkUrl: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString()
|
||||
await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
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')('opens a complete HTTP URL from inline code and leaves other code inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-inline-code-links'))
|
||||
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(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
const inlineCodeLink = page.locator('[class*="markdown"] code a')
|
||||
await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl)
|
||||
expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
|
||||
expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
await inlineCodeLink.focus()
|
||||
expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
|
||||
const popupPromise = page.waitForEvent('popup')
|
||||
await inlineCodeLink.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForURL(linkUrl, { timeout: 15_000 })
|
||||
expect(popup.url()).toBe(linkUrl)
|
||||
await popup.close()
|
||||
|
||||
expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0)
|
||||
expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
.split(linkUrl).join('{{linkUrl}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
130
apps/web/tests/math-rendering.e2e.ts
Normal file
130
apps/web/tests/math-rendering.e2e.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/math-rendering', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/math-rendering/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'math-rendering-web-e2e'
|
||||
const DONE = 'MATH_RENDERING_DONE'
|
||||
|
||||
/** Build a settled assistant reply that exercises every supported math delimiter. */
|
||||
function mathFixture(): string {
|
||||
const session = Session.create(SessionId('math-rendering-source'))
|
||||
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const user = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Render this mathematical proof.' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('session/title', {
|
||||
title: 'Math rendering',
|
||||
messageSeqs: [user.seq],
|
||||
source: { kind: 'fallback' },
|
||||
})
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'## Math rendering',
|
||||
'',
|
||||
'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
|
||||
'',
|
||||
'\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
|
||||
'',
|
||||
'$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
|
||||
'',
|
||||
'| Symbol | Value |',
|
||||
'| --- | --- |',
|
||||
'| $\\theta$ | \\(\\frac{1}{5}\\) |',
|
||||
'',
|
||||
DONE,
|
||||
].join('\n'),
|
||||
}],
|
||||
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: '{{sessionId}}',
|
||||
createdAt: 0,
|
||||
cwd: '{{cwd}}',
|
||||
}),
|
||||
...session.events.map(event => JSON.stringify({
|
||||
...event,
|
||||
time: eventTimeOrigin + event.seq * 1_000,
|
||||
})),
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('web e2e: settled Markdown math rendering', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, mathFixture(), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
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')('renders the settled reply without KaTeX errors', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-math-rendering'))
|
||||
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(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
|
||||
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
|
||||
expect(await page.locator('.katex-error').count()).toBe(0)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,28 +1,72 @@
|
||||
// Web e2e scenario: message IconActions + clocks. Cold-seeds the seeded-history
|
||||
// fixture (zero model calls) and pins the settled conversation aria after the
|
||||
// user/assistant footers are focus-revealed — the surface package jsdom tests
|
||||
// cannot substitute for (docs/testing.md snapshot rule).
|
||||
// Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic
|
||||
// completed-turn-tail fork case (zero model calls) and pins the settled
|
||||
// conversation aria after the footers are focus-revealed — the surface package
|
||||
// jsdom tests cannot substitute for (docs/testing.md snapshot rule).
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-actions', import.meta.url))
|
||||
// Borrowed read-only: this scenario needs any settled user+assistant pair, not
|
||||
// a new recording (workspace-management / sidebar-scrollbar pattern).
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'message-actions-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
const MID_TURN_TEXT = 'I will read both files before answering.'
|
||||
const SECOND_PROMPT = 'Now give the final answer.'
|
||||
|
||||
/**
|
||||
* Adapt the borrowed recording into response -> tools -> interrupted Think,
|
||||
* followed by one ordinary completed response. The first response keeps
|
||||
* copy/clock but is not a legal branch point; the second is the real turn tail.
|
||||
* @param raw - Recorded seeded-history JSONL.
|
||||
* @returns A contiguous, closed two-turn fixture.
|
||||
*/
|
||||
function completedTailFixture(raw: string): string {
|
||||
const kept: string[] = []
|
||||
for (const line of raw.trimEnd().split('\n')) {
|
||||
const row = JSON.parse(line) as {
|
||||
type: string
|
||||
seq?: number
|
||||
seq0?: number
|
||||
data?: { content?: unknown[] }
|
||||
}
|
||||
const firstSeq = row.seq ?? row.seq0
|
||||
if (firstSeq !== undefined && firstSeq >= 101) break
|
||||
if (row.type === 'assistant/message' && row.seq === 64) {
|
||||
const content = row.data?.content
|
||||
if (!Array.isArray(content)) throw new Error('borrowed step-one assistant message has no content')
|
||||
content.splice(1, 0, { type: 'text', text: MID_TURN_TEXT })
|
||||
kept.push(JSON.stringify(row))
|
||||
} else {
|
||||
kept.push(line)
|
||||
}
|
||||
}
|
||||
const tail = [
|
||||
{ type: 'step/end', seq: 101, time: 1784974102749, data: { turn: 1, step: 2 } },
|
||||
{ type: 'turn/end', seq: 102, time: 1784974102750, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
{ type: 'turn/start', seq: 103, time: 1784974103000, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user', rpcId: '{{rpcId}}' } } } },
|
||||
{ type: 'user/message', seq: 104, time: 1784974103001, data: { content: [{ type: 'text', text: SECOND_PROMPT }], source: { kind: 'user', rpcId: '{{rpcId}}' } }, surfaceOp: 'append' },
|
||||
{ type: 'step/start', seq: 105, time: 1784974103002, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 106, time: 1784974103003, data: { turn: 2, step: 1, content: [{ type: 'text', text: 'DONE' }], provenance: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }, sourceEventSeqs: [], surfaceOp: 'append' },
|
||||
{ type: 'step/end', seq: 107, time: 1784974103004, data: { turn: 2, step: 1 } },
|
||||
{ type: 'turn/end', seq: 108, time: 1784974103005, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
return `${[...kept, ...tail.map(row => JSON.stringify(row))].join('\n')}\n`
|
||||
}
|
||||
|
||||
describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -36,11 +80,11 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await mkdir(sessionCwd, { recursive: true })
|
||||
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
|
||||
await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
|
||||
const raw = completedTailFixture(await readFile(SEED, 'utf8'))
|
||||
expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -51,7 +95,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('lists the seeded session and reveals user/assistant IconActions', async () => {
|
||||
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
@@ -59,35 +103,90 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
|
||||
// hover/focus-within). User has three actions; each finalized assistant
|
||||
// text node has copy + branch.
|
||||
const copyButtons = page.getByRole('button', { name: '复制' })
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
// hover/focus-within). Every durable message footer keeps branch visible,
|
||||
// but only the final assistant at a completed transcript tail enables it.
|
||||
const copyButtons = page.getByRole('button', { name: 'Copy' })
|
||||
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4)
|
||||
await copyButtons.first().focus()
|
||||
await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
|
||||
const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
|
||||
await expect.poll(() => branchButtons.count(), { timeout: 5_000 }).toBe(4)
|
||||
await expect.poll(
|
||||
() => branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))),
|
||||
{ timeout: 5_000 },
|
||||
).toEqual(['true', 'true', 'true', null])
|
||||
await branchButtons.first().focus()
|
||||
await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 })
|
||||
.toBe('Available only on the last message of a completed turn')
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', {
|
||||
name: '选择模型,当前 deepseek-v4-flash',
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
await page.getByRole('button', { name: '复制' }).first().focus()
|
||||
await page.getByRole('button', { name: 'Copy' }).first().focus()
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
|
||||
// The last message action belongs to the completed second-turn assistant.
|
||||
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
|
||||
{ timeout: 15_000 },
|
||||
).toBeDefined()
|
||||
await expect.poll(
|
||||
() => page.locator('[role="treeitem"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(3)
|
||||
await expect.poll(
|
||||
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
// The row action owns a distinct ui-workspace injection from the message
|
||||
// action above, so exercise both through the loaded app before capture.
|
||||
const sourceRow = page.locator('[role="treeitem"][aria-selected="true"]')
|
||||
const rowBox = await sourceRow.boundingBox()
|
||||
if (rowBox === null) throw new Error('fork source row has no layout box')
|
||||
const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]')
|
||||
await sourceRow.hover({ position: { x: rowBox.width - 16, y: rowBox.height / 2 } })
|
||||
await expect.poll(() => actionButton.isVisible(), { timeout: 2_000 }).toBe(true)
|
||||
const buttonBox = await actionButton.boundingBox()
|
||||
if (buttonBox === null) throw new Error('fork source row action has no layout box')
|
||||
await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2)
|
||||
await page.getByRole('menuitem', { name: 'Fork session' }).click()
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession !== undefined).length,
|
||||
{ timeout: 15_000 },
|
||||
).toBe(2)
|
||||
await expect.poll(
|
||||
() => page.locator('[role="treeitem"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(4)
|
||||
await expect.poll(
|
||||
() => page.locator('[role="treeitem"][aria-selected="true"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
const tree = await captureStableAria(
|
||||
page,
|
||||
'[role="tree"][aria-label="Sessions"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
152
apps/web/tests/models-settings.e2e.ts
Normal file
152
apps/web/tests/models-settings.e2e.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Web e2e scenario: the Models settings page end to end through the real
|
||||
// wire — the add card offers the dormant pi-ai catalog, typing an API key
|
||||
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
|
||||
// while the settings document records only that reference; the saved row
|
||||
// appears after the route topology invalidation without presenting liveness
|
||||
// as provider status. The customized-settings fold writes the curated
|
||||
// reasoning field as a merge patch. Zero model calls: configuration is pure
|
||||
// settings/credentials/llm-domain traffic, so there is no fixture and a
|
||||
// stray stream would fail loud on the open seam. The provider under test is
|
||||
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
|
||||
// never shadow the derived reference. Removing that row is guarded by the
|
||||
// localized provider-confirmation dialog before the unset reaches the wire.
|
||||
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 {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
|
||||
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
|
||||
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
|
||||
const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: Models settings page configures a dormant provider', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
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('opens the add card over the dormant directory vocabulary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
|
||||
// The dormant pi-ai adapter contributes its whole installed catalog; no
|
||||
// provider is configured yet, so the page is one add button.
|
||||
const add = dialog.getByRole('button', { name: '添加提供方' })
|
||||
await add.waitFor({ timeout: 10_000 })
|
||||
// The button enables once the dormant catalog lands in the join.
|
||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
await add.click()
|
||||
const pick = dialog.getByLabel('提供方')
|
||||
await pick.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
|
||||
const options = await pick.locator('option').allTextContents()
|
||||
expect(options).toContain('anthropic')
|
||||
expect(options).toContain('minimax-cn')
|
||||
await pick.selectOption('minimax-cn')
|
||||
await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('stores the key under the derived reference and the route registers live', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The profile lands in settings.yaml with only the derived reference, the
|
||||
// key value lands in the harness home's .env, the dormant route
|
||||
// registers, and the topology frame invalidates the page into the row.
|
||||
const row = dialog.getByText('minimax-cn', { exact: true }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('minimax-cn:')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
expect(document).not.toContain('sk-e2e-minimax')
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
expect(await page.content()).not.toContain('sk-e2e-minimax')
|
||||
}, 60_000)
|
||||
|
||||
it('applies a customized-settings field as a merge patch', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.getByRole('button', { name: '编辑' }).click()
|
||||
await dialog.getByText('自定义设置').click()
|
||||
const effort = dialog.getByLabel('推理强度')
|
||||
await effort.waitFor({ timeout: 10_000 })
|
||||
await effort.selectOption('high')
|
||||
await dialog.getByRole('button', { name: '保存', exact: true }).click()
|
||||
// The editor closes back to the row; the fold's write merged into the
|
||||
// stored profile beside the reference.
|
||||
await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('reasoning: high')
|
||||
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('confirms provider deletion before removing its settings profile', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
|
||||
const settingsDialog = page.getByRole('dialog', { name: '设置' })
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
await deleteDialog.waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(
|
||||
page,
|
||||
'[role="dialog"][aria-label="删除模型提供方?"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
|
||||
|
||||
await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
|
||||
await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
|
||||
await page.getByRole('dialog', { name: '删除模型提供方?' })
|
||||
.getByRole('button', { name: '删除提供方', exact: true }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).not.toContain('minimax-cn:')
|
||||
expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
|
||||
.toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
|
||||
await expect.poll(
|
||||
async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -9,20 +9,21 @@
|
||||
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 type { Browser, Page, Response } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, 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'
|
||||
import { newEnglishPage, 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 SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'navigation-panes-web-e2e'
|
||||
@@ -33,16 +34,50 @@ const SEED_ID = 'navigation-panes-web-e2e'
|
||||
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.'
|
||||
|
||||
async function baselineResponse(
|
||||
page: Page,
|
||||
method: 'session.list' | 'workspace.list',
|
||||
): Promise<Response> {
|
||||
return page.waitForResponse(response => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname === `/api/${method}`
|
||||
), { timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function assertBaselineSucceeded(response: Response, method: string): Promise<void> {
|
||||
expect(response.ok(), `${method} baseline HTTP response`).toBe(true)
|
||||
const body = await response.json() as { result?: { ok?: unknown } }
|
||||
expect(body.result?.ok, `${method} baseline RPC result`).toBe(true)
|
||||
}
|
||||
|
||||
async function ensureSeedOpen(page: Page): Promise<void> {
|
||||
const chat = page.getByRole('tab', { name: 'Chat', exact: true })
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
if (await chat.count() === 0) {
|
||||
await search.fill('WATERFALL')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
|
||||
await result.click()
|
||||
await chat.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
await chat.click()
|
||||
await page.getByText('FIRST_DONE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
if (await search.inputValue() !== '') {
|
||||
await search.fill('')
|
||||
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let slotErrors: string[]
|
||||
let tripwire: ReturnType<typeof watchConsole> = { warnings: [], pageErrors: [] }
|
||||
let slotErrors: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace;
|
||||
// The workspace-aware flow runs sessions in <workspaceCwd>/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')
|
||||
@@ -56,7 +91,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
}, 120_000)
|
||||
|
||||
beforeEach(async () => {
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
slotErrors = []
|
||||
page.on('console', (message) => {
|
||||
@@ -64,13 +102,52 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
slotErrors.push(message.text())
|
||||
}
|
||||
})
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
// Initial navigation and list ownership settle only after both independent
|
||||
// RPC baselines succeed; arm before navigation so neither response is missed.
|
||||
const sessionBaseline = baselineResponse(page, 'session.list')
|
||||
const workspaceBaseline = baselineResponse(page, 'workspace.list')
|
||||
const [, sessionResponse, workspaceResponse] = await Promise.all([
|
||||
page.goto(scaffold.baseUrl, { waitUntil: 'load' }),
|
||||
sessionBaseline,
|
||||
workspaceBaseline,
|
||||
])
|
||||
await Promise.all([
|
||||
assertBaselineSucceeded(sessionResponse, 'session.list'),
|
||||
assertBaselineSucceeded(workspaceResponse, 'workspace.list'),
|
||||
])
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// The frame mounts before the asynchronous session-list baseline lands.
|
||||
// Search must target the settled seeded row, not the startup input that
|
||||
// the ready projection replaces.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterEach(async () => {
|
||||
const failures: unknown[] = []
|
||||
try {
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
warnings: tripwire.warnings,
|
||||
}).toEqual({
|
||||
pageErrors: [],
|
||||
slotErrors: [],
|
||||
warnings: [],
|
||||
})
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
await page?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'navigation case cleanup failed')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
const failures: unknown[] = []
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'navigation e2e cleanup failed')
|
||||
})
|
||||
|
||||
it.skipIf(MODE !== 'record')('records the two-turn seed live through the composer', async () => {
|
||||
@@ -95,44 +172,65 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
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()
|
||||
it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
|
||||
// The API baselines can settle before React commits their projection. The
|
||||
// seeded count is the final user-visible barrier before editing search.
|
||||
await page.getByText('1 session', { exact: true }).waitFor({ timeout: 30_000 })
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
// The cold row has not been opened, so only the persisted log can satisfy
|
||||
// this query. First search lazily reconciles the SQLite content index.
|
||||
await search.fill('zzzqx-no-such-session')
|
||||
await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
|
||||
await expect.poll(
|
||||
() => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(0)
|
||||
|
||||
await search.fill('WATERFALL')
|
||||
const resultTree = page.getByRole('tree', { name: 'Search results' })
|
||||
const result = resultTree.getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
|
||||
await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
|
||||
timeout: 10_000,
|
||||
}).toBeGreaterThanOrEqual(1)
|
||||
const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
|
||||
|
||||
await result.click()
|
||||
// Search navigation addresses the session, not a specific event, and the
|
||||
// query remains until the user explicitly clears it.
|
||||
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
|
||||
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)
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
await page.waitForTimeout(100)
|
||||
const overlayLayout = await page.getByRole('table').evaluate((table) => {
|
||||
const host = table.closest('[data-conversation-scroll]')
|
||||
const seat = host?.querySelector('[data-composer-seat]') ?? null
|
||||
const pane = table.parentElement
|
||||
return {
|
||||
hostPosition: host === null ? null : getComputedStyle(host).position,
|
||||
paneOverflowX: pane === null ? null : getComputedStyle(pane).overflowX,
|
||||
paneScrollableWidth: pane === null ? null : pane.scrollWidth - pane.clientWidth,
|
||||
seatPosition: seat === null ? null : getComputedStyle(seat).position,
|
||||
}
|
||||
})
|
||||
expect(overlayLayout).toEqual({
|
||||
hostPosition: 'relative',
|
||||
paneOverflowX: 'hidden',
|
||||
paneScrollableWidth: 0,
|
||||
seatPosition: 'absolute',
|
||||
})
|
||||
expect({
|
||||
pageErrors: tripwire.pageErrors,
|
||||
slotErrors,
|
||||
@@ -146,19 +244,42 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
|
||||
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.locator('tr[data-kind="tool"]').first().click()
|
||||
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
|
||||
const details = page.getByRole('complementary', { name: 'Event details' })
|
||||
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await details.getByRole('tabpanel').evaluate(panel => getComputedStyle(panel).overflowX))
|
||||
.toBe('hidden')
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
|
||||
heading: getComputedStyle(heading).backgroundColor,
|
||||
panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
|
||||
}))
|
||||
expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
await page.getByRole('tab', { name: 'Result' }).click()
|
||||
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
|
||||
const assistantSpan = page.locator('[data-timeline-span="message"][data-assistant-timing="true"]').first()
|
||||
await assistantSpan.hover()
|
||||
const timingTooltip = page.getByRole('tooltip')
|
||||
await timingTooltip.waitFor({ timeout: 5_000 })
|
||||
await expect.poll(() => timingTooltip.textContent(), { timeout: 5_000 }).toMatch(/TTFT .* Decoding/)
|
||||
const assistantTimingStyle = await assistantSpan.evaluate(node => ({
|
||||
background: getComputedStyle(node).backgroundImage,
|
||||
ttft: getComputedStyle(node).getPropertyValue('--trajectory-assistant-ttft'),
|
||||
}))
|
||||
expect(assistantTimingStyle.background).toContain('linear-gradient')
|
||||
expect(assistantTimingStyle.ttft).toMatch(/%$/)
|
||||
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
|
||||
await page.getByRole('complementary', { name: 'Event details' })
|
||||
.getByRole('button', { name: 'Close details' }).click()
|
||||
await details.getByRole('button', { name: 'Close details' }).click()
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
|
||||
await ensureSeedOpen(page)
|
||||
await page.getByRole('tab', { name: 'Trajectory' }).click()
|
||||
const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
|
||||
await plot.waitFor({ timeout: 15_000 })
|
||||
const before = await page.locator('tr[data-kind]').count()
|
||||
const box = await plot.boundingBox()
|
||||
if (box === null) throw new Error('trajectory timeline plot has no layout box')
|
||||
@@ -173,33 +294,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column open', async () => {
|
||||
it.skipIf(MODE === 'record')('bash and file-path rows leave the default details column closed', 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 ensureSeedOpen(page)
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
// The row click is the card's expand toggle (unified tool-row
|
||||
// interaction); it must not drive layout geometry either way.
|
||||
await bashRow.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// 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 }).toBeNull()
|
||||
// details either — the expanded terminal card is read in place.
|
||||
await page.locator('[data-sample="bash"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// 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 }).toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
}, 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 ensureSeedOpen(page)
|
||||
// The card is expand-gated behind the whole-row toggle (the unified
|
||||
// tool-row interaction): open it if this fresh view leaves it collapsed.
|
||||
// Expanded, the recorded command's own output sits in the message flow,
|
||||
// derived from the logged call/result presentations alone.
|
||||
const bashRow = page.locator('[data-sample="bash"]').first()
|
||||
await bashRow.waitFor({ timeout: 15_000 })
|
||||
if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
|
||||
const card = page.locator('[data-sample="bash"] ~ div [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
|
||||
@@ -253,7 +380,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
}
|
||||
})
|
||||
expect(dot.state).toBe('done')
|
||||
expect(dot.label).toBe('已完成')
|
||||
expect(dot.label).toBe('Done')
|
||||
expect(dot.beforePrompt).toBe(true)
|
||||
expect(dot.insideCard).toBe(true)
|
||||
expect(dot.leftOfPrompt).toBe(true)
|
||||
@@ -270,16 +397,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
|
||||
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('复制成功')
|
||||
.toBe('Copied')
|
||||
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(slotErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
|
||||
'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
220
apps/web/tests/onboarding-deepseek-config.e2e.ts
Normal file
220
apps/web/tests/onboarding-deepseek-config.e2e.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
// Keyless browser e2e: the shipped DeepSeek adapter stays mounted while its
|
||||
// credential is absent, onboarding routes to the real Models editor, and its
|
||||
// write lands in an isolated harness home without a reload or model call.
|
||||
import { randomBytes } from 'node:crypto'
|
||||
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 {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
|
||||
WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
|
||||
const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
|
||||
const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
|
||||
const MODELS_EXPECTED = join(SNAPSHOT_DIR, 'models.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const browserConsole: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
// The scenario asserts the shipped Chinese copy, so the browser asks for it.
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
page.on('console', message => browserConsole.push(message.text()))
|
||||
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('stores a key write-only and observes configured state without restarting', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
|
||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||
const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
|
||||
expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
|
||||
expect(await welcome.locator('button').count()).toBe(1)
|
||||
|
||||
const mask = page.locator('[class*="onboardingMask"]')
|
||||
expect(await mask.count()).toBe(1)
|
||||
const maskStyles = await mask.evaluate((mask) => {
|
||||
const style = getComputedStyle(mask)
|
||||
const rect = mask.getBoundingClientRect()
|
||||
return {
|
||||
position: style.position,
|
||||
left: style.left,
|
||||
right: style.right,
|
||||
top: style.top,
|
||||
bottom: style.bottom,
|
||||
background: style.backgroundColor,
|
||||
backdropFilter: style.backdropFilter,
|
||||
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
||||
}
|
||||
})
|
||||
expect(maskStyles).toEqual({
|
||||
position: 'absolute',
|
||||
left: '0px',
|
||||
right: '0px',
|
||||
top: '80px',
|
||||
bottom: '0px',
|
||||
background: 'rgba(0, 0, 0, 0.24)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
rect: { left: 0, top: 80, right: 1440, bottom: 960 },
|
||||
})
|
||||
|
||||
// Closing the process/page before acknowledgement writes nothing, so the
|
||||
// same durable profile presents the notice again after reload.
|
||||
const firstReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
|
||||
await credentialStep.waitFor({ timeout: 15_000 })
|
||||
expect(await credentialStep.getByRole('textbox').count()).toBe(0)
|
||||
const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
|
||||
|
||||
await credentialStep.getByRole('button', { name: '前往配置' }).click()
|
||||
await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
|
||||
const keyInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await keyInput.waitFor({ timeout: 10_000 })
|
||||
|
||||
const secret = `dsh_onboarding_${randomBytes(12).toString('hex')}`
|
||||
await keyInput.fill(secret)
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await keyInput.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
|
||||
expect(stored.includes(`DEEPSEEK_API_KEY=${secret}`)).toBe(true)
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
|
||||
// The same open Models surface reuses the refreshed join and exposes the
|
||||
// configured write-only placeholder without a reload.
|
||||
const deepSeekRow = settings.getByText('DeepSeek', { exact: true }).first()
|
||||
await deepSeekRow.waitFor({ timeout: 10_000 })
|
||||
await deepSeekRow.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
const configuredInput = settings.getByLabel('API 密钥', { exact: true })
|
||||
await configuredInput.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(
|
||||
() => configuredInput.getAttribute('placeholder'),
|
||||
{ timeout: 10_000 },
|
||||
).toBe('已配置——输入新值可替换')
|
||||
|
||||
const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
|
||||
|
||||
const secondReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
// A different stored copy version represents an intentional version bump:
|
||||
// the welcome step returns even though the credential is already ready.
|
||||
await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
|
||||
}])
|
||||
const thirdReloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
|
||||
|
||||
expect((await page.content()).includes(secret)).toBe(false)
|
||||
expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
|
||||
expect(browserConsole.some(line => line.includes(secret))).toBe(false)
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('configures arbitrary DeepSeek models and prompts after the selected model is removed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-models'))
|
||||
// Opened here rather than inherited: the credential test reloads the page
|
||||
// to exercise the welcome step, so nothing carries an open dialog across.
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const settings = page.getByRole('dialog', { name: '设置' })
|
||||
await settings.waitFor({ timeout: 10_000 })
|
||||
await settings.getByRole('button', { name: '模型' }).click()
|
||||
const deepSeek = settings.getByText('DeepSeek', { exact: true }).first()
|
||||
await deepSeek.waitFor({ timeout: 10_000 })
|
||||
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
|
||||
await settings.getByText('自定义设置').click()
|
||||
await settings.getByRole('button', { name: /删除模型/ }).first().click()
|
||||
await settings.getByRole('button', { name: '添加模型' }).click()
|
||||
const customModelId = settings.getByLabel('模型 ID 2')
|
||||
await customModelId.fill('private-preview')
|
||||
await settings.getByLabel('显示名称 2').fill('Private Preview')
|
||||
// Capacities live behind the row's own disclosure, as in the pi-ai form.
|
||||
await settings.getByRole('button', { name: '容量 2' }).click()
|
||||
await settings.getByLabel('上下文窗口 2').fill('131072')
|
||||
await settings.getByLabel('最大输出 token 数 2').fill('64K')
|
||||
|
||||
const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE)
|
||||
await settings.getByRole('button', { name: '保存', exact: true }).click()
|
||||
await customModelId.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('id: deepseek-v4-pro')
|
||||
expect(document).toContain('id: private-preview')
|
||||
expect(document).toContain('name: Private Preview')
|
||||
expect(document).toContain('contextWindow: 131072')
|
||||
expect(document).toContain('maxTokens: 64000')
|
||||
expect(document).not.toContain('id: deepseek-v4-flash')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
// A connected Workspace is what puts a live composer — and its model
|
||||
// trigger — on the page; the scaffold boots without one.
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'model-fallback-e2e')
|
||||
|
||||
const modelTrigger = page.getByRole('button', { name: '选择模型', exact: true })
|
||||
await modelTrigger.waitFor({ timeout: 10_000 })
|
||||
await modelTrigger.click()
|
||||
await page.getByRole('menuitem', { name: /模型/ }).click()
|
||||
expect(await page.getByText('deepseek-v4-flash', { exact: true }).count()).toBe(0)
|
||||
await page.getByRole('menuitemradio', { name: 'Private Preview' }).waitFor({ timeout: 10_000 })
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['missing.expected.md', 'models.expected.md', 'welcome.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
171
apps/web/tests/permission-policy-context.e2e.ts
Normal file
171
apps/web/tests/permission-policy-context.e2e.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// Web acceptance for current sandbox-policy context. A real Chromium drives
|
||||
// the shipped /permission command through all three presets; record mode uses
|
||||
// the real provider, while replay keeps the same provider-authored behavior
|
||||
// keyless. Assertions read the exact durable header, runtime-context messages,
|
||||
// and tool calls, so assistant prose alone cannot satisfy the scenario.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, fixtureUserPrompts, launchWebScaffold, recordFixture,
|
||||
watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/permission-policy-context', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const PROMPTS = [
|
||||
'Can you create or edit a normal file right now under the current policy? Answer directly in one sentence. Do not call a tool just to discover the policy.',
|
||||
'Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools.',
|
||||
'Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools.',
|
||||
'Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion.',
|
||||
] as const
|
||||
|
||||
const PRESET_LABELS = ['Read Only', 'Full access', 'Workspace Write'] as const
|
||||
|
||||
function requestSystems(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'request/header') return []
|
||||
return typeof event.data.header.system === 'string' ? [event.data.header.system] : []
|
||||
})
|
||||
}
|
||||
|
||||
function runtimeContexts(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'user/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return []
|
||||
return event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
})
|
||||
}
|
||||
|
||||
function assistantTexts(events: readonly SessionEvent[]): string[] {
|
||||
return events.flatMap((event) => {
|
||||
if (event.type !== 'assistant/message') return []
|
||||
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('').replaceAll('**', '')
|
||||
return text.length === 0 ? [] : [text]
|
||||
})
|
||||
}
|
||||
|
||||
function callArgs(event: Extract<SessionEvent, { type: 'tool/call' }>): Record<string, unknown> {
|
||||
return JSON.parse(event.data.arguments) as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('web e2e: current sandbox policy reaches the model before tools', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let disposeApproval: (() => void) | undefined
|
||||
let sessionWorkspace: string | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE })
|
||||
disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
|
||||
scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
|
||||
sessionWorkspace = session.header.cwd
|
||||
sessionEvents.push(event)
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
disposeApproval?.()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('switches read-only, danger-full-access, and workspace-write through the real GUI command path', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-permission-policy-context'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual(PROMPTS)
|
||||
}
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
let sessionId: Awaited<ReturnType<WebScaffold['whenTurnSettled']>> | undefined
|
||||
for (const [index, preset] of ['read-only', 'danger-full-access', 'workspace-write'].entries()) {
|
||||
await input.fill(`/permission ${preset}`)
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: `Access mode, current: ${PRESET_LABELS[index]}` })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPTS[index] as string)
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
await expect.poll(() => input.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
}
|
||||
|
||||
await input.fill('/permission read-only')
|
||||
await input.press('Enter')
|
||||
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPTS[3])
|
||||
await input.press('Enter')
|
||||
sessionId = await settled
|
||||
|
||||
if (sessionId === undefined) throw new Error('permission-policy scenario completed no model turn')
|
||||
if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}, 240_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('records cache-safe current policy before the corresponding model behavior', async () => {
|
||||
const systems = requestSystems(sessionEvents)
|
||||
expect(systems).toHaveLength(1)
|
||||
expect(systems[0]).not.toContain('Current DSH file policy:')
|
||||
expect(systems[0]).not.toContain('Approval policy:')
|
||||
expect(systems[0]).not.toContain('Approval prompts are disabled in this session')
|
||||
|
||||
const contexts = runtimeContexts(sessionEvents)
|
||||
expect(contexts).toHaveLength(4)
|
||||
expect(contexts[0]).toContain('Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
|
||||
expect(contexts[0]).toContain('Do not refuse a required modification from this policy alone')
|
||||
expect(contexts[0]).toContain('Approval policy: ask.')
|
||||
expect(contexts[1]).toContain('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
|
||||
expect(contexts[1]).toContain('Approval prompts are disabled in this session')
|
||||
|
||||
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
|
||||
expect(contexts[2]).toContain(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(canonicalPath(sessionWorkspace))}. Some platform temporary areas may also be writable.`)
|
||||
expect(contexts[2]).toContain('Approval policy: ask.')
|
||||
expect(contexts[2]).not.toContain('Approval prompts are disabled in this session')
|
||||
expect(contexts[3]).toContain('Current DSH file policy: read-only.')
|
||||
|
||||
const answers = assistantTexts(sessionEvents)
|
||||
expect(answers.length).toBeGreaterThanOrEqual(4)
|
||||
expect(answers[0]).toMatch(/read-only.*(?:denied|cannot modify|cannot create or edit)/i)
|
||||
expect(answers[1]).toMatch(/does not restrict.*(?:file operations|(?:write\/edit tools|write and edit tools).*one-shot bash commands)/i)
|
||||
expect(answers[2]).toBe('WORKSPACE_POLICY_SEEN')
|
||||
const calls = sessionEvents.filter(
|
||||
(event): event is Extract<SessionEvent, { type: 'tool/call' }> => event.type === 'tool/call',
|
||||
)
|
||||
expect(calls.every(call => call.data.turn === 4)).toBe(true)
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2)
|
||||
const firstCall = calls[0]
|
||||
if (firstCall === undefined) throw new Error('neutral policy task produced no tool call')
|
||||
expect(callArgs(firstCall)['sandbox_permissions']).toBeUndefined()
|
||||
expect(calls.some(call => callArgs(call)['sandbox_permissions'] !== undefined)).toBe(true)
|
||||
expect(sessionEvents.some(event => event.type === 'tool/result'
|
||||
&& JSON.stringify(event.data).includes('[sandbox: file access denied under read-only mode]'))).toBe(true)
|
||||
expect(sessionEvents.some(event => event.type === 'approval/asked')).toBe(true)
|
||||
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
|
||||
expect(await readFile(join(sessionWorkspace, 'policy-neutral.txt'), 'utf8')).toBe('POLICY_NEUTRAL_OK')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('stays clean and keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl'])
|
||||
})
|
||||
})
|
||||
11
apps/web/tests/pin-browse-picker.overlay.yml
Normal file
11
apps/web/tests/pin-browse-picker.overlay.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the
|
||||
# in-browser directory picker. The shipped row is `-auto`, which resolves to
|
||||
# the native OS chooser on a loopback bind with a local display — an
|
||||
# interaction a Playwright page cannot drive, so the resolved backend would
|
||||
# otherwise decide whether the smoke passes. The disable+insert pair mirrors
|
||||
# apps/web/tests/scaffold.ts.
|
||||
- id: directory-picker
|
||||
disabled: true
|
||||
- insert:
|
||||
- id: directory-picker-browse
|
||||
name: '@deepseek-ai/dsh-host-directory-picker-browse'
|
||||
123
apps/web/tests/plan-review.e2e.ts
Normal file
123
apps/web/tests/plan-review.e2e.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Web e2e scenario: the plan-review takeover. The shipped composition mounts
|
||||
// plan mode and its client seat, so `/plan <task>` enters plan mode for real
|
||||
// and the recorded turn ends on exit_plan_mode blocking against the live
|
||||
// userInteraction seam. The composer is then occupied by the plan decision
|
||||
// card — not the generic question flow — and approving it through the card
|
||||
// completes the turn with the approval in the log.
|
||||
// Replay is deterministic: the plan content arrives from replayed chunks, the
|
||||
// review wait is real, and the approve click is the test's own gesture (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 { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-review', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
// The waiting golden owns the decision card; the approved golden owns the
|
||||
// transcript the approval leaves behind — the state the card cannot see.
|
||||
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
|
||||
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
|
||||
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// One command line: /plan enters plan mode and submits the rest as the turn's
|
||||
// message. The task is deliberately self-contained (nothing to explore in a
|
||||
// fresh workspace) so the recorded turn is a plan and its review, and the
|
||||
// approved continuation is one word.
|
||||
const TASK = 'Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. '
|
||||
+ 'Call exit_plan_mode with a short plan of at most five bullet points. '
|
||||
+ 'Once the plan is approved, reply with the single word DONE and stop.'
|
||||
const LINE = `/plan ${TASK}`
|
||||
|
||||
describe('web e2e: plan review takeover 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()
|
||||
// English page: the decision copy is the surface under test, and the
|
||||
// golden pins one language.
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('reviews the plan on a decision card and approves through it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review'))
|
||||
if (MODE !== 'record') {
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK])
|
||||
}
|
||||
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(LINE)
|
||||
await input.press('Enter')
|
||||
|
||||
// The card takes over the input area while exit_plan_mode blocks. Its
|
||||
// presence is a STABLE waiting state (it stays until answered), so a plain
|
||||
// waitFor is race-free.
|
||||
const card = page.locator('[data-plan-review-key]')
|
||||
await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
|
||||
// The plan-review request must NOT land on the generic question flow.
|
||||
expect(await page.locator('[data-question-key]').count()).toBe(0)
|
||||
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
|
||||
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
|
||||
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
|
||||
if (MODE !== 'record') {
|
||||
const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(REVIEW_EXPECTED, snapshot, MODE)
|
||||
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
}
|
||||
|
||||
await card.getByRole('button', { name: 'Approve' }).click()
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
return
|
||||
}
|
||||
// World state: the approval reached the tool, and plan mode is left behind.
|
||||
const results = sessionEvents.filter(e => e.type === 'tool/result')
|
||||
expect(JSON.stringify(results.at(-1))).toContain('Plan approved')
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Card gone; regular input restored.
|
||||
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
|
||||
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
|
||||
await expect.poll(() => page.locator('textarea').first().isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
103
apps/web/tests/pwsh-terminal.e2e.ts
Normal file
103
apps/web/tests/pwsh-terminal.e2e.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Keyless browser regression for pwsh UI parity with bash: a seeded session
|
||||
// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the
|
||||
// api-proxy recomputes presentation views from logged args/result content)
|
||||
// must render as a bash-shaped terminal card with the parsed exit-status
|
||||
// pill — not the generic console-fenced card the pwsh presenter used to
|
||||
// emit. The seed is authored, not recorded: its header line carries no `cwd`
|
||||
// field (seedSession writes the session cwd itself, and a Windows temp path
|
||||
// substituted into the header would not round-trip through its JSON parse),
|
||||
// and no event references the workspace, so the lane replays on any host
|
||||
// with a usable `pwsh` — the lane mounts the pwsh stack through an overlay
|
||||
// (the shipped tree keeps the bash stack).
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/pwsh-terminal', import.meta.url))
|
||||
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
|
||||
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
|
||||
const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url))
|
||||
const PROMPT = 'Run a PowerShell command that fails, then stop.'
|
||||
const SEED_ID = 'pwsh-terminal-web-e2e'
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The overlay swaps the shipped bash executor for @deepseek-ai/dsh-pwsh-local;
|
||||
// a host without a usable `pwsh` cannot boot it, so the lane self-skips,
|
||||
// mirroring the pwshOnly ACP scenarios. The probe follows the executor's own
|
||||
// resolution (Program Files installs on Windows are found even when bare
|
||||
// `pwsh` is not on PATH), the same judgment the tool-pwsh tests reuse; record
|
||||
// mode skips the lane anyway, so the probe stays inert there.
|
||||
const HAS_PWSH = MODE === 'record' ? false : spawnSync(
|
||||
resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'],
|
||||
{ encoding: 'utf8' },
|
||||
).status === 0
|
||||
|
||||
describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
|
||||
beforeAll(async () => {
|
||||
const fixture = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(fixture), 'seed fixture must carry the single drive prompt').toEqual([PROMPT])
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
await seedSession(scaffold, fixture, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders the seeded pwsh call as a terminal card with the parsed exit pill', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal'))
|
||||
// Open the seeded session through content search: the sidebar groups
|
||||
// sessions by workspace and its row order is world-dependent, while the
|
||||
// search index covers the seeded log deterministically.
|
||||
const search = page.getByPlaceholder('Search name, keywords', { exact: false })
|
||||
await search.fill('Run a PowerShell command')
|
||||
const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1)
|
||||
await result.click()
|
||||
await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 })
|
||||
// The tool row is expand-gated: the settled bash-shaped row carries the
|
||||
// shell-family variant, and the terminal card lives in the expanded body.
|
||||
const row = page.locator('[data-tool="pwsh"]').first()
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
if (await row.getAttribute('aria-expanded') !== 'true') await row.click()
|
||||
const card = page.locator('[data-terminal]').first()
|
||||
await card.waitFor({ timeout: 15_000 })
|
||||
// The parsed exit pill replaces the `[exit code: 1]` marker in the output
|
||||
// body — the bash tool's terminal presentation, not the generic fence.
|
||||
const text = await card.textContent()
|
||||
expect(text).toContain('exit code 1')
|
||||
expect(text).toContain('Get-Item : Cannot find path')
|
||||
expect(text).not.toContain('[exit code: 1]')
|
||||
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
|
||||
// normalizeAria collapses the workspace basename with a '/' split, which
|
||||
// misses Windows temp paths; collapse it here too (a no-op on POSIX) so
|
||||
// the golden is platform-independent.
|
||||
.split(scaffold.workspaceCwd.split(/[\\/]/).pop()!).join('{{workspace}}')
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it('guards the lane fixture inventory', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'terminal-card.expected.md'])
|
||||
})
|
||||
})
|
||||
20
apps/web/tests/pwsh-terminal.overlay.yml
Normal file
20
apps/web/tests/pwsh-terminal.overlay.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell
|
||||
# twin: the bash executor row is disabled (patches cannot rename a row — `name`
|
||||
# is a guard) and the pwsh executor + tool are inserted. The permission service
|
||||
# refuses an unconfined executor by design (presets bundle a sandbox mode), so
|
||||
# its row is disabled too — this lane renders a seeded session, never a
|
||||
# permission decision. The seeded scenario renders the logged pwsh call/result
|
||||
# through the real tools on replay; no command executes, but the composition
|
||||
# must boot the pwsh executor, so the lane skips on hosts without a usable
|
||||
# `pwsh`.
|
||||
- id: bash-sandbox
|
||||
name: '@deepseek-ai/dsh-bash-sandbox'
|
||||
disabled: true
|
||||
- id: permission
|
||||
name: '@deepseek-ai/dsh-permission'
|
||||
disabled: true
|
||||
- insert:
|
||||
- id: pwsh-local
|
||||
name: '@deepseek-ai/dsh-pwsh-local'
|
||||
- id: tool-pwsh
|
||||
name: '@deepseek-ai/dsh-tool-pwsh'
|
||||
@@ -18,20 +18,22 @@ import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, 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 SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
|
||||
const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
|
||||
// Final golden: the answered transcript — the question resolved into its tool
|
||||
// round trip and the final reply, the state the composer goldens cannot see.
|
||||
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The options carry long descriptions on purpose: the squeeze assertion below
|
||||
// needs option copy that WRAPS, which is the only shape that reproduces a
|
||||
// collapsed row painting its copy outside its own box.
|
||||
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 two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.'
|
||||
const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.'
|
||||
|
||||
describe('web e2e: resident question composer round trip', () => {
|
||||
let scaffold: WebScaffold
|
||||
@@ -44,12 +46,12 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
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 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -75,11 +77,17 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
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)
|
||||
|
||||
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
|
||||
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
|
||||
if (MODE !== 'record') {
|
||||
// This golden owns the stable question surface; the answered-state
|
||||
// golden below owns the resulting transcript.
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||
}
|
||||
|
||||
// Squeezed card: the option rows are the capped card's scroll content, so
|
||||
@@ -124,9 +132,17 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
|
||||
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 blue = composer.getByRole('checkbox', { name: 'Blue' })
|
||||
await blue.click()
|
||||
const custom = composer.getByRole('textbox')
|
||||
await custom.fill('Include accessibility notes')
|
||||
expect(await blue.getAttribute('aria-checked')).toBe('true')
|
||||
expect(await custom.inputValue()).toBe('Include accessibility notes')
|
||||
if (MODE !== 'record') {
|
||||
const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE)
|
||||
}
|
||||
await custom.press('Enter')
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
@@ -135,10 +151,18 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}
|
||||
// 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')
|
||||
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
|
||||
block.type === 'tool-result'
|
||||
? block.content.filter(item => item.type === 'text').map(item => item.text)
|
||||
: [],
|
||||
)).at(-1)
|
||||
expect(JSON.parse(answerText ?? '')).toEqual({
|
||||
answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
|
||||
})
|
||||
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)
|
||||
expect(await selectedRow.locator('[data-state="warning"]').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.
|
||||
@@ -149,6 +173,12 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}, 200_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.jsonl',
|
||||
'ui.expected.md',
|
||||
'sidebar.expected.md',
|
||||
'composed.expected.md',
|
||||
'answered.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
273
apps/web/tests/queue-actions.e2e.ts
Normal file
273
apps/web/tests/queue-actions.e2e.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
// 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 } from 'vitest'
|
||||
import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.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 UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.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'
|
||||
const EDITED = 'Edited queue item'
|
||||
const TAIL = 'Queue item preserved after stop'
|
||||
const WAKE = 'Wake the preserved queue'
|
||||
|
||||
/** 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')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
|
||||
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 })
|
||||
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.baseUrl, { 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('textarea').first()
|
||||
const firstSettled = scaffold.whenTurnSettled()
|
||||
await input.fill(ACTIVE_PROMPT)
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
|
||||
|
||||
for (const text of [REMOVE, EDIT]) {
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
|
||||
.toBe('false')
|
||||
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' }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
const queueBox = await page.locator('[data-queue-dock]').boundingBox()
|
||||
const composerBox = await page.locator('[data-composer-card]').boundingBox()
|
||||
expect(queueBox).not.toBeNull()
|
||||
expect(composerBox).not.toBeNull()
|
||||
expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
|
||||
expect(queueBox!.x + queueBox!.width)
|
||||
.toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
|
||||
const queueLeftInset = queueBox!.x - composerBox!.x
|
||||
const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
|
||||
const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
|
||||
}
|
||||
})
|
||||
expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
|
||||
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
|
||||
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
|
||||
await editor.fill(EDITED)
|
||||
const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
|
||||
await page.getByRole('button', { name: 'Save queued message' }).click()
|
||||
await page.getByText(EDITED, { exact: true }).waitFor()
|
||||
|
||||
const removeRow = page.getByText(REMOVE, { exact: true }).locator('..')
|
||||
await removeRow.getByRole('button', { name: 'Remove queued message' }).click()
|
||||
await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
|
||||
|
||||
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)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
|
||||
await input.fill(TAIL)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
() => page.getByRole('button', { name: 'Remove queued message' }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(2)
|
||||
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
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)
|
||||
|
||||
const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, 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, TAIL, WAKE])
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
}, 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 })
|
||||
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.baseUrl, { 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('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
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 input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
|
||||
.toBe('false')
|
||||
|
||||
const layoutSnapshot = await captureStableAria(
|
||||
page,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
|
||||
|
||||
const expectAlignedContextPanels = async () => {
|
||||
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
|
||||
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
|
||||
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
|
||||
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')('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
53
apps/web/tests/remote-welcome.e2e.ts
Normal file
53
apps/web/tests/remote-welcome.e2e.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Trusted non-loopback Web access must not wedge on the loopback-only
|
||||
// settings API while the mandatory product notice owns the viewport.
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE } from './support.ts'
|
||||
import { WELCOME_NOTICE_COPY } from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: remote welcome notice', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ remoteAuthority: 'remote.localhost', welcomeNoticePending: true })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('#root', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('advances process-locally and presents the notice again after reload', async () => {
|
||||
const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
|
||||
|
||||
await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
await expect.poll(
|
||||
() => page.locator('#root').evaluate(root => (root as HTMLElement).inert),
|
||||
{ timeout: 15_000 },
|
||||
).toBe(false)
|
||||
const reloadWarnings = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, reloadWarnings)
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -9,20 +9,23 @@
|
||||
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
|
||||
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
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 { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url))
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url))
|
||||
const SYSTEM_PROMPT_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/system-prompt.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
// The scenario's one drive prompt. Record sends it; replay asserts the
|
||||
@@ -35,6 +38,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let settledSessionId: SessionId | undefined
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -43,12 +47,12 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
})
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connect a Workspace so the composer scenarios start live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -69,11 +73,44 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
settledSessionId = sessionId
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 200_000)
|
||||
|
||||
it('records the Web surface, source checkout, and session cwd in the request header', async () => {
|
||||
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
|
||||
const agent = scaffold.ctx.agents.get(settledSessionId)
|
||||
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
|
||||
const system = agent.session.requestHeader()?.system
|
||||
if (system === undefined) throw new Error('the settled Web request has no system prompt')
|
||||
const prefix = system.split('\n\n').slice(0, 4).join('\n\n')
|
||||
.split(REPO_ROOT).join('{{sourceRoot}}')
|
||||
.split(join(scaffold.workspaceCwd, 'workspace')).join('{{cwd}}')
|
||||
.split(scaffold.baseUrl).join('{{webUrl}}')
|
||||
await compareOrRefreshGolden(SYSTEM_PROMPT_EXPECTED, prefix, MODE)
|
||||
})
|
||||
|
||||
it('exposes the assembled Web URL to the real bash tool', async () => {
|
||||
if (settledSessionId === undefined) throw new Error('the drive turn did not publish a session id')
|
||||
const agent = scaffold.ctx.agents.get(settledSessionId)
|
||||
if (agent === undefined) throw new Error(`the settled Web agent ${settledSessionId} is no longer live`)
|
||||
const result = await scaffold.ctx.tools.execute({
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
callId: CallId('web-url-probe'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
|
||||
description: 'Print current Web runtime',
|
||||
},
|
||||
agent,
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
|
||||
.toBe(`${scaffold.baseUrl}\nproduction\n`)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled'))
|
||||
// Browser settled-poll after host completion (host strictly precedes render).
|
||||
@@ -106,7 +143,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true)
|
||||
expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1)
|
||||
await page.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash',
|
||||
name: 'Select model, current DeepSeek-V4-Flash',
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
@@ -129,6 +166,6 @@ describe('web e2e: fresh round trip through the real assembly', () => {
|
||||
it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'system-prompt.expected.md', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
57
apps/web/tests/scaffold-hermetic.e2e.ts
Normal file
57
apps/web/tests/scaffold-hermetic.e2e.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
const bundle = join(root, name)
|
||||
await mkdir(bundle, { recursive: true })
|
||||
await writeFile(join(bundle, 'SKILL.md'), `---
|
||||
name: ${name}
|
||||
description: Must not enter the Web replay scaffold
|
||||
---
|
||||
|
||||
Ambient host state.
|
||||
`)
|
||||
}
|
||||
|
||||
it('isolates replay skill discovery from every ambient host root', async () => {
|
||||
const ambient = await mkdtemp(join(tmpdir(), 'dsh-web-ambient-skills-'))
|
||||
const dshHome = join(ambient, 'dsh-home')
|
||||
const agentsHome = join(ambient, 'agents-home')
|
||||
const bundled = join(ambient, 'bundled')
|
||||
await Promise.all([
|
||||
writeSkill(join(dshHome, 'skills'), 'ambient-dsh'),
|
||||
writeSkill(join(agentsHome, 'skills'), 'ambient-agents'),
|
||||
writeSkill(bundled, 'ambient-bundled'),
|
||||
])
|
||||
|
||||
const originalDshHome = process.env.DSH_HOME
|
||||
const originalAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const originalBundled = process.env.DSH_BUNDLED_SKILL_DIR
|
||||
process.env.DSH_HOME = dshHome
|
||||
process.env.DSH_AGENTS_HOME = agentsHome
|
||||
process.env.DSH_BUNDLED_SKILL_DIR = bundled
|
||||
let scaffold: WebScaffold | undefined
|
||||
try {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = (await scaffold.ctx.skills.list({ cwd: scaffold.workspaceCwd })).map(skill => skill.name)
|
||||
expect(names).not.toContain('ambient-dsh')
|
||||
expect(names).not.toContain('ambient-agents')
|
||||
expect(names).not.toContain('ambient-bundled')
|
||||
} finally {
|
||||
try {
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
if (originalDshHome === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = originalDshHome
|
||||
if (originalAgentsHome === undefined) delete process.env.DSH_AGENTS_HOME
|
||||
else process.env.DSH_AGENTS_HOME = originalAgentsHome
|
||||
if (originalBundled === undefined) delete process.env.DSH_BUNDLED_SKILL_DIR
|
||||
else process.env.DSH_BUNDLED_SKILL_DIR = originalBundled
|
||||
await rm(ambient, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,20 +1,24 @@
|
||||
// Shared scaffold for the keyless browser e2e lane (Agent Note:
|
||||
// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
|
||||
// Boots the REAL web composition — the shipped apps/cli/cordis.yml through
|
||||
// Boots the REAL web composition — the shipped base plus web overlay through
|
||||
// the vendored Loader (the same include boot AppCLIEntry drives), patched the
|
||||
// snapshot way — so a real chromium exercises the real HTTP/SSE wire, the
|
||||
// api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||
// replay (default, keyless: llm-deepseek row disabled, dsh-llm-replay row
|
||||
// inserted in providers mode), record (real adapter + key, harvests fixtures
|
||||
// from live session memory), refresh (keyless replay that rewrites goldens).
|
||||
// snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
|
||||
// downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
|
||||
// replay (default, keyless: normally disables the llm-deepseek row and
|
||||
// inserts dsh-llm-replay in providers mode), record (real adapter + key,
|
||||
// harvests fixtures from live session memory), refresh (keyless replay that
|
||||
// rewrites goldens). A first-run option keeps the real adapter mounted while
|
||||
// masking its credential, without making a model call.
|
||||
//
|
||||
// Composition divergences from `dsh web`, all deliberate, all via include
|
||||
// patches over the SAME tree (never a second yml): temp persistenceRoot;
|
||||
// workspace-context disabled (recorded fixtures must not embed this repo's
|
||||
// AGENTS.md); session-title-llm disabled (its fire-and-forget title call
|
||||
// would race the loop for the session's replay cursor); webserver pinned to
|
||||
// port 0 with the built dist; keyless modes disable llm-deepseek and fill
|
||||
// the open llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// patches after the shipped surface overlay, over the SAME tree (never a
|
||||
// second yml): temp persistenceRoot; host-level skill roots confined to the
|
||||
// temp workspace while project skill discovery remains real; workspace-context
|
||||
// disabled (recorded fixtures must not embed this repo's AGENTS.md);
|
||||
// session-title-llm disabled (its fire-and-forget title call would race the
|
||||
// loop for the session's replay cursor); webserver pinned to port 0 with the
|
||||
// built dist; ordinary keyless modes disable llm-deepseek and fill the open
|
||||
// llm seam post-boot with installLlmReplay on the settled root ctx
|
||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||
// assertConsumed for the teardown fixture-consumption check).
|
||||
import { existsSync } from 'node:fs'
|
||||
@@ -28,7 +32,12 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
|
||||
import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot'
|
||||
import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { dshHomePath } from '@deepseek-ai/dsh-paths'
|
||||
import {
|
||||
WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
|
||||
} from '@deepseek-ai/dsh-client-ui-settings-general'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
|
||||
import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
|
||||
import SessionStore, {
|
||||
@@ -44,9 +53,10 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
// Empty type imports carry the httpServer/agents/sessionPersistence Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { prepareWebRuntimeContext } from '../../cli/src/web.ts'
|
||||
import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts'
|
||||
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */
|
||||
/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
|
||||
export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
|
||||
|
||||
/**
|
||||
@@ -60,8 +70,9 @@ export function webSnapshotMode(): WebSnapshotMode {
|
||||
throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
/** The shipped composition under test: apps/cli's config tree. */
|
||||
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
|
||||
/** The shipped composition under test: apps/cli's shared base and web overlay. */
|
||||
const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/config/base.cordis.yml')
|
||||
const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/config/web.cordis.yml')
|
||||
|
||||
// Replay publishes the provider catalog the gateway routes to (providers
|
||||
// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
|
||||
@@ -69,16 +80,24 @@ const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml')
|
||||
// post-step pressure check would warn every step). The published
|
||||
// contextWindow keeps that pressure path provably inert for small fixtures.
|
||||
const REPLAY_PROVIDERS = [{
|
||||
id: 'deepseek',
|
||||
id: 'deepseek-official',
|
||||
name: 'DeepSeek',
|
||||
models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }],
|
||||
}]
|
||||
|
||||
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
|
||||
if (contextWindow === undefined) return REPLAY_PROVIDERS
|
||||
return REPLAY_PROVIDERS.map(provider => ({
|
||||
...provider,
|
||||
models: provider.models.map(model => ({ ...model, contextWindow })),
|
||||
}))
|
||||
}
|
||||
|
||||
/** A booted web scaffold: real composition, mode-selected model backend, temp world. */
|
||||
export interface WebScaffold {
|
||||
/** The active snapshot mode this scaffold booted under. */
|
||||
mode: WebSnapshotMode
|
||||
/** Browser-facing origin (http://127.0.0.1:<bound port>). */
|
||||
/** Browser-facing origin for the bound test server. */
|
||||
baseUrl: string
|
||||
/** Settled root context (the in-process barrier seam; headless event subscription is its sanctioned use). */
|
||||
ctx: Context
|
||||
@@ -86,6 +105,8 @@ export interface WebScaffold {
|
||||
workspaceCwd: string
|
||||
/** Temp persistence root (seeded sessions land here through the real API). */
|
||||
persistenceRoot: string
|
||||
/** Isolated harness home the settings/credentials rows write ($DSH_HOME double). */
|
||||
harnessHome: string
|
||||
/** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
|
||||
whenTurnSettled(timeoutMs?: number): Promise<SessionId>
|
||||
/** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
|
||||
@@ -94,6 +115,12 @@ export interface WebScaffold {
|
||||
|
||||
/** Options for {@link launchWebScaffold}. */
|
||||
export interface LaunchOptions {
|
||||
/**
|
||||
* Optional product overlay applied after the shipped Web surface and before
|
||||
* the scaffold's hermetic test patches, matching AppCLIEntry's `--config`
|
||||
* ordering.
|
||||
*/
|
||||
extraOverlayPath?: string
|
||||
/**
|
||||
* Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
|
||||
* in replay/refresh modes; ignored in record mode (the real adapter
|
||||
@@ -102,6 +129,11 @@ export interface LaunchOptions {
|
||||
* mounts).
|
||||
*/
|
||||
replayFixture?: string
|
||||
/**
|
||||
* Recorded child logs assigned in child creation order. Each child owns its
|
||||
* own positional replay cursor across initial and continuation turns.
|
||||
*/
|
||||
replayChildFixtures?: string[]
|
||||
/**
|
||||
* Optional replay.override.json sidecar (whole-script replacement or
|
||||
* `{ patches }` augmentation) for throw/hang scenarios not expressible as
|
||||
@@ -110,6 +142,8 @@ export interface LaunchOptions {
|
||||
replayOverride?: string
|
||||
/** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
|
||||
paceMs?: number
|
||||
/** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
|
||||
replayContextWindow?: number
|
||||
/**
|
||||
* Tool presentation mode patched onto the shipped `tools` row (`code`
|
||||
* collapses the wire to run_code + the SDK prompt section). Omit for the
|
||||
@@ -123,6 +157,31 @@ export interface LaunchOptions {
|
||||
* remain reconstructable without making the tools a product default.
|
||||
*/
|
||||
cordisTools?: boolean
|
||||
/**
|
||||
* Keep the shipped DeepSeek adapter mounted while masking the process
|
||||
* environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
|
||||
* keyless first-run configuration lane; the default disables the adapter.
|
||||
*/
|
||||
deepSeekMissingCredential?: boolean
|
||||
/**
|
||||
* Patch the shipped DeepSeek search row to a deterministic endpoint and
|
||||
* credential reference. Browser search scenarios keep the real provider and
|
||||
* credentials seam while avoiding external search traffic and ambient keys.
|
||||
*/
|
||||
deepSeekSearch?: {
|
||||
/** Anthropic-compatible base URL; the provider appends `/messages`. */
|
||||
baseURL: string
|
||||
/** Credential reference resolved by the shipped search provider. */
|
||||
apiKeyEnv: string
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
* Browse through a trusted non-loopback hostname that the browser resolves
|
||||
* to loopback (for example `*.localhost`). The test server stays bound to
|
||||
* 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
|
||||
*/
|
||||
remoteAuthority?: string
|
||||
}
|
||||
|
||||
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
|
||||
@@ -142,6 +201,7 @@ async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persiste
|
||||
export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
|
||||
requireDist()
|
||||
const mode = webSnapshotMode()
|
||||
const browserHost = options.remoteAuthority ?? '127.0.0.1'
|
||||
if (mode === 'record') {
|
||||
// Both owning vitest configs (web unconditionally, snapshot in record
|
||||
// mode) load the repo-root .env before this file runs.
|
||||
@@ -149,7 +209,26 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
|
||||
}
|
||||
}
|
||||
if (mode === 'record' && options.deepSeekMissingCredential === true) {
|
||||
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
|
||||
}
|
||||
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
|
||||
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
|
||||
let credentialEnvironmentRestored = false
|
||||
const restoreCredentialEnvironment = (): void => {
|
||||
if (credentialEnvironmentRestored || !maskDeepSeekCredential) return
|
||||
credentialEnvironmentRestored = true
|
||||
if (originalDeepSeekCredential === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
} else {
|
||||
process.env.DEEPSEEK_API_KEY = originalDeepSeekCredential
|
||||
}
|
||||
}
|
||||
const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
|
||||
// Isolated harness home: the settings/credentials rows resolve $DSH_HOME
|
||||
// paths at load, and an in-process boot must NEVER touch the developer's
|
||||
// real ~/.dsh document or credential file.
|
||||
const harnessHome = join(workspaceCwd, '.dsh-home')
|
||||
let persistenceRoot: string
|
||||
try {
|
||||
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
|
||||
@@ -159,27 +238,79 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
|
||||
throw error
|
||||
}
|
||||
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
|
||||
// The include patch set — the same mechanism AppCLIEntry and the ACP
|
||||
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
|
||||
// stops matching a row fails the boot sweep loudly instead of drifting).
|
||||
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_OVERLAY_PATH)
|
||||
const extraOverlayPatches = options.extraOverlayPath === undefined
|
||||
? []
|
||||
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
|
||||
const patches: PatchOptions[] = [
|
||||
...surfacePatches,
|
||||
...extraOverlayPatches,
|
||||
{ id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
|
||||
// storage-json's './.storages' yml default is cwd-relative and resolves
|
||||
// per write; the scaffold restores the original cwd after boot, so the
|
||||
// row gets an absolute temp root (removed with the workspace at close).
|
||||
{ id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
|
||||
// storage-json's yml root is anchored to the real $DSH_HOME; pin the row
|
||||
// to an absolute temp root (removed with the workspace at close) so tests
|
||||
// never write the user's harness home.
|
||||
{ id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
|
||||
// Skill discovery is model-visible input. Pin every host-level root inside
|
||||
// the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
|
||||
// cannot change replay requests or conversation goldens. Project roots stay
|
||||
// enabled against the same empty temp workspace, preserving the real seam.
|
||||
{
|
||||
id: 'skill-local',
|
||||
config: {
|
||||
dshHome: join(workspaceCwd, '.dsh-home'),
|
||||
agentsHome: join(workspaceCwd, '.agents-home'),
|
||||
bundledSkillDir: join(workspaceCwd, '.bundled-skills'),
|
||||
watch: false,
|
||||
},
|
||||
},
|
||||
// fs/bash cwd default to process.cwd(); the gateway injects the same
|
||||
// value into session.cwd — chdir below anchors all three to the temp
|
||||
// workspace, keeping the composition untouched.
|
||||
{ id: 'workspace-context', disabled: true },
|
||||
{ id: 'session-title-llm', disabled: true },
|
||||
{ id: 'webserver', config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX } },
|
||||
// Fixture sessions must never leave the process: the shipped row defaults
|
||||
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
|
||||
// names in the ambient environment).
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX },
|
||||
},
|
||||
...options.remoteAuthority === undefined
|
||||
? []
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
{ id: 'settings', config: { dshHome: harnessHome } },
|
||||
{ id: 'credentials', config: { dshHome: harnessHome } },
|
||||
// The shipped directory-picker row is the -auto chooser, which resolves
|
||||
// the interaction from the RUNNING host (display, SSH launch, bind). The
|
||||
// lane's goldens are interaction-specific (workspace-management drives
|
||||
// the in-app browse dialog), so pin -browse deterministically on every
|
||||
// host: patch `name` is an assertion, not an override, hence the
|
||||
// disable+insert pair.
|
||||
{ id: 'directory-picker', disabled: true },
|
||||
{ insert: [{ id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' }] },
|
||||
...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
|
||||
...options.cordisTools === true
|
||||
? [{ insert: [{ id: 'tool-cordis', name: 'cordis:tool-cordis' }] }]
|
||||
: [],
|
||||
...mode === 'record' ? [] : [{ id: 'llm-deepseek', disabled: true }],
|
||||
...options.deepSeekSearch === undefined
|
||||
? []
|
||||
: [{
|
||||
id: 'web-search-deepseek',
|
||||
config: {
|
||||
apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
|
||||
baseURL: options.deepSeekSearch.baseURL,
|
||||
},
|
||||
}],
|
||||
...mode === 'record' || options.deepSeekMissingCredential === true
|
||||
? []
|
||||
: [{ id: 'llm-deepseek', disabled: true }],
|
||||
]
|
||||
|
||||
// Sessions inherit the gateway's process.cwd() default; run the boot from
|
||||
@@ -191,38 +322,48 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
try {
|
||||
process.chdir(workspaceCwd)
|
||||
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
|
||||
// This direct Loader harness supplies the same root-path capability as app-boot.
|
||||
ctx.provide('dshHomePath', dshHomePath)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
// The shipped CLI deliberately has no dependency on this opt-in package.
|
||||
// Keep the Loader row real without broadening the product installation.
|
||||
if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis
|
||||
prepareWebRuntimeContext(ctx, REPO_ROOT, 'production')
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(resolve(CONFIG_PATH)).href, patches },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, 'web e2e scaffold')
|
||||
if (options.welcomeNoticePending !== true) {
|
||||
await ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
|
||||
op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
|
||||
}])
|
||||
}
|
||||
const boundPort = ctx.get('httpServer')?.port
|
||||
if (boundPort === undefined) {
|
||||
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
|
||||
}
|
||||
port = boundPort
|
||||
|
||||
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
|
||||
// in keyless modes; a scenario with no fixture leaves the seam empty so a
|
||||
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
|
||||
// plugin row, returns the ReplayHandle for the teardown consumption check.
|
||||
// Fill the open llm seam on the settled root ctx. Ordinary keyless modes
|
||||
// disable llm-deepseek; the first-run lane keeps it mounted but has no
|
||||
// replay fixture and never streams. The direct install, unlike the plugin
|
||||
// row, returns the ReplayHandle for the teardown consumption check.
|
||||
if (mode !== 'record' && options.replayFixture !== undefined) {
|
||||
replayHandle = installLlmReplay(ctx, {
|
||||
file: options.replayFixture,
|
||||
providers: REPLAY_PROVIDERS,
|
||||
providers: replayProviders(options.replayContextWindow),
|
||||
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
|
||||
...(options.replayChildFixtures === undefined ? {} : { childFiles: options.replayChildFixtures }),
|
||||
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
|
||||
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
|
||||
restoreCredentialEnvironment()
|
||||
if (cleanupFailures.length > 0) {
|
||||
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
|
||||
}
|
||||
@@ -232,31 +373,27 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
}
|
||||
|
||||
return {
|
||||
harnessHome,
|
||||
mode,
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
baseUrl: `http://${browserHost}:${port}`,
|
||||
ctx,
|
||||
workspaceCwd,
|
||||
persistenceRoot,
|
||||
// Barrier stack: the in-process turn/end identifies the session, then
|
||||
// agent.whenIdle() covers the persistence flush (the idle flip follows
|
||||
// the flush), and the caller's browser settled-poll comes last because
|
||||
// host completion strictly precedes render.
|
||||
// Barrier stack: the in-process turn/end identifies the session, its
|
||||
// explicit flush makes the transcript durable, and the caller's browser
|
||||
// settled-poll comes last because host completion strictly precedes render.
|
||||
whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> {
|
||||
return new Promise<SessionId>((resolveSettled, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
off()
|
||||
reject(new Error(`no turn/end within ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
const off = ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => {
|
||||
const off = ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type !== 'turn/end') return
|
||||
clearTimeout(timer)
|
||||
off()
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent === undefined) {
|
||||
reject(new Error(`turn/end for ${session.id} but no live agent`))
|
||||
return
|
||||
}
|
||||
agent.whenIdle().then(() => { resolveSettled(session.id) }, reject)
|
||||
ctx.sessions.flush(session)
|
||||
.then(() => { resolveSettled(session.id) }, reject)
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -270,7 +407,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
try {
|
||||
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
|
||||
} finally {
|
||||
restoreCredentialEnvironment()
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
|
||||
},
|
||||
}
|
||||
@@ -335,15 +476,29 @@ export function fixtureUserPrompts(fixtureText: string): string[] {
|
||||
* @param id - the seeded session id (stable for deterministic goldens).
|
||||
* @returns the seeded id.
|
||||
*/
|
||||
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
|
||||
/**
|
||||
* Realize a recorded seed fixture against one scaffold: substitute the
|
||||
* `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the
|
||||
* scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
|
||||
* price content exactly as the host will fold it) and still pass the result
|
||||
* through {@link seedSession}.
|
||||
* @param scaffold - the booted scaffold whose workspace the seed targets.
|
||||
* @param fixtureText - the committed seed fixture text.
|
||||
* @param id - the session id the seed is realized for.
|
||||
* @returns the realized fixture text.
|
||||
*/
|
||||
export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, id: string): string {
|
||||
const realized = fixtureText
|
||||
.split('{{sessionId}}').join(id)
|
||||
.split('{{cwd}}').join(scaffold.workspaceCwd)
|
||||
const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd
|
||||
const rewritten = fixtureCwd === undefined
|
||||
return fixtureCwd === undefined
|
||||
? realized
|
||||
: realized.split(fixtureCwd).join(scaffold.workspaceCwd)
|
||||
const events = parseSessionLog(rewritten)
|
||||
}
|
||||
|
||||
export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise<SessionId> {
|
||||
const events = parseSessionLog(realizeSeedFixture(scaffold, fixtureText, id))
|
||||
if (events.length === 0) throw new Error('seed fixture has no events')
|
||||
const last = events[events.length - 1]!
|
||||
// An open final turn would be mutated by resume's crash repair on first
|
||||
@@ -377,22 +532,39 @@ export async function seedSession(scaffold: WebScaffold, fixtureText: string, id
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration
|
||||
* volatility collapse to stable tokens.
|
||||
* Normalize an aria snapshot: uuid, cwd, workspace-basename, duration, and
|
||||
* decode-throughput volatility collapse to stable tokens.
|
||||
*
|
||||
* Throughput needs a token for the same reason durations do, and no fixture
|
||||
* can supply one: the figure divides a replayed step's output tokens by the
|
||||
* wall time the local run took to stream them, so it moves between two runs
|
||||
* on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay
|
||||
* (26333 tok/s for a 3 ms stream).
|
||||
*/
|
||||
function normalizeAria(snapshot: string, workspaceCwd: string): string {
|
||||
// The header breadcrumb renders the workspace's basename, not the full
|
||||
// The session heading renders the workspace's basename, not the full
|
||||
// path, so both spellings must collapse to the token.
|
||||
const base = workspaceCwd.split('/').pop()!
|
||||
return snapshot
|
||||
.split(workspaceCwd).join('{{cwd}}')
|
||||
.split(base).join('{{workspace}}')
|
||||
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
|
||||
.replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}')
|
||||
// The optional space in `\d+m ?\d+s` covers both minute spellings: the
|
||||
// stats line's compact `2m42s` and the message-chrome template's `2m 42s`.
|
||||
.replace(
|
||||
/~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m ?\d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
|
||||
duration => duration.startsWith('~') ? duration : '{{duration}}',
|
||||
)
|
||||
.replace(
|
||||
/约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
|
||||
duration => duration.startsWith('约') ? duration : '{{duration}}',
|
||||
)
|
||||
.replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
|
||||
// Message IconActions clocks widen by calendar day/year; collapse every
|
||||
// shape so goldens stay stable across midnight and year boundaries.
|
||||
.replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
|
||||
.replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
|
||||
}
|
||||
|
||||
@@ -438,9 +610,9 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
|
||||
* holds exactly the expected files and every committed JSONL is a scrub
|
||||
* fixed-point without a run-local browser RPC id.
|
||||
* Fixture-inventory guard: the scenario directory holds exactly the expected
|
||||
* files and every committed JSONL is a scrub fixed-point without a run-local
|
||||
* browser RPC id.
|
||||
* @param dir - the scenario snapshot directory.
|
||||
* @param expected - the exact expected file inventory.
|
||||
*/
|
||||
|
||||
170
apps/web/tests/search-card.snapshot.ts
Normal file
170
apps/web/tests/search-card.snapshot.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled search-card snapshot: boots the real built `packages/client/*/lib/
|
||||
// client.js` bundles through AppWebEntry's ModuleLoader path against the keyless
|
||||
// FixtureApiClient transport (no API key, no model round), opens the fixture
|
||||
// session, and pins the search card the `grep` turn (fixture turn 66) renders in
|
||||
// the assembled application. The built-boot smoke proves the graph boots but
|
||||
// carries no behavior assertions by contract; this is the assembled-output check
|
||||
// that a broken SearchRow registration or a dropped card would fail — the
|
||||
// per-package suites bench over src and cannot see the bundled wiring.
|
||||
//
|
||||
// Keyless and deterministic: the fixture is the fake server, so the grep turn's
|
||||
// matches, its truncation summary, and its head/tail cap are fixed in the
|
||||
// fixture, not harvested from a live model. The recovery-footer arm is a pure
|
||||
// derivation over the result view, pinned at every render site by the
|
||||
// ui-conversation suite; here the fixture turn exercises the assembled card
|
||||
// shape and its cap.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/search-card/grep-card.expected.txt')
|
||||
const refreshing = process.env.DSH_SNAPSHOT === 'record' || process.env.DSH_SNAPSHOT === 'refresh'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
/** Normalize a rendered search card to a stable text shape: the kind, the banner
|
||||
* summary, each file header (path + count), each visible match line, the expand
|
||||
* control label, and the recovery footer. CSS-module class names carry a
|
||||
* per-build hash in one of two schemes — ui-primitives emits `_<name>_<hash>`
|
||||
* (name bounded by underscores), ui-conversation emits `<hash>_<name>` (name at
|
||||
* the end). `hasClass` matches a module class by its logical name under either,
|
||||
* without matching a longer name that contains it (`line` must not hit
|
||||
* `lineNumber`). */
|
||||
function hasClass(el: Element, name: string): boolean {
|
||||
return [...el.classList].some(cls => cls === name || cls.endsWith(`_${name}`) || cls.startsWith(`_${name}_`) || cls.includes(`_${name}_`))
|
||||
}
|
||||
|
||||
function cardShape(root: Element): string {
|
||||
const card = root.querySelector('[data-search]')
|
||||
if (card === null) return '<no search card>'
|
||||
const pick = (from: Element, name: string): Element[] =>
|
||||
[...from.querySelectorAll('*')].filter(el => hasClass(el, name))
|
||||
const lines: string[] = [`kind=${card.getAttribute('data-search')}`]
|
||||
const summary = pick(card, 'summary')[0]?.textContent?.trim()
|
||||
if (summary !== undefined && summary !== '') lines.push(`summary=${summary}`)
|
||||
for (const header of pick(card, 'fileHeader')) lines.push(`file=${header.textContent?.trim() ?? ''}`)
|
||||
for (const row of pick(card, 'line')) lines.push(`line=${row.textContent?.trim() ?? ''}`)
|
||||
const expand = pick(card, 'expand')[0]?.textContent?.trim()
|
||||
if (expand !== undefined && expand !== '') lines.push(`expand=${expand}`)
|
||||
const recovery = pick(root, 'searchRecovery')[0]?.textContent?.trim()
|
||||
if (recovery !== undefined && recovery !== '') lines.push(`recovery=${recovery}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// English pinned before boot so the sidebar's role/text locators stay
|
||||
// deterministic (the built-boot smoke's convention).
|
||||
localStorage.setItem('dsh.locale', 'en')
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('assembled search card', () => {
|
||||
it('renders the grep card, its truncation summary, and its capped head/tail slice from the built bundles', async () => {
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
loadBundle: async (url) => {
|
||||
const code = bundles.get(url)
|
||||
if (code === undefined) throw new Error(`missing built bundle ${url}`)
|
||||
;(0, eval)(code)
|
||||
},
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
|
||||
// Wait for chat content to reach the fixture's later turns (the bash sample
|
||||
// is turn 65, the grep card turn 66).
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
// The grep turn's keyed SearchRow composes ToolRow: the card is collapsed
|
||||
// by default, so wait for the summary row, then expand it to reach the card.
|
||||
await waitFor(() => {
|
||||
const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool'))
|
||||
expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep')
|
||||
}, { timeout: 10_000 })
|
||||
|
||||
// `data-tool` sits on the ToolRow root; the collapsed row is the expand
|
||||
// toggle. Click it so the card and its recovery footer mount, then shape the
|
||||
// whole row (the card lives inside ToolRow's body wrapper).
|
||||
const grepRow = document.querySelector('[data-tool="grep"]')!
|
||||
act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) })
|
||||
await waitFor(() => {
|
||||
expect(grepRow.querySelector('[data-search]')).not.toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
const shape = cardShape(grepRow)
|
||||
if (refreshing) {
|
||||
mkdirSync(dirname(EXPECTED), { recursive: true })
|
||||
writeFileSync(EXPECTED, shape)
|
||||
}
|
||||
await expect(shape).toMatchFileSnapshot(EXPECTED)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
// Web e2e scenario: seeded history. A recorded session seeded cold through
|
||||
// the REAL persistence API renders purely from the log — the surface nothing
|
||||
// else covers: sidebar cold listing, the implicit resume/attach inside the
|
||||
// history RPC, history-page tool views, and the client fold of historical
|
||||
// history RPC, history-page tool views, and the client's log-ordered transcript
|
||||
// events — with ZERO model calls in replay (no replay fixture; a stray stream
|
||||
// fails loud on the open llm seam). The seed is a recorded fixture under the
|
||||
// fails loud on the open llm seam). The cold session also carries the one
|
||||
// keyless command-row surface: an Access-chip pick runs `/permission` on the
|
||||
// host, so the settled row's copy has a golden here. The seed is a recorded
|
||||
// fixture under the
|
||||
// same record discipline as every other: DSH_SNAPSHOT=record drives the turn
|
||||
// live through the composer (real read tool against seeded workspace files)
|
||||
// and harvests seed.jsonl; replay/refresh seed it cold and only render.
|
||||
@@ -12,21 +15,142 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
launchWebScaffold, realizeSeedFixture, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url))
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url))
|
||||
// The command-row golden: the same conversation after one /permission switch,
|
||||
// which is the only surface that shows a settled command row's copy.
|
||||
const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'seeded-history-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
/**
|
||||
* Append a complete, valid compaction transaction over the recorded turn's own
|
||||
* surface. The recording stays model-authentic and reusable; replay adds this
|
||||
* deterministic condition before seeding it cold, so the scenario pins the bug
|
||||
* this change fixes — a landed compaction must not erase history the reader
|
||||
* already saw — through the real host and the real browser.
|
||||
* @param raw - the seed fixture text, already realized (placeholder-free) so
|
||||
* the shadow price below is computed from the exact strings the host folds.
|
||||
* @param meter - the composed token meter; the appended `compact/summary`'s
|
||||
* shadow price must be the exact heuristic price of the shadowed nodes, the
|
||||
* way compact-basic derives it, because the token-meter projections subtract
|
||||
* it verbatim.
|
||||
* @returns the fixture with a compacted turn appended.
|
||||
*/
|
||||
function withCompaction(raw: string, meter: TokenMeterService): string {
|
||||
const lines = raw.trimEnd().split('\n')
|
||||
const events = lines.slice(1).map(line => JSON.parse(line) as {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
surfaceOp?: unknown
|
||||
data?: { turn?: unknown; message?: unknown; content?: unknown; callId?: unknown; isError?: unknown }
|
||||
})
|
||||
const surfaceSeqs = events
|
||||
.filter(event => event.surfaceOp === 'append'
|
||||
&& (event.type === 'user/message'
|
||||
|| event.type === 'assistant/message'
|
||||
|| event.type === 'tool/result'))
|
||||
.map(event => event.seq)
|
||||
const first = surfaceSeqs[0]
|
||||
const last = surfaceSeqs.at(-1)
|
||||
const tail = events.at(-1)
|
||||
if (first === undefined || last === undefined || tail === undefined) {
|
||||
throw new Error('seeded-history compaction requires a non-empty closed surface')
|
||||
}
|
||||
// The transaction opens the turn after the recording's last closed one; read
|
||||
// it from the fixture so a re-recording with a different turn count stays
|
||||
// valid instead of appending a duplicate turn number.
|
||||
const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
|
||||
if (typeof lastTurn !== 'number') {
|
||||
throw new Error('seeded-history compaction requires a recording ending on a closed turn')
|
||||
}
|
||||
const turn = lastTurn + 1
|
||||
let seq = tail.seq + 1
|
||||
let time = tail.time + 1
|
||||
/**
|
||||
* Append one event at the next seq/time.
|
||||
* @param event - the event body, without seq/time.
|
||||
* @returns the seq it took, so provenance cites the push instead of arithmetic over the push order below.
|
||||
*/
|
||||
const at = (event: Record<string, unknown>): number => {
|
||||
const taken = seq++
|
||||
lines.push(JSON.stringify({ ...event, seq: taken, time: time++ }))
|
||||
return taken
|
||||
}
|
||||
at({ type: 'turn/start', data: { turn } })
|
||||
const startSeq = at({ type: 'compact/start', data: { turn } })
|
||||
// Load-bearing exactness: the projections subtract this count verbatim, so
|
||||
// it must equal what the host's fold prices for these nodes. The estimator
|
||||
// prices message CONTENT only, so a minimal wrapper per storage shape is
|
||||
// exact — pre-identity rows carry bare `content` (the persistence read path
|
||||
// upgrades them), a current row carries the full `message` envelope.
|
||||
const priceRow = (row: (typeof events)[number]): number => {
|
||||
if (row.data?.message !== undefined) {
|
||||
const message = deriveEventMessage(row as unknown as SessionEvent)
|
||||
return message === null ? 0 : meter.estimateMessage(message)
|
||||
}
|
||||
const content = row.data?.content as ContentBlock[]
|
||||
if (row.type === 'tool/result') {
|
||||
return meter.estimateMessage({
|
||||
content: [{ type: 'tool-result', toolCallId: row.data?.callId, content, isError: row.data?.isError === true }],
|
||||
} as unknown as Message)
|
||||
}
|
||||
// An empty-content assistant message derives no transcript entry.
|
||||
if (row.type === 'assistant/message' && content.length === 0) return 0
|
||||
return meter.estimateMessage({ content } as unknown as Message)
|
||||
}
|
||||
const shadowedTokenCount = surfaceSeqs.reduce((total, surfaceSeq) => {
|
||||
const event = events.find(candidate => candidate.seq === surfaceSeq)
|
||||
if (event === undefined) throw new Error(`seeded-history compaction: shadowed seq ${surfaceSeq} is not in the seed`)
|
||||
return total + priceRow(event)
|
||||
}, 0)
|
||||
const summarySeq = at({
|
||||
type: 'compact/summary',
|
||||
data: {
|
||||
summary: [{
|
||||
type: 'text',
|
||||
text: '## Cold resume compact summary\n\n- The exact summary remains available.',
|
||||
}],
|
||||
shadowedRange: { start: first, end: last },
|
||||
shadowedSeqs: surfaceSeqs,
|
||||
shadowedTokenCount,
|
||||
provider: 'snapshot',
|
||||
model: 'snapshot-compactor',
|
||||
},
|
||||
})
|
||||
at({
|
||||
type: 'user/message',
|
||||
data: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
|
||||
}],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: first, end: last },
|
||||
sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
|
||||
})
|
||||
at({ type: 'compact/end', data: { turn } })
|
||||
at({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
@@ -35,7 +159,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The workspace-aware flow runs sessions in <workspaceRoot>/workspace
|
||||
// The workspace-aware flow runs sessions in <workspaceCwd>/workspace
|
||||
// (the composer's default draft name); the read-tool targets must live in
|
||||
// that session cwd. Pre-creating the directory is safe: create-by-name
|
||||
// adopts an existing directory.
|
||||
@@ -46,10 +170,13 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (MODE !== 'record') {
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
const meter = scaffold.ctx.get('tokenMeter')
|
||||
if (meter === undefined) throw new Error('seeded-history requires the composed token meter')
|
||||
const realized = realizeSeedFixture(scaffold, raw, SEED_ID)
|
||||
await seedSession(scaffold, withCompaction(realized, meter), SEED_ID)
|
||||
}
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -112,26 +239,113 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await sessionRow.click()
|
||||
// Settled barrier for history: the recorded final assistant text renders.
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText('Context compacted', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
// Tool cards render from logged tool/call + tool/result alone (views are
|
||||
// host-recomputed per page; the generic card is the documented default).
|
||||
const toolRows = page.locator('[data-variant], [data-sample]')
|
||||
await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// The bug this fixes: the compaction shadowed the whole recorded surface on
|
||||
// the model side, and the prompt and full tool output are still on screen.
|
||||
expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
|
||||
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: '<system-reminder>\n'
|
||||
+ 'The following workspace instructions may be relevant to your work. '
|
||||
+ 'Use them as guidance when applicable.\n\n'
|
||||
+ Array.from({ length: 24 }, (_, index) => `Instruction ${index + 1}: preserve the logged context contract.`).join('\n')
|
||||
+ '\n</system-reminder>',
|
||||
}],
|
||||
source: {
|
||||
kind: 'workspace-instructions',
|
||||
form: 'instructions',
|
||||
baseline: true,
|
||||
changes: [{
|
||||
action: 'set',
|
||||
scope: '.\u0000AGENTS.md',
|
||||
path: 'AGENTS.md',
|
||||
digest: 'context-injection-browser-snapshot',
|
||||
}],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
// The header names the producer the durable source records, so the
|
||||
// reconciled instruction file is readable without expanding the row.
|
||||
await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
|
||||
await page.getByRole('button', {
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the selected id, but no catalog
|
||||
// adapter exists to provide its presentation name.
|
||||
name: '选择模型,当前 deepseek-v4-flash',
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
// This scenario deliberately leaves the LLM seam open to prove zero
|
||||
// model calls. History still restores the routed id, but without an
|
||||
// advertised catalog row the selector prompts for a listed replacement.
|
||||
await page.getByRole('button', { name: 'Select model', exact: true })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
|
||||
const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
|
||||
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
const collapsedIcon = disclosure.locator('svg').first()
|
||||
const collapsedIconBox = await collapsedIcon.boundingBox()
|
||||
expect(collapsedIconBox?.width).toBe(14)
|
||||
expect(collapsedIconBox?.height).toBe(14)
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
const body = page.locator('[data-context-injection-body]')
|
||||
await body.waitFor({ timeout: 5_000 })
|
||||
// The instructions form names the file it reconciled above the text, and
|
||||
// the text keeps the framing the model read rather than a cleaned excerpt.
|
||||
expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded'])
|
||||
expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>')
|
||||
const headerBox = await disclosure.boundingBox()
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
|
||||
expect(headerBox.height).toBe(24)
|
||||
expect(bodyBox.x - headerBox.x).toBe(22)
|
||||
expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4)
|
||||
expect(bodyBox.height).toBe(141)
|
||||
|
||||
const style = await body.evaluate((element) => {
|
||||
const computed = getComputedStyle(element)
|
||||
return {
|
||||
backgroundColor: computed.backgroundColor,
|
||||
borderRadius: computed.borderRadius,
|
||||
color: computed.color,
|
||||
fontSize: computed.fontSize,
|
||||
lineHeight: computed.lineHeight,
|
||||
padding: [
|
||||
computed.paddingTop,
|
||||
computed.paddingRight,
|
||||
computed.paddingBottom,
|
||||
computed.paddingLeft,
|
||||
],
|
||||
scrolls: element.scrollHeight > element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(style).toEqual({
|
||||
backgroundColor: 'rgb(249, 250, 251)',
|
||||
borderRadius: '8px',
|
||||
color: 'rgb(129, 133, 140)',
|
||||
fontSize: '11px',
|
||||
lineHeight: '16px',
|
||||
padding: ['10px', '16px', '12px', '12px'],
|
||||
scrolls: true,
|
||||
})
|
||||
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log stay details-inert', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow'))
|
||||
// Interaction over cold-resumed history: read summaries are host-open
|
||||
@@ -140,18 +354,78 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const fileLink = page.locator('[data-variant="read"] button').first()
|
||||
await fileLink.waitFor({ timeout: 10_000 })
|
||||
const frame = page.locator('[style*="grid-template-columns"]').first()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBeNull()
|
||||
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
|
||||
await fileLink.click()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBeNull()
|
||||
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
|
||||
// Path label survives from the recorded args (a.txt).
|
||||
await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
|
||||
const marker = page.getByRole('button', { name: /Context compacted/ })
|
||||
await marker.waitFor({ timeout: 10_000 })
|
||||
expect(await marker.getAttribute('aria-expanded')).toBe('false')
|
||||
await marker.click()
|
||||
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
|
||||
await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
|
||||
timeout: 5_000,
|
||||
}).toBe(1)
|
||||
expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
|
||||
// Restore the shared page state for any later case.
|
||||
await marker.click()
|
||||
await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
|
||||
// The Access chip submits `/permission <preset>` — a host command with no
|
||||
// model call, so the settled row renders keylessly over this cold history.
|
||||
// The row copy is the assertion: `permission · preset read-only`,
|
||||
// where neither half repeats the other (the dispatched `/` and its
|
||||
// argument stay out of the title, and the settlement text never restates
|
||||
// the command's own name).
|
||||
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await page.getByRole('button', { name: 'Access mode, current: Read Only' }).waitFor({ timeout: 10_000 })
|
||||
// Scoped to the row itself, so unrelated page text that happens to read
|
||||
// `permission` (a future resident slash menu) cannot satisfy or break it.
|
||||
const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
|
||||
await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
|
||||
expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
|
||||
const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
|
||||
if (agent === undefined) throw new Error('seeded session did not attach an agent')
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Short injected context.' }],
|
||||
source: { kind: 'plugin', plugin: 'fixture' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true })
|
||||
await disclosure.waitFor({ timeout: 10_000 })
|
||||
await disclosure.click()
|
||||
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
// The instructions row above stays expanded from the geometry case; the
|
||||
// opaque body is the one without a declared form.
|
||||
const body = page.locator('[data-context-injection-body]:not([data-context-form])')
|
||||
const bodyBox = await body.boundingBox()
|
||||
if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
|
||||
expect(bodyBox.height).toBeLessThan(141)
|
||||
expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
// No replay fixture was installed and the llm seam is open — any stray
|
||||
// stream would have failed the turn loudly. Cleanliness pins the wire.
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'seed.jsonl', 'ui.expected.md'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings', dir: 'ui-settings', url: '/plugins/ui-settings.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-settings-general', dir: 'ui-settings-general', url: '/plugins/ui-settings-general.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings', '@deepseek-ai/dsh-client-locale'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-models', dir: 'ui-models', url: '/plugins/ui-models.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-settings'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-model', dir: 'ui-model', url: '/plugins/ui-model.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-command'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureTiming {
|
||||
appendTitle(id: string, title: string): void
|
||||
}
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
history.replaceState(null, '', '/?fixture')
|
||||
document.title = 'DeepSeek Harness'
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Read only the stable, user-facing title surfaces from the assembled app. */
|
||||
function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; documentTitle: string } {
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
const sidebar = within(tree).getByText(label).textContent ?? ''
|
||||
const breadcrumb = within(screen.getByRole('navigation', { name: 'Session hierarchy' }))
|
||||
.getByRole('button', { name: label }).textContent ?? ''
|
||||
return { sidebar, breadcrumb, documentTitle: document.title }
|
||||
}
|
||||
|
||||
it('projects titles and routes the next turn through the selected model in the built fixture app', async () => {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
|
||||
const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||
// The fixture Intent selects the workspace, so the current-group effect
|
||||
// already expanded it; clicking the header would now collapse (the twist
|
||||
// stays live since intent stopped forcing expansion).
|
||||
await within(tree).findByText('4 sessions')
|
||||
|
||||
const initialLabel = 'Fixture 历史会话'
|
||||
const initialRowLabel = await screen.findByText(initialLabel)
|
||||
const initialRow = initialRowLabel.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (initialRow === null) throw new Error('fixture session row missing')
|
||||
fireEvent.click(initialRow)
|
||||
await waitFor(() => { expect(document.title).toBe(`${initialLabel} — DeepSeek Harness`) })
|
||||
const initial = titleSurfaces(initialLabel)
|
||||
|
||||
const revisedLabel = 'Fixture 修订标题'
|
||||
const timing = (globalThis as Record<string, unknown>).__fxTiming as FixtureTiming
|
||||
act(() => { timing.appendTitle('fx-alpha', revisedLabel) })
|
||||
await waitFor(() => { expect(document.title).toBe(`${revisedLabel} — DeepSeek Harness`) })
|
||||
const revised = titleSurfaces(revisedLabel)
|
||||
|
||||
// fx-alpha carries the fixture's resident answerable approval, so the
|
||||
// approval panel has taken over the composer (the real takeover behavior);
|
||||
// answer it to restore the composer chrome before asserting the model seat.
|
||||
fireEvent.click(await screen.findByRole('button', { name: '允许一次' }))
|
||||
const modelTrigger = await screen.findByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(modelTrigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Model/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /GPT-5/ }))
|
||||
await waitFor(() => {
|
||||
expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Medium')
|
||||
})
|
||||
fireEvent.click(modelTrigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Max' }))
|
||||
await waitFor(() => {
|
||||
expect(modelTrigger.getAttribute('aria-label')).toBe('选择模型,当前 GPT-5,推理等级 Max')
|
||||
})
|
||||
|
||||
// fx-alpha starts in the running state. Selecting above is intentionally
|
||||
// allowed for the next turn; stop the fixture's resident run before sending
|
||||
// the route-report prompt.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stop generating' }))
|
||||
const composer = await screen.findByPlaceholderText('Message the agent')
|
||||
fireEvent.change(composer, { target: { value: 'report model' } })
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('当前模型:openai/gpt-5 · 推理等级:max', {}, { timeout: 10_000 })
|
||||
|
||||
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-title.json')
|
||||
})
|
||||
@@ -2,26 +2,30 @@
|
||||
// section switching, both close paths), the Appearance preference row (the
|
||||
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
|
||||
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
|
||||
// and the Language row (settings-scoped localization + persisted dsh.locale).
|
||||
// the Language row (settings-scoped localization + persisted dsh.locale),
|
||||
// the busy-state Enter preference, plus Permission as the persisted default
|
||||
// for subsequently created sessions.
|
||||
// Zero model calls: everything is pure client + persistence state on a blank
|
||||
// frame, so there is no fixture and a stray stream would fail loud on the
|
||||
// open llm seam.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url))
|
||||
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
describe('web e2e: settings modal and General preferences', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -30,7 +34,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
// Chinese browser: the shared page asserts the localized settings surface
|
||||
// the client derives from it (the English default has its own spec below).
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -50,15 +56,39 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
// General is the active section by default; its skeleton rows plus the
|
||||
// functional Language and Appearance rows render.
|
||||
// General is active by default; Permission, Language and Appearance are functional.
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
|
||||
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
|
||||
await openDocument.waitFor({ timeout: 10_000 })
|
||||
let openRequests = 0
|
||||
await page.route('**/api/settings.openDocument', async (route) => {
|
||||
const envelope = route.request().postDataJSON() as {
|
||||
rpcId: string
|
||||
payload: Record<string, never>
|
||||
}
|
||||
expect(envelope.payload).toEqual({})
|
||||
openRequests += 1
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
type: 'server-response',
|
||||
rpcId: envelope.rpcId,
|
||||
result: { ok: true, value: { opened: true } },
|
||||
}),
|
||||
})
|
||||
})
|
||||
await openDocument.click()
|
||||
await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
|
||||
await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await page.unroute('**/api/settings.openDocument')
|
||||
// Golden of the freshly opened dialog (default zh, General active).
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE)
|
||||
// Section switch: aria-current moves; Models is deliberately empty.
|
||||
// Section switch: aria-current moves (the Models page itself has its own scenario file).
|
||||
await dialog.getByRole('button', { name: '模型' }).click()
|
||||
await expect.poll(() => dialog.getByRole('button', { name: '模型' }).getAttribute('aria-current'), { timeout: 5_000 }).toBe('true')
|
||||
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBeNull()
|
||||
@@ -73,6 +103,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('stores Permission as the default for future sessions without changing an existing session', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
|
||||
const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
const selector = dialog.getByRole('button', { name: 'Workspace Write' })
|
||||
await selector.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
|
||||
await selector.click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(document).toContain('permission:')
|
||||
expect(document).toContain('defaultPreset: read-only')
|
||||
expect(existing.events.find(event => event.type === 'permission/preset')?.data)
|
||||
.toEqual({ preset: 'workspace-write' })
|
||||
|
||||
const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
|
||||
expect(created.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'read-only' }],
|
||||
['sandbox/mode', { mode: 'read-only' }],
|
||||
['approval/policy', { policy: 'ask' }],
|
||||
])
|
||||
|
||||
await dialog.getByRole('button', { name: 'Read Only' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Full access' }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
|
||||
const enable = confirmation.getByRole('button', { name: '启用 Full access' })
|
||||
expect(await enable.isDisabled()).toBe(true)
|
||||
await confirmation.getByRole('checkbox').click()
|
||||
await enable.click()
|
||||
await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
|
||||
const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
|
||||
expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
|
||||
const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
|
||||
expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
|
||||
['permission/preset', { preset: 'danger-full-access' }],
|
||||
['sandbox/mode', { mode: 'danger-full-access' }],
|
||||
['approval/policy', { policy: 'never' }],
|
||||
])
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the theme through the Appearance cubes and persists across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
|
||||
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
|
||||
@@ -128,6 +207,32 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('persists the busy-state Enter behavior across reload and restores Queue', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '设置' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '排队发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '插话发送' }).click()
|
||||
await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer')
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
const reloaded = page.getByRole('dialog', { name: '设置' })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 })
|
||||
await reloaded.getByRole('button', { name: '插话发送' }).click()
|
||||
await page.getByRole('menuitem', { name: '排队发送' }).click()
|
||||
await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 })
|
||||
expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue')
|
||||
await page.keyboard.press('Escape')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('switches the settings surface language and persists dsh.locale', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language'))
|
||||
await page.getByRole('button', { name: '设置', exact: true }).click()
|
||||
@@ -163,6 +268,30 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it('opens an English browser in English without any stored preference', async () => {
|
||||
// A second page under a different browser language: nothing is persisted
|
||||
// for it, so the settings surface must follow the browser rather than the
|
||||
// product fallback the shared zh page shows.
|
||||
const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
|
||||
const enTripwire = watchConsole(enPage)
|
||||
onTestFailed(() => saveFailureShot(enPage, 'web-e2e-settings-browser-language'))
|
||||
try {
|
||||
await enPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await enPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
expect(await enPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull()
|
||||
await enPage.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = enPage.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 })
|
||||
// This page has no closing inventory spec to sweep its console, so the
|
||||
// scenario clears both tripwire channels itself.
|
||||
expect(enTripwire.pageErrors).toEqual([])
|
||||
expect(enTripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
await enPage.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md'])
|
||||
|
||||
99
apps/web/tests/shipped-composition.e2e.ts
Normal file
99
apps/web/tests/shipped-composition.e2e.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// Boots the shipped Web composition over the built dist this lane already uses
|
||||
// and asserts what that composition produces: the model-visible tool catalog
|
||||
// and the sandbox/approval knobs it ships with. No browser and no model call —
|
||||
// these are composition facts, and the browser scenarios in this lane cover the
|
||||
// surface itself.
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { launchWebScaffold, type WebScaffold } from './scaffold.ts'
|
||||
|
||||
/**
|
||||
* The catalog the shipped Web composition puts in front of the model, minus the
|
||||
* ripgrep-dependent pair below. The absences are deliberate, not incidental
|
||||
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
|
||||
* sandbox row confines, `web_fetch` chooses its own request target, and
|
||||
* `mcp_*` servers spawn outside `ctx.bash`. The composition Agent Note owns the
|
||||
* rationale and its sources.
|
||||
*/
|
||||
const EXPECTED_TOOLS = [
|
||||
'ask_user_question',
|
||||
'bash',
|
||||
'create_goal',
|
||||
'edit',
|
||||
'exit_plan_mode',
|
||||
'get_goal',
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'send_message',
|
||||
'skill',
|
||||
'str_replace_editor',
|
||||
'subagent',
|
||||
'subagent_fork',
|
||||
'task_kill',
|
||||
'task_list',
|
||||
'task_output',
|
||||
'todo_write',
|
||||
'update_goal',
|
||||
'web_search',
|
||||
'workflow',
|
||||
'write',
|
||||
]
|
||||
|
||||
/**
|
||||
* `glob` and `grep` come from `dsh-tool-fs-search`, which spawns the PACKAGED
|
||||
* ripgrep binary (`@vscode/ripgrep`) through the subprocess seam, so the pair
|
||||
* is always present on every host — asserted as fixed members, not a host
|
||||
* dependency.
|
||||
*/
|
||||
const RIPGREP_TOOLS = ['glob', 'grep']
|
||||
|
||||
let scaffold: WebScaffold | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await scaffold?.close()
|
||||
scaffold = undefined
|
||||
})
|
||||
|
||||
it('assembles the shipped Web catalog with the confined access default', async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
const names = scaffold.ctx.tools.schemas().map(schema => schema.name).sort()
|
||||
expect(names.filter(name => !RIPGREP_TOOLS.includes(name))).toEqual(EXPECTED_TOOLS)
|
||||
// The packaged ripgrep binary ships with the dependency, so the pair is a
|
||||
// fixed roster member on every host.
|
||||
expect(names.filter(name => RIPGREP_TOOLS.includes(name))).toEqual(RIPGREP_TOOLS)
|
||||
// `workspace-write` is not "the workspace and nothing else": the shared roots
|
||||
// helper always admits the temp directories too. Pinning it against an
|
||||
// explicit mode keeps the claim independent of this surface's default, and
|
||||
// keeps a future boundary test from being run inside /tmp — where an
|
||||
// "escape" write succeeds by design and reads as a sandbox failure.
|
||||
expect(writableRoots(scaffold.ctx.sandboxPolicy.resolve({ mode: 'workspace-write' }))).toEqual(
|
||||
expect.arrayContaining([canonicalPath('/tmp'), canonicalPath(tmpdir())]),
|
||||
)
|
||||
expect(scaffold.ctx.sandboxPolicy.defaultMode).toBe('workspace-write')
|
||||
expect(scaffold.ctx.approval.config.policy).toBe('ask')
|
||||
expect(scaffold.ctx.permission.defaultPreset).toBe('workspace-write')
|
||||
|
||||
const handle = await scaffold.ctx.agents.create({
|
||||
sessionId: SessionId('shipped-command-catalog'),
|
||||
meta: { cwd: scaffold.workspaceCwd },
|
||||
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
})
|
||||
try {
|
||||
expect(scaffold.ctx.commands.list(handle.agent)).toContainEqual({
|
||||
name: 'feedback',
|
||||
description: 'record feedback about this session',
|
||||
input: { hint: '<text>' },
|
||||
})
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
}, 120_000)
|
||||
@@ -44,6 +44,12 @@
|
||||
// overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with
|
||||
// the other assertions in its test silenced.
|
||||
//
|
||||
// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair
|
||||
// to `transparent` while the pointer is outside the column), so every
|
||||
// measurement below states which pointer position it was taken at: the
|
||||
// scenario parks the pointer over the sidebar before asserting a colour, and
|
||||
// the quiet state and its linger get their own test.
|
||||
//
|
||||
// Chromium also takes the `::-webkit-scrollbar*` path, not the standard
|
||||
// properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind
|
||||
// `@supports not selector(::-webkit-scrollbar)`, which is false here. The
|
||||
@@ -67,7 +73,7 @@ import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/sidebar-scrollbar', import.meta.url))
|
||||
@@ -106,6 +112,10 @@ interface ListMetrics {
|
||||
overflows: boolean
|
||||
/** Border-box width minus client width: the space the scrollbar takes out of the content area. */
|
||||
band: number
|
||||
/** Distance from the scrollbar's right edge to the sidebar edge. */
|
||||
scrollbarEdgeOffset: number
|
||||
/** Distance from the first row background's right edge to the sidebar edge. */
|
||||
rowEdgeInset: number
|
||||
/** Client-area right edge in viewport coordinates (`clientWidth` excludes the scrollbar band). */
|
||||
clientRight: number
|
||||
/** Border-box right edge in viewport coordinates. */
|
||||
@@ -133,6 +143,8 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const time = list.querySelector<HTMLElement>('[class*="time"]')
|
||||
if (time === null) throw new Error('no row relative-time element in the sidebar list')
|
||||
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error('no row in the sidebar list')
|
||||
// Each indirection variable is resolved through its own throwaway probe
|
||||
// appended to the list: `var()` substitution then happens where the list
|
||||
// sits in the cascade, which is the claim, and `color` normalizes whatever
|
||||
@@ -167,6 +179,9 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
const style = getComputedStyle(list)
|
||||
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
|
||||
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
|
||||
const listRect = list.getBoundingClientRect()
|
||||
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
|
||||
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
|
||||
return {
|
||||
gutter: style.scrollbarGutter,
|
||||
width: pseudoWidth,
|
||||
@@ -177,9 +192,11 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
token: resolve('--dsh-scrollbar-thumb'),
|
||||
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
band: list.getBoundingClientRect().width - list.clientWidth,
|
||||
clientRight: list.getBoundingClientRect().left + list.clientWidth,
|
||||
borderRight: list.getBoundingClientRect().right,
|
||||
band: listRect.width - list.clientWidth,
|
||||
scrollbarEdgeOffset: sidebarEdge - listRect.right,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
clientRight: listRect.left + list.clientWidth,
|
||||
borderRight: listRect.right,
|
||||
timeRight: time.getBoundingClientRect().right,
|
||||
// The bar is drawn in the rightmost `barWidth` of the border box, whether
|
||||
// or not that space was reserved. Its width comes from the sheet where the
|
||||
@@ -188,11 +205,60 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
// absent. Taking the UA width as the fallback is what keeps the assertion
|
||||
// honest: assuming 0 there would report no occlusion precisely in the
|
||||
// state that has it.
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (list.getBoundingClientRect().right - barWidth)),
|
||||
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure only overflow and row inset, which remain observable when every
|
||||
* session is hidden under a collapsed workspace group.
|
||||
* @param page - the page under test.
|
||||
* @returns the list overflow state and first row's trailing inset.
|
||||
*/
|
||||
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset'>> {
|
||||
return page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
|
||||
if (row === null) throw new Error('no row in the sidebar list')
|
||||
const sidebarEdge = list.parentElement?.getBoundingClientRect().right
|
||||
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
|
||||
return {
|
||||
overflows: list.scrollHeight > list.clientHeight,
|
||||
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** One palette's readings, taken at both pointer positions. */
|
||||
interface PaletteMetrics {
|
||||
/** Everything measured with the pointer over the list, which is when a thumb exists. */
|
||||
hovered: ListMetrics
|
||||
/** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */
|
||||
quietThumb: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one palette at both pointer positions, ending with the pointer back
|
||||
* over the list so a caller measuring further leaves it revealed.
|
||||
* @param page - the page under test.
|
||||
* @returns the palette's quiet thumb and its hovered metrics.
|
||||
*/
|
||||
async function measurePalette(page: Page): Promise<PaletteMetrics> {
|
||||
await pointAt(page, 'away')
|
||||
// Poll rather than sleep the linger out: the wait is the column's, and a
|
||||
// fixed sleep would either race it or pad every palette.
|
||||
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
|
||||
const quietThumb = await resolveThumb(page)
|
||||
await pointAt(page, 'list')
|
||||
// Poll the reveal too: the reading below is a colour, and taking it in the
|
||||
// same tick as the pointer move would race React's flush and land a
|
||||
// transparent thumb in the golden.
|
||||
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
|
||||
return { hovered: await measureList(page), quietThumb }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the golden body: the resolved scrollbar style of the list in each
|
||||
* palette, plus the geometric relations the fix establishes.
|
||||
@@ -208,20 +274,23 @@ function measureList(page: Page): Promise<ListMetrics> {
|
||||
* @param dark - metrics measured under the dark palette.
|
||||
* @returns the golden body, without a trailing newline.
|
||||
*/
|
||||
function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
|
||||
const palette = (name: string, metrics: ListMetrics): string[] => [
|
||||
function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
|
||||
const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
|
||||
`## ${name}`,
|
||||
'',
|
||||
`- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
|
||||
`- scrollbar-gutter: ${metrics.gutter}`,
|
||||
`- ::-webkit-scrollbar width: ${metrics.width}`,
|
||||
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
|
||||
`- scrollbar-width: ${metrics.standardWidth}`,
|
||||
`- scrollbar-color: ${metrics.standardColor}`,
|
||||
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
|
||||
`- --dsh-scrollbar-thumb: ${metrics.token}`,
|
||||
`- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`,
|
||||
`- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
|
||||
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
|
||||
`- list overflows: ${String(metrics.overflows)}`,
|
||||
`- reserved band: ${String(metrics.band)}px`,
|
||||
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
|
||||
`- row background inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
|
||||
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
|
||||
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
|
||||
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
|
||||
@@ -235,6 +304,48 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string {
|
||||
].join('\n').trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
|
||||
* geometry. Own probe element for the same reason {@link measureList} uses
|
||||
* one: `getComputedStyle` returns a live declaration.
|
||||
* @param page - the page under test.
|
||||
* @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
|
||||
*/
|
||||
function resolveThumb(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const list = document.querySelector<HTMLElement>('[role="tree"][aria-label="Sessions"]')
|
||||
if (list === null) throw new Error('sidebar session list not in the DOM')
|
||||
const probe = document.createElement('span')
|
||||
probe.style.color = 'var(--dsh-scrollbar-thumb)'
|
||||
list.append(probe)
|
||||
const value = getComputedStyle(probe).color
|
||||
probe.remove()
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
/** Fully transparent, which is how the quiet column spells "no thumb". */
|
||||
const NO_THUMB = 'rgba(0, 0, 0, 0)'
|
||||
|
||||
/**
|
||||
* Park the pointer over the session list or outside the sidebar entirely. The
|
||||
* column reveals its scrollbars from real pointer movement, so a scenario that
|
||||
* never moves the mouse measures the quiet state whatever it intended to.
|
||||
* @param page - the page under test.
|
||||
* @param where - `list` to point at the session list, `away` for the far side
|
||||
* of the viewport (the conversation column).
|
||||
*/
|
||||
async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
|
||||
const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox()
|
||||
if (box === null) throw new Error('sidebar session list has no layout box')
|
||||
const viewport = page.viewportSize()
|
||||
if (viewport === null) throw new Error('page has no viewport')
|
||||
const target = where === 'list'
|
||||
? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
||||
: { x: viewport.width - 5, y: box.y + box.height / 2 }
|
||||
await page.mouse.move(target.x, target.y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the seeded rows: every seeded session is unattached, so they all sit
|
||||
* in the collapsed Ungrouped bucket. Converges on expanded rather than
|
||||
@@ -275,11 +386,15 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
browser = await chromium.launch()
|
||||
// Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
|
||||
// the list with room to spare.
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 800 } })
|
||||
page = await newEnglishPage(browser, 800)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expandSeededSessions(page)
|
||||
// Every assertion about a thumb colour needs a drawn thumb, and the column
|
||||
// only draws one under the pointer; the quiet state is asserted where it is
|
||||
// the subject rather than left as an ambient condition of the whole file.
|
||||
await pointAt(page, 'list')
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -299,6 +414,8 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
// drawn over it. Removing the declaration makes it exactly 0. The value
|
||||
// itself is not pinned — it tracks `scrollbar-width` and the platform.
|
||||
expect(metrics.band).toBeGreaterThan(0)
|
||||
expect(metrics.scrollbarEdgeOffset).toBe(2)
|
||||
expect(metrics.rowEdgeInset).toBe(12)
|
||||
// The reported symptom, stated directly: no part of the row's relative time
|
||||
// lies under the bar. Measures 7 on clean master — the `h` of `1h` is the
|
||||
// covered part. Unlike the client-edge comparison below it does not go
|
||||
@@ -317,6 +434,48 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
|
||||
const revealed = await resolveThumb(page)
|
||||
expect(revealed).not.toBe(NO_THUMB)
|
||||
await pointAt(page, 'away')
|
||||
// The linger, measured as a state rather than a duration: the thumb is
|
||||
// still drawn on the leave itself, and gone once the window has passed. A
|
||||
// tighter timing assertion would pin the wall clock of a CI machine.
|
||||
expect(await resolveThumb(page)).toBe(revealed)
|
||||
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
|
||||
// The reservation is unconditional, so nothing moved while the bar was
|
||||
// hidden — this is what buys `transparent` over hiding the bar itself.
|
||||
const quiet = await measureList(page)
|
||||
expect(quiet.gutter).toBe('stable')
|
||||
expect(quiet.band).toBeGreaterThan(0)
|
||||
expect(quiet.timeCoveredBy).toBe(0)
|
||||
// Scrolling without a pointer — what a keyboard or a touch drag does —
|
||||
// leaves the column quiet. This is the change's one deliberate loss, and
|
||||
// it is pinned here rather than only described, so making a scroll
|
||||
// re-reveal the bar has to be a decision rather than a side effect.
|
||||
await page.locator('[role="tree"][aria-label="Sessions"]').evaluate((el) => { el.scrollTop += 200 })
|
||||
await page.waitForTimeout(500)
|
||||
expect(await resolveThumb(page)).toBe(NO_THUMB)
|
||||
await pointAt(page, 'list')
|
||||
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps the row background inset when overflow disappears', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 12 })
|
||||
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
await bucket.click()
|
||||
try {
|
||||
await expect.poll(async () => (await measureRowInset(page)).overflows, { timeout: 10_000 }).toBe(false)
|
||||
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 12 })
|
||||
} finally {
|
||||
await expandSeededSessions(page)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('renders the themed thumb through the WebKit path in both palettes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
|
||||
const light = await measureList(page)
|
||||
@@ -354,9 +513,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
|
||||
|
||||
it('matches the committed scrollbar geometry golden in both palettes', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
|
||||
const light = await measureList(page)
|
||||
const light = await measurePalette(page)
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
const dark = await measureList(page)
|
||||
const dark = await measurePalette(page)
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
|
||||
115
apps/web/tests/skill-invocation-policy.e2e.ts
Normal file
115
apps/web/tests/skill-invocation-policy.e2e.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Web e2e scenario: the real host filters skill.list to the model-and-user
|
||||
// intersection before the browser slash source renders candidates. A real
|
||||
// chromium connects a fresh workspace seeded with all four policy quadrants;
|
||||
// no model call is issued, so a stray stream fails loud on the open LLM seam.
|
||||
import { mkdir, 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 {
|
||||
assertFixtureInventory,
|
||||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/skill-invocation-policy', import.meta.url))
|
||||
const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
interface SeedSkill {
|
||||
name: string
|
||||
description: string
|
||||
frontmatter: string
|
||||
}
|
||||
|
||||
const SKILLS: readonly SeedSkill[] = [
|
||||
{
|
||||
name: 'policy-shared',
|
||||
description: 'Available to both model and user invocation',
|
||||
frontmatter: '',
|
||||
},
|
||||
{
|
||||
name: 'policy-model-only',
|
||||
description: 'Available only to model invocation',
|
||||
frontmatter: 'user-invocable: false\n',
|
||||
},
|
||||
{
|
||||
name: 'policy-user-only',
|
||||
description: 'Available only to user invocation',
|
||||
frontmatter: 'disable-model-invocation: true\n',
|
||||
},
|
||||
{
|
||||
name: 'policy-trusted-only',
|
||||
description: 'Available only to trusted internal callers',
|
||||
frontmatter: 'disable-model-invocation: true\nuser-invocable: false\n',
|
||||
},
|
||||
]
|
||||
|
||||
async function seedSkills(workspaceCwd: string): Promise<void> {
|
||||
for (const skill of SKILLS) {
|
||||
const directory = join(workspaceCwd, 'workspace', '.agents', 'skills', skill.name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
const policyLines = skill.frontmatter === '' ? [] : skill.frontmatter.trimEnd().split('\n')
|
||||
await writeFile(join(directory, 'SKILL.md'), [
|
||||
'---',
|
||||
`name: ${skill.name}`,
|
||||
`description: ${skill.description}`,
|
||||
...policyLines,
|
||||
'---',
|
||||
'',
|
||||
`# ${skill.name}`,
|
||||
'',
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
||||
|
||||
describe('web e2e: skill invocation policy through the real host', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSkills(scaffold.workspaceCwd)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('renders only the model-and-user intersection in slash candidates', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-skill-invocation-policy'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/policy')
|
||||
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await expect.poll(
|
||||
() => menu.getByRole('option', { name: /policy-shared/ }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
expect(await menu.getByRole('option', { name: /policy-model-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(0)
|
||||
expect(await menu.getByRole('option', { name: /policy-trusted-only/ }).count()).toBe(0)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
// Assembled keyless snapshot of the slash/input/session convergence under the
|
||||
// agent-parity model: the New Session view state locks the composer until a
|
||||
// Workspace is picked (connectWorkspace materializes the full Session+Agent),
|
||||
// the '/' menu serves the session's wire command catalog (sessions are always
|
||||
// agent-backed — no draft/materialized split), a leadingInput command claims,
|
||||
// submits over the wire, and notices its result, and the SAME composer
|
||||
// textarea then carries the first plain send, whose ACCEPTANCE (not attempt)
|
||||
// flips blank and surfaces the session in lists. This is the user-visible
|
||||
// acceptance anchor — package mocks do not substitute for the assembled
|
||||
// application transcript.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||
|
||||
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] },
|
||||
{
|
||||
id: '@deepseek-ai/dsh-client-ui-workspace',
|
||||
dir: 'ui-workspace',
|
||||
url: '/plugins/ui-workspace.js',
|
||||
rev: 'fx',
|
||||
inject: [
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const bundles = new Map(PLUGINS.map(plugin => [
|
||||
plugin.url,
|
||||
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||
]))
|
||||
|
||||
interface FixtureWindow extends Window {
|
||||
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||
__ModuleLoader__?: unknown
|
||||
}
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
disconnect(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
const win = window as FixtureWindow
|
||||
let unmount: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.title = 'DeepSeek Harness'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => { unmount?.() })
|
||||
unmount = undefined
|
||||
cleanup()
|
||||
delete win.__DSH_BOOT__
|
||||
delete win.__ModuleLoader__
|
||||
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||
document.title = ''
|
||||
history.replaceState(null, '', '/')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the complete built client graph against one keyless fixture branch. */
|
||||
function boot(search: string): void {
|
||||
history.replaceState(null, '', `/${search}`)
|
||||
const root = document.createElement('div')
|
||||
root.id = 'root'
|
||||
document.body.appendChild(root)
|
||||
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||
act(() => {
|
||||
const entry = new AppWebEntry(root, {
|
||||
fetchBundle: (url) => {
|
||||
const code = bundles.get(url)
|
||||
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||
},
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
void entry.run()
|
||||
unmount = () => { entry.dispose() }
|
||||
})
|
||||
}
|
||||
|
||||
/** Collapse decorative whitespace while preserving the text a user sees. */
|
||||
function visibleText(element: Element): string {
|
||||
return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Type into the machine-driven composer and let the change echo back. */
|
||||
async function typeComposer(composer: HTMLTextAreaElement, value: string): Promise<void> {
|
||||
fireEvent.change(composer, { target: { value } })
|
||||
await waitFor(() => { expect(composer.value).toBe(value) })
|
||||
}
|
||||
|
||||
it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => {
|
||||
boot('?fixture=empty')
|
||||
|
||||
// View state: no session entity — the composer renders locked; only the
|
||||
// workspace picker is live.
|
||||
const locked = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
||||
'Choose a workspace to start', {}, { timeout: 10_000 },
|
||||
)
|
||||
expect(locked.disabled).toBe(true)
|
||||
|
||||
// Pick (create) a Workspace: connectWorkspace materializes the full
|
||||
// Session+Agent and the provider swaps in the live blank-session hero.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Choose workspace' })
|
||||
.find(el => el.getAttribute('aria-haspopup') === 'menu')!)
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: 'Create a new workspace' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a new workspace' })
|
||||
fireEvent.change(within(dialog).getByRole('textbox', { name: 'New workspace name' }), {
|
||||
target: { value: 'nova' },
|
||||
})
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create workspace' }))
|
||||
|
||||
const composer = await screen.findByPlaceholderText<HTMLTextAreaElement>(
|
||||
'Describe what you want to build', {}, { timeout: 10_000 },
|
||||
)
|
||||
expect(composer.disabled).toBe(false)
|
||||
|
||||
// '/' opens the menu with the session's wire command catalog (the session
|
||||
// is agent-backed from birth — the catalog is the single-address list).
|
||||
await typeComposer(composer, '/')
|
||||
const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' })
|
||||
await waitFor(() => { expect(visibleText(menu)).toContain('echo') })
|
||||
const menuText = visibleText(menu)
|
||||
|
||||
// Pick /echo (leadingInput): the claim token lands in the same textarea.
|
||||
fireEvent.mouseDown(screen.getByRole('option', { name: /echo/ }))
|
||||
await waitFor(() => { expect(composer.value).toBe('/echo ') })
|
||||
|
||||
// Type args and submit: the claim executes over the wire and notices its
|
||||
// result; the token is consumed and the draft returns to plain text.
|
||||
await typeComposer(composer, '/echo hello parser')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await screen.findByText('hello parser', {}, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(composer.value).toBe('') })
|
||||
|
||||
// Slash execution does not flip blank: the selected row remains New Session.
|
||||
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||
expect(within(tree).getByText('1 session')).toBeDefined()
|
||||
expect(within(tree).getByText('New Session')).toBeDefined()
|
||||
|
||||
// First plain send through the SAME textarea: acceptance logs the user
|
||||
// message and converts the existing sidebar row out of blank.
|
||||
const before = composer
|
||||
await typeComposer(composer, 'build me a parser')
|
||||
fireEvent.keyDown(composer, { key: 'Enter' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Let's start building")).toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })
|
||||
const after = document.querySelector('textarea')
|
||||
|
||||
expect({
|
||||
menuHadEcho: menuText.includes('echo'),
|
||||
menuHadCompact: menuText.includes('compact'),
|
||||
composerSurvivedConversion: after === before,
|
||||
sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!),
|
||||
}).toMatchInlineSnapshot(`
|
||||
{
|
||||
"composerSurvivedConversion": true,
|
||||
"menuHadCompact": true,
|
||||
"menuHadEcho": true,
|
||||
"sessionListed": "nova1 session",
|
||||
}
|
||||
`)
|
||||
})
|
||||
@@ -20,11 +20,13 @@ import { createServer } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { REPO_ROOT, connectFreshWorkspace, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||
|
||||
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
||||
|
||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||
return new Promise((resolveReady, reject) => {
|
||||
@@ -163,6 +165,8 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-no-call',
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -182,22 +186,29 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
|
||||
it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
|
||||
|
||||
let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
|
||||
const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
|
||||
resolveProviderRequest = resolve
|
||||
interface NativeProviderRequest {
|
||||
messages?: { role?: string; content?: string }[]
|
||||
tools?: { function?: { name?: string } }[]
|
||||
}
|
||||
let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
|
||||
const requests: NativeProviderRequest[] = []
|
||||
const providerRequests = new Promise<NativeProviderRequest[]>((resolve) => {
|
||||
resolveProviderRequests = resolve
|
||||
})
|
||||
const provider = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
|
||||
const parsed = JSON.parse(body) as NativeProviderRequest
|
||||
if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed)
|
||||
if (requests.length === 1) resolveProviderRequests(requests)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
@@ -212,6 +223,119 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const address = provider.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
|
||||
{
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-workspace',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(workspace, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
})
|
||||
const capturedRequests = await Promise.race([
|
||||
providerRequests,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
|
||||
}),
|
||||
])
|
||||
const captured = capturedRequests[0]
|
||||
if (captured === undefined) {
|
||||
throw new Error('provider did not receive the workspace projection request')
|
||||
}
|
||||
const workspaceMessage = captured.messages?.find(message =>
|
||||
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
|
||||
const systemMessage = captured.messages?.find(message => message.role === 'system')
|
||||
const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
|
||||
.replace('{{webUrl}}', baseUrl)
|
||||
expect(systemMessage?.content).toContain(expectedWebSection)
|
||||
expect(workspaceMessage).toMatchInlineSnapshot(`
|
||||
{
|
||||
"content": "<system-reminder>
|
||||
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
web-workspace-context-probe
|
||||
|
||||
</system-reminder>",
|
||||
"role": "user",
|
||||
}
|
||||
`)
|
||||
expect(captured.tools?.map(tool => tool.function?.name)
|
||||
.filter(name => name === 'web_search' || name === 'web_fetch'))
|
||||
.toMatchInlineSnapshot(`
|
||||
[
|
||||
"web_search",
|
||||
]
|
||||
`)
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
: Promise.resolve()
|
||||
if (child.exitCode === null) child.kill('SIGTERM')
|
||||
await closed
|
||||
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a partial transport failure through the shipped Web composition', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
|
||||
const promptMarker = 'WEB_RETRY_REQUEST'
|
||||
const recoveredMarker = 'WEB_RETRY_RECOVERED'
|
||||
let mainAttempts = 0
|
||||
const provider = createServer((request, response) => {
|
||||
let body = ''
|
||||
request.setEncoding('utf8')
|
||||
request.on('data', (chunk: string) => { body += chunk })
|
||||
request.on('end', () => {
|
||||
const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
|
||||
const titleRequest = parsed.max_tokens === 64
|
||||
const mainRequest = !titleRequest && body.includes(promptMarker)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
if (!mainRequest) {
|
||||
response.end([
|
||||
'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
return
|
||||
}
|
||||
mainAttempts++
|
||||
if (mainAttempts === 1) {
|
||||
response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
|
||||
setTimeout(() => { response.destroy() }, 20)
|
||||
return
|
||||
}
|
||||
response.end([
|
||||
`data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
|
||||
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n\n'))
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
|
||||
const address = provider.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
|
||||
@@ -219,7 +343,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: 'keyless-web-workspace',
|
||||
DEEPSEEK_API_KEY: 'keyless-web-retry',
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
@@ -233,29 +357,24 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
content: [{ type: 'text', text: promptMarker }],
|
||||
})
|
||||
const captured = await Promise.race([
|
||||
providerRequest,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
|
||||
}),
|
||||
])
|
||||
const workspaceMessage = captured.messages?.find(message =>
|
||||
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
|
||||
expect(workspaceMessage).toMatchInlineSnapshot(`
|
||||
{
|
||||
"content": "<system-reminder>
|
||||
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
|
||||
|
||||
Instructions from: AGENTS.md
|
||||
|
||||
web-workspace-context-probe
|
||||
|
||||
</system-reminder>",
|
||||
"role": "user",
|
||||
}
|
||||
`)
|
||||
let page: HistoryPage | undefined
|
||||
await expect.poll(async () => {
|
||||
page = await history(baseUrl, created.sessionId)
|
||||
return hasAssistantMarker(page, recoveredMarker)
|
||||
}, { timeout: 20_000 }).toBe(true)
|
||||
if (page === undefined) throw new Error('retry history was not observed')
|
||||
const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
|
||||
expect(mainAttempts).toBe(2)
|
||||
expect(retry?.data).toMatchObject({
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
failure: { code: 'TRANSPORT' },
|
||||
})
|
||||
expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
@@ -265,7 +384,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
await new Promise<void>(resolveClose => provider.close(() => { resolveClose() }))
|
||||
rmSync(workspace, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
|
||||
requireDist()
|
||||
@@ -310,6 +429,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
||||
DSH_TOOLS_MODE: 'code',
|
||||
DSH_HOME: join(workspace, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(workspace, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -358,17 +478,23 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
|
||||
// the global Harness home inside the temp world; tsx also needs the repo's
|
||||
// loader and tsconfig paths pointed at explicitly.
|
||||
// the host-level Harness and shared-agent homes inside the temp world; tsx
|
||||
// also needs the repo's loader and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)],
|
||||
[
|
||||
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port),
|
||||
// Pin the in-browser picker: the shipped `-auto` row would resolve to
|
||||
// the native OS chooser on this bind, and no page can drive that.
|
||||
'--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
||||
],
|
||||
{
|
||||
cwd: sessionsDir,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
|
||||
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -376,7 +502,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
)
|
||||
baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
page = await newEnglishPage(browser)
|
||||
page.on('pageerror', e => pageErrors.push(String(e)))
|
||||
await page.goto(baseUrl, { waitUntil: 'load' })
|
||||
}, 120_000)
|
||||
@@ -403,8 +529,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
|
||||
it('2+3 empty-state first send completes a real model round', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-first-round'))
|
||||
// This scenario spawns its own server against a fresh $DSH_HOME, so the
|
||||
// first-run welcome notice is unacknowledged and its overlay owns pointer
|
||||
// events (the shared scaffold acknowledges it before boot instead). The
|
||||
// notice is anchored structurally, not by its copy: this spec sits in the
|
||||
// client TypeScript program, which does not reference the package that
|
||||
// owns the strings.
|
||||
const welcome = page.locator('[class*="onboardingOverlay"]')
|
||||
await welcome.waitFor({ timeout: 15_000 })
|
||||
await welcome.getByRole('button').click()
|
||||
await welcome.waitFor({ state: 'detached', timeout: 15_000 })
|
||||
// Fresh world: connect a Workspace so the composer starts live.
|
||||
await connectFreshWorkspace(page)
|
||||
await connectFreshWorkspace(page, sessionsDir)
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
await screen(page, '02-empty-state')
|
||||
@@ -454,7 +590,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
await screen(page, '07-back-to-chat')
|
||||
})
|
||||
|
||||
it('5 bash differential rendering: tool row click leaves the default details column open', async () => {
|
||||
it('5 bash differential rendering: tool row click leaves the default details column closed', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'w5-tool-details'))
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('请用 bash 工具运行命令 echo w5marker 然后告诉我结果')
|
||||
@@ -463,14 +599,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
// Bash renders through the third-party sample registration. Match that
|
||||
// exact row: other clickable variants (for example Think disclosure)
|
||||
// may precede the tool call in document order.
|
||||
const toolRow = page.locator('[data-sample="bash-global"]')
|
||||
const toolRow = page.locator('[data-sample="bash"]')
|
||||
await toolRow.waitFor({ timeout: 120_000 })
|
||||
await screen(page, '08-bash-round')
|
||||
expect(await detailsTrack(page)).toBe(360)
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await toolRow.click()
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays open.
|
||||
expect(await detailsTrack(page)).toBe(360)
|
||||
await screen(page, '09-details-open')
|
||||
// Tool rows no longer drive layout.openDetails; the default column stays closed.
|
||||
expect(await detailsTrack(page)).toBe(0)
|
||||
await screen(page, '09-details-closed')
|
||||
}, 150_000)
|
||||
|
||||
it('6 sidebar drag widens the column and resets across reload', async () => {
|
||||
|
||||
10
apps/web/tests/snapshots/access-confirmation/ui.expected.md
Normal file
10
apps/web/tests/snapshots/access-confirmation/ui.expected.md
Normal file
@@ -0,0 +1,10 @@
|
||||
- dialog "确认启用 Full access?":
|
||||
- heading "确认启用 Full access?" [level=2]
|
||||
- button "Close":
|
||||
- img
|
||||
- img
|
||||
- paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
|
||||
- checkbox "我已了解风险,并愿意继续"
|
||||
- text: 我已了解风险,并愿意继续
|
||||
- button "取消"
|
||||
- button "启用 Full access" [disabled]
|
||||
64
apps/web/tests/snapshots/approval-composer/session.jsonl
Normal file
64
apps/web/tests/snapshots/approval-composer/session.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,4 @@
|
||||
- text: Waiting for approval
|
||||
- group "Approval details": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt"
|
||||
- button "Reject"
|
||||
- button "Allow once"
|
||||
33
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
33
apps/web/tests/snapshots/bash-abort-row/ui.expected.md
Normal file
@@ -0,0 +1,33 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Run two shell commands: wait" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Run two shell commands: wait for cancellation, then write skipped.txt. {{date}} {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- 'button "Failed Bash Error: tool call aborted" [expanded]':
|
||||
- img
|
||||
- text: "Failed Bash Error: tool call aborted"
|
||||
- text: "IN { \"command\": \"node -e \\\"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\\\"\", \"description\": \"Wait until cancellation\" } OUT Error: tool call aborted"
|
||||
- button "Inspect"
|
||||
- 'button "Failed Bash Error: tool call aborted before dispatch"':
|
||||
- img
|
||||
- text: "Failed Bash Error: tool call aborted before dispatch"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok
|
||||
@@ -1,9 +1,9 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785013630399,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785013630411,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1c4a6a0f-a7bd-4642-919d-6ed7395df98b"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785013630411,"data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785013630418,"data":{"title":"Using ONE run_code program: run","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785013630479,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":4,"time":1785013630480,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785013631481,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785013631481,"data":{"turn":1,"step":1,"index":0,"dt":[182,27,0,0,1,39,1,0,11,0,0,1,0,25,0,1,0,0,25,28,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,28,1,0,0,0,23,1,0,1,0,0,25,1,0,0,0,26,0,0,0,26,0,26,0,0,1,25,1,0,0,0,0,25,1,26,0,1,26,29],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}}
|
||||
{"type":"assistant/chunk","seq":81,"time":1785013632219,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
@@ -12,13 +12,13 @@
|
||||
{"type":"assistant/chunk","seq":202,"time":1785013633103,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":203,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}}
|
||||
{"type":"assistant/chunk","seq":204,"time":1785013633104,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0d217af9-3bb3-425b-b53e-7ab0366f0c66"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":205,"time":1785013633108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":206,"time":1785013633108,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":207,"time":1785013633173,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}}
|
||||
{"type":"tool/code-dispatch","seq":208,"time":1785013633196,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}}
|
||||
{"type":"tool/code-dispatch-start","seq":209,"time":1785013633197,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"}}}
|
||||
{"type":"tool/code-dispatch","seq":210,"time":1785013633198,"data":{"parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:code:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"}]}}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"content":[{"type":"tool-result","toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false}],"role":"user","id":"1580d4a8-5760-415f-984f-f93927271d3f"}},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":211,"time":1785013633201,"data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/workspace/missing.txt\\\": not found\"\n }\n}"}],"isError":false},"sourceEventSeqs":[206],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":212,"time":1785013633204,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":213,"time":1785013633207,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":214,"time":1785013633985,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -30,6 +30,6 @@
|
||||
{"type":"assistant/chunk","seq":233,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":234,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}}}
|
||||
{"type":"assistant/chunk","seq":235,"time":1785013634223,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3037ec34-6c2a-4e01-91eb-a0ef89ce0d15"}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":236,"time":1785013634224,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}},"sourceEventSeqs":[214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":237,"time":1785013634225,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":238,"time":1785013634225,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -5,48 +5,45 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to write a single `run_code` program that:"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- button "Code Run bash echo and catch missing file read":
|
||||
- img
|
||||
- img
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- text: Code Run bash echo and catch missing file read
|
||||
- img
|
||||
- text: Bash Echo CODE_ROUND_OK Read
|
||||
- button "missing.txt"
|
||||
- text: Bash Echo CODE_ROUND_OK Failed
|
||||
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
|
||||
- img
|
||||
- text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
|
||||
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The program ran successfully. Let me now reply DONE as instructed.
|
||||
- paragraph: DONE
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} cache hit 52% · 17,490 tokens · 1 turns · 2 steps
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "7% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 52% Input 17.2K tok · Output 252 tok
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Composer draft scrolling (14-line cap, two text layers, one scrollport)
|
||||
|
||||
## At the start of the draft
|
||||
|
||||
- draft overflows the capped box: true
|
||||
- visible lines: 14
|
||||
- the textarea holds no scroll offset of its own: true
|
||||
- all three layers wrap at one width: true
|
||||
- scroll offset: 0px
|
||||
- caret and glyphs stay level when the offset changes: true
|
||||
- first draft line is on screen: true
|
||||
- last draft line is on screen: false
|
||||
|
||||
## Scrolled to the end of the draft
|
||||
|
||||
- offset moved: true
|
||||
- caret sits on its own glyphs: true
|
||||
- caret and glyphs stay level when the offset changes: true
|
||||
- first draft line has scrolled out above: true
|
||||
- last draft line is on screen: true
|
||||
|
||||
## Draft ending in a newline, scrolled to the end
|
||||
|
||||
- caret sits on its own glyphs: true
|
||||
- the draft's own last line is on screen: true
|
||||
|
||||
## Right after pasting a long block at the end
|
||||
|
||||
- the composer scrolled to the caret it left: true
|
||||
- caret and glyphs stay level when the offset changes: true
|
||||
- the pasted block's last line is on screen: true
|
||||
@@ -0,0 +1,37 @@
|
||||
# Input card position across the Chat and Trajectory tabs
|
||||
|
||||
## Wide viewport (1680px, card at its cap)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 8px
|
||||
- input card left edge moves between tabs: 0px
|
||||
- input card right edge moves between tabs: 0px
|
||||
- input card width changes between tabs: 0px
|
||||
|
||||
## Narrow viewport (800px, card shrinking with the column)
|
||||
|
||||
- Chat: scrollbar-gutter stable, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter stable, overflow hidden/auto
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 8px
|
||||
- input card left edge moves between tabs: 0px
|
||||
- input card right edge moves between tabs: 0px
|
||||
- input card width changes between tabs: 0px
|
||||
|
||||
## Wide viewport, reservation removed in the page (control)
|
||||
|
||||
- Chat: scrollbar-gutter auto, overflow auto/auto
|
||||
- Chat scroller scrolls: true
|
||||
- Chat reserved band: 8px
|
||||
- Trajectory: scrollbar-gutter auto, overflow hidden/hidden
|
||||
- Trajectory scroller scrolls: false
|
||||
- Trajectory reserved band: 0px
|
||||
- input card left edge moves between tabs: 4px
|
||||
- input card right edge moves between tabs: 4px
|
||||
- input card width changes between tabs: 0px
|
||||
@@ -1,9 +1,9 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785157562825,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785157562881,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"27e1fc72-20d0-4875-bf5b-d34750441f30"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785157562882,"data":{"content":[{"type":"text","text":"Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785157562883,"data":{"title":"Use only Cordis tools. First","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785157562937,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":4,"time":1785157562938,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785157564667,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785157564667,"data":{"turn":1,"step":1,"index":0,"dt":[115,31,3,0,1,0,1,21,1,0,0,0,1,23,0,1,0,0,0,24,2,1,0,0,0,28,2,0,0,0,1,23,2,22,2,1,25,2,0,0,25,27,1,1,0,0,28,2,0,0,0,0,32,1,31,1,0,0,0,9,29,3,0,0,0,1,23,1,0,0,0,27,4,0,0,0,23,2,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Call"," `","cord","is","_in","spect","`"," with"," `","what",":"," \"","t","emporary","\"`\n","2","."," Call"," `","cord","is","_m","ount","`"," with"," the"," exact"," code"," provided","\n","3","."," Read"," the"," returned"," id"," and"," call"," `","cord","is","_un","mount","`"," with"," that"," exact"," id","\n","4","."," Reply"," exactly"," \"","C","ORD","IS","_","UI","_D","ONE","\""," and"," stop","\n\n","Let"," me"," start"," with"," step"," ","1","."]}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1785157565360,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":100,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":101,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}}}}
|
||||
{"type":"assistant/chunk","seq":102,"time":1785157565491,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"a3b56157-c6fd-43f8-bc8a-0e2a0c99a8b5"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":103,"time":1785157565495,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Call `cordis_inspect` with `what: \"temporary\"`\n2. Call `cordis_mount` with the exact code provided\n3. Read the returned id and call `cordis_unmount` with that exact id\n4. Reply exactly \"CORDIS_UI_DONE\" and stop\n\nLet me start with step 1."},{"type":"tool-call","id":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":15137,"outputTokens":128,"cacheReadTokens":1280,"reasoningTokens":81}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":104,"time":1785157565496,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","name":"cordis_inspect","arguments":"{\"what\": \"temporary\"}"}}
|
||||
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_KZk918WtlKan9pHMULIT8794"},"content":[{"type":"tool-result","toolCallId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false}],"role":"user","id":"95ae124e-f065-4aab-a9c5-bd33c7eafff1"}},"sourceEventSeqs":[104],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":105,"time":1785157565500,"data":{"turn":1,"step":1,"callId":"call_00_KZk918WtlKan9pHMULIT8794","content":[{"type":"text","text":"## Temporary Plugins\nNo temporary Plugins are running. Temporary Plugins created with cordis_mount disappear when DSH restarts."}],"isError":false},"sourceEventSeqs":[104],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":106,"time":1785157565503,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":107,"time":1785157565503,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":108,"time":1785157566524,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -25,9 +25,9 @@
|
||||
{"type":"assistant/chunk","seq":157,"time":1785157567041,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":158,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":159,"time":1785157567042,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b90af675-f604-4fb4-9c27-89c91b27f4ce"}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":160,"time":1785157567043,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."},{"type":"tool-call","id":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":72,"outputTokens":82,"cacheReadTokens":16512,"reasoningTokens":22}},"sourceEventSeqs":[108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":161,"time":1785157567043,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","name":"cordis_mount","arguments":"{\"code\": \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\"}"}}
|
||||
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361"},"content":[{"type":"tool-result","toolCallId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"3148fc0a-f511-4b72-abd5-d184e974cd49"}},"sourceEventSeqs":[161],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":162,"time":1785157567049,"data":{"turn":1,"step":2,"callId":"call_00_OyrlxqqpnvzTe11sN5Oy2361","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-noop\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[161],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":163,"time":1785157567050,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":164,"time":1785157567050,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":165,"time":1785157567835,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -38,9 +38,9 @@
|
||||
{"type":"assistant/chunk","seq":204,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":205,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":206,"time":1785157568279,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdc5b840-4302-486a-8065-fa59bf13f2d9"}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":207,"time":1785157568280,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."},{"type":"tool-call","id":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":63,"outputTokens":72,"cacheReadTokens":16640,"reasoningTokens":23}},"sourceEventSeqs":[165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":208,"time":1785157568280,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_00_e38S6zeYdZGvbhecUCil6659"},"content":[{"type":"tool-result","toolCallId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"758a7b27-56e3-4997-8c4d-effc699cc5e6"}},"sourceEventSeqs":[208],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":209,"time":1785157568281,"data":{"turn":1,"step":3,"callId":"call_00_e38S6zeYdZGvbhecUCil6659","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[208],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":210,"time":1785157568282,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":211,"time":1785157568282,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":212,"time":1785157569185,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -51,6 +51,6 @@
|
||||
{"type":"assistant/chunk","seq":244,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CORDIS_UI_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":245,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":246,"time":1785157569551,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"71540121-7bf6-410e-975c-f5ea99d8175a"}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":247,"time":1785157569553,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop."},{"type":"text","text":"CORDIS_UI_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":29,"outputTokens":30,"cacheReadTokens":16768,"reasoningTokens":22}},"sourceEventSeqs":[212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":248,"time":1785157569554,"data":{"turn":1,"step":4}}
|
||||
{"type":"turn/end","seq":249,"time":1785157569554,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -5,72 +5,60 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Think The user wants me to:":
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to:"
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- button "Inspect temporary":
|
||||
- img
|
||||
- img
|
||||
- text: Inspect temporary
|
||||
- text: Inspect temporary
|
||||
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
|
||||
- button "复制":
|
||||
- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]':
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button [expanded]:
|
||||
- img
|
||||
- text: Mount temporary Plugin typescript
|
||||
- button "复制"
|
||||
- text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: typescript
|
||||
- button "Copy"
|
||||
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
|
||||
- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts).
|
||||
- button "Inspect"
|
||||
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
|
||||
- button "复制":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- button:
|
||||
- button "Unmount temporary Plugin dyn-1":
|
||||
- img
|
||||
- img
|
||||
- text: Unmount temporary Plugin dyn-1
|
||||
- text: Unmount temporary Plugin dyn-1
|
||||
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
|
||||
- img
|
||||
- img
|
||||
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
|
||||
- paragraph: CORDIS_UI_DONE
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} cache hit 77% · 66,813 tokens · 1 turns · 4 steps
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "13% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 4 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 77% Input 66.5K tok · Output 312 tok
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# AppFrame drag handles
|
||||
|
||||
## sidebar
|
||||
|
||||
- hit strip present: true
|
||||
- cursor: col-resize
|
||||
- pill generated: false
|
||||
@@ -1,9 +1,9 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"2c594e5a-bbcc-4c64-b5ee-8e84eb5dd949"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784973850888,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1784973850889,"data":{"turn":1,"step":1,"index":0,"dt":[199,1,0,0,0,18,1,0,0,0,0,27,0,1,0,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}}
|
||||
{"type":"assistant/chunk","seq":23,"time":1784973851217,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
@@ -12,9 +12,9 @@
|
||||
{"type":"assistant/chunk","seq":52,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":1784973851493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"e5d8d5fb-f555-4fda-948f-9ab178145929"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":55,"time":1784973851498,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":56,"time":1784973851499,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_BYXlxjFaalMg95YVqEeF2495"},"content":[{"type":"tool-result","toolCallId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false}],"role":"user","id":"215febf9-ff3c-42b8-93b7-27ce1505c1c2"}},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":57,"time":1784973851515,"data":{"turn":1,"step":1,"callId":"call_00_BYXlxjFaalMg95YVqEeF2495","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[56],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":58,"time":1784973851517,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":59,"time":1784973851518,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1784973852194,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -26,6 +26,6 @@
|
||||
{"type":"assistant/chunk","seq":88,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":89,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}}}}
|
||||
{"type":"assistant/chunk","seq":90,"time":1784973852460,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"61e83a98-18a9-4f1f-9151-c8783cc39901"}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":91,"time":1784973852461,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":97,"outputTokens":26,"cacheReadTokens":7808,"reasoningTokens":23}},"sourceEventSeqs":[60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":92,"time":1784973852461,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":93,"time":1784973852462,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
You are an AI agent powered by the DeepSeek Harness SDK.
|
||||
|
||||
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
|
||||
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
|
||||
|
||||
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
@@ -5,43 +5,40 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to run a simple bash command and reply with "DONE".
|
||||
- button "复制":
|
||||
- button "Bash Echo the test string":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- img
|
||||
- text: Bash Echo the test string
|
||||
- text: Bash Echo the test string
|
||||
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
|
||||
- img
|
||||
- img
|
||||
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
|
||||
- paragraph: DONE
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} cache hit 99% · 15,818 tokens · 1 turns · 2 steps
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "6% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok
|
||||
|
||||
8
apps/web/tests/snapshots/goal-bar/active.expected.md
Normal file
8
apps/web/tests/snapshots/goal-bar/active.expected.md
Normal file
@@ -0,0 +1,8 @@
|
||||
- img
|
||||
- text: Ongoing Goal guard rapid clear clicks
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
@@ -0,0 +1,7 @@
|
||||
- listbox "Trigger suggestions":
|
||||
- text: Commands
|
||||
- option "compact Compact older conversation history" [selected]
|
||||
- option "goal set or view the goal for a long-running task"
|
||||
- option "permission Switch the permission preset (sandbox mode + approval policy)"
|
||||
- option "plan Enter or leave plan mode"
|
||||
- option "model Select the model for this conversation"
|
||||
@@ -7,7 +7,7 @@
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Create workspace":
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
@@ -16,27 +16,24 @@
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "New Session now" [selected]
|
||||
- button "设置":
|
||||
- treeitem "New Session" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: 设置
|
||||
- text: Let's start building
|
||||
- text: Settings
|
||||
- text: Let's start building Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- tooltip "Commands"
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 详情
|
||||
- button "关闭详情"
|
||||
- text: 点击消息流中的工具行查看详情
|
||||
- text: Details
|
||||
- button "Close details"
|
||||
- text: Click a tool row in the message flow to view its details
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
- button "New session"
|
||||
- button "Collapse sidebar":
|
||||
- img
|
||||
- button "New session":
|
||||
- img
|
||||
- text: New Session
|
||||
- text: Workspaces
|
||||
- button "Group by":
|
||||
- img
|
||||
- button "Add workspace":
|
||||
- img
|
||||
- button "Search sessions":
|
||||
- img
|
||||
- textbox "Search name, keywords..."
|
||||
- tree "Sessions":
|
||||
- treeitem "workspace 1 session" [expanded]:
|
||||
- img
|
||||
- text: workspace 1 session
|
||||
- treeitem "New Session" [selected]
|
||||
- button "Settings":
|
||||
- img
|
||||
- text: Settings
|
||||
- text: Let's start building Preview
|
||||
- button "Choose workspace":
|
||||
- img
|
||||
- text: workspace
|
||||
- img
|
||||
- textbox "Describe what you want to build"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Plan mode on, press to turn off": Plan
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: Details
|
||||
- button "Close details"
|
||||
- text: Click a tool row in the message flow to view its details
|
||||
@@ -5,32 +5,32 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||
- paragraph: LIGHTHOUSE
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} cache hit 99% · 7,810 tokens · 1 turns · 1 steps
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "6% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"ee28d2ee-08b2-4ed7-a1e9-84865f55e2af"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
@@ -12,6 +12,6 @@
|
||||
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2101eadc-3475-4c77-ae0a-0107b12c34bc"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -5,29 +5,28 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- paragraph: partial
|
||||
- text: 已停止
|
||||
- button "复制":
|
||||
- text: Stopped
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} 0 tokens · 1 turns · 1 steps
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
|
||||
@@ -5,22 +5,23 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- status:
|
||||
- text: This turn failedAPI key is invalid
|
||||
- code: AUTH
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -5,32 +5,34 @@
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- button "编辑":
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- button "▸ 上下文注入"
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- group:
|
||||
- status: Retried model request (1/2) · {{duration}}
|
||||
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
|
||||
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} cache hit 99% · 7,869 tokens · 1 turns · 1 steps
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "6% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784998084441,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1784998084454,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"dc9a43b2-63a3-49bc-97e9-9dfb6465f6c1"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1784998084454,"data":{"content":[{"type":"text","text":"Reply with a one-sentence description of event sourcing, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784998084457,"data":{"title":"Reply with a one-sentence description","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784998084519,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":4,"time":1784998084520,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784998084900,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1784998084900,"data":{"turn":1,"step":1,"index":0,"dt":[153,3,0,29,1,0,0,28,0,1,0,0,0,28,0,0,0,29,1,0,29,0,0,29,0,0,36,0,21,29],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}}
|
||||
{"type":"assistant/chunk","seq":37,"time":1784998085318,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
@@ -12,6 +12,6 @@
|
||||
{"type":"assistant/chunk","seq":86,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}}
|
||||
{"type":"assistant/chunk","seq":87,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}}
|
||||
{"type":"assistant/chunk","seq":88,"time":1784998085814,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"3396618c-56a2-4f97-a9c7-2a3cbb16a3c8"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":89,"time":1784998085818,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."},{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":90,"time":1784998085820,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":91,"time":1784998085821,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
||||
52
apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md
Normal file
52
apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md
Normal file
@@ -0,0 +1,52 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "CJK strong emphasis" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Render adjacent CJK strong emphasis. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- heading "CJK strong emphasis" [level=2]
|
||||
- paragraph:
|
||||
- strong: 注意:
|
||||
- text: 内容
|
||||
- paragraph:
|
||||
- strong: "Notice:"
|
||||
- text: 内容
|
||||
- paragraph:
|
||||
- strong: 事件中间件(waterfall)
|
||||
- text: 实现
|
||||
- paragraph:
|
||||
- strong: 事件中间件(waterfall)
|
||||
- text: 实现
|
||||
- paragraph:
|
||||
- strong: 句号。
|
||||
- text: 后续
|
||||
- paragraph:
|
||||
- strong: Period.
|
||||
- text: 后续
|
||||
- paragraph:
|
||||
- strong: 提醒!
|
||||
- text: 继续
|
||||
- paragraph:
|
||||
- strong: Warning!
|
||||
- text: 继续
|
||||
- paragraph: CJK_STRONG_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
31
apps/web/tests/snapshots/markdown-images/ui.expected.md
Normal file
31
apps/web/tests/snapshots/markdown-images/ui.expected.md
Normal file
@@ -0,0 +1,31 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Markdown image policy" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Show the Markdown image policy. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- heading "Markdown images" [level=2]
|
||||
- paragraph:
|
||||
- img "Remote test image"
|
||||
- paragraph: Local test image
|
||||
- paragraph: REMOTE_IMAGE_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
@@ -0,0 +1,43 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Inline code links" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Show the local preview URL. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- heading "Inline code links" [level=2]
|
||||
- paragraph:
|
||||
- text: "Preview:"
|
||||
- code:
|
||||
- link "{{linkUrl}}":
|
||||
- /url: {{linkUrl}}
|
||||
- paragraph:
|
||||
- text: "Standard:"
|
||||
- link "Open preview":
|
||||
- /url: {{linkUrl}}
|
||||
- paragraph:
|
||||
- text: "Command:"
|
||||
- code: curl {{linkUrl}}
|
||||
- paragraph:
|
||||
- text: "Unsafe:"
|
||||
- code: javascript:alert(1)
|
||||
- paragraph: INLINE_CODE_LINK_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps Input 0 tok · Output 0 tok
|
||||
47
apps/web/tests/snapshots/math-rendering/ui.expected.md
Normal file
47
apps/web/tests/snapshots/math-rendering/ui.expected.md
Normal file
@@ -0,0 +1,47 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Math rendering" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Render this mathematical proof. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- heading "Math rendering" [level=2]
|
||||
- paragraph:
|
||||
- text: Inline dollar
|
||||
- math: θ
|
||||
- text: and backslash
|
||||
- math: 1 5
|
||||
- text: .
|
||||
- math: π 4 < θ < π 2
|
||||
- math: θ ∈ ( π 4 , π 2 ) . (1)
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "Symbol Value":
|
||||
- columnheader "Symbol"
|
||||
- columnheader "Value"
|
||||
- rowgroup:
|
||||
- row:
|
||||
- cell:
|
||||
- math: θ
|
||||
- cell:
|
||||
- math: 1 5
|
||||
- paragraph: MATH_RENDERING_DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} Input 0 tok · Output 0 tok
|
||||
@@ -0,0 +1,7 @@
|
||||
- tree "Sessions":
|
||||
- treeitem "Ungrouped 3 sessions" [expanded]:
|
||||
- img
|
||||
- text: Ungrouped 3 sessions
|
||||
- treeitem "Use the read tool twice (2) now" [selected]
|
||||
- treeitem "Use the read tool twice (1) now"
|
||||
- treeitem "Use the read tool twice 1min"
|
||||
@@ -4,48 +4,55 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
|
||||
- button "复制":
|
||||
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- tooltip "复制"
|
||||
- button "在新对话中分支":
|
||||
- img
|
||||
- button "编辑":
|
||||
- tooltip "Copy"
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
|
||||
- button "复制":
|
||||
- paragraph: I will read both files before answering.
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: {{clock}}
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- button "Read a.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "a.txt"
|
||||
- button "Read b.txt":
|
||||
- img
|
||||
- img
|
||||
- text: Read
|
||||
- button "b.txt"
|
||||
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
|
||||
- img
|
||||
- img
|
||||
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
|
||||
- text: Stopped Now give the final answer. 7/25 {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- paragraph: DONE
|
||||
- button "复制":
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "在新对话中分支":
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} cache hit 98% · 15,962 tokens · 1 turns · 2 steps
|
||||
- text: 7/25 {{clock}} Ran for {{duration}}
|
||||
- textbox "Message the agent"
|
||||
- button "Add attachment":
|
||||
- button "Commands":
|
||||
- img
|
||||
- text: Danger Full Access
|
||||
- combobox "Access mode":
|
||||
- option "Read Only"
|
||||
- option "Workspace Write"
|
||||
- option "Danger Full Access" [selected]
|
||||
- button "选择模型,当前 deepseek-v4-flash":
|
||||
- text: deepseek-v4-flash
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model":
|
||||
- text: Select model
|
||||
- img
|
||||
- button "Send message" [disabled]
|
||||
- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: minimax-cn
|
||||
- button "编辑"
|
||||
- button "删除"
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
@@ -0,0 +1,7 @@
|
||||
- dialog "删除模型提供方?":
|
||||
- heading "删除模型提供方?" [level=2]
|
||||
- button "关闭":
|
||||
- img
|
||||
- paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
|
||||
- button "取消"
|
||||
- button "删除提供方"
|
||||
61
apps/web/tests/snapshots/models-settings/empty.expected.md
Normal file
61
apps/web/tests/snapshots/models-settings/empty.expected.md
Normal file
@@ -0,0 +1,61 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list
|
||||
- text: 提供方
|
||||
- combobox "提供方":
|
||||
- option "amazon-bedrock"
|
||||
- option "ant-ling"
|
||||
- option "anthropic"
|
||||
- option "azure-openai-responses"
|
||||
- option "cerebras"
|
||||
- option "cloudflare-ai-gateway"
|
||||
- option "cloudflare-workers-ai"
|
||||
- option "deepseek"
|
||||
- option "fireworks"
|
||||
- option "github-copilot"
|
||||
- option "google"
|
||||
- option "google-vertex"
|
||||
- option "groq"
|
||||
- option "huggingface"
|
||||
- option "kimi-coding"
|
||||
- option "minimax"
|
||||
- option "minimax-cn" [selected]
|
||||
- option "mistral"
|
||||
- option "moonshotai"
|
||||
- option "moonshotai-cn"
|
||||
- option "nvidia"
|
||||
- option "openai"
|
||||
- option "openai-codex"
|
||||
- option "opencode"
|
||||
- option "opencode-go"
|
||||
- option "openrouter"
|
||||
- option "qwen-token-plan"
|
||||
- option "qwen-token-plan-cn"
|
||||
- option "together"
|
||||
- option "vercel-ai-gateway"
|
||||
- option "xai"
|
||||
- option "xiaomi"
|
||||
- option "xiaomi-token-plan-ams"
|
||||
- option "xiaomi-token-plan-cn"
|
||||
- option "xiaomi-token-plan-sgp"
|
||||
- option "zai"
|
||||
- option "zai-coding-cn"
|
||||
- text: API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 输入 API 密钥
|
||||
- group: 自定义设置
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
@@ -0,0 +1,2 @@
|
||||
- tree "Search results":
|
||||
- 'treeitem "{{workspace}} {{workspace}} ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"'
|
||||
@@ -1,9 +1,9 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785011380476,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785011380489,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"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."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"7dcfe547-5555-4e5d-b2a0-3f9d2e0836a9"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":1,"time":1785011380490,"data":{"content":[{"type":"text","text":"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."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785011380492,"data":{"title":"NavScenario: first run bash to","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785011380549,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"request/header","seq":4,"time":1785011380550,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785011380917,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785011380917,"data":{"turn":1,"step":1,"index":0,"dt":[110,25,1,0,0,25,1,0,26,0,1,0,27,1,0,0,0,26,1,0,0,26,0,0,0,0,1,25,0,0,25,0,1,0,0,0,26,1,0,25,0,0,27,1,0,0,0,0,25,28,0,1,0,0,0,27,0,0,0,25,1,24,1,0,25],"texts":["The"," user"," wants"," me"," to"," follow"," a"," specific"," navigation"," scenario","."," Let"," me",":\n\n","1","."," Run"," bash"," to"," print"," \"","NA","V","IG","ATION","_OK","\"\n","2","."," Read"," nav","-a",".md"," and"," nav","-b",".md"," in"," two"," read"," calls"," in"," ONE"," message","\n","3","."," Reply"," with"," \"","FIR","ST","_D","ONE","\"\n\n","Let"," me"," start"," with"," the"," bash"," command"," and"," the"," reads","."]}}
|
||||
{"type":"assistant/chunk","seq":72,"time":1785011381556,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
@@ -18,13 +18,13 @@
|
||||
{"type":"assistant/chunk","seq":130,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":3,"block":{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":131,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}}}}
|
||||
{"type":"assistant/chunk","seq":132,"time":1785011382087,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"83b27ad0-4286-478f-92ef-3bc82bf0589b"}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":133,"time":1785011382091,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to follow a specific navigation scenario. Let me:\n\n1. Run bash to print \"NAVIGATION_OK\"\n2. Read nav-a.md and nav-b.md in two read calls in ONE message\n3. Reply with \"FIRST_DONE\"\n\nLet me start with the bash command and the reads."},{"type":"tool-call","id":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"},{"type":"tool-call","id":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"},{"type":"tool-call","id":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":140,"outputTokens":197,"cacheReadTokens":7680,"reasoningTokens":66}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":134,"time":1785011382092,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","name":"bash","arguments":"{\"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\"}"}}
|
||||
{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_kFKHaEXcTYEex0iDZw0C2432"},"content":[{"type":"tool-result","toolCallId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false}],"role":"user","id":"d6e29f9e-8177-4f12-b1b6-2eda4c8f5c31"}},"sourceEventSeqs":[134],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":135,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_00_kFKHaEXcTYEex0iDZw0C2432","content":[{"type":"text","text":"NAVIGATION_OK\n"}],"isError":false},"sourceEventSeqs":[134],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":136,"time":1785011382105,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","name":"read","arguments":"{\"file_path\": \"nav-a.md\"}"}}
|
||||
{"type":"tool/call","seq":137,"time":1785011382106,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","name":"read","arguments":"{\"file_path\": \"nav-b.md\"}"}}
|
||||
{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212"},"content":[{"type":"tool-result","toolCallId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-a.md</path>\n<type>file</type>\n<content>\n1: # alpha nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"efca7baf-cbb8-462f-ad75-580357b58676"}},"sourceEventSeqs":[136],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224"},"content":[{"type":"tool-result","toolCallId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-b.md</path>\n<type>file</type>\n<content>\n1: # beta nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"e1c70190-9e68-429d-963d-8a30ab779ddd"}},"sourceEventSeqs":[137],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":138,"time":1785011382113,"data":{"turn":1,"step":1,"callId":"call_01_tK4hIIRVTMgAvdzs7m9j6212","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-a.md</path>\n<type>file</type>\n<content>\n1: # alpha nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[136],"surfaceOp":"append"}
|
||||
{"type":"tool/result","seq":139,"time":1785011382114,"data":{"turn":1,"step":1,"callId":"call_02_k8Z6wGirxfnW96Iv8mkz9224","content":[{"type":"text","text":"<path>{{cwd}}/workspace/nav-b.md</path>\n<type>file</type>\n<content>\n1: # beta nav\n\n(End of file - total 1 lines)\n</content>"}],"isError":false},"sourceEventSeqs":[137],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":140,"time":1785011382117,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":141,"time":1785011382118,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":142,"time":1785011382673,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
@@ -35,11 +35,11 @@
|
||||
{"type":"assistant/chunk","seq":200,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"FIRST_DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":201,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}}}}
|
||||
{"type":"assistant/chunk","seq":202,"time":1785011383090,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51},"message":{"role":"assistant","content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"0115844e-fac0-4985-9ba7-dcf1dde63b83"}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":203,"time":1785011383091,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"All three calls succeeded:\n1. bash printed \"NAVIGATION_OK\"\n2. nav-a.md contains \"# alpha nav\"\n3. nav-b.md contains \"# beta nav\"\n\nNow I need to reply with the single word \"FIRST_DONE\"."},{"type":"text","text":"FIRST_DONE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":349,"outputTokens":56,"cacheReadTokens":7808,"reasoningTokens":51}},"sourceEventSeqs":[142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":204,"time":1785011383091,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":205,"time":1785011383092,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"turn/start","seq":206,"time":1785011383106,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"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."}],"source":{"kind":"user","rpcId":"{{rpcId}}"},"role":"user","id":"1d7a8563-4ae6-49f3-9a65-41f3df300a75"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":207,"time":1785011383107,"data":{"content":[{"type":"text","text":"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."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":208,"time":1785011383107,"data":{"turn":2,"step":1}}
|
||||
{"type":"assistant/chunk","seq":209,"time":1785011383497,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":210,"time0":1785011383497,"data":{"turn":2,"step":1,"index":0,"dt":[125,23,1,0,0,88,0,0,5,0,1,0,0,7,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," specific"," format","."," Let"," me"," do"," that","."]}}
|
||||
@@ -49,6 +49,6 @@
|
||||
{"type":"assistant/chunk","seq":247,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}}}}
|
||||
{"type":"assistant/chunk","seq":248,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}}}}
|
||||
{"type":"assistant/chunk","seq":249,"time":1785011383903,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"70b38db9-38a2-4f9f-8094-1bd799f5f270"}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","seq":250,"time":1785011383904,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a specific format. Let me do that."},{"type":"text","text":"## Navigation Summary\n\n- alpha nav\n- beta nav\n\n```\necho WATERFALL\n```"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":141,"outputTokens":36,"cacheReadTokens":8064,"reasoningTokens":16}},"sourceEventSeqs":[209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":251,"time":1785011383904,"data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","seq":252,"time":1785011383904,"data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
- text: 已完成 {{workspace}} echo NAVIGATION_OK
|
||||
- button "复制"
|
||||
- text: Done {{workspace}} echo NAVIGATION_OK
|
||||
- button "Copy"
|
||||
- text: NAVIGATION_OK
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
- button "Collapse calls": Calls
|
||||
- img
|
||||
- searchbox "Search trajectory"
|
||||
- region "Trajectory timeline"
|
||||
- region "Trajectory timeline":
|
||||
- tooltip "ASSISTANT {{clock}} → {{clock}} Total 1,542 ms · TTFT 368 ms · Decoding 1,174 ms"
|
||||
- table:
|
||||
- rowgroup:
|
||||
- row "SYSTEM, Initial System Prompt":
|
||||
- cell "SYSTEM"
|
||||
- cell "Initial System Prompt"
|
||||
- 'row "USER, 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."':
|
||||
- cell "Turn 1 USER"
|
||||
- cell "Turn 1 USER": USER
|
||||
- 'cell "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."'
|
||||
- 'row "Request 1, ASSISTANT, The user wants me to follow a specific navigation scenario. Let me: Run bash to print \"NAVIGATION_OK\" Read nav-a.md and nav-b.md in two read calls in ONE message Reply with \"FIRST_DONE\" Let me start with the bash command and the reads."':
|
||||
- 'cell "Request #1 ASSISTANT"':
|
||||
@@ -33,7 +34,7 @@
|
||||
- text: ASSISTANT
|
||||
- cell "FIRST_DONE"
|
||||
- 'row "USER, 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."':
|
||||
- cell "Turn 2 USER"
|
||||
- cell "Turn 2 USER": USER
|
||||
- 'cell "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."'
|
||||
- row "Request 3, ASSISTANT, Navigation Summary alpha nav beta nav echo WATERFALL":
|
||||
- 'cell "Request #3 ASSISTANT"':
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- region "添加一个 API Key 开始使用":
|
||||
- heading "添加一个 API Key 开始使用" [level=2]
|
||||
- paragraph: 配置 DeepSeek 官方模型,即可开始使用。
|
||||
- button "稍后配置"
|
||||
- button "前往配置"
|
||||
@@ -0,0 +1,71 @@
|
||||
- dialog "设置":
|
||||
- navigation:
|
||||
- text: 设置
|
||||
- button "通用设置":
|
||||
- img
|
||||
- text: 通用设置
|
||||
- button "模型":
|
||||
- img
|
||||
- text: 模型
|
||||
- button "打开配置文件"
|
||||
- button "关闭":
|
||||
- img
|
||||
- text: 关闭
|
||||
- heading "模型" [level=2]
|
||||
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
|
||||
- list:
|
||||
- listitem:
|
||||
- text: DeepSeek
|
||||
- button "编辑"
|
||||
- text: DeepSeek deepseek-official API 密钥
|
||||
- textbox "API 密钥":
|
||||
- /placeholder: 已配置——输入新值可替换
|
||||
- group:
|
||||
- text: 自定义设置 API 地址
|
||||
- textbox "API 地址":
|
||||
- /placeholder: https://api.deepseek.com
|
||||
- text: 推理强度
|
||||
- combobox "推理强度":
|
||||
- option "默认" [selected]
|
||||
- option "off"
|
||||
- option "high"
|
||||
- option "max"
|
||||
- region "模型目录":
|
||||
- text: 模型目录 已自定义模型目录
|
||||
- button "恢复默认模型"
|
||||
- textbox "模型 ID 1":
|
||||
- /placeholder: 模型 ID
|
||||
- text: deepseek-v4-pro
|
||||
- textbox "显示名称 1":
|
||||
- /placeholder: 显示名称
|
||||
- text: DeepSeek-V4-Pro
|
||||
- button "容量 1":
|
||||
- img
|
||||
- button "删除模型 1":
|
||||
- img
|
||||
- textbox "模型 ID 2":
|
||||
- /placeholder: 模型 ID
|
||||
- text: private-preview
|
||||
- textbox "显示名称 2":
|
||||
- /placeholder: 显示名称
|
||||
- text: Private Preview
|
||||
- button "容量 2" [expanded]:
|
||||
- img
|
||||
- button "删除模型 2":
|
||||
- img
|
||||
- text: 上下文窗口
|
||||
- textbox "上下文窗口 2":
|
||||
- /placeholder: 1M
|
||||
- text: "131072"
|
||||
- text: 最大输出 token 数
|
||||
- textbox "最大输出 token 数 2":
|
||||
- /placeholder: 256K
|
||||
- text: 64K
|
||||
- button "添加模型":
|
||||
- img
|
||||
- text: 添加模型
|
||||
- button "取消"
|
||||
- button "保存"
|
||||
- button "添加提供方":
|
||||
- img
|
||||
- text: 添加提供方
|
||||
@@ -0,0 +1,9 @@
|
||||
- region "内测声明":
|
||||
- heading "内测声明" [level=2]
|
||||
- paragraph: 感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
|
||||
- blockquote: “如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
|
||||
- paragraph:
|
||||
- text: 为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,
|
||||
- strong: 如果您有任何反馈与建议,请在企业微信群中留言告诉我们
|
||||
- text: 。每一条反馈,都会帮助我们把它打磨得更好。
|
||||
- button "继续"
|
||||
124
apps/web/tests/snapshots/permission-policy-context/session.jsonl
Normal file
124
apps/web/tests/snapshots/permission-policy-context/session.jsonl
Normal file
File diff suppressed because one or more lines are too long
49
apps/web/tests/snapshots/plan-review/approved.expected.md
Normal file
49
apps/web/tests/snapshots/plan-review/approved.expected.md
Normal file
@@ -0,0 +1,49 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- 'button "Plan a small change: add" [disabled]'
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- img
|
||||
- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."
|
||||
- paragraph:
|
||||
- text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with
|
||||
- code: exit_plan_mode
|
||||
- text: .
|
||||
- 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"':
|
||||
- img
|
||||
- img
|
||||
- text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
|
||||
- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."':
|
||||
- img
|
||||
- img
|
||||
- text: "Think The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."
|
||||
- paragraph: DONE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "4% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 10.2K tok · Output 346 tok
|
||||
44
apps/web/tests/snapshots/plan-review/review.expected.md
Normal file
44
apps/web/tests/snapshots/plan-review/review.expected.md
Normal file
@@ -0,0 +1,44 @@
|
||||
- region "Approve this plan and leave plan mode?":
|
||||
- text: Plan review
|
||||
- heading "Add --greeting flag to CLI" [level=1]:
|
||||
- text: Add
|
||||
- code: "--greeting"
|
||||
- text: flag to CLI
|
||||
- list:
|
||||
- listitem:
|
||||
- strong: Locate the CLI entry point
|
||||
- text: (e.g.,
|
||||
- code: cli.py
|
||||
- text: ","
|
||||
- code: main.go
|
||||
- text: ","
|
||||
- code: index.js
|
||||
- text: etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).
|
||||
- listitem:
|
||||
- strong: Register a new optional string argument
|
||||
- text: named
|
||||
- code: "--greeting"
|
||||
- text: with a short alias (
|
||||
- code: "-g"
|
||||
- text: if available) and a sensible default value (e.g.,
|
||||
- code: "\"Hello\""
|
||||
- text: ).
|
||||
- listitem:
|
||||
- strong: Thread the parsed value
|
||||
- text: through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).
|
||||
- listitem:
|
||||
- strong: Update the help text
|
||||
- text: so
|
||||
- code: "--help"
|
||||
- text: or
|
||||
- code: "-h"
|
||||
- text: shows the new flag with its description.
|
||||
- listitem:
|
||||
- strong: No tests or config changes
|
||||
- text: unless they already exist and directly validate the flag's presence.
|
||||
- status
|
||||
- button "Chat about it":
|
||||
- img
|
||||
- text: Chat about it
|
||||
- button "Refuse"
|
||||
- button "Approve"
|
||||
39
apps/web/tests/snapshots/plan-review/session.jsonl
Normal file
39
apps/web/tests/snapshots/plan-review/session.jsonl
Normal file
@@ -0,0 +1,39 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785406804293,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"command/run","seq":0,"time":1785406804350,"data":{"commandId":"cmd-228a60ef-1","name":"plan","args":" Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop.","source":{"kind":"user"}}}
|
||||
{"type":"plan/mode","seq":1,"time":1785406804350,"data":{"active":true}}
|
||||
{"type":"command/done","seq":2,"time":1785406804352,"data":{"commandId":"cmd-228a60ef-1","kind":"success","text":"Plan mode on. Use /plan off to leave."}}
|
||||
{"type":"turn/start","seq":3,"time":1785406804353,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":4,"time":1785406804353,"data":{"content":[{"type":"text","text":"Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"a22bd0d2-15dc-4bfb-b978-0d5961459e57"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":5,"time":1785406804354,"data":{"title":"Plan a small change: add","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":6,"time":1785406804355,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":7,"time":1785406804356,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1785406805697,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":9,"time0":1785406805698,"data":{"turn":1,"step":1,"index":0,"dt":[151,31,0,0,0,0,18,1,0,0,0,31,1,0,0,0,1,23,0,0,0,19,0,0,37,1,0,0,0,0,12,1,0,0,28,0,1,0,24,1,0,0,0,0,26,1,0,0,26,1,22],"texts":["The"," user"," wants"," me"," to"," plan"," a"," small"," change"," to"," add"," a"," `","--","gre","eting","`"," flag"," to"," a"," CLI","."," They"," explicitly"," told"," me"," not"," to"," read"," or"," write"," any"," files",","," and"," to"," call"," exit","_","plan","_mode"," with"," a"," short"," plan","."," Let"," me"," do"," that"," directly","."]}}
|
||||
{"type":"assistant/chunk","seq":61,"time":1785406806155,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"text-chunks","seq0":62,"time0":1785406806156,"data":{"turn":1,"step":1,"index":1,"dt":[0,20,0,34,23,26,0,1,0,0,28,0,0,0,32,0,15,1,0,30,1,0,0,23,1,0,24,0,0,0],"texts":["Since"," the"," user"," has"," explicitly"," asked"," me"," not"," to"," read"," or"," write"," any"," files"," and"," to"," go"," straight"," to"," planning",","," I","'ll"," proceed"," with"," `","exit","_","plan","_mode","`."]}}
|
||||
{"type":"assistant/chunk","seq":93,"time":1785406806493,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}}
|
||||
{"type":"tool-call-chunks","seq0":94,"time0":1785406806493,"data":{"turn":1,"step":1,"index":2,"dt":[27,1,0,0,0,24,0,0,29,1,0,0,0,1,20,0,27,1,0,21,28,0,0,31,1,23,0,0,1,23,0,0,0,23,1,0,28,20,26,28,1,0,29,1,0,0,21,28,0,0,25,1,26,1,0,0,24,1,0,26,0,0,0,1,23,1,0,0,22,29,0,31,1,1,0,21,1,0,0,0,0,20,0,32,25,0,0,22,0,0,27,0,1,0,31,0,1,0,0,20,1,0,0,0,0,24,0,1,0,26,1,29,1,26,1,0,25,30,1,18,0,30,0,0,30,21,1,28,0,21,1,0,0,23,1,23,36,0,0,1,0,13,1,28,27,0,0,25,1,45,1,11,0,21,0,0,0,0,23,0,0,32,1,0,0,0,16,28,0,27,1,0,22,31,1,24,1,21,32,1,0,24,1,0,24],"id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","args":["","{","\"","plan","\"",": ","\"","#"," Add"," `","--","gre","eting","`"," flag"," to"," CLI","\\n\\n","-"," **","Loc","ate"," the"," CLI"," entry"," point","**"," (","e",".g",".,"," `","cli",".py","`,"," `","main",".go","`,"," `","index",".js","`"," etc",".)"," and"," find"," the"," argument"," parser"," definition"," (","arg","parse",","," click",","," cob","ra",","," y","args",","," or"," similar",").\\n","-"," **","Register"," a"," new"," optional"," string"," argument","**"," named"," `","--","gre","eting","`"," with"," a"," short"," alias"," (`","-","g","`"," if"," available",")"," and"," a"," sensible"," default"," value"," (","e",".g",".,"," `","\\\"","Hello","\\\"","`",").\\n","-"," **","Thread"," the"," parsed"," value","**"," through"," the"," main"," handler"," function"," so"," it"," is"," passed"," where"," the"," greeting"," string"," is"," used"," (","e",".g",".,"," the"," welcome","/","response"," message",").\\n","-"," **","Update"," the"," help"," text","**"," so"," `","--","help","`"," or"," `","-","h","`"," shows"," the"," new"," flag"," with"," its"," description",".\\n","-"," **","No"," tests"," or"," config"," changes","**"," unless"," they"," already"," exist"," and"," directly"," validate"," the"," flag","'s"," presence",".","\"","}"]}}
|
||||
{"type":"assistant/chunk","seq":280,"time":1785406808318,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."}}}}
|
||||
{"type":"assistant/chunk","seq":281,"time":1785406808318,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with `exit_plan_mode`."}}}}
|
||||
{"type":"assistant/chunk","seq":282,"time":1785406808318,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","arguments":"{\"plan\": \"# Add `--greeting` flag to CLI\\n\\n- **Locate the CLI entry point** (e.g., `cli.py`, `main.go`, `index.js` etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).\\n- **Register a new optional string argument** named `--greeting` with a short alias (`-g` if available) and a sensible default value (e.g., `\\\"Hello\\\"`).\\n- **Thread the parsed value** through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).\\n- **Update the help text** so `--help` or `-h` shows the new flag with its description.\\n- **No tests or config changes** unless they already exist and directly validate the flag's presence.\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":283,"time":1785406808319,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4529,"outputTokens":303,"cacheReadTokens":640,"reasoningTokens":52}}}}
|
||||
{"type":"assistant/chunk","seq":284,"time":1785406808319,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":285,"time":1785406808324,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."},{"type":"text","text":"Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with `exit_plan_mode`."},{"type":"tool-call","id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","arguments":"{\"plan\": \"# Add `--greeting` flag to CLI\\n\\n- **Locate the CLI entry point** (e.g., `cli.py`, `main.go`, `index.js` etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).\\n- **Register a new optional string argument** named `--greeting` with a short alias (`-g` if available) and a sensible default value (e.g., `\\\"Hello\\\"`).\\n- **Thread the parsed value** through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).\\n- **Update the help text** so `--help` or `-h` shows the new flag with its description.\\n- **No tests or config changes** unless they already exist and directly validate the flag's presence.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"b9e18116-68f6-4442-b5a5-a33277ef526c"},"usage":{"inputTokens":4529,"outputTokens":303,"cacheReadTokens":640,"reasoningTokens":52}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":286,"time":1785406808325,"data":{"turn":1,"step":1,"callId":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","arguments":"{\"plan\": \"# Add `--greeting` flag to CLI\\n\\n- **Locate the CLI entry point** (e.g., `cli.py`, `main.go`, `index.js` etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).\\n- **Register a new optional string argument** named `--greeting` with a short alias (`-g` if available) and a sensible default value (e.g., `\\\"Hello\\\"`).\\n- **Thread the parsed value** through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).\\n- **Update the help text** so `--help` or `-h` shows the new flag with its description.\\n- **No tests or config changes** unless they already exist and directly validate the flag's presence.\"}"}}
|
||||
{"type":"tool/result","seq":287,"time":1785406808720,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_TPh5fH8x5kRezK115kgx8155"},"content":[{"type":"tool-result","toolCallId":"call_00_TPh5fH8x5kRezK115kgx8155","content":[{"type":"text","text":"Plan approved — plan mode exited; carry out the plan starting with your next step."}],"isError":false}],"role":"user","id":"e7cf1f9f-e143-48df-b975-440a9d61a538"}},"sourceEventSeqs":[286],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":288,"time":1785406808721,"data":{"turn":1,"step":1}}
|
||||
{"type":"plan/mode","seq":289,"time":1785406808721,"data":{"active":false}}
|
||||
{"type":"step/start","seq":290,"time":1785406808723,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":291,"time":1785406808723,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":292,"time":1785406809855,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":293,"time0":1785406809855,"data":{"turn":1,"step":2,"index":0,"dt":[133,28,1,0,0,17,0,28,1,0,0,29,0,0,0,0,0,21,0,0,0,35,0,0,0,0,0,14,1,37,1,0,1,17,0,0,0,0,1],"texts":["The"," plan"," was"," approved","."," The"," user","'s"," last"," instruction"," says",":"," \"","Once"," the"," plan"," is"," approved",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop",".\""," So"," I"," should"," just"," reply"," with"," D","ONE"," and"," stop","."]}}
|
||||
{"type":"assistant/chunk","seq":333,"time":1785406810252,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":334,"time":1785406810253,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":335,"time":1785406810253,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":336,"time":1785406810256,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."}}}}
|
||||
{"type":"assistant/chunk","seq":337,"time":1785406810256,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":338,"time":1785406810256,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":441,"outputTokens":43,"cacheReadTokens":4608,"reasoningTokens":40}}}}
|
||||
{"type":"assistant/chunk","seq":339,"time":1785406810256,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":340,"time":1785406810257,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9da552-d236-4166-9d06-dffa9bace4cd"},"usage":{"inputTokens":441,"outputTokens":43,"cacheReadTokens":4608,"reasoningTokens":40}},"sourceEventSeqs":[292,293,294,295,296,297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":341,"time":1785406810258,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":342,"time":1785406810258,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
1
apps/web/tests/snapshots/plan-review/sidebar.expected.md
Normal file
1
apps/web/tests/snapshots/plan-review/sidebar.expected.md
Normal file
@@ -0,0 +1 @@
|
||||
- 'treeitem "Plan awaiting review Plan a small change: add now" [selected]'
|
||||
19
apps/web/tests/snapshots/pwsh-terminal/seed.jsonl
Normal file
19
apps/web/tests/snapshots/pwsh-terminal/seed.jsonl
Normal file
@@ -0,0 +1,19 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747}
|
||||
{"type":"turn/start","seq":0,"time":1784974200000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1784974200001,"data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1784974200002,"data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1784974200010,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1784974200011,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1784974200200,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Run the failing pwsh command."}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Run the failing pwsh command."}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":1784974200300,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_pwsh_fail_0001","name":"pwsh","argumentsDelta":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":1784974200310,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":1784974200311,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":1784974200500,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":16,"time":1784974200501,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":17,"time":1784974200501,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user