import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { access, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { expect, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { itWithCaptureCapability } from "./processCaptureCapability.js"; import { captureProcessScenario, type FinalizationHost, } from "../../../src/process/capture/ProcessHarness.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js"; import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js"; import { expectUnverifiedHostCleanup } from "../../support/hostCleanup.js"; import { type ProcessCaptureCleanupHost, observeLaunchedExecutable, observeSelectedExecutable, } from "../../../src/process/capture/ProcessCaptureLifecycle.js"; import { parseProcessScenario } from "../../../src/domain/process/processScenario.js"; import { emptyProcessCapture } from "../../../src/domain/process/processCapture.fixture.js"; import type { ProcessCapture } from "../../../src/domain/process/processCaptureParsing.js"; const processFixture = fileURLToPath( new URL("../../fixtures/processFidelity.mjs", import.meta.url), ); const snapshotCancellationFixture = fileURLToPath( new URL("../../fixtures/processSnapshotCancellation.mjs", import.meta.url), ); const execFileAsync = promisify(execFile); type CaptureRun = Awaited>; type PartialCapture = Extract< NonNullable, { readonly capture: unknown } >["capture"]; const emptyFilesystemCoverage = emptyProcessCapture().truncation_details.filesystem_before; const captureObservations = ( result: CaptureRun, ): { readonly capture: ProcessCapture | PartialCapture; readonly cleanupIncomplete: boolean; } => { if (result.ok) return { capture: result.value, cleanupIncomplete: false }; if (!(result.error instanceof ProcessCaptureError)) throw result.error; expectUnverifiedHostCleanup(result.error); const partial = result.error.partialObservation; if (partial === undefined || !("capture" in partial)) throw new Error( "cleanup-incomplete capture omitted completed observations", ); const capture = partial.capture; expect(capture.settlement.cleanup_outcome).toBe("failed"); return { capture, cleanupIncomplete: true }; }; const processState = async (pid: number): Promise => { try { const { stdout } = await execFileAsync("ps", [ "-o", "stat=", "-p", String(pid), ]); return stdout.trim() || undefined; } catch (cause: unknown) { if ( cause instanceof Error && "code" in cause && cause.code === 1 && "stdout" in cause && cause.stdout === "" ) return undefined; throw cause; } }; const waitForProcessExit = async (pid: number): Promise => { const deadline = Date.now() + 2_000; while (Date.now() < deadline) { const state = await processState(pid); if (state === undefined || state.startsWith("Z")) return; await new Promise((resolve) => setTimeout(resolve, 20)); } throw new Error(`Process ${String(pid)} did not exit within the test bound`); }; const readDetachedFixturePids = async (marker: string): Promise => { let contents: string; try { contents = await readFile(marker, "utf8"); } catch (cause: unknown) { if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return []; throw cause; } const parsed: unknown = JSON.parse(contents); if (typeof parsed !== "object" || parsed === null) return []; const candidatePids = [ "ownedPid" in parsed ? parsed.ownedPid : undefined, "unownedPid" in parsed ? parsed.unownedPid : undefined, ]; return candidatePids.flatMap((pid) => typeof pid === "number" && Number.isSafeInteger(pid) && pid > 1 ? [pid] : [], ); }; const terminateFixtureProcess = async (pid: number): Promise => { try { process.kill(pid, "SIGKILL"); } catch (cause: unknown) { if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) throw cause; } await waitForProcessExit(pid); }; itWithCaptureCapability( "keeps the process deadline bounded when the wall clock moves backward", async () => { const wallStart = Date.now(); let wallReads = 0; const now = vi.spyOn(Date, "now").mockImplementation(() => { wallReads += 1; return wallReads === 1 ? wallStart : wallStart - 60_000 + wallReads; }); const controller = new AbortController(); const fallback = setTimeout(() => controller.abort(), 1_500); try { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "hang"], working_directory: dirname(processFixture), timeout_ms: 100, idle_timeout_ms: 5_000, }), controller.signal, ); expect(result.ok).toBe(true); if (!result.ok) throw result.error; expect(result.value.exit.reason).toBe("timeout"); } finally { clearTimeout(fallback); now.mockRestore(); } }, 10_000, ); itWithCaptureCapability( "preserves reversed wall-clock observations and monotonic frames across capture", async () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date("2026-01-02T00:00:00.000Z")); try { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "partial"], working_directory: dirname(processFixture), settle_ms: 0, timeout_ms: 5_000, idle_timeout_ms: 5_000, }), undefined, process.platform, process.env, undefined, undefined, { async report(update) { if (update.phase === "running") vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); }, }, ); expect(result.ok).toBe(true); if (!result.ok) throw result.error; expect(result.value.frames.length).toBeGreaterThan(0); expect(Date.parse(result.value.manifest.completed_at)).toBeLessThan( Date.parse(result.value.manifest.started_at), ); expect(result.value.limitations).toContain( "Wall-clock completion time precedes start time; elapsed capture timing is recorded separately and remains monotonic.", ); expect( result.value.frames.map(({ at_ms }) => at_ms), "capture-relative frame times stay ordered and nonnegative", ).toEqual( result.value.frames .map(({ at_ms }) => at_ms) .sort((left, right) => left - right), ); expect(result.value.frames.every(({ at_ms }) => at_ms >= 0)).toBe(true); expect(result.value.exit.reason).toBe("exited"); expect(result.value.settlement.elapsed_ms).toBeGreaterThan(0); } finally { vi.useRealTimers(); } }, 10_000, ); itWithCaptureCapability( "cleans detached run-token children omitted by sampling and preserves an unowned neighbor", async () => { const root = await createTestTempDirectory("rea-detached-cleanup-"); const pidMarker = join(root, "children.json"); let ownedPid: number | undefined; let unownedPid: number | undefined; try { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "detached-cleanup", pidMarker], working_directory: root, settle_ms: 0, timeout_ms: 5_000, idle_timeout_ms: 5_000, limits: { processes: 1 }, }), ); const { capture, cleanupIncomplete } = captureObservations(result); const parsed: unknown = JSON.parse(await readFile(pidMarker, "utf8")); if ( typeof parsed !== "object" || parsed === null || !("ownedPid" in parsed) || typeof parsed.ownedPid !== "number" || !("unownedPid" in parsed) || typeof parsed.unownedPid !== "number" ) throw new TypeError("detached-child fixture wrote invalid PIDs"); // Register fixture children before assertions so teardown can clean them // even when a later expectation fails. ownedPid = parsed.ownedPid; unownedPid = parsed.unownedPid; expect(capture.process_samples.some(({ pid }) => pid === ownedPid)).toBe( false, ); if (cleanupIncomplete) expect(capture.settlement.cleanup_outcome).toBe("failed"); else expect(result.ok && result.value.cleanup.owned_process_group).toBe( "verified", ); await waitForProcessExit(ownedPid); const unownedState = await processState(unownedPid); expect(unownedState).toBeDefined(); expect(unownedState?.startsWith("Z")).toBe(false); } finally { try { let fixturePids: number[] = []; let teardownFailure: unknown; try { fixturePids = await readDetachedFixturePids(pidMarker); } catch (cause: unknown) { teardownFailure = cause; } for (const pid of new Set([ ...fixturePids, ...(ownedPid === undefined ? [] : [ownedPid]), ...(unownedPid === undefined ? [] : [unownedPid]), ])) { try { await terminateFixtureProcess(pid); } catch (cause: unknown) { teardownFailure ??= cause; } } if (teardownFailure !== undefined) throw teardownFailure; } finally { await rm(root, { recursive: true, force: true }); } } }, 10_000, ); itWithCaptureCapability( "retains collected observations when completion and cleanup both fail", async () => { let snapshotCalls = 0; const captureSnapshot: typeof snapshotRoots = async () => { snapshotCalls += 1; if (snapshotCalls === 1) return { files: [], truncated: false, completeRoots: [], coverage: emptyFilesystemCoverage, }; throw new Error("fixture final snapshot failure"); }; const cleanupHost: ProcessCaptureCleanupHost = { platform: process.platform, cleanupProcessGroup: async () => ({ cleaned: false, reason: "fixture cleanup could not be verified", }), verifyTokenOwnedProcesses: async () => ({ cleaned: false, reason: "fixture cleanup could not be verified", }), removeTemporaryRoot: async (path) => rm(path, { recursive: true, force: true }), }; const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "partial"], working_directory: dirname(processFixture), settle_ms: 0, timeout_ms: 5_000, idle_timeout_ms: 5_000, }), undefined, process.platform, process.env, captureSnapshot, cleanupHost, ); if (result.ok) throw new Error("expected cleanup-incomplete failure"); if (!(result.error instanceof ProcessCaptureError)) throw result.error; const partial = result.error.partialObservation; expect(result.error.reason).toBe("cleanup_incomplete"); expect(result.error.executionFailure).toBe( "fixture final snapshot failure", ); const projected = projectAnalysisError(result.error); expect(projected).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete", resources: ["owned_process_group", "temporary_root"], }, }); expect(projected).not.toHaveProperty("stack"); expect(projected).not.toHaveProperty("cause"); expect(partial).toBeDefined(); if (partial === undefined || !("observations" in partial)) throw new Error("expected incomplete process observations"); const observations = partial.observations; expect( Object.keys(observations), "a default scenario keeps the base observation keys through the run path", ).not.toContain("finalization"); expect(observations.frames.state).toBe("available"); if (observations.frames.state !== "available") throw new Error("expected terminal output observations"); expect( observations.frames.value.map(({ data }) => data).join(""), ).toContain("partial-frame"); expect(observations.rendered_frames.state).toBe("available"); if (observations.rendered_frames.state !== "available") throw new Error("expected rendered terminal observations"); expect(observations.rendered_frames.value.length).toBeGreaterThan(0); expect(observations.interaction_events.state).toBe("available"); expect(observations.exit).toMatchObject({ state: "available", value: { reason: "exited" }, }); expect(observations.settlement.state).toBe("available"); expect(observations.process_samples.state).toBe("available"); expect(observations.target_pid).toEqual({ state: "available", value: 1 }); expect(observations.filesystem_snapshots.before.state).toBe("available"); if (observations.filesystem_snapshots.before.state !== "available") throw new Error("expected initial filesystem snapshot"); expect(observations.filesystem_snapshots.before.value.truncated).toBe( false, ); expect(observations.filesystem_snapshots.after).toEqual({ state: "unavailable", reason: "Final filesystem snapshot failed: fixture final snapshot failure", }); expect(observations.event_journal.state).toBe("available"); if (observations.event_journal.state !== "available") throw new Error("expected capture event journal"); expect( observations.event_journal.value.some( ({ collection }) => collection === "lifecycle", ), ).toBe(true); expect(observations.manifest.state).toBe("unavailable"); expect(snapshotCalls).toBe(2); }, 10_000, ); itWithCaptureCapability( "keeps selected executable evidence separate when the path identity changes before validation", async () => { const root = await createTestTempDirectory("rea-executable-replaced-"); const executable = join(root, "selected-executable"); await symlink(process.execPath, executable); try { const selected = await observeSelectedExecutable(executable); expect(selected.sha256).toMatch(/^[a-f0-9]{64}$/u); await rm(executable); await symlink("/bin/sh", executable); expect(observeLaunchedExecutable(executable, selected)).toEqual({ selectedSha256: selected.sha256, sha256: null, state: "unknown", reason: "Executable path metadata changed between sampling and spawn.", }); } finally { await rm(root, { recursive: true, force: true }); } }, ); itWithCaptureCapability( "keeps the prelaunch executable sample when the selected path is replaced during capture", async () => { const root = await createTestTempDirectory("rea-executable-identity-"); const executable = join(root, "selected-executable"); const readyMarker = join(root, "child-ready"); await symlink(process.execPath, executable); const launchedDigest = createHash("sha256") .update(await readFile(process.execPath)) .digest("hex"); try { const capturePromise = captureProcessScenario( parseProcessScenario({ executable, arguments: [processFixture, "ready-hang", readyMarker], working_directory: root, timeout_ms: 1_500, idle_timeout_ms: 5_000, }), ); const deadline = Date.now() + 2_000; let ready = false; while (!ready && Date.now() < deadline) { try { await access(readyMarker); ready = true; } catch { await new Promise((resolve) => setTimeout(resolve, 10)); } } expect(ready).toBe(true); await rm(executable); await symlink("/bin/sh", executable); const result = await capturePromise; const { capture } = captureObservations(result); expect(capture.manifest).toMatchObject({ selected_executable_sha256: launchedDigest, executable_sha256: launchedDigest, executable_identity: { state: "path_metadata_unchanged", reason: null, }, }); expect(capture.exit.reason).toBe("timeout"); expect(capture.limitations).toContain( "The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.", ); } finally { await rm(root, { recursive: true, force: true }); } }, 10_000, ); itWithCaptureCapability( "records external symlink metadata without following the target", async () => { const root = await createTestTempDirectory("rea-symlink-test-"); await symlink("/etc/passwd", join(root, "escape")); try { const result = await captureProcessScenario( parseProcessScenario({ executable: "/usr/bin/true", working_directory: root, filesystem_observation_paths: [root], }), ); const { capture } = captureObservations(result); const escaped = capture.files_after.find((file) => file.path.endsWith(":escape"), ); expect(escaped?.symlink_target).toBe("/etc/passwd"); expect(capture.truncated).toBe(false); expect(JSON.stringify(capture.files_after)).not.toContain(root); expect( capture.files_after.some((file) => file.path.includes("passwd")), ).toBe(false); } finally { await rm(root, { recursive: true, force: true }); } }, ); itWithCaptureCapability( "distinguishes timeout from cancellation and cleans both runs", async () => { const timedOut = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "hang"], working_directory: dirname(processFixture), timeout_ms: 50, idle_timeout_ms: 5_000, }), ); const timedOutCapture = captureObservations(timedOut); expect(timedOutCapture.capture.exit.reason).toBe("timeout"); if (!timedOutCapture.cleanupIncomplete && timedOut.ok) { expect(timedOut.value.cleanup).toMatchObject({ owned_process_group: "verified", temporary_root: "removed", }); for (const { pid } of timedOut.value.cleanup.unverified_processes ?? []) { expect(timedOut.value.residual_unknowns).toContainEqual({ scope: "process", reason: expect.stringContaining( `${String(pid)} could not be verified`, ), }); } } const controller = new AbortController(); setTimeout(() => controller.abort(), 50); const cancelled = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "hang"], working_directory: dirname(processFixture), timeout_ms: 5_000, idle_timeout_ms: 5_000, }), controller.signal, ); if (cancelled.ok) throw new Error("expected cancellation"); expect(cancelled.error.message).toContain("cancelled"); }, ); const finalizationFixture = fileURLToPath( new URL("../../fixtures/processFinalization.mjs", import.meta.url), ); const captureFinalizationFixture = async ( mode: "cooperative" | "ignoring" | "exits", scenario: Readonly>, signal?: AbortSignal, ): Promise<{ readonly result: CaptureRun; readonly root: string }> => { const root = await createTestTempDirectory("rea-finalization-"); const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [finalizationFixture, mode, root], working_directory: root, filesystem_observation_paths: [root], idle_timeout_ms: 10_000, ...scenario, }), signal, ); return { result, root }; }; const observedFile = ( capture: ProcessCapture | PartialCapture, name: string, ): { readonly sha256: string | null } | undefined => capture.files_after.find((file) => file.path.endsWith(name)); itWithCaptureCapability( "lets a cooperative target write its final report within the finalization interval", async () => { // The deadline leaves Node enough time to register its SIGTERM handler. const { result } = await captureFinalizationFixture("cooperative", { timeout_ms: 1_500, finalization_ms: 1_500, }); const { capture } = captureObservations(result); expect(capture.exit.reason, "initiating deadline stays the reason").toBe( "timeout", ); expect( capture.exit.code, "a deadline reason never declares a normal exit code", ).toBeNull(); expect(capture.exit.finalization, "finalization is recorded").toMatchObject( { requested_ms: 1_500, exit_code: 0, signals: [{ signal: "SIGTERM", delivery: "signaled" }], }, ); expect( capture.exit.finalization?.elapsed_ms, "a cooperative exit ends before the interval does", ).toBeLessThan(1_500); expect( observedFile(capture, "final.json")?.sha256, "final report is retained with a digest", ).toMatch(/^[0-9a-f]{64}$/u); expect( capture.frames.map(({ data }) => data).join(""), "output written during finalization is captured", ).toContain("finalized"); }, 15_000, ); itWithCaptureCapability( "records no finalization when the target exits before any deadline", async () => { const { result } = await captureFinalizationFixture("exits", { finalization_ms: 1_500, }); const { capture } = captureObservations(result); expect(capture.exit.reason, "the target exited on its own").toBe("exited"); expect(capture.exit.code, "its exit code is kept").toBe(0); expect( capture.exit, "no deadline fired, so nothing was finalized", ).not.toHaveProperty("finalization"); }, 15_000, ); itWithCaptureCapability( "starts finalization when the idle deadline fires", async () => { const { result } = await captureFinalizationFixture("cooperative", { timeout_ms: 10_000, idle_timeout_ms: 1_500, finalization_ms: 1_200, }); const { capture } = captureObservations(result); expect(capture.exit.reason, "idle deadline is the initiating reason").toBe( "idle_timeout", ); expect( capture.exit.finalization, "idle finalization is recorded", ).toMatchObject({ requested_ms: 1_200, signals: [{ signal: "SIGTERM", delivery: "signaled" }], }); expect( observedFile(capture, "final.json")?.sha256, "final report is retained with a digest", ).toMatch(/^[0-9a-f]{64}$/u); }, 15_000, ); itWithCaptureCapability( "forces the kill when the target ignores the finalization signal", async () => { const { result } = await captureFinalizationFixture("ignoring", { timeout_ms: 500, finalization_ms: 1_500, }); const { capture } = captureObservations(result); expect(capture.exit.reason, "initiating deadline stays the reason").toBe( "timeout", ); expect( capture.exit.finalization, "finalization attempts are recorded", ).toMatchObject({ requested_ms: 1_500, signals: [ { signal: "SIGTERM", delivery: "signaled" }, { signal: "SIGKILL", delivery: "signaled" }, ], }); expect( capture.exit.signal, "the target that ignores SIGTERM and SIGINT ends on SIGKILL", ).toBe(9); expect( capture.exit.finalization?.elapsed_ms, "the whole interval elapsed before the kill", ).toBeGreaterThanOrEqual(1_500); expect( capture.exit.finalization?.elapsed_ms, "the kill followed the interval promptly", ).toBeLessThan(4_000); expect( observedFile(capture, "final.json"), "an ignored signal writes no final report", ).toBeUndefined(); }, 15_000, ); itWithCaptureCapability( "keeps the immediate kill when no finalization interval is configured", async () => { const { result } = await captureFinalizationFixture("cooperative", { timeout_ms: 500, }); const { capture } = captureObservations(result); expect(capture.exit.reason, "deadline reason is unchanged").toBe("timeout"); expect( capture.exit.signal, "the default deadline still ends on SIGKILL", ).toBe(9); expect( capture.exit, "default captures carry no finalization record", ).not.toHaveProperty("finalization"); expect( observedFile(capture, "final.json"), "SIGKILL leaves no final report", ).toBeUndefined(); }, 15_000, ); itWithCaptureCapability( "cancels before any deadline without starting finalization", async () => { const controller = new AbortController(); setTimeout(() => controller.abort(), 1_500); const started = Date.now(); const { result } = await captureFinalizationFixture( "ignoring", { timeout_ms: 20_000, finalization_ms: 20_000 }, controller.signal, ); if (result.ok) throw new Error("expected cancellation"); if (!(result.error instanceof ProcessCaptureError)) throw result.error; const partial = result.error.partialObservation; if (partial === undefined || !("observations" in partial)) throw new Error("expected cancelled process observations"); expect( partial.observations.exit, "cancellation before any deadline is not a finalization", ).toMatchObject({ state: "available", value: { reason: "cancelled" } }); expect( partial.observations.exit, "no SIGTERM interval ran, so no finalization record exists", ).not.toHaveProperty("value.finalization"); expect( Date.now() - started, "cancellation does not wait for either deadline", ).toBeLessThan(10_000); }, 15_000, ); itWithCaptureCapability( "cancels immediately while a finalization interval is running", async () => { const controller = new AbortController(); // Abort well after the 300 ms deadline so finalization is already running. setTimeout(() => controller.abort(), 2_000); const started = Date.now(); const { result } = await captureFinalizationFixture( "ignoring", { timeout_ms: 300, finalization_ms: 20_000 }, controller.signal, ); if (result.ok) throw new Error("expected cancellation"); if (!(result.error instanceof ProcessCaptureError)) throw result.error; const partial = result.error.partialObservation; if (partial === undefined || !("observations" in partial)) throw new Error("expected cancelled process observations"); expect( partial.observations.exit, "cancellation is the exit reason and signal attempts are recorded", ).toMatchObject({ state: "available", value: { reason: "cancelled", finalization: { requested_ms: 20_000, signals: [ { signal: "SIGTERM", delivery: "signaled" }, { signal: "SIGKILL", delivery: "signaled" }, ], }, }, }); expect( partial.observations.finalization, "the attempts are published beside the exit, also for a failed run", ).toMatchObject({ state: "available", value: { requested_ms: 20_000, signals: [ { signal: "SIGTERM", delivery: "signaled" }, { signal: "SIGKILL" }, ], }, }); if (result.error.cleanupIncomplete) { expectUnverifiedHostCleanup(result.error); expect( result.error.executionFailure, "the host cleanup error wraps the cancellation", ).toBe("process capture was cancelled"); } const cancellation = result.error.cleanupIncomplete ? result.error.cause : result.error; expect(cancellation, "cancellation wins over finalization").toMatchObject({ reason: "cancelled", userCategory: "cancelled", }); expect( Date.now() - started, "cancellation does not wait for the finalization interval", ).toBeLessThan(8_000); }, 15_000, ); itWithCaptureCapability( "keeps the attempts when the escalation cannot be delivered through the retained identity", async () => { const calls: string[] = []; let observedAt: number | undefined; let firstSignalAt: number | undefined; const finalizationHost: FinalizationHost = { observe: async () => { observedAt ??= performance.now(); return { state: "readable" as const, identity: "launch" }; }, signal: async (_pid, identity, signal) => { firstSignalAt ??= performance.now(); calls.push(`${identity}:${signal}`); return signal === "SIGTERM" ? "signaled" : "unverified"; }, }; const root = await createTestTempDirectory("rea-finalization-host-"); const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [finalizationFixture, "ignoring", root], working_directory: root, filesystem_observation_paths: [root], idle_timeout_ms: 10_000, timeout_ms: 500, finalization_ms: 500, }), undefined, process.platform, process.env, undefined, undefined, undefined, undefined, finalizationHost, ); if (result.ok) throw new Error("expected an unobserved exit"); if (!(result.error instanceof ProcessCaptureError)) throw result.error; if (result.error.cleanupIncomplete) expectUnverifiedHostCleanup(result.error); expect( calls, "both finalization signals went through the identity-checked host", ).toEqual(["launch:SIGTERM", "launch:SIGKILL"]); expect( (firstSignalAt ?? 0) - (observedAt ?? Number.POSITIVE_INFINITY), "the start identity was read at launch, well before the 500 ms deadline signalled", ).toBeGreaterThanOrEqual(300); const partial = result.error.partialObservation; if (partial === undefined || !("observations" in partial)) throw new Error("expected incomplete process observations"); expect( partial.observations.finalization, "the attempts and their delivery results are published for a failed capture", ).toMatchObject({ state: "available", value: { requested_ms: 500, elapsed_ms: null, exit_code: null, signals: [ { signal: "SIGTERM", delivery: "signaled" }, { signal: "SIGKILL", delivery: "unverified" }, ], }, }); expect( partial.observations.exit.state, "no exit is invented when the escalation was not delivered", ).toBe("unavailable"); }, 15_000, ); itWithCaptureCapability( "classifies cancellation raised by the initial filesystem snapshot", async () => { const root = await createTestTempDirectory("rea-snapshot-initial-cancel-"); const path = join(root, "input"); await writeFile(path, "observed\n"); const controller = new AbortController(); const { signal } = controller; const throwIfAborted = signal.throwIfAborted.bind(signal); Object.defineProperty(signal, "throwIfAborted", { value: () => { controller.abort(); throwIfAborted(); }, }); const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, working_directory: root, filesystem_observation_paths: [path], }), signal, ); if (result.ok) throw new Error("expected initial snapshot cancellation"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "cancelled", category: "cancelled", details: { operation: "process_capture", cleanup: "complete" }, }); expect(result.error).toMatchObject({ reason: "cancelled", userCategory: "cancelled", }); }, ); itWithCaptureCapability( "classifies cancellation raised by the final filesystem snapshot", async () => { const root = await createTestTempDirectory("rea-snapshot-final-cancel-"); const controller = new AbortController(); let initialSnapshotCompleted = false; const captureSnapshot: typeof snapshotRoots = async (scenario, signal) => { if (initialSnapshotCompleted) controller.abort(); const snapshot = await snapshotRoots(scenario, signal); initialSnapshotCompleted = true; return snapshot; }; const resultPromise = captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [snapshotCancellationFixture, root], working_directory: dirname(snapshotCancellationFixture), filesystem_observation_paths: [root], settle_ms: 0, }), controller.signal, process.platform, process.env, captureSnapshot, ); const result = await resultPromise; if (result.ok) throw new Error("expected final snapshot cancellation"); expect(initialSnapshotCompleted).toBe(true); if (!(result.error instanceof ProcessCaptureError)) throw result.error; if (result.error.cleanupIncomplete) { expectUnverifiedHostCleanup(result.error); expect(result.error.executionFailure).toBe( "process capture was cancelled", ); } const cancellation = result.error.cleanupIncomplete ? result.error.cause : result.error; expect(cancellation, result.error.message).toMatchObject({ reason: "cancelled", userCategory: "cancelled", }); }, ); itWithCaptureCapability( "preserves filesystem errors that are not caller cancellation", async () => { const root = await createTestTempDirectory("rea-snapshot-io-error-"); const obstruction = join(root, "not-a-directory"); await writeFile(obstruction, "file"); const controller = new AbortController(); const captureSnapshot: typeof snapshotRoots = async (scenario, signal) => { try { return await snapshotRoots(scenario, signal); } catch (cause: unknown) { controller.abort(); throw cause; } }; const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, working_directory: root, filesystem_observation_paths: [join(obstruction, "child")], }), controller.signal, process.platform, process.env, captureSnapshot, ); if (result.ok) throw new Error("expected filesystem observation failure"); expect(result.error).toMatchObject({ reason: "capture_failed", cause: { code: "ENOTDIR" }, }); }, ); itWithCaptureCapability( "captures input, Unicode, and resize across PTY startup", async () => { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "interactive"], working_directory: dirname(processFixture), events: [ { type: "resize", at_ms: 0, columns: 100, rows: 40 }, { type: "input", at_ms: 0, data: "answer\n" }, ], normalization: { time_bucket_ms: 10 }, timeout_ms: 20_000, idle_timeout_ms: 10_000, }), ); const { capture } = captureObservations(result); const output = capture.frames.map(({ data }) => data).join(""); expect(output).toContain("prompt>"); expect(output).toContain("input:answer unicode:雪"); expect(output).toContain("resize:100x40"); expect(capture.interaction_events).toMatchObject([ { type: "resize", outcome: "dispatched", scheduled_at_ms: 0 }, { type: "input", outcome: "dispatched", scheduled_at_ms: 0 }, ]); const resized = capture.rendered_frames.find( ({ columns, rows, lines }) => columns === 100 && rows === 40 && lines.join("\n").includes("input:answer unicode:雪"), ); expect(resized).toBeDefined(); expect(resized?.lines.join("\n")).toContain("input:answer unicode:雪"); expect(capture.exit.code).toBe(0); }, ); itWithCaptureCapability( "dispatches an external signal without depending on child startup output", async () => { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "hang"], working_directory: dirname(processFixture), events: [{ type: "signal", at_ms: 0, signal: "SIGTERM" }], timeout_ms: 2_000, idle_timeout_ms: 2_000, }), ); const { capture } = captureObservations(result); expect(capture.interaction_events).toMatchObject([ { type: "signal", data: "SIGTERM", outcome: "dispatched" }, ]); expect(capture.exit).toMatchObject({ signal: 15, reason: "exited" }); }, ); itWithCaptureCapability( "buffers newline input until a silent PTY fixture is ready to read it", async () => { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "silent-interactive"], working_directory: dirname(processFixture), events: [ { type: "resize", at_ms: 25, columns: 100, rows: 40 }, { type: "input", at_ms: 50, data: "answer\n" }, ], timeout_ms: 20_000, idle_timeout_ms: 20_000, }), ); const { capture } = captureObservations(result); expect(capture.frames.map(({ data }) => data).join("")).toContain( "input:answer", ); expect(capture.interaction_events).toMatchObject([ { type: "resize", scheduled_at_ms: 25, outcome: "dispatched" }, { type: "input", scheduled_at_ms: 50, outcome: "dispatched" }, ]); expect( capture.interaction_events.every( ({ scheduled_at_ms, dispatched_at_ms }) => dispatched_at_ms >= scheduled_at_ms, ), ).toBe(true); expect( capture.frames.find(({ data }) => data.includes("input:answer"))?.at_ms, ).toBeGreaterThanOrEqual(50); }, ); itWithCaptureCapability( "samples and cleans a source-owned child and grandchild process tree", async () => { const result = await captureProcessScenario( parseProcessScenario({ executable: process.execPath, arguments: [processFixture, "tree"], working_directory: dirname(processFixture), // The post-capture ps lookup requires OS PIDs, not display identities. normalization: { pids: false }, timeout_ms: 20_000, idle_timeout_ms: 20_000, }), ); const { capture } = captureObservations(result); expect(capture.exit).toMatchObject({ code: 0, reason: "exited" }); expect(capture.frames.map(({ data }) => data).join("")).toContain( "tree-ready", ); const commands = capture.process_samples.map(({ command }) => command); expect(commands.some((command) => command.includes("tree-child"))).toBe( true, ); expect(commands.some((command) => command.includes("forks.js"))).toBe( false, ); expect( commands.some((command) => command.includes("tree-grandchild")), ).toBe(true); expect(JSON.stringify(capture.process_samples)).toContain( dirname(processFixture), ); // Observe only this run's sampled descendants; never buffer the host table. const fixturePids = [ ...new Set( capture.process_samples .filter( ({ command }) => command.includes(`${processFixture} tree-child`) || command.includes(`${processFixture} tree-grandchild`), ) .map(({ pid }) => pid), ), ]; let remainingCommands = ""; try { remainingCommands = ( await execFileAsync( "ps", ["-p", fixturePids.join(","), "-o", "command="], { maxBuffer: 64 * 1024 }, ) ).stdout; } catch (cause: unknown) { // ps exits 1 with no rows when all selected processes have exited. if ( !(cause instanceof Error) || !("code" in cause) || cause.code !== 1 || !("stdout" in cause) || cause.stdout !== "" ) throw cause; } expect(remainingCommands).not.toContain(`${processFixture} tree-child`); expect(remainingCommands).not.toContain( `${processFixture} tree-grandchild`, ); }, 20_000, );