mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
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:
@@ -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
@@ -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
@@ -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,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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` })
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user