test(snapshot): cover session-scoped sandbox roots

This commit is contained in:
Tianyi Cui
2026-07-21 01:16:29 +08:00
parent dc874a90bd
commit 7677d49c93
16 changed files with 186 additions and 19 deletions
+15 -7
View File
@@ -106,7 +106,7 @@ export interface RunResult {
stderr: string
/** The session id the server issued (undefined if no session was created). */
sessionId?: string
/** The temp cwd the session ran in (the bash workspace). */
/** The generated cwd the session ran in (the bash workspace). */
cwd: string
/**
* Every persisted session log harvested after the run, ordered primary-first:
@@ -137,11 +137,19 @@ export interface RunOptions {
childFiles?: string[]
/**
* Optional `<scenario>/workspace/` directory whose contents are copied into
* the temp cwd BEFORE the run — the standard way to seed files the agent
* the generated cwd BEFORE the run — the standard way to seed files the agent
* operates on (a file to read, edit, or grep). Absent for scenarios that
* start from an empty workspace.
*/
workspaceDir?: string
/**
* Parent directory for the generated session cwd. Defaults to
* `os.tmpdir()`. A scenario that must distinguish its workspace from the
* sandbox's always-writable temporary roots can place the generated child
* under `os.homedir()` instead. The harness removes only that generated
* child, never the supplied parent.
*/
workspaceParent?: string
/**
* Alternate LIVE config path for the boot (absolute), overriding
* {@link AgentUnderTest.configPath} for this run. A scenario needing a
@@ -162,15 +170,15 @@ function scenarioSpillRoot(fixtureFile: string): string {
/**
* Run a scenario end-to-end against a freshly-spawned subprocess. Owns the
* child and its temp dirs; always tears them down. Returns the captured stdout
* child and its generated dirs; always tears them down. Returns the captured stdout
* and (record mode) the harvested session-log path.
*
* @param input The scenario's input script (steps + optional permission answers).
* @param opts The agent to boot, the mode, and the fixture wiring.
* @returns The captured stdout/stderr, session id, temp cwd, and harvested logs.
* @returns The captured stdout/stderr, session id, generated cwd, and harvested logs.
*/
export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> {
const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-'))
const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-'))
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Fixed path length: spill-policy budgets the preview against the REAL path
// before stdout normalization, so tmpdir() length differences churn expected outputs.
@@ -184,7 +192,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
let sessionLogs: HarvestedLog[] = []
const outcome = await (async (): Promise<RunResult> => {
// Seed the workspace if the scenario ships one (a file the agent reads/edits).
// Copied into the temp cwd so the agent's bash tools see it; the expected outputs
// Copied into the generated cwd so the agent's bash tools see it; the expected outputs
// normalize the cwd, so the seeded paths stay stable across runs.
if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) {
await cp(opts.workspaceDir, cwd, { recursive: true })
@@ -251,7 +259,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
// persistence) and exits. Then await exit so the harvested log is complete.
await active.close()
// Harvest EVERY persisted log (parent + any subagent children) while the
// temp dirs still exist, ordered primary-first.
// generated dirs still exist, ordered primary-first.
sessionLogs = await harvestSessionLogs(sessionsRoot)
return {
rawStdout: launched.rawStdout(),
@@ -1,5 +1,5 @@
/**
* Pure ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids,
* Pure ACP transcript and session-log normalizers. They scrub session ids, run cwd, RPC ids,
* timestamps, and hook duration while preserving deterministic event sequence numbers.
* Request-header scrubbers stay composable so one scenario per header class can pin prompt and
* tool-schema sidecars while retaining any model-visible prefix in the session log.
@@ -29,7 +29,7 @@ const SNAPSHOT_SPILL_PATH_RE = new RegExp(
export interface NormalizeContext {
/** The session id(s) the run issued — replaced with `{{sessionId}}`. */
sessionIds: string[]
/** The temp cwd the run used — replaced with `{{cwd}}`. */
/** The generated cwd the run used — replaced with `{{cwd}}`. */
cwd: string
}
+8 -1
View File
@@ -100,6 +100,12 @@ export interface Scenario {
* {@link headerClass}.
*/
configPath?: string
/**
* Parent directory for the generated session cwd. Defaults to the platform
* temp directory; set this when temp is itself part of the behavior under
* test and the scenario needs an independent project location.
*/
workspaceParent?: string
}
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
@@ -178,7 +184,7 @@ export function fixtureContext(fixture: string): NormalizeContext {
* The `data.header` payload of every `request/header` event in a session
* JSONL, in log order, with the log's volatile values scrubbed first
* ({@link normalizeSessionLog}) so headers harvested from different runs —
* each embedding its own temp cwd in the composed prompt — compare on equal
* each embedding its own generated cwd in the composed prompt — compare on equal
* footing.
*
* @param rawLog The session `.jsonl` content to extract headers from.
@@ -488,6 +494,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// replays from its own script. In RECORD they are harvested, not read.
...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {},
...existsSync(workspaceDir) ? { workspaceDir } : {},
...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {},
// A scenario booting an overlay tree passes its own live config; the
// bin's replay swap derives the sibling `*cordis.snapshot.yml` from it.
...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {},