334 lines
11 KiB
TypeScript
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,
|
|
);
|