rea/tests/boundary/process/processCaptureLifecycle.tes...

334 lines
11 KiB
TypeScript

import { execFile } from "node:child_process";
import { 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 } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { itWithCaptureCapability } from "./processCaptureCapability.js";
import { captureProcessScenario } from "../../../src/process/capture/ProcessHarness.js";
import { snapshotRoots } from "../../../src/process/capture/FilesystemSnapshot.js";
import { parseProcessScenario } from "../../../src/domain/process/processCapture.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);
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],
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
const escaped = result.value.files_after.find((file) =>
file.path.endsWith(":escape"),
);
expect(escaped?.symlink_target).toBe("/etc/passwd");
expect(result.value.truncated).toBe(false);
expect(JSON.stringify(result.value.files_after)).not.toContain(root);
expect(
result.value.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,
}),
);
expect(timedOut.ok).toBe(true);
if (!timedOut.ok) throw timedOut.error;
expect(timedOut.value.exit.reason).toBe("timeout");
expect(timedOut.value.cleanup).toEqual({
owned_process_group: "verified",
temporary_root: "removed",
});
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,
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error.message).toContain("cancelled");
},
);
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,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected initial snapshot cancellation");
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;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected final snapshot cancellation");
expect(initialSnapshotCompleted).toBe(true);
expect(result.error).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,
);
expect(result.ok).toBe(false);
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,
}),
);
if (!result.ok) throw result.error;
expect(result.ok).toBe(true);
const output = result.value.frames.map(({ data }) => data).join("");
expect(output).toContain("prompt>");
expect(output).toContain("input:answer unicode:雪");
expect(output).toContain("resize:100x40");
expect(result.value.interaction_events).toMatchObject([
{ type: "resize", outcome: "dispatched", scheduled_at_ms: 0 },
{ type: "input", outcome: "dispatched", scheduled_at_ms: 0 },
]);
const resized = result.value.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(result.value.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,
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.interaction_events).toMatchObject([
{ type: "signal", data: "SIGTERM", outcome: "dispatched" },
]);
expect(result.value.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,
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"input:answer",
);
expect(result.value.interaction_events).toMatchObject([
{ type: "resize", scheduled_at_ms: 25, outcome: "dispatched" },
{ type: "input", scheduled_at_ms: 50, outcome: "dispatched" },
]);
expect(
result.value.interaction_events.every(
({ scheduled_at_ms, dispatched_at_ms }) =>
dispatched_at_ms >= scheduled_at_ms,
),
).toBe(true);
expect(
result.value.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),
timeout_ms: 20_000,
idle_timeout_ms: 20_000,
}),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.exit).toMatchObject({ code: 0, reason: "exited" });
expect(result.value.frames.map(({ data }) => data).join("")).toContain(
"tree-ready",
);
const commands = result.value.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(result.value.process_samples)).toContain(
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,
);