scaffold.ts 77 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672
  1. // Shared scaffold for the keyless browser e2e lane (Agent Note:
  2. // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md).
  3. // Boots the REAL web composition — the dsh-base and dsh-web-app bundle
  4. // patches over the empty profile root through the vendored Loader (the same
  5. // layer stack the profile boot composes), patched the
  6. // snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
  7. // downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
  8. // replay (default, keyless: normally disables the direct DeepSeek rows and
  9. // inserts dsh-llm-replay in providers mode), record (real adapter + key,
  10. // harvests fixtures from live session memory), refresh (keyless replay that
  11. // rewrites goldens). A first-run option keeps the real adapter mounted while
  12. // masking its credential, without making a model call.
  13. //
  14. // Composition divergences from `dsh web`, all deliberate, all via include
  15. // patches after the shipped bundle layers, over the SAME tree (never a
  16. // second yml): temp persistenceRoot; host-level skill roots confined to the
  17. // temp workspace while project skill discovery remains real; agent-instructions
  18. // disabled (recorded fixtures must not embed this repo's AGENTS.md);
  19. // session-title-llm disabled (its fire-and-forget title call would race the
  20. // loop for the session's replay cursor); webserver pinned to port 0 with the
  21. // built dist; ordinary keyless modes disable both direct adapters and fill the open
  22. // llm seam post-boot with installLlmReplay on the settled root ctx
  23. // (the plugin-row path discards the ReplayHandle; the direct install keeps
  24. // assertConsumed for the teardown fixture-consumption check).
  25. import { existsSync, readFileSync } from 'node:fs'
  26. import { createHash } from 'node:crypto'
  27. import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
  28. import { tmpdir } from 'node:os'
  29. import { basename, dirname, join, resolve } from 'node:path'
  30. import { pathToFileURL } from 'node:url'
  31. import type { Page } from 'playwright'
  32. import { expect } from 'vitest'
  33. import { Context } from '@deepseek-ai/cordis'
  34. import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
  35. import Loader from '@deepseek-ai/cordis-plugin-loader'
  36. import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  37. import Group from '@deepseek-ai/cordis-plugin-group'
  38. import {
  39. captureExpectedWorkspaceSnapshot,
  40. captureWorkspaceSnapshot,
  41. type CaptureWorkspaceSnapshotOptions,
  42. assertSessionFixtureVersion,
  43. formatSystemPromptSnapshot,
  44. formatToolSchemasSnapshot,
  45. normalizedSystemPrompts,
  46. normalizedToolSchemas,
  47. parseSnapshotManifest,
  48. redactSessionSnapshotIds,
  49. normalizeSessionSnapshots,
  50. parseSessionFixtureName,
  51. scrubModelRequestBulk,
  52. scrubSessionSnapshot,
  53. sessionFixtureFiles,
  54. sessionFixtureName,
  55. stabilizeFixtureMessageIds,
  56. stabilizeRefreshLog,
  57. writesCurrentSessionFixtures,
  58. type NormalizeContext,
  59. } from '@deepseek-ai/dsh-session-snapshot'
  60. import type { Profile, ProfileContext, ProfileResolutionMode } from '@deepseek-ai/dsh-app-boot'
  61. import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
  62. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  63. import type {
  64. LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, RetryPolicyConfig, StreamChunk,
  65. } from '@deepseek-ai/dsh-llm'
  66. import type { ReplayHandle, ReplayProviderConfig } from '@deepseek-ai/dsh-llm-replay'
  67. import {
  68. installLlmReplay,
  69. parseSessionLog,
  70. prepareSessionSnapshotFixtureForComparison,
  71. } from '@deepseek-ai/dsh-llm-replay'
  72. import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
  73. import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
  74. import {
  75. SESSION_FORMAT_VERSION,
  76. SessionId,
  77. type Session,
  78. type SessionEvent,
  79. type SessionHeader,
  80. } from '@deepseek-ai/dsh-session'
  81. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  82. // Empty type imports carry the webServer/agents/sessionPersistence Context merges.
  83. import type {} from '@deepseek-ai/dsh-host-webserver'
  84. import type {} from '@deepseek-ai/dsh-agent'
  85. import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
  86. import { REPO_ROOT, requireBuilt, requireDist } from './support.ts'
  87. type AppBoot = typeof import('@deepseek-ai/dsh-app-boot')
  88. let builtAppBoot: AppBoot | undefined
  89. /**
  90. * The launcher's own module, as built: the manager and HMR plugins the profile
  91. * loads reload the tree through this copy's registry of the root Include, so
  92. * the scaffold mounts through the same copy rather than the source import.
  93. * Resolved on the first launch, which needs the build anyway, so the fixture
  94. * helpers this module also exports load without one.
  95. */
  96. function appBoot(): AppBoot {
  97. builtAppBoot ??= requireBuilt('@deepseek-ai/dsh-app-boot') as AppBoot
  98. return builtAppBoot
  99. }
  100. // Host-side web e2e cannot import a browser package: doing so would pull that
  101. // package's complete TS project into this graph. Mirrored from
  102. // packages/client/ui-settings-models/src/onboarding-copy.ts; drift makes the
  103. // default pre-acknowledgement stop suppressing the notice and fails loudly.
  104. // import {
  105. // WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
  106. // WELCOME_NOTICE_VERSION, WELCOME_NOTICE_COPY,
  107. // } from '@deepseek-ai/dsh-client-ui-settings-models'
  108. export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding'
  109. export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
  110. export const WELCOME_NOTICE_VERSION = '2026-08-13.1'
  111. export const WELCOME_NOTICE_COPY = {
  112. zh: {
  113. title: '内测声明',
  114. body: 'DeepSeek Harness 目前的 0.1 版本仍处在面向 Harness 开发者进行测试的阶段,还有许多地方需要持续改进和打磨,希望听取广大开发者的反馈建议。预计 DeepSeek Harness 的核心插件以及基础 API 都会在接下来的一段时间内快速迭代、持续演化。\n\n我们期待与全球开发者一起,在开源、开放、可复用、可组合的基础设施之上,共同探索智能上限。欢迎全球 Harness 开发者加入 DSH 插件生态。',
  115. continueLabel: '继续',
  116. },
  117. } as const
  118. /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */
  119. export type WebSnapshotMode = 'replay' | 'record' | 'refresh'
  120. /**
  121. * Resolve and validate the lane's snapshot mode.
  122. * @returns the active mode; unset/empty selects replay.
  123. */
  124. export function webSnapshotMode(): WebSnapshotMode {
  125. const value = process.env.DSH_SNAPSHOT
  126. if (value === undefined || value === '' || value === 'replay') return 'replay'
  127. if (value === 'record' || value === 'refresh') return value
  128. throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`)
  129. }
  130. /**
  131. * Compare a session-driven Web scenario's complete workspace with its committed independent expected state.
  132. * @param scenarioDir - Absolute recorded-session scenario directory.
  133. * @param workspaceRoot - Absolute cwd used by the controlled session.
  134. * @param options - Root entries the scenario owns outside the expected state, such as a `.git` directory it initialized.
  135. */
  136. export async function assertFinalWorkspaceSnapshot(
  137. scenarioDir: string, workspaceRoot: string, options: CaptureWorkspaceSnapshotOptions = {},
  138. ): Promise<void> {
  139. const manifestPath = join(scenarioDir, 'snapshot.yml')
  140. const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
  141. expect(manifest.workspace?.final, `${manifest.scenario ?? scenarioDir}: mutating Web scenario declares workspace.final`)
  142. .toBe(true)
  143. const actual = await captureWorkspaceSnapshot(workspaceRoot, options)
  144. const expected = await captureExpectedWorkspaceSnapshot(join(scenarioDir, 'workspace.expected'))
  145. expect(actual, `${manifest.scenario ?? scenarioDir}: complete final workspace`).toEqual(expected)
  146. }
  147. async function ownsReplayFixture(replayFixture: string | undefined): Promise<boolean> {
  148. if (replayFixture === undefined) return false
  149. const fixture = parseSessionFixtureName(basename(replayFixture))
  150. if (fixture === undefined || fixture.index !== 0) return false
  151. const manifestPath = join(dirname(replayFixture), 'snapshot.yml')
  152. if (!existsSync(manifestPath)) return false
  153. const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
  154. return manifest.session === undefined
  155. }
  156. /**
  157. * Resolve one requested fixture role to its highest committed generation.
  158. * @param path - any generation path for the requested parent or child role.
  159. * @param allowAbsent - Keep an absent canonical path only for an override-only replay script.
  160. * @returns the highest canonical sibling generation, or the input for non-Session files.
  161. */
  162. export async function selectedSessionFixture(path: string, allowAbsent = false): Promise<string> {
  163. const requested = parseSessionFixtureName(basename(path))
  164. if (requested === undefined) return path
  165. const entries = await readdir(dirname(path))
  166. if (allowAbsent && !entries.some(name => parseSessionFixtureName(name) !== undefined)) return path
  167. const selected = sessionFixtureFiles(entries)
  168. .find(candidate => candidate.index === requested.index)
  169. if (selected === undefined) throw new Error(`${path}: missing Session fixture role ${requested.index}`)
  170. const resolved = join(dirname(path), selected.name)
  171. assertSessionFixtureVersion(selected.name, await readFile(resolved, 'utf8'))
  172. return resolved
  173. }
  174. /**
  175. * Return the current-writer target without replacing the requested older fixture.
  176. * @param path - any canonical fixture generation for one role.
  177. * @param version - generation emitted by the current writer.
  178. * @returns the canonical sibling path for that role and generation.
  179. */
  180. export function recordedSessionFixturePath(path: string, version: number): string {
  181. const fixture = parseSessionFixtureName(basename(path))
  182. if (fixture === undefined) throw new Error(`record harvest: invalid Session fixture path ${path}`)
  183. return join(dirname(path), sessionFixtureName(fixture.index, version))
  184. }
  185. /** The shipped composition under test: the dsh-base and dsh-web-app bundle patches over the empty profile root. */
  186. const BASE_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml')
  187. const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml')
  188. /** The installation anchor whose dependency surface the profile module fallback mirrors. */
  189. const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
  190. // Replay publishes the provider catalog the gateway routes to (providers
  191. // mode, never catch-all: with both direct adapters disabled no adapter exists, so a
  192. // catch-all would leave resolveModelInfo unroutable and compaction-basic's
  193. // post-step pressure check would warn every step). The published
  194. // contextWindow keeps that pressure path provably inert for small fixtures.
  195. const REPLAY_PROVIDERS = [{
  196. id: 'deepseek-official',
  197. name: 'DeepSeek',
  198. models: [
  199. { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 },
  200. {
  201. id: 'deepseek-v4-flash-vision-exp',
  202. name: 'DeepSeek-V4-Flash-Vision-Exp',
  203. contextWindow: 1_000_000,
  204. inputModalities: ['text', 'image'] as const,
  205. defaultMaxTokens: 256_000,
  206. reasoningEfforts: ['off', 'low', 'high', 'max'],
  207. defaultReasoningEffort: 'high',
  208. },
  209. ],
  210. }]
  211. /**
  212. * The routes a shipped composition always has, with no ability to stream.
  213. * A fixture-less keyless scenario issues no model calls, but its tree must
  214. * still answer `listProviders()` — surfaces legitimately gate on whether any
  215. * adapter serves a session's route, and an empty registry is a test artifact,
  216. * not a product state.
  217. */
  218. class RouteOnlyAdapter extends LlmAdapter {
  219. constructor(private readonly providers: typeof REPLAY_PROVIDERS) {
  220. super()
  221. }
  222. override providerInfo(provider: string): LlmProviderInfo {
  223. return { id: provider, name: this.providers.find(entry => entry.id === provider)?.name ?? provider }
  224. }
  225. override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
  226. return Promise.resolve((this.providers.find(entry => entry.id === provider)?.models ?? [])
  227. .map(model => ({ provider, id: model.id, name: model.name })))
  228. }
  229. override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
  230. const listed = this.providers.find(entry => entry.id === provider)?.models
  231. .find(entry => entry.id === model)
  232. return Promise.resolve({
  233. provider,
  234. id: model,
  235. name: listed?.name ?? model,
  236. ...listed?.contextWindow === undefined ? {} : { contextWindow: listed.contextWindow },
  237. })
  238. }
  239. override async *stream(): AsyncIterable<StreamChunk> {
  240. throw new Error(
  241. 'web e2e scaffold: a model call was issued by a scenario that declared no replay fixture'
  242. + ' — pass replayFixture, or keep the scenario free of model calls',
  243. )
  244. }
  245. }
  246. function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
  247. return REPLAY_PROVIDERS.map(provider => ({
  248. ...provider,
  249. id: messages ? 'deepseek-messages' : provider.id,
  250. models: provider.models.map(model => ({
  251. ...model,
  252. ...contextWindow === undefined ? {} : { contextWindow },
  253. })),
  254. }))
  255. }
  256. /** A booted web scaffold: real composition, mode-selected model backend, temp world. */
  257. export interface WebScaffold {
  258. /** The active snapshot mode this scaffold booted under. */
  259. mode: WebSnapshotMode
  260. /** Browser-facing origin for the bound test server. */
  261. baseUrl: string
  262. /** Process-token URL that establishes this scaffold's browser session. */
  263. authenticatedUrl: string
  264. /** Settled root context (the in-process readiness barrier; headless event subscription is its sanctioned use). */
  265. ctx: Context
  266. /** Temp project directory sessions run in (shell/fs tool cwd). */
  267. workspaceCwd: string
  268. /** Temp persistence root (seeded sessions land here through the real API). */
  269. persistenceRoot: string
  270. /** Isolated harness home the settings/credentials rows write ($DSH_HOME double). */
  271. harnessHome: string
  272. /** Send a browser-equivalent Host request with this scaffold's authenticated cookie. */
  273. hostFetch(path: string, init?: RequestInit): Promise<Response>
  274. /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
  275. whenTurnSettled(timeoutMs?: number): Promise<SessionId>
  276. /**
  277. * Tear everything down; asserts the replay fixture was fully consumed first
  278. * (replay/refresh), unless booted with replayProvidersOnly (whose fixture
  279. * is validated call-free at boot).
  280. */
  281. close(): Promise<void>
  282. }
  283. /** Options for {@link launchWebScaffold}. */
  284. export interface LaunchOptions {
  285. /** Profile resolver backend used by this test Host; defaults to runtime coverage. */
  286. profileResolutionMode?: Extract<ProfileResolutionMode, 'dual' | 'runtime'>
  287. /** Enable the real Open In rows with deterministic launch-environment facts. */
  288. openInAppEnvironment?: LaunchEnvironmentSnapshot
  289. /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */
  290. compareReplaySession?: boolean
  291. /**
  292. * Optional product overlay applied after the shipped Web surface and before
  293. * the scaffold's hermetic test patches, matching the launcher's `--patch`
  294. * ordering.
  295. */
  296. extraOverlayPath?: string
  297. /**
  298. * Additional package manifests whose dependency closures supply experimental
  299. * profile layers named by {@link extraOverlayPath}.
  300. */
  301. extraInstallAnchors?: string[]
  302. /**
  303. * Manage the scaffold profile the way the launcher does: a `profileContext`
  304. * over the profile directory, whose manifest lists the shipped web bundles
  305. * and each package directory here as an installed dependency (`file:` in the
  306. * manifest, a symlink under the profile's `node_modules`); `enabled` also
  307. * lists a bundle in `dsh.profile.bundles`. The plugin manager mounts on such
  308. * a profile, and the root Include is mounted from the profile's own layers.
  309. * The base bundle's `hmr` row turns on with the profile context, so
  310. * configuration changes apply live; `hmr: false` disables that row through
  311. * an overlay, leaving changes for the next start.
  312. */
  313. profile?: {
  314. hmr?: boolean
  315. packages: { dir: string; enabled?: boolean }[]
  316. }
  317. /**
  318. * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
  319. * in replay/refresh modes; ignored in record mode (the real adapter
  320. * answers). Omit for scenarios issuing no model calls — a stray stream then
  321. * fails loud with NO_ADAPTER (both direct adapters are disabled and no replay row
  322. * mounts). With {@link replayProvidersOnly}, the fixture must record no
  323. * model calls (its header alone mounts the catalog).
  324. */
  325. replayFixture?: string
  326. /** Explicit replay routes for scenarios exercising provider-dependent behavior; replay/refresh only. */
  327. replayProviders?: ReplayProviderConfig[]
  328. /**
  329. * Mount the replay provider catalog (the model directory the UI shows)
  330. * without consuming any recorded script: for scenarios that never call a
  331. * model but need the real provider/model labels rendered. Requires
  332. * {@link replayFixture} whose log records no model calls, and rejects
  333. * {@link replayOverride} and {@link replayChildFixtures}; the teardown
  334. * consumption check is skipped for this mode. `replayFixture` without this
  335. * flag keeps the consumption check.
  336. */
  337. replayProvidersOnly?: boolean
  338. /**
  339. * Recorded child logs assigned in child creation order. Each child owns its
  340. * own positional replay cursor across initial and continuation turns.
  341. */
  342. replayChildFixtures?: string[]
  343. /**
  344. * Optional replay.override.json sidecar (whole-script replacement or
  345. * `{ patches }` augmentation) for throw/hang scenarios not expressible as
  346. * recorded chunks; replay/refresh only.
  347. */
  348. replayOverride?: string
  349. /**
  350. * Retry policy registered on every replay provider route, for failure-
  351. * injection scenarios that must exhaust recovery quickly instead of walking
  352. * the shared normal default's five backed-off retries; replay/refresh only.
  353. */
  354. replayRetryPolicy?: RetryPolicyConfig
  355. /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */
  356. paceMs?: number
  357. /** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */
  358. replayContextWindow?: number
  359. /**
  360. * Tool presentation mode patched onto the shipped `tools` row (`code`
  361. * collapses the wire to run_code + the SDK prompt section). Omit for the
  362. * yml default. The PTC runtime row is always in the tree, so no extra
  363. * insertion is needed.
  364. */
  365. toolsMode?: 'native' | 'ptc' | 'both'
  366. /**
  367. * Keep the shipped DeepSeek adapter mounted while masking the process
  368. * environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the
  369. * keyless first-run configuration lane; the default disables the adapter.
  370. */
  371. deepSeekMissingCredential?: boolean
  372. /** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
  373. deepSeekMessages?: boolean
  374. /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
  375. welcomeNoticePending?: boolean
  376. /**
  377. * Patch the shipped DeepSeek search row to a deterministic endpoint and
  378. * credential reference. Browser search scenarios keep the real provider and
  379. * credentials seam while avoiding external search traffic and ambient keys.
  380. */
  381. deepSeekSearch?: {
  382. /** Anthropic-compatible base URL; the provider appends `/messages`. */
  383. baseURL: string
  384. /** Credential reference resolved by the shipped search provider. */
  385. apiKeyEnv: string
  386. }
  387. /**
  388. * Replace the roster row the scaffold pins by default (no configured roots,
  389. * default `standard` — the plugin's own shipped presets). Supply this only
  390. * to change WHICH presets a scenario sees beyond the shipped set — a
  391. * writable user root, a different default. The patch lands after the
  392. * default, so it wins.
  393. */
  394. agentPresets?: {
  395. /** Roots to discover after the plugin's shipped root, in precedence order. */
  396. roots: { path: string; trust: 'system' | 'user' }[]
  397. /** The preset a session that names none is composed from. */
  398. default: string
  399. }
  400. /**
  401. * Patch the telemetry exporter URL while preserving the shipped enabled
  402. * setting. A scenario-owned loopback collector contains all fixture uploads.
  403. */
  404. telemetryUrl?: string
  405. /** Mode when telemetryUrl is supplied; defaults to FEEDBACK_ONLY without enabling a disabled row. */
  406. telemetryMode?: 'FEEDBACK_ONLY'
  407. /** SDK batch cadence for a scenario-owned collector; omitted to retain the SDK default. */
  408. telemetryScheduledDelayMillis?: number
  409. /**
  410. * Browse through a trusted non-loopback hostname that the browser resolves
  411. * to loopback (for example `*.localhost`). The test server stays bound to
  412. * 127.0.0.1; a non-resolving authority fails before Host trust is exercised.
  413. */
  414. remoteAuthority?: string
  415. /** Reuse an existing harness home so a second Host can verify user settings across origins. */
  416. harnessHome?: string
  417. }
  418. /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
  419. async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise<unknown[]> {
  420. const failures: unknown[] = []
  421. await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error))
  422. await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  423. await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  424. return failures
  425. }
  426. /**
  427. * Boot the real web composition under the current snapshot mode.
  428. * @param options - replay fixture selection and pacing.
  429. * @returns the running scaffold.
  430. */
  431. export async function launchWebScaffold(options: LaunchOptions = {}): Promise<WebScaffold> {
  432. requireDist()
  433. const {
  434. auditStartupEntries, composeEntries, createProfileResolutionGeneration, healProfilesModuleFallback, initProfile,
  435. mountRootInclude, readProfileManifest, readProfilePatches, loadOverlayPatches, PluginPackages,
  436. } = appBoot()
  437. const mode = webSnapshotMode()
  438. const replayFixture = options.replayFixture === undefined
  439. ? undefined
  440. : await selectedSessionFixture(options.replayFixture, options.replayOverride !== undefined)
  441. const replayChildFixtures = options.replayChildFixtures === undefined
  442. ? undefined
  443. : await Promise.all(options.replayChildFixtures.map(path => selectedSessionFixture(path)))
  444. const compareReplaySession = options.compareReplaySession ?? await ownsReplayFixture(replayFixture)
  445. const browserHost = options.remoteAuthority ?? '127.0.0.1'
  446. if (mode === 'record') {
  447. // Both owning vitest configs (web unconditionally, snapshot in record
  448. // mode) load the repo-root .env before this file runs.
  449. if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) {
  450. throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)')
  451. }
  452. }
  453. if (mode === 'record' && options.deepSeekMissingCredential === true) {
  454. throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
  455. }
  456. const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
  457. const messages = options.deepSeekMessages === true
  458. const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
  459. let credentialEnvironmentRestored = false
  460. const restoreCredentialEnvironment = (): void => {
  461. if (credentialEnvironmentRestored || !maskDeepSeekCredential) return
  462. credentialEnvironmentRestored = true
  463. if (originalDeepSeekCredential === undefined) {
  464. Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
  465. } else {
  466. process.env.DEEPSEEK_API_KEY = originalDeepSeekCredential
  467. }
  468. }
  469. const workspaceCwd = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')))
  470. // Isolated harness home: the settings/credentials rows resolve $DSH_HOME
  471. // paths at load, and an in-process boot must NEVER touch the developer's
  472. // real ~/.dsh document or credential file.
  473. const harnessHome = options.harnessHome ?? join(workspaceCwd, '.dsh-home')
  474. // Skill discovery is model-visible input, and its roots now resolve inside a
  475. // PRESET — a subtree this lane's include patches cannot reach, because the
  476. // roster mounts it directly per session rather than as a row of the booted
  477. // tree. The row's documented fallback is the environment, so pin that: the
  478. // whole scaffold lifetime, not just the boot, since presets mount when a
  479. // session is created. Without this a developer's real ~/.dsh/skills silently
  480. // enters replay requests and goldens while CI sees none. `DSH_HOME` follows
  481. // the resolved harness home so a scaffold sharing another's home — the
  482. // cross-port persistence scenario — pins the same roots the settings and
  483. // credentials rows were configured with.
  484. const skillRootEnvironment = {
  485. DSH_HOME: harnessHome,
  486. DSH_AGENTS_HOME: join(workspaceCwd, '.agents-home'),
  487. DSH_BUNDLED_SKILL_DIR: join(workspaceCwd, '.bundled-skills'),
  488. }
  489. const originalSkillRootEnvironment = Object.fromEntries(
  490. Object.keys(skillRootEnvironment).map(key => [key, process.env[key]]),
  491. )
  492. let skillRootEnvironmentRestored = false
  493. const restoreSkillRootEnvironment = (): void => {
  494. if (skillRootEnvironmentRestored) return
  495. skillRootEnvironmentRestored = true
  496. for (const [key, value] of Object.entries(originalSkillRootEnvironment)) {
  497. if (value === undefined) Reflect.deleteProperty(process.env, key)
  498. else process.env[key] = value
  499. }
  500. }
  501. Object.assign(process.env, skillRootEnvironment)
  502. let persistenceRoot: string
  503. try {
  504. persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
  505. } catch (error) {
  506. const failures: unknown[] = [error]
  507. await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
  508. restoreSkillRootEnvironment()
  509. if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
  510. throw error
  511. }
  512. if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
  513. // The include patch set — the same layer stack the profile boot composes
  514. // (bundle patches in dsh.profile.bundles order), applied over the SAME empty root (a
  515. // patch id that stops matching a row fails the boot sweep loudly instead of
  516. // drifting).
  517. const basePatches = loadOverlayPatches('web e2e scaffold', BASE_PATCH_PATH)
  518. const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_PATCH_PATH)
  519. const extraOverlayPatches = options.extraOverlayPath === undefined
  520. ? []
  521. : loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
  522. const composedRows = composeEntries([basePatches, surfacePatches, extraOverlayPatches])
  523. const webRuntimeConfig = composedRows.find(row => row.id === 'web-runtime')?.config as {
  524. surfaceContext?: boolean
  525. } | undefined
  526. const surfaceContext = webRuntimeConfig?.surfaceContext !== false
  527. // The scaffold's own overrides, above every bundle layer like `--patch` overlays.
  528. const overlayPatches: PatchOptions[] = [
  529. // Without HMR the profile applies configuration changes at its next start.
  530. ...options.profile?.hmr === false ? [{ id: 'hmr', disabled: true }] : [],
  531. { id: 'session-log-deepseek', config: { enabled: false } },
  532. // The historical Messages fixture retains its recorded route during replay;
  533. // live configuration uses the shared DeepSeek route. Explicit overlays win.
  534. ...messages
  535. ? [{ id: 'agent-default-model', config: { provider: mode === 'record' || maskDeepSeekCredential ? 'deepseek-official' : 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
  536. : mode === 'record' || options.deepSeekMissingCredential === true
  537. ? []
  538. : [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
  539. ...extraOverlayPatches,
  540. // The roster's shipped presets are the plugin's own, bundled inside
  541. // `dsh-agent-presets` and prepended by it. Pin only the machine-local
  542. // root away: a developer's own `~/.dsh/.agent-presets` must not be able
  543. // to change a golden.
  544. {
  545. id: 'agent-presets',
  546. config: {
  547. default: 'standard',
  548. includeUserRoot: false,
  549. },
  550. },
  551. { id: 'session-persistence-jsonl', config: { root: persistenceRoot } },
  552. // Content search is enabled here although the shipped bundles default it
  553. // off (`openAt: never`, pinned by apps/cli/tests/lazy-search-startup):
  554. // the seeded-session scenarios navigate by content search, and these e2e
  555. // runs are the assembled coverage for the opt-in search path.
  556. { id: 'session-query-sqlite', config: { path: ':memory:', openAt: 'first-search' } },
  557. // storage-json's yml root is anchored to the real $DSH_HOME; pin the row
  558. // to an absolute temp root (removed with the workspace at close) so tests
  559. // never write the user's harness home.
  560. { id: 'storage-json', config: { root: join(workspaceCwd, '.dsh-storages') } },
  561. // Skill discovery is model-visible input. Pin every host-level root inside
  562. // the owned temp world so ~/.dsh, ~/.agents, and a bundled-root env setting
  563. // cannot change replay requests or conversation goldens. Project roots stay
  564. // enabled against the same empty temp workspace, preserving the real seam.
  565. {
  566. id: 'skill-filesystem',
  567. config: {
  568. dshHome: join(workspaceCwd, '.dsh-home'),
  569. agentsHome: join(workspaceCwd, '.agents-home'),
  570. bundledSkillDir: join(workspaceCwd, '.bundled-skills'),
  571. watch: false,
  572. },
  573. },
  574. // fs/bash cwd default to process.cwd(); the gateway injects the same
  575. // value into session.cwd — chdir below anchors all three to the temp
  576. // workspace, keeping the composition untouched.
  577. { id: 'agent-instructions', disabled: true },
  578. { id: 'session-title-llm', disabled: true },
  579. // Fixture sessions must never leave the process: the shipped row defaults
  580. // to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
  581. // names in the ambient environment). A scenario with a local collector
  582. // preserves the shipped disabled setting instead of overriding it.
  583. options.telemetryUrl === undefined
  584. ? { id: 'session-telemetry-otel', disabled: true }
  585. : {
  586. id: 'session-telemetry-otel',
  587. config: {
  588. mode: options.telemetryMode ?? 'FEEDBACK_ONLY',
  589. exporter: { url: options.telemetryUrl },
  590. ...(options.telemetryScheduledDelayMillis === undefined ? {} : {
  591. processor: { scheduledDelayMillis: options.telemetryScheduledDelayMillis },
  592. }),
  593. shutdownTimeoutMillis: 1_000,
  594. },
  595. },
  596. // Use an ephemeral port while preserving the shipped compression policy;
  597. // a patch replaces the row's complete config.
  598. {
  599. id: 'webserver',
  600. config: {
  601. host: '127.0.0.1', port: 0, compression: 'gzip',
  602. compressionLevel: 1, compressionThresholdBytes: 1024,
  603. },
  604. },
  605. // The bundle's web-runtime row resolves the same built dist under test
  606. // (apps/web IS @deepseek-ai/dsh-web-frontend); native browser opening and the
  607. // URL line are disabled because this scaffold owns its Playwright browser.
  608. // Preserve the composed surface-context choice because a patch replaces
  609. // the row's complete config.
  610. { id: 'web-runtime', config: { openBrowser: false, printUrl: false, surfaceContext } },
  611. ...options.remoteAuthority === undefined
  612. ? []
  613. : [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
  614. { id: 'settings', config: { dshHome: harnessHome } },
  615. { id: 'credentials', config: { dshHome: harnessHome } },
  616. // The shipped directory-picker row is the -auto chooser, which resolves
  617. // the interaction from the RUNNING host (display, SSH launch, bind). The
  618. // lane's goldens are interaction-specific (workspace-management drives
  619. // the in-app browse dialog), so pin -browse deterministically on every
  620. // host: patch `name` is an assertion, not an override, hence the
  621. // disable+insert pair.
  622. { id: 'directory-picker', disabled: true },
  623. { insert: [
  624. { id: 'directory-picker-browse', name: '@deepseek-ai/dsh-host-directory-picker-browse' },
  625. { id: 'ui-directory-picker-browse', name: '@deepseek-ai/dsh-client-ui-directory-picker-browse' },
  626. ] },
  627. // Ordinary scenarios exclude host-dependent application discovery. The
  628. // Open In scenario supplies launch facts that suppress every native probe.
  629. { id: 'open-in-app', disabled: options.openInAppEnvironment === undefined },
  630. { id: 'ui-open-in-app', disabled: options.openInAppEnvironment === undefined },
  631. ...options.agentPresets === undefined
  632. ? []
  633. // Never the derived harness-home root: a developer's own presets must not
  634. // be able to change a golden, whatever roots a scenario asks for.
  635. : [{ id: 'agent-presets', config: { ...options.agentPresets, includeUserRoot: false } }],
  636. ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }],
  637. ...options.deepSeekSearch === undefined
  638. ? []
  639. : [{
  640. id: 'web-search-deepseek',
  641. config: {
  642. apiKeyEnv: options.deepSeekSearch.apiKeyEnv,
  643. baseURL: options.deepSeekSearch.baseURL,
  644. },
  645. }],
  646. ...maskDeepSeekCredential && !messages ? [] : [
  647. { id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
  648. config: messages ? {} : { protocol: 'chat-completions' } },
  649. ],
  650. ]
  651. const patches: PatchOptions[] = [...basePatches, ...surfacePatches, ...overlayPatches]
  652. // Sessions inherit the gateway's process.cwd() default; run the boot from
  653. // the temp workspace so tool cwd, session cwd, and fixtures agree.
  654. const originalCwd = process.cwd()
  655. const ctx = new Context()
  656. if (options.openInAppEnvironment !== undefined) ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.openInAppEnvironment)
  657. const observedSessions = new Map<SessionId, Session>()
  658. const stopObservingSessions = ctx.on('session/created', (session) => {
  659. observedSessions.set(session.id, session)
  660. })
  661. let port = 0
  662. let baseUrl = ''
  663. let authenticatedUrl = ''
  664. let cookieHeader = ''
  665. let replayHandle: ReplayHandle | undefined
  666. try {
  667. process.chdir(workspaceCwd)
  668. const profileDir = join(harnessHome, 'profiles', 'scaffold')
  669. const extraLayers: Profile['layers'] = await Promise.all((options.extraInstallAnchors ?? []).map(async (anchor) => {
  670. const manifest = JSON.parse(await readFile(anchor, 'utf8')) as { name?: unknown }
  671. if (typeof manifest.name !== 'string' || manifest.name === '') {
  672. throw new Error(`web scaffold extra install anchor has no package name: ${anchor}`)
  673. }
  674. const packageDir = dirname(anchor)
  675. // A real profile already has each bundle installed by `dsh plugin add`.
  676. // Reproduce that link so a private bundle can import its own plugin.
  677. const installedLink = join(profileDir, 'node_modules', manifest.name)
  678. await mkdir(dirname(installedLink), { recursive: true })
  679. await symlink(packageDir, installedLink, 'junction')
  680. return {
  681. packageName: manifest.name,
  682. packageDir,
  683. patchPath: join(packageDir, 'cordis.patch.yml'),
  684. patches: [],
  685. }
  686. }))
  687. const profile: Profile = {
  688. name: 'scaffold',
  689. dir: profileDir,
  690. layers: extraLayers,
  691. patchPath: join(profileDir, 'cordis.patch.yml'),
  692. patches: [],
  693. }
  694. const profileResolutionMode = options.profileResolutionMode ?? 'runtime'
  695. const resolutionOptions = { installAnchor: INSTALL_ANCHOR, home: harnessHome, profile }
  696. const resolution = profileResolutionMode === 'runtime'
  697. ? await createProfileResolutionGeneration(resolutionOptions)
  698. : await healProfilesModuleFallback(resolutionOptions)
  699. await mkdir(profileDir, { recursive: true })
  700. const rootConfig = join(profileDir, 'cordis.yml')
  701. await writeFile(rootConfig, '[]\n')
  702. ctx.baseUrl = pathToFileURL(profileDir).href + '/'
  703. let profileContext: ProfileContext | undefined
  704. if (options.profile !== undefined) {
  705. // A real profile: the shipped web bundles plus each fixture package,
  706. // installed the way `dsh plugin add` leaves them.
  707. const dependencies: Record<string, string> = {}
  708. const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']
  709. for (const entry of options.profile.packages) {
  710. const manifest = JSON.parse(await readFile(join(entry.dir, 'package.json'), 'utf8')) as { name: string }
  711. dependencies[manifest.name] = `file:${entry.dir}`
  712. if (entry.enabled === true) bundles.push(manifest.name)
  713. const link = join(profileDir, 'node_modules', manifest.name)
  714. await mkdir(dirname(link), { recursive: true })
  715. await symlink(entry.dir, link, 'junction')
  716. }
  717. initProfile(profileDir, bundles)
  718. const manifest = readProfileManifest('dsh', profileDir)
  719. manifest.dependencies = dependencies
  720. await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
  721. profileContext = {
  722. name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR,
  723. cwd: workspaceCwd, home: harnessHome, startedBundles: bundles,
  724. overlays: overlayPatches, telemetryDisabledEnv: undefined,
  725. }
  726. // HMR gates file-driven reloads on application readiness, which the
  727. // launcher commits after boot; this direct harness is ready at once.
  728. ctx.provide('appReady', { onReady: (listener) => { listener(); return () => {} } })
  729. ctx.provide('profileContext', profileContext)
  730. }
  731. // This direct Loader harness supplies the same root-path capability as app-boot.
  732. ctx.provide('dshHomePath', dshHomePath)
  733. // A host with no command line still provides one: the web bundle's startup
  734. // row releases the rows waiting on it, and with no arguments each starts on
  735. // the values this scaffold composed above. An exit request can only come
  736. // from a rejected argument, which a fixed empty list has none of.
  737. provideCmdline(ctx, {
  738. args: [],
  739. exit: (code) => {
  740. throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
  741. },
  742. })
  743. await ctx.plugin(PluginPackages, {
  744. generation: resolution,
  745. behavior: profileResolutionMode === 'dual' ? 'verify' : 'enforce',
  746. })
  747. await ctx.plugin(Loader)
  748. if (profileContext === undefined) {
  749. ctx.loader.builtins.include = Include
  750. // `cordis:group` beside it, exactly as `boot()` registers it: a group row is
  751. // how a preset gives one `isolate` realm to a provider and its consumers,
  752. // and a preset resolving package names from its own directory cannot reach
  753. // `@deepseek-ai/cordis-plugin-group` by name.
  754. ctx.loader.builtins.group = Group
  755. await ctx.loader.create({
  756. name: 'cordis:include',
  757. config: { path: pathToFileURL(rootConfig).href, patches },
  758. })
  759. } else {
  760. // The launcher's own mount, so the manager's reloads find the root Include
  761. // and compose the same layers the profile files name; bare names still
  762. // resolve through the resolution generation above, as in the direct mount.
  763. await mountRootInclude(ctx, rootConfig, readProfilePatches('dsh', profileContext))
  764. }
  765. await ctx.loader.await()
  766. await auditStartupEntries(ctx, 'web e2e scaffold')
  767. if (options.welcomeNoticePending !== true) {
  768. await ctx.settings.mutate(WELCOME_NOTICE_SETTINGS_NAMESPACE, [{
  769. op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: WELCOME_NOTICE_VERSION,
  770. }])
  771. }
  772. const boundPort = ctx.get('webServer')?.port
  773. if (boundPort === undefined) {
  774. throw new Error('web e2e scaffold: webServer service missing after settled boot')
  775. }
  776. port = boundPort
  777. // Fill the open llm seam on the settled root ctx. Ordinary keyless modes
  778. // disable the direct adapter; the first-run lane keeps the selected adapter but has no
  779. // replay fixture and never streams. The direct install, unlike the plugin
  780. // row, returns the ReplayHandle for the teardown consumption check.
  781. if (options.replayProvidersOnly) {
  782. if (replayFixture === undefined) {
  783. throw new Error('replayProvidersOnly requires replayFixture (its file supplies the header)')
  784. }
  785. const fixtureText = readFileSync(replayFixture, 'utf8')
  786. // The consumption check is skipped for this mode, so no script source
  787. // may carry callable entries: reject override/child sources outright
  788. // and any call-bearing fixture.
  789. if (options.replayOverride !== undefined || replayChildFixtures !== undefined) {
  790. throw new Error('replayProvidersOnly cannot combine with replayOverride or replayChildFixtures')
  791. }
  792. // A fixture without a session header row must not mount the catalog
  793. // silently: the consumption-skip assumes the header-only shape.
  794. let headerType: unknown
  795. try {
  796. headerType = (JSON.parse(fixtureText.trimStart().split('\n', 1)[0] ?? '') as { type?: unknown }).type
  797. } catch {
  798. headerType = undefined
  799. }
  800. if (headerType !== 'session') {
  801. throw new Error('replayProvidersOnly fixture must open with a session header row')
  802. }
  803. const recorded = parseSessionLog(fixtureText)
  804. const hasModelCall = recorded.some(event => (
  805. event.type === 'assistant/message' || event.type === 'assistant/attempt'
  806. || event.type === 'request/header' || event.type === 'tool/call'
  807. ))
  808. if (hasModelCall) {
  809. throw new Error('replayProvidersOnly fixture must record no model calls')
  810. }
  811. }
  812. if (mode !== 'record' && replayFixture !== undefined) {
  813. replayHandle = installLlmReplay(ctx, {
  814. file: replayFixture,
  815. providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
  816. ...provider,
  817. ...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
  818. })),
  819. ...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
  820. ...(replayChildFixtures === undefined ? {} : { childFiles: replayChildFixtures }),
  821. ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
  822. })
  823. } else if (mode !== 'record' && options.deepSeekMissingCredential !== true) {
  824. // No fixture and no shipped adapter would leave the tree with ZERO
  825. // provider routes — a state no product composition has, and one the
  826. // composer refuses to type into. Register the same routes
  827. // a fixture would, with streaming that still fails loud: the scenario
  828. // issues no model calls, and one that slipped in must not pass quietly.
  829. ctx.effect(() => ctx.llm.registerAdapter(
  830. replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
  831. new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
  832. ), 'web e2e scaffold: route-only adapter')
  833. }
  834. baseUrl = `http://${browserHost}:${String(port)}`
  835. authenticatedUrl = ctx.connection.authenticatedUrl(baseUrl)
  836. const login = await fetch(authenticatedUrl, { redirect: 'manual' })
  837. const setCookie = login.headers.get('set-cookie')
  838. if (login.status !== 303 || login.headers.get('location') !== '/' || setCookie === null) {
  839. throw new Error('web e2e scaffold: browser token exchange did not return its session cookie')
  840. }
  841. cookieHeader = setCookie.split(';', 1)[0] ?? ''
  842. if (cookieHeader.length === 0) {
  843. throw new Error('web e2e scaffold: browser token exchange returned an empty session cookie')
  844. }
  845. } catch (error) {
  846. if (process.cwd() !== originalCwd) process.chdir(originalCwd)
  847. const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
  848. restoreCredentialEnvironment()
  849. restoreSkillRootEnvironment()
  850. if (cleanupFailures.length > 0) {
  851. throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
  852. }
  853. throw error
  854. } finally {
  855. if (process.cwd() !== originalCwd) process.chdir(originalCwd)
  856. }
  857. return {
  858. harnessHome,
  859. mode,
  860. baseUrl,
  861. authenticatedUrl,
  862. ctx,
  863. workspaceCwd,
  864. persistenceRoot,
  865. hostFetch(path: string, init: RequestInit = {}): Promise<Response> {
  866. const headers = new Headers(init.headers)
  867. headers.set('cookie', cookieHeader)
  868. return fetch(new URL(path, baseUrl), { ...init, headers })
  869. },
  870. // Barrier stack: the in-process turn/end identifies the session, its
  871. // explicit flush makes the transcript durable, and the caller's browser
  872. // settled-poll comes last because host completion strictly precedes render.
  873. whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise<SessionId> {
  874. return new Promise<SessionId>((resolveSettled, reject) => {
  875. const timer = setTimeout(() => {
  876. off()
  877. reject(new Error(`no turn/end within ${timeoutMs}ms`))
  878. }, timeoutMs)
  879. const off = ctx.on('session/event', (session: Session, event: SessionEvent) => {
  880. if (event.type !== 'turn/end') return
  881. clearTimeout(timer)
  882. off()
  883. ctx.sessions.flush(session)
  884. .then(() => { resolveSettled(session.id) }, reject)
  885. })
  886. })
  887. },
  888. async close(): Promise<void> {
  889. const failures: unknown[] = []
  890. if (mode !== 'record'
  891. && replayFixture !== undefined
  892. && options.replayProvidersOnly !== true
  893. && compareReplaySession) {
  894. try {
  895. await assertReplaySession(
  896. [...observedSessions.values()],
  897. replayFixture,
  898. mode,
  899. `http://${browserHost}:${port}`,
  900. harnessHome,
  901. )
  902. } catch (error) {
  903. failures.push(error)
  904. }
  905. }
  906. // Fixture-consumption check first, while the run's binding state is
  907. // still authoritative — a scenario that drove fewer model calls than
  908. // recorded fails here instead of drifting green. Skipped for
  909. // replayProvidersOnly, whose fixture is validated call-free at boot.
  910. if (!options.replayProvidersOnly) {
  911. try {
  912. replayHandle?.assertConsumed()
  913. } catch (error) {
  914. failures.push(error)
  915. }
  916. }
  917. try {
  918. stopObservingSessions()
  919. failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
  920. } finally {
  921. restoreCredentialEnvironment()
  922. restoreSkillRootEnvironment()
  923. }
  924. if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
  925. },
  926. }
  927. }
  928. /**
  929. * Serialize a live session to the canonical raw session-JSONL layout — the
  930. * in-memory record-mode harvest, so the on-disk zstd default never matters.
  931. */
  932. function rawSessionLog(session: Session): string {
  933. const encodedEvents = (session.snapshotEvents() as unknown as readonly SessionFormatEvent[])
  934. .map(event => sessionFormatCatalog.encodeCurrentEvent(event))
  935. const header = sessionFormatCatalog.encodeCurrentHeader({
  936. ...session.header,
  937. delegationDepth: session.header.delegationDepth ?? 0,
  938. }, session.inheritedEventCount)
  939. return [
  940. JSON.stringify(header),
  941. ...encodedEvents.map(record => JSON.stringify(record)),
  942. '',
  943. ].join('\n')
  944. }
  945. function mapJsonStringValues(value: unknown, map: (value: string) => string): unknown {
  946. if (typeof value === 'string') return map(value)
  947. if (Array.isArray(value)) return value.map(item => mapJsonStringValues(item, map))
  948. if (value !== null && typeof value === 'object') {
  949. return Object.fromEntries(Object.entries(value).map(([key, item]) => [
  950. key,
  951. mapJsonStringValues(item, map),
  952. ]))
  953. }
  954. return value
  955. }
  956. /** Tokenize the browser timezone carried by user message sources. */
  957. function normalizeClientTimeZones(value: unknown): unknown {
  958. if (Array.isArray(value)) return value.map(item => normalizeClientTimeZones(item))
  959. if (value !== null && typeof value === 'object') {
  960. const next = Object.fromEntries(Object.entries(value).map(([key, item]) => [
  961. key,
  962. normalizeClientTimeZones(item),
  963. ]))
  964. const source = (next as { source?: unknown }).source
  965. if (source !== null && typeof source === 'object'
  966. && (source as { kind?: unknown }).kind === 'user'
  967. && typeof (source as { clientTimeZone?: unknown }).clientTimeZone === 'string') {
  968. return {
  969. ...next,
  970. source: { ...source, clientTimeZone: '{{clientTimeZone}}' },
  971. }
  972. }
  973. return next
  974. }
  975. return value
  976. }
  977. const WEB_PATH_TEXT_BOUNDARY_RE = /[\s<>'"`()\[\]{},;:!?=]/
  978. const WEB_FILE_URI_PATH_PREFIX_RE = /(?:^|[^a-z0-9+.-])file:\/\/\/?$/i
  979. function isWebCwdMatch(value: string, start: number, length: number): boolean {
  980. const before = value[start - 1]
  981. const after = value[start + length]
  982. const afterPunctuation = value[start + length + 1]
  983. const startsAtBoundary = before === undefined
  984. || WEB_PATH_TEXT_BOUNDARY_RE.test(before)
  985. || WEB_FILE_URI_PATH_PREFIX_RE.test(value.slice(0, start))
  986. const endsAtBoundary = after === undefined
  987. || after === '/'
  988. || after === '\\'
  989. || WEB_PATH_TEXT_BOUNDARY_RE.test(after)
  990. || after === '.' && (afterPunctuation === undefined || WEB_PATH_TEXT_BOUNDARY_RE.test(afterPunctuation))
  991. return startsAtBoundary && endsAtBoundary
  992. }
  993. function replaceWebCwd(value: string, cwd: string): string {
  994. let cursor = 0
  995. let normalized = ''
  996. while (cursor < value.length) {
  997. const match = value.indexOf(cwd, cursor)
  998. if (match < 0) return normalized + value.slice(cursor)
  999. const end = match + cwd.length
  1000. if (isWebCwdMatch(value, match, cwd.length)) {
  1001. normalized += value.slice(cursor, match) + '{{cwd}}'
  1002. cursor = end
  1003. } else {
  1004. normalized += value.slice(cursor, end)
  1005. cursor = end
  1006. }
  1007. }
  1008. return normalized
  1009. }
  1010. /**
  1011. * Normalize Web-only volatile strings while preserving JSON structure and row framing.
  1012. * @param log - raw Session JSONL.
  1013. * @param workspaceCwd - optional scaffold parent used before a live Session selects its cwd.
  1014. * @returns compact JSONL with run-local strings tokenized.
  1015. */
  1016. export function normalizeWebSessionVolatiles(log: string, workspaceCwd?: string): string {
  1017. const headerLine = log.split(/\r?\n/).find(line => line.trim().length > 0)
  1018. const header = headerLine === undefined ? undefined : JSON.parse(headerLine) as { cwd?: unknown }
  1019. const sessionCwd = typeof header?.cwd === 'string' && header.cwd.length > 0 ? header.cwd : undefined
  1020. const cwdSpellings = [...new Set([sessionCwd ?? workspaceCwd]
  1021. .filter((value): value is string => typeof value === 'string' && value.length > 0)
  1022. .flatMap((value) => {
  1023. const forward = value.replaceAll('\\', '/')
  1024. const native = /^[A-Za-z]:[\\/]/.test(value) ? forward.replaceAll('/', '\\') : value
  1025. return [value, value.replaceAll('\\', '\\\\'), forward, native]
  1026. }))].sort((left, right) => right.length - left.length)
  1027. return log.split(/\r?\n/).map((line) => {
  1028. if (line.trim() === '') return line
  1029. const record = normalizeClientTimeZones(mapJsonStringValues(JSON.parse(line), (value) => {
  1030. let normalized = value
  1031. .replace(/Anonymous user: [0-9a-f-]{36}(?=\.$)/gi, 'Anonymous user: {{anonymousUserId}}')
  1032. for (const cwd of cwdSpellings) normalized = replaceWebCwd(normalized, cwd)
  1033. return normalized
  1034. })) as { type?: unknown; data?: { endpoint?: unknown } }
  1035. if (record.type === 'web/deepseek-search-llm-request' && typeof record.data?.endpoint === 'string') {
  1036. record.data.endpoint = '{{webSearchEndpoint}}'
  1037. }
  1038. return JSON.stringify(record)
  1039. }).join('\n')
  1040. }
  1041. function stableSessionFixture(
  1042. session: Session,
  1043. existing: string,
  1044. workspaceCwd: string,
  1045. harnessHome: string,
  1046. ): string {
  1047. const prepared = prepareSessionSnapshotFixtureForComparison(
  1048. normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd),
  1049. )
  1050. const stabilized = existing === ''
  1051. ? prepared
  1052. : stabilizeRefreshLog(prepared, existing, [], {
  1053. sessionIds: [String(session.id)],
  1054. cwd: workspaceCwd,
  1055. })
  1056. const fresh = scrubSessionSnapshot(stabilized)
  1057. .split(session.id).join('{{session:1}}')
  1058. .split(harnessHome).join('{{harnessHome}}')
  1059. const stable = redactSessionSnapshotIds(stabilizeFixtureMessageIds([fresh], [existing]))[0]
  1060. if (stable === undefined) throw new Error('session harvest produced no stabilized fixture')
  1061. return stable
  1062. }
  1063. async function assertReplaySession(
  1064. sessions: readonly Session[],
  1065. fixturePath: string,
  1066. mode: WebSnapshotMode,
  1067. webUrl: string,
  1068. harnessHome: string,
  1069. ): Promise<void> {
  1070. let expected = await readFile(fixturePath, 'utf8')
  1071. const fixtureDir = dirname(fixturePath)
  1072. const manifestPath = join(fixtureDir, 'snapshot.yml')
  1073. const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
  1074. let expectedPath = fixturePath
  1075. const userPrompts = fixtureUserPrompts(expected)
  1076. const candidates = sessions.filter((session) => {
  1077. if (session.header.parentSession !== undefined) return false
  1078. const actual = session.snapshotEvents().flatMap((event) => {
  1079. if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
  1080. const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
  1081. return text.length === 0 ? [] : [text]
  1082. })
  1083. return JSON.stringify(actual) === JSON.stringify(userPrompts)
  1084. })
  1085. expect(candidates, `Web replay fixture ${fixturePath} must match one live root session`).toHaveLength(1)
  1086. const session = candidates[0] as Session
  1087. const sessionCwd = session.header.cwd
  1088. if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`)
  1089. const actual = rawSessionLog(session)
  1090. if (mode === 'refresh' && writesCurrentSessionFixtures(manifest, mode)) {
  1091. expected = stableSessionFixture(session, expected, sessionCwd, harnessHome)
  1092. expectedPath = recordedSessionFixturePath(fixturePath, session.header.version)
  1093. await writeFile(expectedPath, expected)
  1094. }
  1095. const expectedHeader = JSON.parse(expected.split('\n').find(line => line.trim() !== '') ?? '{}') as {
  1096. id?: unknown
  1097. cwd?: unknown
  1098. }
  1099. const actualContext: NormalizeContext = { sessionIds: [String(session.id)], cwd: sessionCwd }
  1100. const expectedContext: NormalizeContext = {
  1101. sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [],
  1102. cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0',
  1103. }
  1104. const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0]
  1105. ?.split(harnessHome).join('{{harnessHome}}')
  1106. const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]
  1107. ?.split(harnessHome).join('{{harnessHome}}')
  1108. expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot)
  1109. if (manifest.header?.pin !== true) return
  1110. const normalizePrompt = (value: string): string => value
  1111. .split(REPO_ROOT).join('{{sourceRoot}}')
  1112. .split(webUrl).join('{{webUrl}}')
  1113. const prompts = normalizedSystemPrompts(actual, actualContext).map(normalizePrompt)
  1114. const schemas = normalizedToolSchemas(actual, actualContext)
  1115. const promptPath = join(fixtureDir, 'system-prompt.expected.md')
  1116. const schemaPath = join(fixtureDir, 'tool-schemas.expected.json')
  1117. const promptSnapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1))
  1118. const schemaSnapshot = formatToolSchemasSnapshot(schemas[0] as unknown[], schemas.slice(1))
  1119. if (mode === 'refresh') {
  1120. await Promise.all([writeFile(promptPath, promptSnapshot), writeFile(schemaPath, schemaSnapshot)])
  1121. }
  1122. expect(promptSnapshot, `${fixturePath}: system-prompt pin`).toBe(await readFile(promptPath, 'utf8'))
  1123. expect(schemaSnapshot, `${fixturePath}: tool-schema pin`).toBe(await readFile(schemaPath, 'utf8'))
  1124. }
  1125. /**
  1126. * Record-mode fixture write-back: harvest the live session, scrub the
  1127. * system-prompt text to {{system}} and header tool schemas to {{tools}},
  1128. * tokenize the run-local cwd, redact opaque identities with typed
  1129. * relationship-preserving tokens, and write the fixture.
  1130. * A manifest-retained historical generation makes the write-back a no-op.
  1131. * @param scaffold - the record-mode scaffold.
  1132. * @param sessionId - the driven session.
  1133. * @param fixturePath - the committed session.jsonl target.
  1134. */
  1135. export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise<void> {
  1136. const agent = scaffold.ctx.agents.get(sessionId)
  1137. if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`)
  1138. const manifestPath = join(dirname(fixturePath), 'snapshot.yml')
  1139. const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
  1140. if (!writesCurrentSessionFixtures(manifest, 'record')) return
  1141. const target = recordedSessionFixturePath(fixturePath, agent.session.header.version)
  1142. const existingPath = existsSync(target) ? target : fixturePath
  1143. const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : ''
  1144. await writeFile(target, stableSessionFixture(
  1145. agent.session,
  1146. existing,
  1147. scaffold.workspaceCwd,
  1148. scaffold.harnessHome,
  1149. ))
  1150. }
  1151. /**
  1152. * The user prompts recorded in a fixture, in order — the single source tying
  1153. * spec drive steps to recorded reality so script and fixture cannot drift.
  1154. * @param fixtureText - raw session.jsonl contents.
  1155. * @returns the recorded user prompt texts.
  1156. */
  1157. export function fixtureUserPrompts(fixtureText: string): string[] {
  1158. return parseSessionLog(fixtureText).flatMap((event) => {
  1159. if (event.type !== 'user/message' || event.data.source.kind !== 'user') return []
  1160. const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('')
  1161. return text.length > 0 ? [text] : []
  1162. })
  1163. }
  1164. /** Deterministic UUID used when a seed fixture's typed identity token is materialized. */
  1165. export function fixtureIdentity(
  1166. kind: 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id',
  1167. ordinal: number,
  1168. ): string {
  1169. const hex = createHash('sha256').update(`${kind}:${ordinal}`).digest('hex').slice(0, 32).split('')
  1170. hex[12] = '4'
  1171. hex[16] = ['8', '9', 'a', 'b'][Number.parseInt(hex[16] as string, 16) % 4] as string
  1172. return `${hex.slice(0, 8).join('')}-${hex.slice(8, 12).join('')}-${hex.slice(12, 16).join('')}-${hex.slice(16, 20).join('')}-${hex.slice(20).join('')}`
  1173. }
  1174. /**
  1175. * Realize a recorded seed fixture against one scaffold: substitute the
  1176. * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the
  1177. * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to
  1178. * price content exactly as the host will fold it) and still pass the result
  1179. * through {@link seedSession}.
  1180. * @param scaffold - the booted scaffold whose workspace the seed targets.
  1181. * @param fixtureText - the committed seed fixture text.
  1182. * @param id - the session id the seed is realized for.
  1183. * @returns the realized fixture text.
  1184. */
  1185. export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, id: string): string {
  1186. const firstLine = fixtureText.split(/\r?\n/).find(line => line.trim().length > 0)
  1187. const fixtureCwd = firstLine === undefined
  1188. ? undefined
  1189. : (JSON.parse(firstLine) as { cwd?: unknown }).cwd
  1190. return fixtureText.split(/\r?\n/).map((line) => {
  1191. if (line.trim() === '') return line
  1192. const realized = mapJsonStringValues(JSON.parse(line), (value) => {
  1193. let result = typeof fixtureCwd === 'string'
  1194. ? value.split(fixtureCwd).join(scaffold.workspaceCwd)
  1195. : value
  1196. result = result
  1197. .split('{{sessionId}}').join(id)
  1198. .split('{{session:1}}').join(id)
  1199. .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`)
  1200. .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) =>
  1201. fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal)))
  1202. .split('{{harnessHome}}').join(scaffold.harnessHome)
  1203. .split('{{cwd}}').join(scaffold.workspaceCwd)
  1204. return result
  1205. })
  1206. return JSON.stringify(realized)
  1207. }).join('\n')
  1208. }
  1209. /**
  1210. * Parse a committed web seed fixture through the replay reader.
  1211. * @param fixtureText - session JSONL fixture contents.
  1212. * @returns the current header line, parsed header, and logical events.
  1213. */
  1214. /** Give a migrated fixture stream positive relative timing before its final wall-clock rebase. */
  1215. function spreadMigratedSeedStream(
  1216. stream: SessionEvent<'assistant/message'>['data']['stream'],
  1217. ): SessionEvent<'assistant/message'>['data']['stream'] {
  1218. let nextTime = 0
  1219. return stream.map((record) => {
  1220. if ('time' in record) {
  1221. const timed = { ...record, time: nextTime }
  1222. nextTime += 1
  1223. return timed
  1224. }
  1225. const timed = { ...record, time0: nextTime }
  1226. nextTime += record.dt.reduce((total, delta) => total + delta, 0) + 1
  1227. return timed
  1228. })
  1229. }
  1230. export function parseSeedFixture(fixtureText: string): {
  1231. headerLine: string
  1232. header: Record<string, unknown>
  1233. events: SessionEvent[]
  1234. } {
  1235. const sourceHeaderLine = fixtureText.split(/\r?\n/).find(line => line.trim().length > 0)
  1236. if (sourceHeaderLine === undefined) throw new Error('seed fixture has no session header')
  1237. const sourceHeader = JSON.parse(sourceHeaderLine) as { version?: unknown }
  1238. const current = prepareSessionSnapshotFixtureForComparison(fixtureText)
  1239. const headerLine = current.split(/\r?\n/).find(line => line.trim().length > 0)
  1240. if (headerLine === undefined) throw new Error('seed fixture has no session header')
  1241. const header = JSON.parse(headerLine) as Record<string, unknown>
  1242. if (header.type !== 'session') throw new Error('seed fixture must start with a session header')
  1243. const events = parseSessionLog(current).map((event) => {
  1244. if (sourceHeader.version === SESSION_FORMAT_VERSION) return event
  1245. if (event.type === 'assistant/message') {
  1246. return { ...event, data: { ...event.data, stream: spreadMigratedSeedStream(event.data.stream) } }
  1247. }
  1248. if (event.type === 'assistant/attempt') {
  1249. return { ...event, data: { ...event.data, stream: spreadMigratedSeedStream(event.data.stream) } }
  1250. }
  1251. return event
  1252. })
  1253. return { headerLine, header, events }
  1254. }
  1255. /**
  1256. * Render logical events as an envelope-free web seed fixture.
  1257. * @param headerLine - original session header line.
  1258. * @param events - logical session events in order.
  1259. * @returns projected session JSONL.
  1260. */
  1261. export function renderSeedFixture(
  1262. headerLine: string,
  1263. events: readonly ({ readonly seq: number; readonly time: number } & object)[],
  1264. ): string {
  1265. return [
  1266. headerLine,
  1267. ...events.map(({ seq: _seq, time: _time, ...event }) => JSON.stringify(event)),
  1268. '',
  1269. ].join('\n')
  1270. }
  1271. /** Re-anchor one projected embedded stream while preserving every intra-stream gap. */
  1272. function rebaseSeedStream(
  1273. stream: SessionEvent<'assistant/message'>['data']['stream'],
  1274. startAt: number,
  1275. ): SessionEvent<'assistant/message'>['data']['stream'] {
  1276. const first = stream[0]
  1277. if (first === undefined) return stream
  1278. const sourceStart = 'time' in first ? first.time : first.time0
  1279. const delta = startAt - sourceStart
  1280. return stream.map(record => 'time' in record
  1281. ? { ...record, time: record.time + delta }
  1282. : { ...record, time0: record.time0 + delta })
  1283. }
  1284. /** Last logical timestamp carried by an embedded Assistant stream. */
  1285. function seedStreamEnd(
  1286. stream: SessionEvent<'assistant/message'>['data']['stream'],
  1287. ): number | undefined {
  1288. let end: number | undefined
  1289. for (const record of stream) {
  1290. const recordEnd = 'time' in record
  1291. ? record.time
  1292. : record.time0 + record.dt.reduce((total, delta) => total + delta, 0)
  1293. end = end === undefined ? recordEnd : Math.max(end, recordEnd)
  1294. }
  1295. return end
  1296. }
  1297. /**
  1298. * Seed a recorded session fixture into the scaffold's persistence root
  1299. * through the real Session and JSONL APIs.
  1300. * @param scaffold - the target scaffold.
  1301. * @param fixtureText - raw recorded session.jsonl contents.
  1302. * @param id - the seeded session id.
  1303. * @param agentPreset - preset recorded by scenarios that assert resumed composition.
  1304. * @param options - deterministic metadata overrides for ordering-sensitive scenarios.
  1305. * @returns the seeded id.
  1306. */
  1307. export async function seedSession(
  1308. scaffold: WebScaffold,
  1309. fixtureText: string,
  1310. id: string,
  1311. agentPreset?: string,
  1312. options: { readonly createdAt?: number } = {},
  1313. ): Promise<SessionId> {
  1314. const decoded = parseSeedFixture(realizeSeedFixture(scaffold, fixtureText, id))
  1315. const events = decoded.events
  1316. if (events.length === 0) throw new Error('seed fixture has no events')
  1317. const last = events[events.length - 1]!
  1318. // An open final turn would be mutated by resume's crash repair on first
  1319. // open; a committed seed must be a closed recording.
  1320. if (last.type !== 'turn/end') throw new Error(`seed fixture must end in turn/end, got ${last.type}`)
  1321. const createdAt = options.createdAt ?? Date.now() - 60_000
  1322. const meta: SessionHeader = {
  1323. version: SESSION_FORMAT_VERSION,
  1324. id: SessionId(id),
  1325. createdAt,
  1326. isSeeded: false,
  1327. cwd: scaffold.workspaceCwd,
  1328. delegationDepth: 0,
  1329. ...agentPreset === undefined ? {} : { agentPreset },
  1330. }
  1331. const fixtureCreatedAt = decoded.header.createdAt
  1332. if (typeof fixtureCreatedAt !== 'number') {
  1333. throw new Error('seed fixture requires a numeric createdAt header')
  1334. }
  1335. const timeAnchor = fixtureCreatedAt === 0 ? createdAt : fixtureCreatedAt
  1336. let nextTime = timeAnchor
  1337. const materializedEvents: SessionEvent[] = events.map((event) => {
  1338. const time = nextTime
  1339. if (event.type === 'assistant/message') {
  1340. const stream = rebaseSeedStream(event.data.stream, time)
  1341. const completedAt = Math.max(time, seedStreamEnd(stream) ?? time)
  1342. nextTime = completedAt + 1
  1343. return {
  1344. ...event,
  1345. time: completedAt,
  1346. data: { ...event.data, stream },
  1347. }
  1348. }
  1349. if (event.type === 'assistant/attempt') {
  1350. const stream = rebaseSeedStream(event.data.stream, time)
  1351. const completedAt = Math.max(time, seedStreamEnd(stream) ?? time)
  1352. nextTime = completedAt + 1
  1353. return {
  1354. ...event,
  1355. time: completedAt,
  1356. data: { ...event.data, stream },
  1357. }
  1358. }
  1359. nextTime = time + 1
  1360. return { ...event, time }
  1361. })
  1362. await persistSeedSession(scaffold, meta, materializedEvents)
  1363. return meta.id
  1364. }
  1365. /** Materialize one detached Session fixture through the shipped JSONL provider. */
  1366. async function persistSeedSession(
  1367. scaffold: WebScaffold,
  1368. meta: SessionHeader,
  1369. events: readonly SessionEvent[],
  1370. ): Promise<void> {
  1371. const seeder = new Context()
  1372. try {
  1373. // Same root as the booted tree with the plugin's own default compression,
  1374. // so the host's directory-scan list() sees one consistent encoding.
  1375. await seeder.plugin(JsonlSessionPersistence, { root: scaffold.persistenceRoot })
  1376. const handle = await seeder.sessionPersistence.create(meta)
  1377. await handle.append(events)
  1378. await handle.close()
  1379. } finally {
  1380. await seeder.fiber.dispose()
  1381. }
  1382. }
  1383. /**
  1384. * Read one stored session's physical event log through a throwaway read
  1385. * handle. The physical log carries no synthetic closers: a resumed session
  1386. * shows the closers the loop appended durably, and a never-resumed
  1387. * interrupted log stays interrupted.
  1388. * @param scaffold - the booted scaffold whose persistence holds the session.
  1389. * @param id - the stored session to read.
  1390. * @returns the stored events.
  1391. */
  1392. export async function readPersistedEvents(scaffold: WebScaffold, id: SessionId): Promise<readonly SessionEvent[]> {
  1393. const handle = await scaffold.ctx.sessionPersistence.open(id, 'read')
  1394. try {
  1395. return (await handle.read()).events
  1396. } finally {
  1397. await handle.close()
  1398. }
  1399. }
  1400. /**
  1401. * Normalize an aria snapshot: uuid, cwd, workspace-basename, duration,
  1402. * decode-throughput, and path-sensitive compaction estimates collapse to
  1403. * stable tokens.
  1404. *
  1405. * Throughput needs a token for the same reason durations do, and no fixture
  1406. * can supply one: the figure divides a replayed step's output tokens by the
  1407. * wall time the local run took to stream them, so it moves between two runs
  1408. * on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay
  1409. * (26333 tok/s for a 3 ms stream).
  1410. */
  1411. /**
  1412. * Relative-time buckets rendered by a dated row, in both dictionaries.
  1413. *
  1414. * Opt-in per capture: a session-tree golden asserts its own literal age (a
  1415. * fresh row reads `now`, an older one does not), so collapsing the vocabulary
  1416. * everywhere would delete that assertion. A region whose rows are dated from
  1417. * live wall-clock state asks for it instead. Anchored on an aria label's
  1418. * closing quote, where the bucket is always last.
  1419. */
  1420. const ARIA_AGE =
  1421. /(?:now|\d+min|\d+h|\d+d|\d+mo|\d+y|刚刚|\d+分钟|\d+小时|\d+天|\d+个月|\d+年)(?=")/g
  1422. function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): string {
  1423. // The session heading renders the workspace's basename, not the full
  1424. // path, so both spellings must collapse to the token.
  1425. const base = workspaceCwd.split('/').pop()!
  1426. return (age ? snapshot.replace(ARIA_AGE, '{{age}}') : snapshot)
  1427. .split(workspaceCwd).join('{{cwd}}')
  1428. .split(base).join('{{workspace}}')
  1429. .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}')
  1430. // The optional space in `\d+m ?\d+s` covers both minute spellings: the
  1431. // stats line's compact `2m42s` and the message-chrome template's `2m 42s`.
  1432. .replace(
  1433. /~\d+(?:y(?: \d+mo)?|mo(?: \d+d)?)|\b(?:\d+d(?: \d+h(?: \d+m \d+s)?)?|\d+h \d+m \d+s|\d+m ?\d+s|\d+(?:\.\d+)?s|\d+(?:\.\d+)?ms)\b/g,
  1434. duration => duration.startsWith('~') ? duration : '{{duration}}',
  1435. )
  1436. .replace(/\b\d[\d,]*(?:\.\d+)? ms\b/g, '{{duration}}')
  1437. .replace(
  1438. /约\d+(?:年(?:\d+个月)?|个月(?:\d+天)?)|\d+(?:天(?:\d+小时(?:\d+分\d+秒)?)?|小时\d+分\d+秒|分\d+秒|(?:\.\d+)?秒)/g,
  1439. duration => duration.startsWith('约') ? duration : '{{duration}}',
  1440. )
  1441. .replace(/\d+(?:\.\d+)?(?= tok\/s(?!\w))/g, '{{throughput}}')
  1442. // Seeded compaction prices realized file paths, whose length differs
  1443. // between local worktrees and CI scratch directories.
  1444. .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2')
  1445. // Session summaries and Message IconActions clocks cross calendar
  1446. // boundaries; collapse every shape so goldens stay stable across them.
  1447. .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, '{{timestamp}}')
  1448. .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
  1449. .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}')
  1450. .replace(/(?<!\d)\d{1,2}:\d{2}:\d{2}(?:\.\d+)?(?:\s*[AP]M)?(?!\d)/gi, '{{clock}}')
  1451. .replace(/(?<!\d)\d{2}:\d{2}(?!\d)/g, '{{clock}}')
  1452. }
  1453. /**
  1454. * Capture the region's aria snapshot at a settled milestone: poll until two
  1455. * consecutive normalized captures are equal — a single-shot capture races the
  1456. * last React commits.
  1457. * @param page - the page under test.
  1458. * @param selector - the region locator selector.
  1459. * @param workspaceCwd - normalization input.
  1460. * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}`
  1461. * for a region whose rows are dated from live wall-clock state;
  1462. * `replacements` tokenizes scenario-owned values before generic normalization.
  1463. * @returns the stable normalized snapshot.
  1464. */
  1465. export async function captureStableAria(
  1466. page: Page,
  1467. selector: string,
  1468. workspaceCwd: string,
  1469. options: {
  1470. normalizeAge?: boolean
  1471. replacements?: readonly (readonly [value: string, token: string])[]
  1472. } = {},
  1473. ): Promise<string> {
  1474. const region = page.locator(selector).first()
  1475. const age = options.normalizeAge === true
  1476. const normalize = (snapshot: string): string => {
  1477. for (const [value, token] of options.replacements ?? []) {
  1478. snapshot = snapshot.split(value).join(token)
  1479. }
  1480. return normalizeAria(snapshot, workspaceCwd, age)
  1481. }
  1482. let previous = normalize(await region.ariaSnapshot())
  1483. await expect.poll(async () => {
  1484. const current = normalize(await region.ariaSnapshot())
  1485. const stable = current === previous
  1486. previous = current
  1487. return stable
  1488. }, { timeout: 5_000, message: 'aria snapshot did not stabilize' }).toBe(true)
  1489. return previous
  1490. }
  1491. /**
  1492. * Capture a stable aria snapshot with every eligible Turn process expanded,
  1493. * then restore the controls that were closed before the capture.
  1494. * @param page - the page under test.
  1495. * @param selector - the region locator selector.
  1496. * @param workspaceCwd - normalization input.
  1497. * @param options - optional user-visible state to establish before capture.
  1498. * @returns the stable normalized expanded snapshot.
  1499. */
  1500. export async function captureExpandedTurnProcessAria(
  1501. page: Page,
  1502. selector: string,
  1503. workspaceCwd: string,
  1504. options: { scrollToBottom?: boolean } = {},
  1505. ): Promise<string> {
  1506. const controls = page.locator('[data-turn-process]')
  1507. const count = await controls.count()
  1508. expect(count).toBeGreaterThan(0)
  1509. const opened: number[] = []
  1510. for (let index = 0; index < count; index++) {
  1511. const control = controls.nth(index)
  1512. if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue
  1513. await control.click()
  1514. opened.push(index)
  1515. }
  1516. try {
  1517. if (options.scrollToBottom === true) {
  1518. const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
  1519. const scroll = page.locator('[data-conversation-scroll]')
  1520. await expect.poll(async () => {
  1521. const distanceFromBottom = await scroll.evaluate((host) => {
  1522. host.scrollTop = host.scrollHeight
  1523. return host.scrollHeight - host.clientHeight - host.scrollTop
  1524. })
  1525. return Math.abs(distanceFromBottom) <= 1 && await backToBottom.count() === 0
  1526. }, { timeout: 10_000 }).toBe(true)
  1527. }
  1528. return await captureStableAria(page, selector, workspaceCwd)
  1529. } finally {
  1530. for (const index of opened.reverse()) {
  1531. const control = controls.nth(index)
  1532. if (await control.getAttribute('aria-expanded') === 'true') await control.click()
  1533. }
  1534. }
  1535. }
  1536. /**
  1537. * Compare a normalized golden, or rewrite it under refresh. Refresh is the
  1538. * ONLY writer: a missing golden in replay mode fails with the healing command
  1539. * instead of silently self-bootstrapping.
  1540. * @param goldenPath - the committed ui.expected.md path.
  1541. * @param actual - the stable normalized snapshot.
  1542. * @param mode - the active snapshot mode.
  1543. */
  1544. export async function compareOrRefreshGolden(goldenPath: string, actual: string, mode: WebSnapshotMode): Promise<void> {
  1545. const payload = `${actual}\n`
  1546. if (mode === 'refresh') {
  1547. await writeFile(goldenPath, payload)
  1548. return
  1549. }
  1550. if (!existsSync(goldenPath)) {
  1551. throw new Error(`missing golden ${goldenPath} — run DSH_SNAPSHOT=refresh pnpm run test:web to generate it`)
  1552. }
  1553. expect(payload).toBe(await readFile(goldenPath, 'utf8'))
  1554. }
  1555. /**
  1556. * Fixture-inventory guard: the scenario directory holds exactly the expected
  1557. * files and every committed JSONL is a header-scrubbed, typed-redaction fixed point.
  1558. * @param dir - the scenario snapshot directory.
  1559. * @param expected - the exact expected file inventory.
  1560. */
  1561. export async function assertFixtureInventory(dir: string, expected: string[]): Promise<void> {
  1562. const entries = (await readdir(dir)).sort()
  1563. const ownsManifest = entries.includes('snapshot.yml')
  1564. const artifacts = entries.filter(name => name !== 'snapshot.yml')
  1565. const roleInventory = (names: readonly string[]): string[] => [...new Set(names.map((name) => {
  1566. const fixture = parseSessionFixtureName(name)
  1567. return fixture === undefined ? name : sessionFixtureName(fixture.index, 0)
  1568. }))].sort()
  1569. expect(roleInventory(artifacts)).toEqual(roleInventory(expected))
  1570. if (ownsManifest) {
  1571. const manifestPath = join(dir, 'snapshot.yml')
  1572. const manifest = parseSnapshotManifest(await readFile(manifestPath, 'utf8'), manifestPath)
  1573. expect(manifest.profile).toBe('web')
  1574. if (manifest.session === undefined) {
  1575. expect(
  1576. artifacts.some(name => parseSessionFixtureName(name)?.index === 0),
  1577. `${dir}: session owner must carry a canonical parent Session fixture`,
  1578. ).toBe(true)
  1579. } else {
  1580. expect(existsSync(resolve(dir, manifest.session.source)), `${dir}: session source`).toBe(true)
  1581. }
  1582. }
  1583. for (const entry of artifacts.filter(name => name.endsWith('.jsonl'))) {
  1584. const content = await readFile(join(dir, entry), 'utf8')
  1585. expect(scrubModelRequestBulk(content), `${dir}/${entry} carries prompt text or tool-schema bulk`).toBe(content)
  1586. expect(redactSessionSnapshotIds([content]), `${dir}/${entry} carries unredacted identities`).toEqual([content])
  1587. }
  1588. }
  1589. /**
  1590. * Console tripwires: reconnect/gap-repair self-healing or a pageerror must
  1591. * fail the scenario, not mask a dead wire behind eventual consistency.
  1592. * @param page - the page under test.
  1593. * @returns live warning/pageerror collectors to assert empty at scenario end.
  1594. */
  1595. export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } {
  1596. const warnings: string[] = []
  1597. const pageErrors: string[] = []
  1598. page.on('console', (message) => {
  1599. const text = message.text()
  1600. if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text)
  1601. })
  1602. page.on('pageerror', (error) => { pageErrors.push(String(error)) })
  1603. return { warnings, pageErrors }
  1604. }
  1605. /**
  1606. * Remove only connection-loss warnings emitted after an intentional reload.
  1607. * Earlier warnings and all gap-repair/discontinuity warnings remain fatal.
  1608. * @param tripwire - the live console-warning collector.
  1609. * @param warningStart - warning count captured immediately before reloading.
  1610. */
  1611. export function acknowledgeReloadConnectionLoss(
  1612. tripwire: ReturnType<typeof watchConsole>,
  1613. warningStart: number,
  1614. ): void {
  1615. const reloadWarnings = tripwire.warnings.splice(warningStart)
  1616. tripwire.warnings.push(...reloadWarnings.filter(text => !/connection lost/i.test(text)))
  1617. }