test(snapshot): isolate persistent tool artifacts

This commit is contained in:
Tianyi Cui
2026-07-29 23:53:59 +08:00
parent 84394e596d
commit d8b70df00c
3 changed files with 57 additions and 43 deletions

View File

@@ -11,7 +11,7 @@
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, isAbsolute, join } from 'node:path'
import { basename, delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
@@ -55,7 +55,7 @@ interface SdkScenario {
children: number
/** Optional scenario-specific live and replay compositions. */
configs?: { live: string; replay: string }
/** Files whose final contents are part of the scenario contract. */
/** Cwd-relative files whose final contents are part of the scenario contract. */
expectedFiles?: Readonly<Record<string, string>>
}
@@ -80,13 +80,11 @@ const SCENARIOS: SdkScenario[] = [
},
{
name: 'persistent-tools',
prompt: 'Prove that bash state persists, then create and edit note.txt.',
prompt: 'Prove that bash state persists, then create and edit the exact file {{cwd}}/note.txt.',
sessionId: 'persistent-tools-snapshot',
children: 0,
configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig },
// Replay returns recorded tool arguments verbatim, so this cross-platform
// POSIX fixture uses one stable absolute path and cleans it around the run.
expectedFiles: { '/tmp/dsh-persistent-tools-snapshot-note.txt': 'beta\n' },
expectedFiles: { 'note.txt': 'beta\n' },
},
]
@@ -96,6 +94,10 @@ interface PersistedLog {
readonly header: Record<string, unknown>
}
interface MissingFile {
readonly missing: true
}
async function jsonlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { recursive: true })
return entries.filter(entry => entry.endsWith('.jsonl')).map(entry => join(dir, entry)).sort()
@@ -125,6 +127,25 @@ function contextOfContents(contents: readonly string[]): NormalizeContext {
}
}
async function hydrateReplayFixtures(scenario: SdkScenario, cwd: string): Promise<string[]> {
const root = join(cwd, '.replay-fixtures')
await mkdir(root, { recursive: true })
return Promise.all(fixtureFiles(scenario).map(async (source) => {
const destination = join(root, basename(source))
await writeFile(destination, (await readFile(source, 'utf8')).replaceAll('{{cwd}}', cwd))
return destination
}))
}
async function readExpectedFile(path: string): Promise<string | MissingFile> {
try {
return await readFile(path, 'utf8')
} catch (error: unknown) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return { missing: true }
throw error
}
}
/**
* Normalize the SDK-visible notification stream: embedded `session.event`
* envelopes get the session-log treatment (times zeroed, headers tokenized),
@@ -163,24 +184,18 @@ async function runScenario(scenario: SdkScenario): Promise<{
result: TurnResult
notifications: HarnessNotification[]
logs: PersistedLog[]
observedFiles: Record<string, string>
observedFiles: Record<string, string | MissingFile>
cwd: string
}> {
const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`))
const sessionsRoot = join(cwd, '.sessions')
const scenarioDir = join(snapshotsDir, scenario.name)
const expectedFilePaths = Object.keys(scenario.expectedFiles ?? {}).map(path =>
isAbsolute(path) ? path : join(cwd, path))
await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true })))
const replayFixtures = recording ? [] : await hydrateReplayFixtures(scenario, cwd)
const launch = resolveExampleLaunch({
srcBin: runtimeBin,
configArgs: [],
tsconfigPath: repoTsconfig,
})
const childFixtures = Array.from(
{ length: scenario.children },
(_, index) => join(scenarioDir, `session.${index + 1}.jsonl`),
)
const [parentFixture, ...childFixtures] = replayFixtures
const env: Record<string, string> = {
...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record<string, string>,
@@ -191,8 +206,8 @@ async function runScenario(scenario: SdkScenario): Promise<{
DSH_CWD: cwd,
DSH_SNAPSHOT: mode,
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
...recording ? {} : {
DSH_SNAPSHOT_FILE: join(scenarioDir, 'session.jsonl'),
...parentFixture === undefined ? {} : {
DSH_SNAPSHOT_FILE: parentFixture,
...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {},
},
}
@@ -211,22 +226,21 @@ async function runScenario(scenario: SdkScenario): Promise<{
})
try {
const notifications: HarnessNotification[] = []
const result = await harness.run(scenario.prompt, {
const result = await harness.run(scenario.prompt.replaceAll('{{cwd}}', cwd), {
sessionId: scenario.sessionId,
onNotification: (notification) => { notifications.push(notification) },
})
await harness.close()
const logs = await persistedLogs(sessionsRoot)
const observedFiles = Object.fromEntries(await Promise.all(
Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string]> => [
Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string | MissingFile]> => [
path,
await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8'),
await readExpectedFile(join(cwd, path)),
]),
))
return { result, notifications, logs, observedFiles, cwd }
} finally {
await harness.close()
await Promise.all(expectedFilePaths.map(async path => rm(path, { force: true })))
await rm(cwd, { recursive: true, force: true })
}
}