test(acp-snapshot): fake ACP bin + unit specs to per-file 100% coverage

A scripted fake ACP agent bin (tests/fixtures/fake-acp-agent.ts) speaks
real newline JSON-RPC through the REAL runScenario spawn path (tsx
loader, temp cwd, env plumbing); every behavior — prompt outcome,
session/new rejection, persisted logs, filesystem noise — comes from a
behavior.json beside the fixture, so specs script whole subprocess runs
from data. harness.spec.ts drives every step op, both expect-error arms,
the permission-stub default, env forwarding, workspace seeding, and the
harvest ordering/noise/fallback branches. suite.spec.ts runs the factory
for real at collection time: a replay suite over committed synthetic
fixtures and a record suite over a temp copy (write-back never touches
the committed tree; ACP_SNAPSHOT_SPEC_BOOTSTRAP=1 re-bootstraps it),
plus direct cases for the exported pure helpers. The suite factory's
pure helpers (childFixturePaths, fixtureContext, normalizedHeaders,
headerDeltaCount) are exported for those direct specs.

Two branches carry justified v8 ignores, both structurally unreachable:
the waiter in-bounds guard (noUncheckedIndexedAccess) and waitForExit's
already-exited race guard (both call sites sit one synchronous frame
after stdin.end()/kill()). The fake bin substitutes the session/new cwd,
not process.cwd(), into scripted logs — the realpath difference
(/private on darwin) is exactly what the real bin's header carries.

packages/support/acp-snapshot/src is at 100% statements, branches,
functions, and lines under the per-file gate.
This commit is contained in:
kingwl
2026-07-08 02:05:06 +08:00
parent 556f847064
commit 610c8e3709
44 changed files with 819 additions and 8 deletions

View File

@@ -224,7 +224,12 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
sessionUpdate(params: SessionNotification): Promise<void> {
for (let i = updateWaiters.length - 1; i >= 0; i--) {
const waiter = updateWaiters[i]
if (waiter !== undefined && waiter.match(params.update)) {
// The index is always in-bounds (i only decreases; splice removes at
// i, so lower entries stay valid); the guard satisfies
// noUncheckedIndexedAccess.
/* v8 ignore next 1 -- unreachable in-bounds guard, see above */
if (waiter === undefined) continue
if (waiter.match(params.update)) {
updateWaiters.splice(i, 1)
waiter.resolve()
}
@@ -351,6 +356,10 @@ async function runStep(
/** Resolve once the child process exits (any code/signal). */
function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
// Race guard: both call sites run within one synchronous frame of
// stdin.end()/kill(), so the exit event cannot have been delivered yet;
// kept for any future caller that awaits in between.
/* v8 ignore next 1 -- unreachable race guard, see above */
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
}

View File

@@ -101,8 +101,14 @@ export interface SnapshotSuiteOptions {
mode: 'replay' | 'record'
}
/** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */
function childFixturePaths(dir: string, childSessions: number): string[] {
/**
* The sibling child-fixture paths for a scenario (`session.1.jsonl` …).
*
* @param dir The scenario's snapshots directory (`<snapshotsDir>/<name>`).
* @param childSessions How many subagent child sessions the scenario records.
* @returns One path per child, 1-based, in fixture order.
*/
export function childFixturePaths(dir: string, childSessions: number): string[] {
return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`))
}
@@ -118,8 +124,11 @@ function childFixturePaths(dir: string, childSessions: number): string[] {
* is an idempotent no-op. A header with no `cwd` falls back to a sentinel that
* cannot occur in a log (NOT `''`, which `String.split` would match on every
* character boundary and corrupt the output).
*
* @param fixture The committed `session.jsonl` content.
* @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}.
*/
function fixtureContext(fixture: string): NormalizeContext {
export function fixtureContext(fixture: string): NormalizeContext {
const firstLine = fixture.split('\n').find(line => line.trim().length > 0) ?? '{}'
const header = JSON.parse(firstLine) as { id?: unknown; cwd?: unknown }
return {
@@ -134,8 +143,12 @@ function fixtureContext(fixture: string): NormalizeContext {
* ({@link normalizeSessionLog}) so headers harvested from different runs —
* each embedding its own temp cwd in the composed prompt — compare on equal
* footing.
*
* @param rawLog The session `.jsonl` content to extract headers from.
* @param ctx The volatile values of the run that produced it.
* @returns The normalized `data.header` payloads, in log order.
*/
function normalizedHeaders(rawLog: string, ctx: NormalizeContext): unknown[] {
export function normalizedHeaders(rawLog: string, ctx: NormalizeContext): unknown[] {
return normalizeSessionLog(rawLog, ctx)
.split('\n')
.filter(line => line.trim().length > 0)
@@ -144,8 +157,13 @@ function normalizedHeaders(rawLog: string, ctx: NormalizeContext): unknown[] {
.map(record => record.data?.header)
}
/** Count the `request/header-delta` events in a session JSONL. */
function headerDeltaCount(rawLog: string): number {
/**
* Count the `request/header-delta` events in a session JSONL.
*
* @param rawLog The session `.jsonl` content.
* @returns How many `request/header-delta` events the log carries.
*/
export function headerDeltaCount(rawLog: string): number {
return rawLog.split('\n')
.filter(line => line.trim().length > 0)
.filter(line => (JSON.parse(line) as { type?: unknown }).type === 'request/header-delta')