test(web): harden replay scaffold lifecycle

This commit is contained in:
Tianyi Cui
2026-07-26 00:33:46 +08:00
parent 882bd4a591
commit f1577d620e
12 changed files with 97 additions and 70 deletions

View File

@@ -105,6 +105,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.
@@ -120,7 +129,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
@@ -143,9 +160,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
@@ -155,34 +174,34 @@ 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.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.paceMs === undefined ? {} : { paceMs: options.paceMs }),
})
}
return {
mode,
baseUrl: `http://127.0.0.1:${port}`,
@@ -222,9 +241,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')
},
}
@@ -247,9 +264,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.
@@ -260,6 +277,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)
}
@@ -389,7 +407,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.
*/
@@ -399,6 +417,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\}\})[^"]+"/)
}
}