2026-10-08 04:46:46 +08:00
import { createHash } from "node:crypto" ;
2026-07-29 22:00:38 +00:00
import { execFile } from "node:child_process" ;
2026-10-08 04:46:46 +08:00
import { access , readFile , rm , symlink , writeFile } from "node:fs/promises" ;
2026-07-29 22:00:38 +00:00
import { dirname , join } from "node:path" ;
import { fileURLToPath } from "node:url" ;
import { promisify } from "node:util" ;
2026-10-05 21:15:04 +08:00
import { expect } from "vitest" ;
2026-07-29 22:00:38 +00:00
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js" ;
2026-10-03 23:22:07 +08:00
import { itWithCaptureCapability } from "./processCaptureCapability.js" ;
2026-07-29 22:00:38 +00:00
2026-10-08 00:46:21 +08:00
import { captureProcessScenario } from "../../../src/process/capture/ProcessHarness.js" ;
2026-10-09 06:47:48 +08:00
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js" ;
2026-10-08 00:46:21 +08:00
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js" ;
2026-10-08 04:46:46 +08:00
import { ProcessCaptureError } from "../../../src/process/capture/ProcessCaptureError.js" ;
import {
type ProcessCaptureCleanupHost ,
observeLaunchedExecutable ,
observeSelectedExecutable ,
} from "../../../src/process/capture/ProcessCaptureLifecycle.js" ;
2026-10-09 22:45:18 +08:00
import { parseProcessScenario } from "../../../src/domain/process/processScenario.js" ;
2026-10-09 23:17:03 +08:00
import { emptyProcessCapture } from "../../../src/domain/process/processCapture.fixture.js" ;
2026-10-09 22:45:18 +08:00
import { type ProcessCapture } from "../../../src/domain/process/processCaptureParsing.js" ;
2026-07-29 22:00:38 +00:00
const processFixture = fileURLToPath (
new URL ( "../../fixtures/processFidelity.mjs" , import . meta . url ),
);
2026-10-07 23:45:23 +08:00
const snapshotCancellationFixture = fileURLToPath (
new URL ( "../../fixtures/processSnapshotCancellation.mjs" , import . meta . url ),
);
2026-07-29 22:00:38 +00:00
const execFileAsync = promisify ( execFile );
2026-10-08 04:46:46 +08:00
type CaptureRun = Awaited < ReturnType < typeof captureProcessScenario >>;
type PartialCapture = Extract <
NonNullable < ProcessCaptureError [ "partialObservation" ] >,
{ readonly capture : unknown }
> [ "capture" ];
2026-10-09 23:17:03 +08:00
const emptyFilesystemCoverage =
emptyProcessCapture (). truncation_details . filesystem_before ;
2026-10-09 02:36:17 +08:00
const expectUnverifiedHostCleanup = ( error : ProcessCaptureError ) : void => {
const report = error . cleanupReport ;
expect ( error . reason , error . message ). toBe ( "cleanup_incomplete" );
2026-10-08 04:46:46 +08:00
expect ( report ? . owned_process_group . state ). toBe ( "unverified" );
2026-10-09 00:29:08 +08:00
const [ summary , diagnostics ] = (
report ? . owned_process_group . reason ?? ""
). split ( ": " );
2026-10-08 04:46:46 +08:00
expect ( summary ). toMatch (
/^process ownership token could not be read for [1-9][0-9]* live process\(es\)$/u,
);
2026-10-09 00:29:08 +08:00
const [ breakdown , liveCandidates ] =
diagnostics ? . split ( "; live candidates " ) ?? [];
2026-10-08 04:46:46 +08:00
const categories = breakdown ? . split ( ", " ) ?? [];
expect ( categories . length ). toBeGreaterThan ( 0 );
const expectedCategory =
process . platform === "linux"
? /^environment_errno_(?:EACCES|EPERM)=[1-9][0-9]*$/u
: /^environment_unavailable=[1-9][0-9]*$/u;
for ( const category of categories ) expect ( category ). toMatch ( expectedCategory );
2026-10-09 00:29:08 +08:00
expect ( liveCandidates ). toMatch (
/^(?:[1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))(?:, [1-9][0-9]*=(?:environment_unavailable|environment_errno_(?:EACCES|EPERM)))*$/u,
);
2026-10-08 04:46:46 +08:00
expect ( report ? . terminal_renderer . state ). toBe ( "cleaned" );
expect ( report ? . temporary_root . state ). toBe ( "cleaned" );
2026-10-09 02:36:17 +08:00
};
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 ;
2026-10-08 04:46:46 +08:00
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 < string | undefined > => {
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 < void > => {
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 < number [] > => {
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 < void > => {
try {
process . kill ( pid , "SIGKILL" );
} catch ( cause : unknown ) {
if ( ! ( cause instanceof Error && "code" in cause && cause . code === "ESRCH" ))
throw cause ;
}
await waitForProcessExit ( pid );
};
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 ;
2026-10-09 20:09:24 +08:00
if ( snapshotCalls === 1 )
2026-10-09 23:17:03 +08:00
return {
files : [],
truncated : false ,
completeRoots : [],
coverage : emptyFilesystemCoverage ,
};
2026-10-08 04:46:46 +08:00
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" ,
);
2026-10-09 06:47:48 +08:00
const projected = projectAnalysisError ( result . error );
expect ( projected ). toMatchObject ({
code : "cleanup_incomplete" ,
details : { cleanup : "incomplete" , resources : [ "owned_process_group" ] },
});
expect ( projected ). not . toHaveProperty ( "stack" );
expect ( projected ). not . toHaveProperty ( "cause" );
2026-10-08 04:46:46 +08:00
expect ( partial ). toBeDefined ();
if ( partial === undefined || ! ( "observations" in partial ))
throw new Error ( "expected incomplete process observations" );
const observations = partial . observations ;
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 ,
);
2026-10-03 23:22:07 +08:00
itWithCaptureCapability (
2026-10-05 20:16:44 +08:00
"records external symlink metadata without following the target" ,
2026-10-03 23:22:07 +08:00
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 ,
2026-10-05 20:16:44 +08:00
filesystem_observation_paths : [ root ],
2026-10-03 23:22:07 +08:00
}),
);
2026-10-08 04:46:46 +08:00
const { capture } = captureObservations ( result );
const escaped = capture . files_after . find (( file ) =>
2026-10-03 23:22:07 +08:00
file . path . endsWith ( ":escape" ),
);
2026-10-05 20:16:44 +08:00
expect ( escaped ? . symlink_target ). toBe ( "/etc/passwd" );
2026-10-08 04:46:46 +08:00
expect ( capture . truncated ). toBe ( false );
expect ( JSON . stringify ( capture . files_after )). not . toContain ( root );
2026-10-05 20:16:44 +08:00
expect (
2026-10-08 04:46:46 +08:00
capture . files_after . some (( file ) => file . path . includes ( "passwd" )),
2026-10-05 20:16:44 +08:00
). toBe ( false );
2026-10-03 23:22:07 +08:00
} finally {
await rm ( root , { recursive : true , force : true });
}
},
);
2026-07-29 22:00:38 +00:00
2026-10-03 23:22:07 +08:00
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 ,
}),
);
2026-10-08 04:46:46 +08:00
const timedOutCapture = captureObservations ( timedOut );
expect ( timedOutCapture . capture . exit . reason ). toBe ( "timeout" );
2026-10-08 23:20:52 +08:00
if ( ! timedOutCapture . cleanupIncomplete && timedOut . ok ) {
expect ( timedOut . value . cleanup ). toMatchObject ({
2026-10-08 04:46:46 +08:00
owned_process_group : "verified" ,
temporary_root : "removed" ,
});
2026-10-08 23:20:52 +08:00
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` ,
),
});
}
}
2026-07-29 22:00:38 +00:00
2026-10-03 23:22:07 +08:00
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" );
},
);
2026-07-29 22:00:38 +00:00
2026-10-07 23:45:23 +08:00
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" );
2026-10-09 06:47:48 +08:00
expect ( projectAnalysisError ( result . error )). toMatchObject ({
code : "cancelled" ,
category : "cancelled" ,
details : { operation : "process_capture" , cleanup : "complete" },
});
2026-10-07 23:45:23 +08:00
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 );
2026-10-09 02:36:17 +08:00
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 ({
2026-10-07 23:45:23 +08:00
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" },
});
},
);
2026-10-03 23:22:07 +08:00
itWithCaptureCapability (
2026-10-06 01:32:46 +08:00
"captures input, Unicode, and resize across PTY startup" ,
2026-10-03 23:22:07 +08:00
async () => {
const result = await captureProcessScenario (
parseProcessScenario ({
executable : process.execPath ,
arguments : [ processFixture , "interactive" ],
working_directory : dirname ( processFixture ),
2026-10-05 21:15:04 +08:00
events : [
2026-10-06 01:32:46 +08:00
{ type : "resize" , at_ms : 0 , columns : 100 , rows : 40 },
{ type : "input" , at_ms : 0 , data : "answer\n" },
2026-10-05 21:15:04 +08:00
],
2026-10-03 23:22:07 +08:00
normalization : { time_bucket_ms : 10 },
timeout_ms : 20_000 ,
idle_timeout_ms : 10_000 ,
}),
);
2026-10-08 04:46:46 +08:00
const { capture } = captureObservations ( result );
const output = capture . frames . map (({ data }) => data ). join ( "" );
2026-10-03 23:22:07 +08:00
expect ( output ). toContain ( "prompt>" );
expect ( output ). toContain ( "input:answer unicode:雪" );
expect ( output ). toContain ( "resize:100x40" );
2026-10-08 04:46:46 +08:00
expect ( capture . interaction_events ). toMatchObject ([
2026-10-06 01:32:46 +08:00
{ type : "resize" , outcome : "dispatched" , scheduled_at_ms : 0 },
{ type : "input" , outcome : "dispatched" , scheduled_at_ms : 0 },
]);
2026-10-08 04:46:46 +08:00
const resized = capture . rendered_frames . find (
2026-10-06 01:32:46 +08:00
({ columns , rows , lines }) =>
columns === 100 &&
rows === 40 &&
lines . join ( "\n" ). includes ( "input:answer unicode:雪" ),
2026-10-04 16:02:36 +08:00
);
expect ( resized ). toBeDefined ();
expect ( resized ? . lines . join ( "\n" )). toContain ( "input:answer unicode:雪" );
2026-10-08 04:46:46 +08:00
expect ( capture . exit . code ). toBe ( 0 );
2026-10-03 23:22:07 +08:00
},
);
itWithCaptureCapability (
2026-10-06 01:32:46 +08:00
"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 ,
}),
);
2026-10-08 04:46:46 +08:00
const { capture } = captureObservations ( result );
expect ( capture . interaction_events ). toMatchObject ([
2026-10-06 01:32:46 +08:00
{ type : "signal" , data : "SIGTERM" , outcome : "dispatched" },
]);
2026-10-08 04:46:46 +08:00
expect ( capture . exit ). toMatchObject ({ signal : 15 , reason : "exited" });
2026-10-06 01:32:46 +08:00
},
);
itWithCaptureCapability (
"buffers newline input until a silent PTY fixture is ready to read it" ,
2026-10-03 23:22:07 +08:00
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 },
2026-10-06 01:32:46 +08:00
{ type : "input" , at_ms : 50 , data : "answer\n" },
2026-10-03 23:22:07 +08:00
],
2026-10-06 01:32:46 +08:00
timeout_ms : 20_000 ,
idle_timeout_ms : 20_000 ,
2026-10-03 23:22:07 +08:00
}),
);
2026-10-08 04:46:46 +08:00
const { capture } = captureObservations ( result );
expect ( capture . frames . map (({ data }) => data ). join ( "" )). toContain (
2026-10-03 23:22:07 +08:00
"input:answer" ,
);
2026-10-08 04:46:46 +08:00
expect ( capture . interaction_events ). toMatchObject ([
2026-10-06 01:32:46 +08:00
{ type : "resize" , scheduled_at_ms : 25 , outcome : "dispatched" },
{ type : "input" , scheduled_at_ms : 50 , outcome : "dispatched" },
2026-10-03 23:22:07 +08:00
]);
2026-10-06 01:32:46 +08:00
expect (
2026-10-08 04:46:46 +08:00
capture . interaction_events . every (
2026-10-06 01:32:46 +08:00
({ scheduled_at_ms , dispatched_at_ms }) =>
dispatched_at_ms >= scheduled_at_ms ,
),
). toBe ( true );
expect (
2026-10-08 04:46:46 +08:00
capture . frames . find (({ data }) => data . includes ( "input:answer" )) ? . at_ms ,
2026-10-06 01:32:46 +08:00
). toBeGreaterThanOrEqual ( 50 );
2026-10-03 23:22:07 +08:00
},
);
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 ),
2026-10-06 01:32:46 +08:00
timeout_ms : 20_000 ,
idle_timeout_ms : 20_000 ,
2026-10-03 23:22:07 +08:00
}),
);
2026-10-08 04:46:46 +08:00
const { capture } = captureObservations ( result );
expect ( capture . exit ). toMatchObject ({ code : 0 , reason : "exited" });
expect ( capture . frames . map (({ data }) => data ). join ( "" )). toContain (
2026-10-06 01:32:46 +08:00
"tree-ready" ,
);
2026-10-08 04:46:46 +08:00
const commands = capture . process_samples . map (({ command }) => command );
2026-10-03 23:22:07 +08:00
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 );
2026-10-08 04:46:46 +08:00
expect ( JSON . stringify ( capture . process_samples )). toContain (
2026-10-03 23:22:07 +08:00
dirname ( processFixture ),
);
const { stdout } = await execFileAsync ( "ps" , [ "-axo" , "command=" ]);
expect ( stdout ). not . toContain ( ` ${ processFixture } tree-child` );
expect ( stdout ). not . toContain ( ` ${ processFixture } tree-grandchild` );
},
20 _000 ,
);