Merge remote-tracking branch 'origin/xtr/session-format-migration' into xtr/message-tool-call-id

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-20-branded-ids.md
#	.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md
#	packages/fs/tool-fs/tests/read-image.spec.ts
#	packages/llm/llm-deepseek/tests/adapter.e2e.ts
#	packages/llm/llm-deepseek/tests/serialize.spec.ts
#	packages/llm/llm-pi-ai/src/context.ts
#	packages/llm/llm-pi-ai/tests/context.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/llm/llm/src/message.ts
#	packages/llm/llm/tests/content.spec.ts
This commit is contained in:
_Kerman
2026-08-22 16:03:48 +08:00
1248 changed files with 29363 additions and 5465 deletions
+6 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile boot, plugin management, and the browser UI alias",
"version": "0.1.1-rc.1",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
@@ -18,6 +18,11 @@
"lib/*.js",
"config"
],
"dsh": {
"configTrees": [
{ "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true }
]
},
"license": "MIT",
"dependencies": {
"@deepseek-ai/cordis-plugin-hmr": "workspace:^",
+1 -4
View File
@@ -1,8 +1,6 @@
#!/usr/bin/env node
/**
* dsh — command-line entry. Dynamic imports per mode keep unrelated modes out
* of each dispatch path; the adapter prints and exits for
* `--help`/`--version`/a parse error, so only a valid mode reaches the switch.
* Command-line entry for dsh.
* @module @deepseek-ai/dsh/bin
*/
@@ -16,7 +14,6 @@ import { parseDshArgs } from './args.ts'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the checked-in manifest resolves with the
// same relative hop from either artifact.
/** This app's version, read from its checked-in package.json. */
function readVersion(): string {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
+12 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
"version": "0.1.1-rc.1",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
@@ -17,12 +17,16 @@
},
"files": [
"dist",
"!dist/**/*.map"
"!dist/**/*.map",
"!dist/preview.html",
"!dist/preview"
],
"scripts": {
"build": "vite build",
"dev": "vite",
"watch": "vite build --watch --no-emptyOutDir"
"watch": "vite build --watch --no-emptyOutDir",
"build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz",
"serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1"
},
"license": "MIT",
"devDependencies": {
@@ -33,16 +37,19 @@
"@deepseek-ai/dsh-client-web": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"@vitejs/plugin-react": "^4.0.0",
"http-server": "^14.1.1",
"fflate": "^0.8.2",
"playwright": "^1.49.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^6.0.3",
"vite": "^6.0.0",
"vitest": "^4.1.8",
"fflate": "^0.8.2"
"vitest": "^4.1.8"
}
}
+1 -5
View File
@@ -1,8 +1,4 @@
/**
* Web application entry: thin bootstrap over the shell library. Everything —
* module-table seeding, the boot page, and the UI-renderer handoff — lives
* in @deepseek-ai/dsh-client-web; this file only finds the mount point.
*/
/** Browser entry for the Web client. */
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
const el = document.getElementById('root')
+2 -2
View File
@@ -3,10 +3,10 @@
* configured loader path and fails loud if that assumption changes.
*/
/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */
/** Fail if browser boot reaches Node's module loader. */
export const createRequire = (): never => {
throw new Error('node:module is not available in the browser')
}
/** Erased type peer for the vendored loader's type-only LoadHookContext import. */
/** Type-only peer for the vendored loader. */
export type LoadHookContext = never
+12
View File
@@ -0,0 +1,12 @@
/**
* Worker-preview bootstrap: the one module preview.html adds ahead of the
* stock entry tag. Connecting the worker host installs the boot globals and
* settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds,
* so everything after this module is the served startup chain verbatim. A
* failed handshake rejects the deferred into the boot page's failure
* rendering; this module owns no page painting.
*/
import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker'
import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client'
await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` })
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9 -46
View File
@@ -1,16 +1,5 @@
// 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 an uncapped card grows with it until the refuse/allow buttons leave the
// viewport — an approval the user could see and not answer.
// Browser geometry for a pending approval whose model-supplied command would
// push the actions outside the viewport without a capped text region.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -29,17 +18,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
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.
// The golden covers the stable waiting panel; direct assertions cover its answer.
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 command length 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.
// Unrelated tokens keep the recorded model from compressing the payload into a
// short shell loop that would not overflow the card.
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.`
@@ -77,19 +61,12 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
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.
// Derive the expected cap from the live composer instead of duplicating its pixel value.
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 override.
await page.locator('[aria-label^="Access mode"]').click()
await page.getByRole('menuitem', { name: 'Read Only' }).click()
await expect.poll(
@@ -101,22 +78,15 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
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 uncapped-card hazard the header names, 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 })
@@ -140,11 +110,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
})
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).
// The panel and composer share one cap; allow sub-pixel layout variance.
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)
@@ -159,12 +126,8 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
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.
// The denied attempt contains platform-specific OS text, so direct state
// and DOM assertions cover the answered outcome.
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')
+4 -11
View File
@@ -1,8 +1,5 @@
// Web e2e scenario: the session-header background-job list over the real
// host. No model call is involved — a genuine `run_in_background` bash call
// registers with `ctx.jobs`, and the assertion chain is the whole delivery
// path: registry change feed → api-proxy `session/jobs` frame → the client's
// `jobsBySession` mirror → the header action.
// Session-header background jobs driven by a real `ctx.jobs` entry. No model
// call is involved.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -30,7 +27,7 @@ const SEED_ID = 'background-job-list-web-e2e'
const COMMAND = 'sleep 45'
/**
* Wait for the Host to publish the live Agent that opening a session resumes.
* Wait for opening a session to publish its live Agent.
* @param scaffold - the booted web scaffold.
* @param sessionId - the opened session's identity.
* @returns the registered Agent instance.
@@ -82,9 +79,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
it('shows a running background job in the session header without a refresh', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running'))
// Point assertion, not a poll: `expect.poll` retries until a predicate
// holds, so polling for zero passes at t=0 and proves nothing. The
// "renders nothing without a task" branch is owned by the component suite.
// Polling for zero would pass at t=0 before delivery and prove nothing.
const trigger = page.getByRole('button', { name: '1 background job running' })
expect(await trigger.count()).toBe(0)
@@ -116,8 +111,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
// The trigger drops its live count once the task leaves running/stopping,
// which is also the proof that settlement reached the browser unprompted.
const idle = page.getByRole('button', { name: '1 background job' })
await idle.waitFor({ timeout: 20_000 })
+2 -21
View File
@@ -1,11 +1,4 @@
// Web e2e scenario: a Code Mode round trip. The scaffold boots the SAME
// shipped tree with the tools row patched to mode: code (the run_code-only
// wire), a real chromium sends a prompt engineered to elicit one run_code
// program with several sub-calls, and the UI must render the code-variant
// parent row with its always-visible nested sub-rows — each sub-row the same
// component a native call renders through — plus details-panel resolution for
// a clicked sub-row. Drive steps wait only on generic completion
// (whenTurnSettled); assertion steps run in replay/refresh only.
// Code Mode browser round trip with nested sub-calls and details selection.
// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless
// DSH_SNAPSHOT=refresh regenerates ui.expected.md.
import { readFile } from 'node:fs/promises'
@@ -24,9 +17,7 @@ const FIXTURE = fileURLToPath(new URL('./snapshots/code-mode-round/session.jsonl
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/code-mode-round/ui.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// The scenario's one drive prompt: elicits one program with a bash sub-call
// and a failing read the program tolerates — the sub-row set the assertions
// need. Never asserted against model prose.
// Elicits the successful and failed sub-rows this scenario asserts.
const PROMPT = '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.'
@@ -48,7 +39,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
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, scaffold.workspaceCwd)
}, 120_000)
@@ -60,7 +50,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive'))
if (MODE !== 'record') {
// Drift guard: the committed fixture must carry exactly the drive prompt.
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
const input = page.locator('textarea').first()
@@ -75,11 +64,9 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
}, 200_000)
it.skipIf(MODE === 'record')('the durable log carries run_code with full-content sub-dispatches', () => {
// Wire discipline: code mode collapsed the call surface to run_code.
const calls = sessionEvents.filter(event => event.type === 'tool/call')
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(new Set(calls.map(call => (call.data as { name: string }).name))).toEqual(new Set(['run_code']))
// Sub-dispatches logged with the complete tool/result vocabulary.
const dispatches = sessionEvents.filter(event => (event.type as string) === 'tool/code-dispatch')
expect(dispatches.length).toBeGreaterThanOrEqual(2)
for (const dispatch of dispatches) {
@@ -107,14 +94,9 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
// description as its summary (the presentCall contract).
const codeRow = page.locator('[data-variant="code"]').first()
await codeRow.waitFor({ timeout: 10_000 })
// Nested rows are visible WITHOUT any expand interaction, inside the
// sub-call nest, each rendered by the same components as native rows:
// 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"]').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)
@@ -124,7 +106,6 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
const frame = page.locator('[style*="grid-template-columns"]').first()
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')
})
+10 -77
View File
@@ -1,35 +1,6 @@
// 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 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.client.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.
// Browser geometry for the composer's caret and visible text layers. A
// same-task gap probe detects deferred scroll synchronization that DOM-only
// tests cannot observe.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -42,13 +13,7 @@ import {
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.
*/
/** Scroll geometry is absent from ARIA snapshots, so this scenario records it directly. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -75,20 +40,11 @@ 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
@@ -98,25 +54,14 @@ interface ComposerMetrics {
*/
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.
* How much the caret-to-glyph gap moves when the offset changes before a
* scroll listener can run.
*/
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
}
@@ -187,14 +132,8 @@ function measureComposer(page: Page): Promise<ComposerMetrics> {
}
/**
* 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 behavior. 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.
* Render platform-neutral comparisons instead of font-dependent glyph
* coordinates.
* @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.
@@ -328,9 +267,7 @@ describe('web e2e: composer draft scrolling', () => {
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).
// One delta past the whole draft: the box clamps at its own end.
await page.mouse.wheel(0, 2000)
await expect.poll(async () => (await measureComposer(page)).scrollTop, { timeout: 10_000 })
.toBeGreaterThan(0)
@@ -453,9 +390,7 @@ describe('web e2e: composer draft scrolling', () => {
const data = new DataTransfer()
data.setData('text/plain', text)
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
// The ordinary case, without a trailing newline, so the collapsed branch
// of the reveal keeps a real engine under it; the case above owns the
// after-newline branch.
// Keep a final glyph so the collapsed caret position has a client rect.
}, `\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)
@@ -465,8 +400,6 @@ describe('web e2e: composer draft scrolling', () => {
}, 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'])
})
+5 -68
View File
@@ -1,47 +1,6 @@
// 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.
//
// The column handles the two edges without reserving the gutter on both: Chat
// keeps `scrollbar-gutter: stable` so its seat's content box never jumps as the
// transcript starts to scroll; the overlay branch does NOT reserve (the view
// owns its own scrollers, so a reserved gutter would only narrow the view's
// content by the bar's width), and the overlay seat instead gives back the
// bar's width (`right: var(--dsh-scrollbar-width)`) so both seats measure the
// same width and the card does not move.
//
// 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 with
// and without the compensation and every comparison below holds vacuously —
// measured: the uncompensated 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 uncompensated cascade in the page — the overlay seat's
// `right` compensation dropped to 0 — 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.
// Browser geometry for the input card across Chat and Trajectory. The browser
// must expose layout-consuming scrollbars, and an uncompensated control keeps
// equal rectangles from passing vacuously.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -55,17 +14,7 @@ import {
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 compensation holds and
* the bar's width when it does not — including under the control, so the golden
* carries the shift the uncompensated cascade produces rather than only its absence.
*/
/** Records platform-neutral distances between the two tabs' card rectangles. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
@@ -127,21 +76,13 @@ const CONTROL_CSS = `
/** 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
}
@@ -149,11 +90,8 @@ interface TabMetrics {
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
}
@@ -306,8 +244,7 @@ describe('web e2e: input card position across view tabs', () => {
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.
// Scrollbars must take layout space here or the comparison is vacuous.
browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
page = await newEnglishPage(browser, WIDE_VIEWPORT.height)
tripwire = watchConsole(page)
+3 -1
View File
@@ -114,7 +114,9 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
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()
// In-page code: an import would not survive serialization, and the page
// entropy source available in every context is getRandomValues.
const identity = Array.from(crypto.getRandomValues(new Uint8Array(8)), byte => byte.toString(16).padStart(2, '0')).join('')
Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
return identity
})
+242
View File
@@ -0,0 +1,242 @@
/**
* Preview acceptance: the browser-only worker deployment boots the real Cordis
* tree out of the packed VFS image and reaches an interactive page.
*
* `dist/preview.html` is the served page plus one bootstrap script tag, so this
* run exercises the shipped startup chain: the worker mounts the image,
* activates the tree, and answers the page's tunnel until the client settles.
* Two milestones prove that happened — the host's `tree active` boot line,
* whose lowering contract must be the one this checkout's packer emits, and the
* workspace hero, which paints only after the client tree comes up over the
* tunnel.
*
* The site is served the way a static host serves it: bytes from `dist/` with
* no rewrite rules, so a missing file is a 404 rather than the index page.
*/
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { extname, join, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium } from 'playwright'
import type { Browser } from 'playwright'
import { expect, it } from 'vitest'
import {
composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT,
} from '@deepseek-ai/dsh-experimental-webworker-packer'
import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime'
import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
/** Where the client looks for the image: the runtime's own name, beside the page. */
const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
/** Profile the preview deployment composes; `build:preview` packs the same one. */
const PROFILE = 'web'
/** Pages the preview needs; the Vite build emits both. */
const PAGES = ['index.html', 'preview.html']
/**
* Content types the preview loads. Anything else is served as opaque bytes.
*
* The image goes out as `application/gzip` with no `content-encoding`: the
* worker inflates the gzip member itself, so a transport-decoded body would
* leave its `DecompressionStream('gzip')` with plain tar bytes to inflate.
*/
const MIME: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.gz': 'application/gzip',
'.webmanifest': 'application/manifest+json',
'.woff2': 'font/woff2',
}
/** Boot line the worker host writes once its tree finished activating. */
const TREE_ACTIVE = 'webworker host: tree active'
/** Image fetch, mount, and tree activation on a loaded machine. */
const BOOT_TIMEOUT_MS = 240_000
/** Client tree settle after the tunnel starts answering. */
const HERO_TIMEOUT_MS = 240_000
/** One served origin over `dist/`. */
interface Site {
readonly origin: string
/** Release the port; call after the browser is gone. */
close(): Promise<void>
}
/**
* Fail before the browser opens a page the build never produced.
* @throws When either preview page is missing from `dist/`.
*/
function requirePreviewPages(): void {
for (const page of PAGES) {
if (existsSync(join(DIST_ROOT, page))) continue
throw new Error(`preview boot needs apps/web/dist/${page} — run \`pnpm run build\` from the repository root`)
}
}
/**
* The image file to serve, packed here when `dist/` carries none: `pnpm run
* build` emits the pages but only `build:preview` packs, so this lane packs
* for itself rather than skipping the deployment it is here to accept. An
* image already in place is used as it stands — the worker refuses one lowered
* against another wrapper contract, and that refusal names the rebuild. A
* self-packed image lands in a temp directory, never in `dist/`: the
* client-artifact digest record treats `dist/` as build-owned, so a test write
* there fails the record check for every later consumer.
* @returns The file to answer `preview/<image>` with, and its teardown.
* @throws When the closure leaves dependencies unresolved, which would pack an
* incomplete image the tree fails on later and further from the cause.
*/
function requireVfsImage(): { path: string; cleanup(): void } {
if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} }
const packed = packVfsImage({
config: composeProfile(REPO_ROOT, PROFILE),
profile: PROFILE,
workspaces: indexWorkspacePackages(REPO_ROOT),
resolveFrom: REPO_ROOT,
configTrees: configTrees(REPO_ROOT),
})
if (packed.missing.length > 0) {
throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
}
const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
const path = join(directory, IMAGE_FILE_NAME)
writeFileSync(path, packed.image)
return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
}
/**
* Answer one request with the file it names under `dist/`; the image path
* answers from wherever {@link requireVfsImage} put the file.
* @param request - Incoming request; only its path is read.
* @param response - Response to write the bytes or the 404 to.
* @param imagePath - File behind `preview/<image>`.
*/
async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise<void> {
const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '')
try {
const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative))
response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' })
response.end(body)
} catch {
// A miss is a miss: the deployment has no SPA fallback, and hiding one
// behind the index page would make a broken asset URL look like a boot
// failure.
response.writeHead(404)
response.end(`not found: ${relative}`)
}
}
/**
* Serve `dist/` over loopback with static-host semantics.
* @param imagePath - File behind `preview/<image>`.
* @returns The origin to navigate, and its teardown.
*/
async function serveDist(imagePath: string): Promise<Site> {
const server = createServer((request, response) => { void respond(request, response, imagePath) })
await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
return {
origin: `http://127.0.0.1:${String(address.port)}`,
close: async () => {
server.closeAllConnections()
await new Promise<void>((closed, reject) => {
server.close((error) => {
if (error === undefined) closed()
else reject(error)
})
})
},
}
}
/**
* Bound one boot milestone so a stall names the milestone instead of surfacing
* as the lane's generic test timeout.
* @param work - The milestone to wait for.
* @param ms - How long it may take.
* @param stalled - Error message when it does not arrive in time.
* @returns What `work` resolved to.
*/
async function within<T>(work: Promise<T>, ms: number, stalled: string): Promise<T> {
let timer: NodeJS.Timeout | undefined
try {
return await Promise.race([
work,
new Promise<never>((_, reject) => { timer = setTimeout(() => { reject(new Error(stalled)) }, ms) }),
])
} finally {
clearTimeout(timer)
}
}
it('boots the packed worker deployment to an interactive page', async () => {
requirePreviewPages()
const image = requireVfsImage()
try {
const site = await serveDist(image.path)
try {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
try {
await bootPreview(site.origin, browser)
} finally {
await browser.close()
}
} finally {
await site.close()
}
} finally {
image.cleanup()
}
}, 600_000)
/**
* Open the preview page and hold it to both boot milestones.
* @param origin - Origin serving `dist/`.
* @param browser - Browser to open the page in.
*/
async function bootPreview(origin: string, browser: Browser): Promise<void> {
const page = await newEnglishPage(browser)
const pageErrors: Error[] = []
page.on('pageerror', (error) => { pageErrors.push(error) })
// Registered before navigation: the worker reports its tree long before the
// tunnel serves the client, so a listener added later would miss the line.
const treeActive = new Promise<string>((reported) => {
page.on('console', (message) => {
const text = message.text()
if (text.includes(TREE_ACTIVE)) reported(text)
})
})
try {
await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' })
const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`)
// The activated tree ran bodies lowered against the contract this
// checkout's packer emits; a dist built before a contract change would
// report the older one.
expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
// The hero's workspace picker is the client tree's first interactive
// surface, so it appears only once the startup chain completed over the
// tunnel.
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
expect(pageErrors.map(error => error.message)).toEqual([])
} catch (error) {
await saveFailureShot(page, 'preview-boot')
throw pageErrors.length === 0
? error
: new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors')
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
it('ships install metadata with the built web application', async () => {
const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
expect(index).toContain('<link rel="manifest" href="./manifest.webmanifest" />')
const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
expect(manifest).toEqual({
+16 -134
View File
@@ -1,67 +1,8 @@
// Web e2e scenario: the sidebar session list's scrollbar as the browser
// actually lays it out — the observable half of the themed scrollbars
// (packages/client/ui-theme/src/styles/scrollbar.css plus the
// `scrollbar-gutter: stable` reservation on WorkspaceBrowser's `.list`). The
// ui-theme/ui-workspace unit specs read the CSS text; only a real engine
// reports the reserved gutter width and the substituted `scrollbar-color`, so
// those two facts live here.
//
// Zero model calls: the list only has to overflow, so the scenario seeds many
// cold sessions from another spec's committed fixture (seeded-history's
// seed.jsonl, reused read-only — this spec needs row count, not new recorded
// content) and never launches a replay row. A stray stream would fail loud
// with NO_ADAPTER.
//
// Headless-chromium caveats, load-bearing for what is asserted below.
//
// Headless chromium defaults to an OVERLAY scrollbar: one drawn on top of the
// content, consuming no layout width unless something reserves space. That is
// the mode in which the reported symptom exists at all, so this environment
// reproduces it rather than merely approximating it — without either
// declaration the list's band is 0 and the bar covers 7px of the relative
// time. (Under a classic space-consuming bar, `clientWidth` already excludes
// the bar and nothing can be covered; a headed run under xvfb behaves that way
// and cannot show the symptom.)
//
// The consequence for assertions: comparing the time element's right edge
// against the list's CLIENT-area right edge holds in both states and proves
// nothing, because with an overlay bar the client edge is the border edge. The
// two signals that do separate the states are the reserved band width and
// `timeCoveredBy`, which measures the overlap against the bar's own width.
//
// Both the `scrollbar-gutter: stable` reservation and the sheet's
// `::-webkit-scrollbar` width are needed for that band, and neither suffices:
// measured on the running app, deleting either one takes the band from 8 to 0
// while the other stays in force. The gutter states that space be reserved; the
// pseudo-element width is what makes chromium treat the bar as occupying layout
// space in the first place.
//
// That conjunction is why `band` and `timeCoveredBy` are both asserted and
// neither replaces the other. Removing only the gutter leaves `timeCoveredBy` at
// 0, because the bar is then 8px wide and the row's right padding is also 8px,
// so it abuts the timestamp without covering it; `band` catches that case.
// Removing both is what produces the reported overlap, and `timeCoveredBy`
// measures it at 7.
//
// 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
// resolved standard properties therefore read `auto`, and that reading is
// asserted — a concrete value would mean the gate leaked and silenced the
// pseudo-element rules. What the theme test measures instead is the pair the
// pseudo-element rules read: the indirection variables as they resolve ON the
// list, plus the `::-webkit-scrollbar-thumb:hover` declaration as it stands in
// the cascade. The hover thumb colour is not observable any other way —
// chromium folds the `:hover` rule into `getComputedStyle(el,
// '::-webkit-scrollbar-thumb')`, so that query reports the hover colour at
// rest and cannot pin either state (measured by deleting the hover rule live:
// the same query flipped from the hover colour to the resting one).
// Browser geometry for the sidebar scrollbar reservation and theme. Headless
// Chromium uses overlay scrollbars, so the reserved band and `timeCoveredBy`
// together distinguish reserved space from a bar painted over content. Its
// computed pseudo-element style also folds in `:hover`, so the test reads that
// declaration from the cascade.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -76,14 +17,7 @@ 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))
/**
* Committed golden of the resolved scrollbar style and geometry, in both
* palettes. The aria goldens the other scenarios commit cannot carry this
* change: it alters no DOM and no accessible name, so their normalized trees are
* byte-identical with and without it. This one records the values instead, which
* makes an unintended shift in thumb colour, band width, or rendering path a
* reviewable diff rather than an assertion someone has to think about.
*/
/** Geometry and resolved style are absent from ARIA snapshots, so this scenario records them directly. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
@@ -91,42 +25,24 @@ const SEED_COUNT = 24
/** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
interface ListMetrics {
/** Resolved `scrollbar-gutter`. */
gutter: string
/** Resolved `::-webkit-scrollbar` width: the pseudo-element path's own sizing. */
width: string
/** Resolved `::-webkit-scrollbar-track` background. */
track: string
/** Resolved `scrollbar-width`, expected `auto` because the gate excludes chromium. */
standardWidth: string
/** Resolved `scrollbar-color`, expected `auto` for the same reason. */
standardColor: string
/** `::-webkit-scrollbar-thumb:hover` background declarations found in the cascade, in sheet order. */
hoverRules: string[]
/** `--dsh-scrollbar-thumb` resolved on the list, serialized as a colour. */
token: string
/** `--dsh-scrollbar-thumb-hover` resolved on the list, serialized the same way. */
hoverToken: string
/** True when the list actually scrolls. */
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. */
borderRight: number
/** Right edge of the first row's relative-time element, the content the unreserved bar covered. */
timeRight: number
/**
* Pixels of the relative time the scrollbar paints over: how far its right
* edge reaches into the band the bar occupies, `[borderRight - barWidth,
* borderRight]`. This is the reported symptom as a number, and it is the one
* geometric signal that separates the two states in this environment — see
* the file header on why `clientWidth` comparisons cannot.
* Pixels of relative time under the scrollbar, measured against the bar's
* width because an overlay scrollbar does not move the client edge.
*/
timeCoveredBy: number
}
@@ -145,13 +61,8 @@ function measureList(page: Page): Promise<ListMetrics> {
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
// notation the palette sheet chose into one comparable serialization. A
// REUSED probe would report only the last value read — `getComputedStyle`
// returns a live declaration, so reassigning `style.color` retroactively
// changes every earlier read.
// Use one probe per variable because computed style declarations are live;
// the color property also normalizes palette syntax.
const resolve = (name: string): string => {
const probe = document.createElement('span')
probe.style.color = `var(${name})`
@@ -160,11 +71,8 @@ function measureList(page: Page): Promise<ListMetrics> {
probe.remove()
return value
}
// The hover colour is read out of the cascade rather than computed:
// chromium reports the `:hover` background for the resting pseudo-element
// too (see the file header), so no computed query separates the states.
// Cross-origin sheets throw on `cssRules`; none is expected, and skipping
// them cannot mask the rule under test, which ships in the app's own CSS.
// Computed pseudo style folds in hover even at rest, so inspect the cascade.
// Cross-origin sheets may throw and cannot contain the app-owned rule.
const hoverRules = [...document.styleSheets]
.flatMap((sheet) => {
try {
@@ -233,9 +141,7 @@ function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'r
/** 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
}
@@ -260,17 +166,8 @@ async function measurePalette(page: Page): Promise<PaletteMetrics> {
}
/**
* Render the golden body: the resolved scrollbar style of the list in each
* palette, plus the geometric relations the scrollbar-gutter/thin-scrollbar
* declarations establish.
*
* Absolute coordinates are deliberately absent. `timeRight`, `clientRight`, and
* `borderRight` depend on the sidebar's laid-out width and on font metrics, so
* committing them would make the golden fail on a machine whose fonts measure
* differently — a fixture that has to be re-recorded per platform documents the
* platform, not the behavior. What is recorded instead is the band, the overlap,
* and the two orderings, each of which is a difference or a comparison and so
* survives any layout that keeps the reservation.
* Render platform-neutral differences and comparisons instead of absolute
* coordinates that depend on sidebar width and font metrics.
* @param light - metrics measured under the light palette.
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
@@ -416,27 +313,12 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum
await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
const metrics = await measureList(page)
expect(metrics.gutter).toBe('stable')
// The control. `band > 0` is the whole observable effect of the
// reservation: the scrollbar is taken out of the content area instead of
// drawn over it. Removing the declaration makes it exactly 0. The value
// itself is not pinned — it tracks `scrollbar-width` and the platform.
// Pin presence, not width, because the width is platform-dependent.
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. Without either declaration it measures 7 — the `h`
// of `1h` is the covered part. Unlike the client-edge comparison below it
// does not go vacuous under an overlay scrollbar, because it measures
// against the bar's own width rather than against a content edge the
// overlay bar does not move. It is not a replacement for the band
// assertion above; see the file header for which regression each one
// catches.
// Measure against the bar because overlay scrollbars do not move the client edge.
expect(metrics.timeCoveredBy).toBe(0)
// Corollaries of the reservation, kept because they pin where the band sits
// rather than only that it exists: the time ends inside the content area,
// and the content area ends before the border box. Each holds in both
// states on its own (see the file header) and is meaningful only alongside
// the two assertions above.
expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
expect(tripwire.pageErrors).toEqual([])
+1
View File
@@ -68,6 +68,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
.replace(/\{\{date\}\} (?=\{\{clock\}\} Ran for)/g, '')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
@@ -40,7 +40,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{date}} {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
- textbox "Message the agent"
- button "Commands":
- img
+3 -13
View File
@@ -1,22 +1,12 @@
// Cold-boot RPC budget. The describe mirror (packages/client/ui-settings) is
// the one `settings.describe` reader in the browser, so startup describe
// traffic stays bounded no matter how many client plugins own a preference.
// A regression here means a consumer bypassed the mirror — grep for
// `settings.describe(` outside ui-settings' client sources.
//
// Zero model calls: the lane only boots chrome, so no replay fixture mounts.
// Cold boot may issue at most two settings.describe calls regardless of client
// plugin count. No model call or replay fixture is involved.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { newEnglishPage } from './support.ts'
/**
* Both reads are the mirror's: once eagerly at bind time over HTTP, and once
* on the first-connection reset — that second read closes the window where a
* document commit lands between the eager read and the SSE subscription and
* its invalidation is lost. Every settings consumer derives from these two.
*/
/** One eager read plus one first-connection reset closes the pre-subscription commit window. */
const DESCRIBE_BUDGET = 2
let scaffold: WebScaffold
+2 -1
View File
@@ -5,6 +5,7 @@
// parked without auto-starting a new turn, and a later waking send resumed the
// preserved FIFO order. No browser: the RPC surface is the product surface
// under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
@@ -28,7 +29,7 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
type: 'client-request',
rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
rpcId: `interrupt-e2e-${method}-${randomUUID()}`,
method,
payload,
}),
+1
View File
@@ -49,6 +49,7 @@
"tests/workspace-management.e2e.ts",
"tests/replay-round-trip.e2e.ts",
"tests/hmr-live.e2e.ts",
"tests/preview-boot.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/cold-blank-session.e2e.ts",
"tests/stats-paged-history.e2e.ts",
+54 -1
View File
@@ -1,3 +1,4 @@
import { readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
@@ -36,6 +37,35 @@ function rejectStandaloneServe(): Plugin {
}
}
/**
* Emit preview.html beside index.html: the built index page with one module
* script — the worker bootstrap entry — spliced ahead of its entry tag. Both
* pages share every chunk; the extra tag is the only difference, so the
* static worker deployment ships the served page verbatim plus its
* bootstrap.
*/
function emitPreviewPage(): Plugin {
let bootstrapFile: string | undefined
return {
name: 'dsh-emit-preview-page',
generateBundle(_options, bundle) {
for (const item of Object.values(bundle)) {
if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName
}
if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
},
async closeBundle() {
// A build that failed before generateBundle has no page to splice.
if (bootstrapFile === undefined) return
const page = await readFile(src('./dist/index.html'), 'utf8')
const anchor = page.indexOf('<script type="module"')
if (anchor === -1) throw new Error('vite: built index.html lost its module entry tag')
const tag = `<script type="module" crossorigin src="./${bootstrapFile}"></script>`
await writeFile(src('./dist/preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)
},
}
}
/**
* Vendor-chunk membership, by exact npm package name — the heavy render
* families (math, highlight, markdown) that change only on dependency bumps.
@@ -108,11 +138,30 @@ function npmPackageOf(id: string): string | undefined {
}
export default defineConfig({
plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()],
// Relative asset URLs: preview.html mounts the same output under any base
// directory, and the served index resolves identically from the site root.
base: './',
plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
build: {
// The worker bootstrap holds its page at top-level await; Vite's default
// `modules` target (es2020-era) rejects that syntax.
target: 'es2022',
sourcemap: true,
rollupOptions: {
input: {
index: src('./index.html'),
// Standalone entry, not an index.html script tag: Vite folds every
// module tag of one page into a single synthetic entry, and only a
// separate input keeps the shared page chunks bootstrap-free.
bootstrap: src('./src/preview.ts'),
},
output: {
// The worker-preview surface groups under dist/preview/ (the page
// itself stays at dist/preview.html), so the published payload can
// exclude it as one directory.
entryFileNames(chunk): string {
return chunk.name === 'bootstrap' ? 'preview/[name]-[hash].js' : 'assets/[name]-[hash].js'
},
// Output layout: the two main chunks stay at assets/ root; lazy
// @shikijs/langs grammar chunks group under assets/langs/; fonts
// (all KaTeX faces referenced by vendor.css) group under
@@ -144,6 +193,10 @@ export default defineConfig({
},
},
},
worker: {
// The preview worker rides dist/preview/ with the rest of that surface.
rollupOptions: { output: { entryFileNames: 'preview/[name]-[hash].js' } },
},
resolve: {
// One instance per shared npm identity: a bare specifier otherwise resolves
// from the importer's directory, so a diverging range ships a second React