425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
import { rm, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { expect, it } from "vitest";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import {
|
|
captureProcessScenario,
|
|
probeProcessCaptureCapability,
|
|
} from "../../../src/application/ProcessHarness.js";
|
|
import {
|
|
parseProcessScenario,
|
|
type ProcessCapture,
|
|
} from "../../../src/domain/processCapture.js";
|
|
import { processCaptureIssues } from "../../../src/domain/processCapture.fixture.js";
|
|
|
|
function createInteractiveScenario(root: string, script: string) {
|
|
return parseProcessScenario({
|
|
approved: true,
|
|
executable: process.execPath,
|
|
arguments: [script],
|
|
working_directory: root,
|
|
reactive: {
|
|
version: 1,
|
|
initial_state: "waiting",
|
|
deadline_ms: 5_000,
|
|
states: [
|
|
{
|
|
id: "waiting",
|
|
max_visits: 1,
|
|
deadline_ms: 2_000,
|
|
on: [
|
|
{
|
|
id: "answer",
|
|
priority: 0,
|
|
max_uses: 1,
|
|
when: {
|
|
kind: "terminal_text",
|
|
view: "decoded",
|
|
encoding: "utf8",
|
|
literal: "Ready",
|
|
case_sensitive: true,
|
|
control_sequences: "include",
|
|
occurrence: 1,
|
|
since: { kind: "scenario_start" },
|
|
consume: true,
|
|
},
|
|
actions: [
|
|
{ type: "send_input", data: "accepted\n", sensitive: true },
|
|
{ type: "close_stdin" },
|
|
],
|
|
target: { kind: "goto", state: "finishing" },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "finishing",
|
|
max_visits: 1,
|
|
deadline_ms: 2_000,
|
|
on: [
|
|
{
|
|
id: "complete",
|
|
priority: 0,
|
|
max_uses: 1,
|
|
when: {
|
|
kind: "terminal_text",
|
|
view: "decoded",
|
|
encoding: "utf8",
|
|
literal: "Done",
|
|
case_sensitive: true,
|
|
control_sequences: "include",
|
|
occurrence: 1,
|
|
since: { kind: "state_entry" },
|
|
consume: true,
|
|
},
|
|
actions: [],
|
|
target: { kind: "finish", outcome: "passed" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
|
|
function assertCompletedCapture(capture: ProcessCapture): void {
|
|
expect(capture.reactive_run).toMatchObject({
|
|
status: "finished",
|
|
outcome: "passed",
|
|
active_state: "finishing",
|
|
});
|
|
expect(
|
|
capture.reactive_run?.transitions.map(({ transition_id }) => transition_id),
|
|
).toEqual(["answer", "complete"]);
|
|
expect(capture.interaction_events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "input",
|
|
data: "<redacted-input:9-bytes>",
|
|
outcome: "dispatched",
|
|
}),
|
|
);
|
|
expect(capture.interaction_events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "stdin_close",
|
|
data: "",
|
|
outcome: "dispatched",
|
|
}),
|
|
);
|
|
expect(capture.frames.map(({ data }) => data).join("")).toContain("Done");
|
|
expect(capture.manifest.comparison_contract).toHaveProperty("reactive");
|
|
expect(JSON.stringify(capture.manifest.comparison_contract)).not.toContain(
|
|
"accepted",
|
|
);
|
|
expect(JSON.stringify(capture.manifest.scenario)).not.toContain("accepted");
|
|
}
|
|
|
|
function assertControlAndTransitionValidation(capture: ProcessCapture): void {
|
|
const reactiveRun = capture.reactive_run;
|
|
if (reactiveRun === null) throw new Error("reactive result missing");
|
|
const lastOrder = capture.event_journal
|
|
?.filter(({ collection }) =>
|
|
[
|
|
"frames",
|
|
"interaction_events",
|
|
"filesystem_checkpoints",
|
|
"shim_events",
|
|
].includes(collection),
|
|
)
|
|
.at(-1)?.capture_order;
|
|
if (lastOrder === undefined)
|
|
throw new Error("reactive observation order missing");
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: {
|
|
...reactiveRun,
|
|
controls: [
|
|
...reactiveRun.controls,
|
|
{
|
|
sequence: reactiveRun.controls.length,
|
|
kind: "target_lost",
|
|
after_capture_order: lastOrder,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
path: `reactive_run.controls[${String(reactiveRun.controls.length)}]`,
|
|
message: "reactive control cannot follow a finished run",
|
|
}),
|
|
);
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: {
|
|
...reactiveRun,
|
|
transitions: reactiveRun.transitions.map((transition, index) =>
|
|
index === 0
|
|
? { ...transition, transition_id: "fabricated" }
|
|
: transition,
|
|
),
|
|
},
|
|
}),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "reactive_run.transitions[0]",
|
|
message:
|
|
"reactive transition is not declared by its recorded source state",
|
|
}),
|
|
);
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: {
|
|
status: "finished",
|
|
outcome: "passed",
|
|
active_state: "waiting",
|
|
transitions: [],
|
|
controls: [],
|
|
},
|
|
}),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "reactive_run.outcome",
|
|
message: "reactive outcome differs from deterministic journal replay",
|
|
}),
|
|
);
|
|
const suffix = reactiveRun.transitions
|
|
.slice(1)
|
|
.map((transition, sequence) => ({
|
|
...transition,
|
|
sequence,
|
|
}));
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: { ...reactiveRun, transitions: suffix },
|
|
}),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "reactive_run.transitions[0].state_before",
|
|
message:
|
|
"reactive transition journal must start at the declared initial state",
|
|
}),
|
|
);
|
|
}
|
|
|
|
function assertReplayValidation(capture: ProcessCapture): void {
|
|
const reactiveRun = capture.reactive_run;
|
|
if (reactiveRun === null) throw new Error("reactive result missing");
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: {
|
|
...reactiveRun,
|
|
transitions: reactiveRun.transitions.map((transition, index) =>
|
|
index === 0
|
|
? { ...transition, trigger_event_ids: [], action_event_ids: [] }
|
|
: transition,
|
|
),
|
|
},
|
|
}),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: "reactive_run.transitions",
|
|
message: expect.stringContaining(
|
|
"reactive transition journal is not reproduced",
|
|
),
|
|
}),
|
|
expect.objectContaining({
|
|
path: "reactive_run.transitions[0].action_event_ids",
|
|
message: expect.stringContaining(
|
|
"recorded action observations do not match proposed effects",
|
|
),
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: { ...reactiveRun, status: "running", outcome: null },
|
|
}),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
message: "completed capture requires a finished reactive outcome",
|
|
}),
|
|
);
|
|
expect(
|
|
processCaptureIssues({
|
|
...capture,
|
|
reactive_run: {
|
|
...reactiveRun,
|
|
transitions: [
|
|
reactiveRun.transitions[0]!,
|
|
{ ...reactiveRun.transitions[0]!, sequence: 1 },
|
|
],
|
|
},
|
|
}),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message: "reactive transition states must be contiguous",
|
|
}),
|
|
expect.objectContaining({
|
|
message:
|
|
"reactive transition differs from deterministic journal replay",
|
|
}),
|
|
]),
|
|
);
|
|
}
|
|
|
|
it("drives a process from terminal observations and retains the reactive transition journal", async () => {
|
|
const root = await createTestTempDirectory("rea-reactive-harness-test-");
|
|
const script = join(root, "reactive.mjs");
|
|
await writeFile(
|
|
script,
|
|
[
|
|
'import { createInterface } from "node:readline";',
|
|
"const input = createInterface({ input: process.stdin, terminal: false });",
|
|
'process.stdout.write("Ready\\n");',
|
|
'input.once("line", () => { process.stdout.write("Done\\n"); input.close(); });',
|
|
].join("\n"),
|
|
);
|
|
const scenario = createInteractiveScenario(root, script);
|
|
try {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const result = await captureProcessScenario(scenario, {
|
|
status: "enabled",
|
|
executableRoots: [dirname(process.execPath)],
|
|
workingRoots: [root],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
assertCompletedCapture(result.value);
|
|
assertControlAndTransitionValidation(result.value);
|
|
assertReplayValidation(result.value);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("records target loss before post-exit settlement can win the deadline race", async () => {
|
|
const root = await createTestTempDirectory("rea-reactive-target-loss-test-");
|
|
const script = join(root, "exit.mjs");
|
|
await writeFile(script, 'process.stdout.write("exiting\\n");\n');
|
|
const scenario = parseProcessScenario({
|
|
approved: true,
|
|
executable: process.execPath,
|
|
arguments: [script],
|
|
working_directory: root,
|
|
settle_ms: 500,
|
|
reactive: {
|
|
version: 1,
|
|
initial_state: "waiting",
|
|
deadline_ms: 5_000,
|
|
states: [
|
|
{
|
|
id: "waiting",
|
|
max_visits: 1,
|
|
// Leave process startup scheduling headroom; this test targets
|
|
// post-exit settlement ordering, not a 300ms startup deadline.
|
|
deadline_ms: 2_000,
|
|
on: [
|
|
{
|
|
id: "unreachable",
|
|
priority: 0,
|
|
max_uses: 1,
|
|
when: {
|
|
kind: "terminal_text",
|
|
view: "decoded",
|
|
encoding: "utf8",
|
|
literal: "never-produced",
|
|
case_sensitive: true,
|
|
control_sequences: "include",
|
|
occurrence: 1,
|
|
since: { kind: "scenario_start" },
|
|
consume: true,
|
|
},
|
|
actions: [],
|
|
target: { kind: "finish", outcome: "passed" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
try {
|
|
const capability = await probeProcessCaptureCapability();
|
|
if (!capability.available) return;
|
|
const result = await captureProcessScenario(scenario, {
|
|
status: "enabled",
|
|
executableRoots: [dirname(process.execPath)],
|
|
workingRoots: [root],
|
|
allowedEnvironment: [],
|
|
networkAccess: "external",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.reactive_run).toMatchObject({
|
|
status: "finished",
|
|
outcome: "target_lost",
|
|
active_state: "waiting",
|
|
transitions: [],
|
|
controls: [
|
|
expect.objectContaining({
|
|
kind: "target_lost",
|
|
}),
|
|
],
|
|
});
|
|
const frame = result.value.frames.at(-1);
|
|
const journal = result.value.event_journal;
|
|
const reactiveRun = result.value.reactive_run;
|
|
if (frame === undefined || journal === undefined || reactiveRun === null)
|
|
throw new Error("ordered reactive evidence missing");
|
|
const laterCaptureOrder = journal.length;
|
|
const withLaterMatch = {
|
|
...result.value,
|
|
frames: [
|
|
...result.value.frames,
|
|
{
|
|
...frame,
|
|
sequence: result.value.frames.length,
|
|
at_ms: frame.at_ms + 1,
|
|
data: "never-produced",
|
|
},
|
|
],
|
|
event_journal: [
|
|
...journal,
|
|
{
|
|
capture_order: laterCaptureOrder,
|
|
collection: "frames" as const,
|
|
index: result.value.frames.length,
|
|
},
|
|
],
|
|
};
|
|
expect(
|
|
processCaptureIssues(withLaterMatch).filter(({ path }) =>
|
|
path.startsWith("reactive_run"),
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
processCaptureIssues({
|
|
...withLaterMatch,
|
|
reactive_run: {
|
|
...reactiveRun,
|
|
controls: reactiveRun.controls.map((control) => ({
|
|
...control,
|
|
after_capture_order: laterCaptureOrder,
|
|
})),
|
|
},
|
|
}),
|
|
).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "reactive_run.outcome",
|
|
message: "reactive outcome differs from deterministic journal replay",
|
|
}),
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true });
|
|
}
|
|
}, 10_000);
|