Files
reverse-engineering-anything/src/application/ProcessReactiveCoordinator.test.ts
T

301 lines
9.1 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
ProcessReactiveCoordinator,
type ProcessReactiveTimerHost,
} from "./ProcessReactiveCoordinator.js";
import { executeProcessReactiveEffects } from "./ProcessReactiveEffects.js";
import { createProcessCaptureJournal } from "./ProcessCaptureJournal.js";
import { subscribeProcessReactiveObservations } from "./ProcessReactiveObservations.js";
import type {
FilesystemCheckpoint,
InteractionEvent,
TerminalFrame,
} from "../domain/processCapture.js";
import {
processReactiveScenarioSchema,
type ProcessReactiveScenario,
} from "../domain/processReactiveScenario.js";
const terminalTrigger = () => ({
kind: "terminal_text" as const,
view: "decoded" as const,
encoding: "utf8" as const,
literal: "Ready",
case_sensitive: true,
control_sequences: "include" as const,
occurrence: 1,
since: { kind: "scenario_start" as const },
consume: true,
});
const timerHost = () => {
const scheduled: Array<{
readonly callback: () => void;
readonly delayMs: number;
cancelled: boolean;
}> = [];
const host: ProcessReactiveTimerHost = {
schedule: (callback, delayMs) => {
const timer = { callback, delayMs, cancelled: false };
scheduled.push(timer);
return { cancel: () => (timer.cancelled = true) };
},
};
return { host, scheduled };
};
const twoStateScenario = (): ProcessReactiveScenario =>
processReactiveScenarioSchema.parse({
initial_state: "starting",
deadline_ms: 30000,
states: [
{
id: "starting",
max_visits: 1,
deadline_ms: 5000,
on: [
{
id: "send",
priority: 1,
max_uses: 1,
when: terminalTrigger(),
actions: [{ type: "send_input", data: "go", sensitive: false }],
target: { kind: "goto", state: "sent" },
},
],
},
{
id: "sent",
max_visits: 1,
deadline_ms: 5000,
on: [
{
id: "confirm",
priority: 1,
max_uses: 1,
when: {
kind: "event",
source: "interaction",
exact: { type: "input", data: "go", outcome: "dispatched" },
ignore_fields: [
"sequence",
"scheduled_at_ms",
"dispatched_at_ms",
],
since: { kind: "scenario_start" },
consume: true,
cardinality: { min: 1, max: 1 },
},
actions: [],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
});
const createHarness = (scenario: ProcessReactiveScenario) => {
const journal = createProcessCaptureJournal();
const frames: TerminalFrame[] = [];
const interactions: InteractionEvent[] = [];
const checkpoints: FilesystemCheckpoint[] = [];
const terminalCalls: string[] = [];
const timers = timerHost();
let now = 100;
const effectHost = {
terminal: () => ({
write: (data: string) => terminalCalls.push(`write:${data}`),
resize: (columns: number, rows: number) =>
terminalCalls.push(`resize:${String(columns)}x${String(rows)}`),
closeInput: () => terminalCalls.push("close-input"),
kill: (signal: "SIGINT" | "SIGTERM" | "SIGKILL") =>
terminalCalls.push(`kill:${signal}`),
}),
renderer: {
resize: (columns: number, rows: number) =>
terminalCalls.push(`render:${String(columns)}x${String(rows)}`),
},
checkpoints: {
captureAndRead: async (name: string) => {
const index = checkpoints.length;
const checkpoint: FilesystemCheckpoint = {
name,
at_ms: now - 100,
files: [],
effects: [],
truncated: false,
};
checkpoints.push(checkpoint);
journal.record("filesystem_checkpoints", index);
return { index, checkpoint };
},
},
interactions,
journal,
startedAtMs: 100,
now: () => now,
};
const coordinator = new ProcessReactiveCoordinator({
scenario,
executor: {
execute: (actions, signal) =>
executeProcessReactiveEffects(effectHost, actions, signal),
},
timerHost: timers.host,
});
const unsubscribe = subscribeProcessReactiveObservations({
journal,
frames,
interactions,
checkpointAt: (index) => checkpoints[index],
processSampleAt: () => undefined,
protocolEventAt: () => undefined,
shimEventAt: () => undefined,
sink: coordinator,
});
return {
coordinator,
frames,
interactions,
checkpoints,
journal,
terminalCalls,
timers: timers.scheduled,
setNow: (value: number) => (now = value),
unsubscribe,
};
};
describe("process reactive coordinator ordering", () => {
it("commits terminal effects before evaluating their journal observations", async () => {
const harness = createHarness(twoStateScenario());
harness.frames.push({ sequence: 0, at_ms: 0, data: "Ready" });
harness.journal.record("frames", 0);
await harness.coordinator.drain();
expect(harness.coordinator.snapshot).toMatchObject({
status: "finished",
outcome: "passed",
active_state: "sent",
});
expect(
harness.coordinator.snapshot.transitions.map(
({ transition_id }) => transition_id,
),
).toEqual(["send", "confirm"]);
expect(harness.terminalCalls).toEqual(["write:go"]);
expect(harness.journal.entries).toEqual([
{ collection: "frames", index: 0, capture_order: 0 },
{ collection: "interaction_events", index: 0, capture_order: 1 },
]);
expect(harness.interactions[0]).toMatchObject({
type: "input",
data: "go",
outcome: "dispatched",
});
harness.unsubscribe();
await harness.coordinator.close();
});
it("executes input, resize, root signal, and checkpoint in journal order", async () => {
const scenario = processReactiveScenarioSchema.parse({
initial_state: "ready",
deadline_ms: 30000,
states: [
{
id: "ready",
max_visits: 1,
deadline_ms: 5000,
on: [
{
id: "act",
priority: 1,
max_uses: 1,
when: terminalTrigger(),
actions: [
{ type: "send_input", data: "secret", sensitive: true },
{ type: "resize", columns: 100, rows: 40 },
{ type: "close_stdin" },
{
type: "send_signal",
target: { kind: "root" },
signal: "SIGINT",
},
{ type: "checkpoint", name: "stopped" },
],
target: { kind: "finish", outcome: "passed" },
},
],
},
],
});
const harness = createHarness(scenario);
harness.setNow(125);
harness.frames.push({ sequence: 0, at_ms: 20, data: "Ready" });
harness.journal.record("frames", 0);
await harness.coordinator.drain();
expect(harness.terminalCalls).toEqual([
"write:secret",
"resize:100x40",
"render:100x40",
"close-input",
"kill:SIGINT",
]);
expect(harness.interactions.map(({ data }) => data)).toEqual([
"<redacted-input:6-bytes>",
"100x40",
"",
"SIGINT",
]);
expect(harness.checkpoints).toHaveLength(1);
expect(
harness.coordinator.snapshot.transitions[0]?.action_event_ids,
).toEqual([
"obs.interaction_events.0",
"obs.interaction_events.1",
"obs.interaction_events.2",
"obs.interaction_events.3",
"obs.filesystem_checkpoints.0",
]);
await harness.coordinator.close();
});
it("ignores a stale state timer after a transition", async () => {
const base = twoStateScenario();
const first = base.states[0];
const second = base.states[1];
if (first === undefined || second === undefined)
throw new Error("two-state fixture is incomplete");
const scenario = processReactiveScenarioSchema.parse({
...base,
states: [
{
...first,
on: first.on.map((transition) => ({
...transition,
actions: [],
})),
},
{
...second,
on: second.on.map((transition) => ({
...transition,
when: { ...terminalTrigger(), literal: "Later" },
})),
},
],
});
const harness = createHarness(scenario);
const oldStateTimer = harness.timers[1];
expect(oldStateTimer?.delayMs).toBe(5000);
harness.frames.push({ sequence: 0, at_ms: 0, data: "Ready" });
harness.journal.record("frames", 0);
await harness.coordinator.drain();
expect(oldStateTimer?.cancelled).toBe(true);
oldStateTimer?.callback();
await harness.coordinator.drain();
expect(harness.coordinator.snapshot).toMatchObject({
status: "running",
active_state: "sent",
outcome: null,
});
const currentStateTimer = harness.timers[2];
currentStateTimer?.callback();
await harness.coordinator.drain();
expect(harness.coordinator.snapshot.outcome).toBe("predicate_timeout");
await harness.coordinator.close();
});
});