Merge remote-tracking branch 'origin/master' into web-e2e-interactions

# Conflicts:
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
#	.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
#	apps/web/tests/scaffold.ts
This commit is contained in:
Tianyi Cui
2026-07-26 04:10:37 +08:00
270 changed files with 10688 additions and 2222 deletions

View File

@@ -80,9 +80,16 @@ describe('web e2e: fresh round trip through the real assembly', () => {
// legal — the chunk-event assertions below carry incrementality.
})
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
// World state, not self-report: bash really ran and the turn closed clean.
const toolCalls = sessionEvents.filter(e => e.type === 'tool/call')
expect(toolCalls.map(e => (e as SessionEvent & { data: { name: string } }).data.name)).toContain('bash')
// World state, not self-report: the real bash executor returned the exact
// command output, and the turn closed cleanly.
const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash')
if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool')
const bashResult = sessionEvents.find(event =>
event.type === 'tool/result' && event.data.callId === bashCall.data.callId)
if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result')
expect(bashResult.data.isError).toBe(false)
expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe('WEB_E2E_OK\n')
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
expect(turnEnds.length).toBe(1)
expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed')

View File

@@ -111,6 +111,15 @@ export interface LaunchOptions {
paceMs?: number
}
/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise<unknown[]> {
const failures: unknown[] = []
await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error))
await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
return failures
}
/**
* Boot the real web composition under the current snapshot mode.
* @param options - replay fixture selection and pacing.
@@ -126,7 +135,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}
}
const workspaceCwd = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-'))
const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
let persistenceRoot: string
try {
persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-'))
} catch (error) {
const failures: unknown[] = [error]
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
throw error
}
// The include patch set — the same mechanism AppCLIEntry and the ACP
// snapshot overlay use, applied over the SAME shipped tree (a patch id that
@@ -149,9 +166,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// Sessions inherit the gateway's process.cwd() default; run the boot from
// the temp workspace so tool cwd, session cwd, and fixtures agree.
const originalCwd = process.cwd()
process.chdir(workspaceCwd)
const ctx = new Context()
let port = 0
let replayHandle: ReplayHandle | undefined
try {
process.chdir(workspaceCwd)
ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
@@ -161,35 +180,35 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
})
await ctx.loader.await()
assertEntriesLoaded(ctx, 'web e2e scaffold')
const boundPort = ctx.get('httpServer')?.port
if (boundPort === undefined) {
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
}
port = boundPort
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
// in keyless modes; a scenario with no fixture leaves the seam empty so a
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
// plugin row, returns the ReplayHandle for the teardown consumption check.
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
} catch (error) {
process.chdir(originalCwd)
await ctx.fiber.dispose()
await rm(workspaceCwd, { recursive: true, force: true }).catch(() => undefined)
await rm(persistenceRoot, { recursive: true, force: true }).catch(() => undefined)
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)
if (cleanupFailures.length > 0) {
throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete')
}
throw error
} finally {
if (process.cwd() !== originalCwd) process.chdir(originalCwd)
}
const port = ctx.get('httpServer')?.port
if (port === undefined) {
await ctx.fiber.dispose()
throw new Error('web e2e scaffold: httpServer service missing after settled boot')
}
// Fill the open llm seam on the settled root ctx (llm-deepseek is disabled
// in keyless modes; a scenario with no fixture leaves the seam empty so a
// stray stream fails loud with NO_ADAPTER). The direct install, unlike the
// plugin row, returns the ReplayHandle for the teardown consumption check.
let replayHandle: ReplayHandle | undefined
if (mode !== 'record' && options.replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: options.replayFixture,
providers: REPLAY_PROVIDERS,
...(options.replayOverride === undefined ? {} : { overrideFile: options.replayOverride }),
...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
return {
mode,
baseUrl: `http://127.0.0.1:${port}`,
@@ -229,9 +248,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
} catch (error) {
failures.push(error)
}
await Promise.resolve(ctx.fiber.dispose()).catch((e: unknown) => failures.push(e))
await rm(workspaceCwd, { recursive: true, force: true }).catch((e: unknown) => failures.push(e))
await rm(persistenceRoot, { recursive: true, force: true }).catch((e: unknown) => failures.push(e))
failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))
if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed')
},
}
@@ -254,9 +271,9 @@ function rawSessionLog(session: Session): string {
* Record-mode fixture write-back: harvest the live session, scrub request
* headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no
* header class — a deliberate deviation logged in the Agent Note's deferred
* work), tokenize the run-local session id and cwd ({{sessionId}}/{{cwd}},
* the committed ACP fixture convention — re-records then diff only on real
* content), and write the committed fixture.
* work), tokenize the run-local session id, cwd, and browser RPC id
* ({{sessionId}}/{{cwd}}/{{rpcId}}, the committed fixture convention —
* re-records then diff only on real content), and write the fixture.
* @param scaffold - the record-mode scaffold.
* @param sessionId - the driven session.
* @param fixturePath - the committed session.jsonl / seed.jsonl target.
@@ -267,6 +284,7 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId,
const tokenized = scrubRequestHeaders(rawSessionLog(agent.session))
.split(sessionId).join('{{sessionId}}')
.split(scaffold.workspaceCwd).join('{{cwd}}')
.replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"')
await writeFile(fixturePath, tokenized)
}
@@ -396,7 +414,7 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string,
/**
* Fixture-inventory guard (the TUI afterAll shape): the scenario directory
* holds exactly the expected files and every committed JSONL is a scrub
* fixed-point (no request-header bulk escaped the record write-back).
* fixed-point without a run-local browser RPC id.
* @param dir - the scenario snapshot directory.
* @param expected - the exact expected file inventory.
*/
@@ -406,6 +424,8 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P
for (const entry of entries.filter(name => name.endsWith('.jsonl'))) {
const content = await readFile(join(dir, entry), 'utf8')
expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content)
expect(content, `${dir}/${entry} carries a run-local rpcId`)
.not.toMatch(/"rpcId":"(?!\{\{rpcId\}\})[^"]+"/)
}
}

View File

@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"c4e068dc-c277-46ae-9713-6a2694027fb5"}}}}
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"c4e068dc-c277-46ae-9713-6a2694027fb5"}},"surfaceOp":"append"}
{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}

View File

@@ -1,6 +1,6 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"}
{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"f95c6f1c-f1b4-42bf-ba40-c05ae0647a70"}}}}
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"f95c6f1c-f1b4-42bf-ba40-c05ae0647a70"}},"surfaceOp":"append"}
{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}