fix(ci): repair capture ownership and portable ABI validation (#5224)

* fix(tests): restore reliable snapshot and container CI checks

Keep exact cancellation causes and partial snapshot facts in regression oracles. Match fault injection to real directory handles and isolated MCP module ownership; await terminal cleanup progress within the existing test deadline. Reuse the valid ASAR Pickle fixture and preserve truncated-container rejection.

* fix(tests): isolate runtime faults and own CLI subprocesses

Keep the runtime provider mock in the isolated project after reused CLI graphs exposed lost fault injection. Use the existing owned CLI fixture for Inspector parity and update command checks, retain caller environment choices, and require the removed alias to fail with COMMAND_NOT_FOUND. Keep current test deadlines and production behavior.

* fix(process): retain executable handles through read cancellation

Use the existing borrowed descriptor reader so stream cancellation cannot invalidate a handle owned by capture. Preserve the exact cancellation and permit immediate scope reuse after cleanup. Reproduce the native-stream failure with actual file IO and retain completed/failed-close controls.

Synchronize runtime cancellation and backwards-clock injection with observed running progress. The corrected clock oracle now rejects a Date.now-based deadline under controlled rollback; keep test deadlines and narrow host cleanup checks unchanged.

* test(process): observe prelaunch cancellation at descriptor reads

* fix(process): retain read failures alongside observed cancellation

* fix(flutter): advertise portable Unicode ABI selectors
This commit is contained in:
gensokyo-dev authored and GitHub committed 2026-10-19 19:32:22 +08:00
1 parent a0edd145d3
commit b999e72476
17 files changed
+431 -226

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ without serial scheduling. Pure domain/service, composition, conformance,
evaluation, CLI boundary, and MCP boundary projects share module graphs. Their
tests avoid process-global state and keep resource cleanup test-scoped. Each
MCP session still owns its resources, and CLI cases retain fresh command
instances or subprocesses. CLI module mocks belong in `tests/process-global/`
instances or subprocesses. CLI and MCP module mocks belong in `tests/process-global/`
so they keep per-file isolation. Product catalog verification stays with the
isolated filesystem boundary tests because it loads both source and compiled
module graphs. Adapter, other boundary, acceptance, and process-global projects
+16 -10
View File
@@ -15,19 +15,25 @@ const ABI_SELECTOR_MAX_CHARACTERS =
/**
* ZIP labels are literal names, including backslashes and controls. They are
* never used as extraction paths. A Unicode regex gives runtime validation
* and JSON Schema the same character unit; Zod max() counts UTF-16 units.
* The final lookahead requires the actual end, including a trailing newline.
* never used as extraction paths. Count Unicode code points without copying
* the label; Zod max() counts UTF-16 units. Advertise the same code-point
* budget as JSON Schema maxLength, including every trailing line terminator.
*/
const ABI_SELECTOR_MESSAGE = `ABI must be the exact lib/ directory label: 1–${String(ABI_SELECTOR_MAX_CHARACTERS)} Unicode characters without '/'.`;
const abiSelectorSchema = z
.string()
.regex(
new RegExp(
`^[^/]{1,${String(ABI_SELECTOR_MAX_CHARACTERS)}}(?![\\s\\S])`,
"u",
),
`ABI must be the exact lib/ directory label: 1–${String(ABI_SELECTOR_MAX_CHARACTERS)} Unicode characters without '/'.`,
);
.min(1, ABI_SELECTOR_MESSAGE)
.refine(
(label) => {
const characters = label[Symbol.iterator]();
for (let count = 0; count <= ABI_SELECTOR_MAX_CHARACTERS; count += 1)
if (characters.next().done) return true;
return false;
},
{ message: ABI_SELECTOR_MESSAGE, abort: true },
)
.regex(/^[^\/]*$/u, ABI_SELECTOR_MESSAGE)
.meta({ maxLength: ABI_SELECTOR_MAX_CHARACTERS });
/**
* Caller intent for Flutter build identification. This family is pure
@@ -4,6 +4,7 @@ import { open, mkdtemp, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setImmediate, setTimeout as delay } from "node:timers/promises";
import { readFileHandleChunks } from "../../filesystem/readFileHandleChunks.js";
import type { IPty } from "@lydell/node-pty";
import {
AnalysisCleanupLease,
@@ -44,6 +45,7 @@ import {
import {
describeProcessCaptureExecutionFailure,
normalizeCaptureFailure,
processCaptureCancelled,
ProcessCaptureError,
} from "./ProcessCaptureError.js";
import { assertNotCancelled } from "./ProcessScenarioRuntimeValidation.js";
@@ -563,13 +565,14 @@ export const observeSelectedExecutable = async (
"Selected executable path changed while it was being opened.",
};
// IO above can settle after cancellation. Do not construct a stream with an
// already-aborted signal: Node can emit a second, unhandled AbortError.
// IO above can settle after cancellation. Native FileHandle streams can
// invalidate the descriptor on abort even with autoClose:false; borrowed
// positional reads leave its close exclusively with the capture owner.
assertNotCancelled(signal);
const hash = createHash("sha256");
for await (const chunk of handle.createReadStream({
autoClose: false,
for await (const chunk of readFileHandleChunks(handle, {
signal,
cancellationFailure: processCaptureCancelled,
})) {
assertNotCancelled(signal);
hash.update(chunk);
@@ -596,7 +599,7 @@ export const observeSelectedExecutable = async (
if (
cause instanceof ProcessCaptureReadFailure &&
!cause.cleanupIncomplete &&
cause.userCategory !== "cancelled" &&
cause.cancellation === undefined &&
cause.outcome.kind === "failed"
) {
const reason = filesystemFailure(cause.outcome.cause);
+10 -4
View File
@@ -36,6 +36,7 @@ export class ProcessCaptureReadFailure<
readonly cleanupLeases: readonly AnalysisCleanupLease[];
readonly cleanupCause: unknown;
readonly retention: CloseRetention;
/** An observed abort is independent of whether cancellation caused the read failure. */
readonly cancellation: Cancellation | undefined;
constructor(options: {
@@ -49,13 +50,18 @@ export class ProcessCaptureReadFailure<
}) {
const cleanupLeases =
options.cleanupLease === undefined ? [] : [options.cleanupLease];
const cancellation =
const primaryCancellation =
options.outcome.kind === "failed"
? operationCancellation(options.outcome.cause, options.signal)
: options.completionCancellation;
const cancellation =
primaryCancellation ??
(options.signal?.aborted === true
? { reason: options.signal.reason }
: undefined);
const primary =
cancellation !== undefined
? cancellation.reason
primaryCancellation !== undefined
? primaryCancellation.reason
: options.outcome.kind === "failed"
? options.outcome.cause
: options.cleanupCause;
@@ -78,7 +84,7 @@ export class ProcessCaptureReadFailure<
`Process capture read failed for ${options.path}${reasons.length === 0 ? "" : `: ${reasons.join("; ")}`}`,
{
cause: primary,
...(cancellation === undefined
...(primaryCancellation === undefined
? {}
: {
userCategory: "cancelled" as const,
@@ -118,7 +118,7 @@ const expectWorkflowQuestionsRetained = (
expect.arrayContaining(
result.residual_unknowns.map((question) =>
expect.objectContaining({
question,
question: `${question} (Evidence ${evidence.evidence_id})`,
supporting_evidence_ids: [evidence.evidence_id],
}),
),
+8 -24
View File
@@ -16,6 +16,7 @@ import {
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { artifactOccurrenceAt } from "../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { asarHeaderBytes } from "../../fixtures/asarHeader.js";
describe("ZIP signature detection", () => {
it.each([
@@ -65,30 +66,8 @@ describe("ZIP signature detection", () => {
const textPkg = join(root, "notes.pkg");
const dmg = join(root, "image.dmg");
const textDmg = join(root, "notes.dmg");
const headerSize = 20;
await writeFile(
asar,
Buffer.from([
4,
0,
0,
0,
headerSize,
0,
0,
0,
headerSize - 4,
0,
0,
0,
2,
0,
0,
0,
0x7b,
0x7d,
]),
);
const asarBytes = asarHeaderBytes(JSON.stringify({ files: {} }));
await writeFile(asar, asarBytes);
await writeFile(textAsar, Buffer.from("not an asar"));
await writeFile(pkg, Buffer.from("xar!"));
await writeFile(textPkg, Buffer.from("not a pkg"));
@@ -103,6 +82,11 @@ describe("ZIP signature detection", () => {
(await classifyAndHashRoot(textAsar, false, await lstat(textAsar)))
.format,
).toBe("file");
// A valid prefix cannot admit extents outside the observed container.
await writeFile(asar, asarBytes.subarray(0, -1));
expect(
(await classifyAndHashRoot(asar, false, await lstat(asar))).format,
).toBe("file");
expect(
(await classifyAndHashRoot(pkg, false, await lstat(pkg))).format,
).toBe("pkg");
+11 -2
View File
@@ -219,7 +219,14 @@ it("advertises and enforces the same Unicode selector grammar through MCP", asyn
if (tool === undefined) throw new Error("Missing AOT tool");
const ajv = new Ajv2020({ strict: false, validateFormats: false });
expect(ajv.validateSchema(tool.inputSchema)).toBe(true);
for (const abi of ["😀".repeat(1010), "l".repeat(1010)]) {
for (const abi of [
"😀".repeat(1010),
"l".repeat(1010),
"😀".repeat(1009) + "\n",
...["\n", "\r", "\u2028", "\u2029"].map(
(terminator) => "l".repeat(1009) + terminator,
),
]) {
const arguments_ = { path: "/fixture.apk", abi };
expect(ajv.validate(tool.inputSchema, arguments_)).toBe(true);
expect(
@@ -235,7 +242,9 @@ it("advertises and enforces the same Unicode selector grammar through MCP", asyn
"",
"lib/x86_64",
"😀".repeat(1011),
"l".repeat(1010) + "\n",
...["\n", "\r", "\u2028", "\u2029"].map(
(terminator) => "l".repeat(1010) + terminator,
),
]) {
const arguments_ = { path: "/fixture.apk", abi };
expect(ajv.validate(tool.inputSchema, arguments_)).toBe(false);
@@ -176,14 +176,11 @@ itWithCaptureCapability(
itWithCaptureCapability(
"keeps the process deadline bounded when the wall clock moves backward",
async () => {
const wallStart = Date.now();
let wallReads = 0;
const now = vi.spyOn(Date, "now").mockImplementation(() => {
wallReads += 1;
return wallReads === 1 ? wallStart : wallStart - 60_000 + wallReads;
});
let wallNow = Date.now();
const now = vi.spyOn(Date, "now").mockImplementation(() => wallNow);
const controller = new AbortController();
const fallback = setTimeout(() => controller.abort(), 1_500);
let fallback: ReturnType<typeof setTimeout> | undefined;
let reachedRunning = false;
try {
const result = await captureProcessScenario(
parseProcessScenario({
@@ -194,10 +191,27 @@ itWithCaptureCapability(
idle_timeout_ms: 5_000,
}),
controller.signal,
process.platform,
process.env,
undefined,
undefined,
{
async report(update) {
if (update.phase === "running" && !reachedRunning) {
reachedRunning = true;
wallNow -= 60_000;
fallback = setTimeout(() => controller.abort(), 1_500);
} else if (
update.phase === "settling" ||
update.phase === "cleanup"
)
clearTimeout(fallback);
},
},
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value.exit.reason).toBe("timeout");
expect(reachedRunning).toBe(true);
const { capture } = captureObservations(result);
expect(capture.exit.reason).toBe("timeout");
} finally {
clearTimeout(fallback);
now.mockRestore();
@@ -584,7 +598,7 @@ itWithCaptureCapability(
}
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);
let reachedRunning = false;
const cancelled = await captureProcessScenario(
parseProcessScenario({
executable: process.execPath,
@@ -594,9 +608,43 @@ itWithCaptureCapability(
idle_timeout_ms: 5_000,
}),
controller.signal,
process.platform,
process.env,
undefined,
undefined,
{
async report(update) {
if (update.phase === "running") {
reachedRunning = true;
controller.abort();
}
},
},
);
if (cancelled.ok) throw new Error("expected cancellation");
expect(cancelled.error.message).toContain("cancelled");
expect(reachedRunning).toBe(true);
if (!(cancelled.error instanceof ProcessCaptureError))
throw cancelled.error;
if (cancelled.error.cleanupIncomplete)
expectUnverifiedHostCleanup(cancelled.error);
else
expect(cancelled.error.cleanupReport).toMatchObject({
owned_process_group: { state: "cleaned" },
terminal_renderer: { state: "cleaned" },
temporary_root: { state: "cleaned" },
});
const cancellation = cancelled.error.cleanupIncomplete
? cancelled.error.cause
: cancelled.error;
expect(cancellation, cancelled.error.message).toMatchObject({
reason: "cancelled",
userCategory: "cancelled",
});
expect(cancelled.error.partialObservation).toMatchObject({
observations: {
exit: { state: "available", value: { reason: "cancelled" } },
},
});
},
);
@@ -206,6 +206,7 @@ const connectProgressClient = async (
): Promise<{
readonly client: Client;
readonly notifications: string[];
readonly cleanupProgress: Promise<void>;
}> => {
const session = createTestBinarySession(() => {
throw new Error("Process capture must not launch a binary provider");
@@ -219,9 +220,14 @@ const connectProgressClient = async (
version: "1",
});
const notifications: string[] = [];
let sawCleanup: (() => void) | undefined;
const cleanupProgress = new Promise<void>((resolve) => {
sawCleanup = resolve;
});
if (recordNotifications) {
client.setNotificationHandler("notifications/progress", (notification) => {
notifications.push(JSON.stringify(notification));
if (notification.params.message?.startsWith("cleanup:")) sawCleanup?.();
});
}
const [clientTransport, serverTransport] =
@@ -231,7 +237,7 @@ const connectProgressClient = async (
onTestFinished(async () => {
await Promise.allSettled([client.close(), server.close(), session.close()]);
});
return { client, notifications };
return { client, notifications, cleanupProgress };
};
itWithCaptureCapability(
@@ -283,7 +289,7 @@ itWithCaptureCapability(
);
itWithCaptureCapability(
"reports unknown resource cleanup when temporary-root preparation fails",
"reports no acquired resources when temporary-root creation fails",
async () => {
const scenario = await writeScenario(
"rea-process-progress-prepare-failed-",
@@ -311,9 +317,9 @@ itWithCaptureCapability(
terminal: true,
details: {
disposition: "failed",
owned_process_group: "unverified",
terminal_renderer: "unverified",
temporary_root: "unverified",
owned_process_group: "not_required",
terminal_renderer: "not_required",
temporary_root: "not_required",
},
});
},
@@ -590,9 +596,13 @@ itWithCaptureCapability(
controller.abort();
await completion;
}
await expect
.poll(() => cancelling.notifications.join("\n"))
.toContain("disposition=cancelled");
// Client cancellation ends its wait before the server has released its
// process tree. Wait for the actual terminal notification under the test's
// existing deadline instead of giving cleanup a separate polling deadline.
await cancelling.cleanupProgress;
expect(cancelling.notifications.join("\n")).toContain(
"disposition=cancelled",
);
expect(cancelling.notifications.join("\n")).toContain(
"owned_process_group=cleaned",
);
@@ -704,6 +704,9 @@ it("preserves an IO failure that arrives with cancellation before descriptor cle
cleanupIncomplete: false,
});
expect((failure as ProcessCaptureReadFailure).userCategory).toBeUndefined();
expect((failure as ProcessCaptureReadFailure).cancellation?.reason).toBe(
reason,
);
expect(injected.fileHandle?.fd).toBe(-1);
});
@@ -96,6 +96,67 @@ afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
vi.restoreAllMocks();
});
it("cancels during executable bytes without transferring descriptor cleanup or blocking the scope", async () => {
const bytes = "executable bytes\n".repeat(16_384);
const path = await createExecutable(bytes);
const controller = new AbortController();
const reason = new Error("cancel after the first physical executable read");
injected.path = path;
const scope = new ProcessCaptureResourceScope();
const fileSystem = {
open: async (selected: string, flags: number) => {
const handle = await open(selected, flags);
const read = handle.read.bind(handle);
const readAndCancel = async (
buffer: Buffer,
offset: number,
length: number,
position: number | bigint | null,
) => {
const result = await read(buffer, offset, length, position);
controller.abort(reason);
return result;
};
vi.spyOn(handle, "read").mockImplementation(
readAndCancel as typeof handle.read,
);
// Exercise the same physical-read interleaving in the old native-stream
// implementation: abort after bytes arrive, while its iterator owns data.
const stream = handle.createReadStream.bind(handle);
vi.spyOn(handle, "createReadStream").mockImplementation((options) => {
const source = stream(options);
source.once("data", () => controller.abort(reason));
return source;
});
return handle;
},
stat: (selected: string) => stat(selected, { bigint: true }),
};
const failure = await caughtFailure(() =>
scope.run(() =>
observeSelectedExecutable(path, controller.signal, fileSystem, scope),
),
);
expect(controller.signal.aborted).toBe(true);
expect(failure.cause).toBe(reason);
expect(failure.userCategory).toBe("cancelled");
expect(failure.cleanupIncomplete).toBe(false);
expect(failure.cleanupLeases).toEqual([]);
expect(injected.closed).toEqual([path]);
expect(injected.handles[0]?.isOpen()).toBe(false);
await expect(
scope.run(() =>
observeSelectedExecutable(path, undefined, undefined, scope),
),
).resolves.toMatchObject({
sha256: createHash("sha256").update(bytes).digest("hex"),
});
await scope.close();
});
it("retains the completed executable observation and descriptor owner after close failure", async () => {
@@ -177,6 +238,80 @@ it("preserves selected executable IO failure when descriptor close also fails",
await scope.close();
});
it.each([
[false, false],
[false, true],
[true, false],
[true, true],
])(
"retains pending read failure and cancellation (system error: %s, close denied: %s)",
async (systemError, closeDenied) => {
const path = await createExecutable("selected executable read failure");
const primary = new Error("physical executable read rejected");
if (systemError) Object.assign(primary, { code: "EIO", syscall: "read" });
const reason = { request: "cancel while the descriptor read is pending" };
const controller = new AbortController();
let enter!: () => void;
let release!: () => void;
const entered = new Promise<void>((resolve) => {
enter = resolve;
});
const pending = new Promise<void>((resolve) => {
release = resolve;
});
injected.path = path;
injected.closeFailure = closeDenied
? new Error("descriptor close denied after failed read")
: undefined;
const scope = new ProcessCaptureResourceScope();
const fileSystem = {
open: async (selected: string, flags: number) => {
const handle = await open(selected, flags);
handle.read = async () => {
enter();
await pending;
throw primary;
};
return handle;
},
stat: (selected: string) => stat(selected, { bigint: true }),
};
const reading = caughtFailure(() =>
scope.run(() =>
observeSelectedExecutable(path, controller.signal, fileSystem, scope),
),
);
await entered;
controller.abort(reason);
release();
const failure = await reading;
expect(failure.cause).toBe(primary);
expect(failure.outcome).toMatchObject({ kind: "failed", cause: primary });
expect(failure.cancellation?.reason).toBe(reason);
expect(failure.userCategory).toBeUndefined();
expect(failure.cleanupIncomplete).toBe(closeDenied);
expect(failure.cleanupCause).toBe(injected.closeFailure);
expect(failure.cleanupLeases).toHaveLength(closeDenied ? 1 : 0);
expect(injected.closed).toEqual([path]);
injected.closeFailure = undefined;
await scope.retryCleanup();
expect(failure.cleanupIncomplete).toBe(false);
expect(injected.handles[0]?.isOpen()).toBe(false);
await expect(
scope.run(() =>
observeSelectedExecutable(path, undefined, undefined, scope),
),
).resolves.toMatchObject({
sha256: createHash("sha256")
.update("selected executable read failure")
.digest("hex"),
});
await scope.close();
},
);
it("keeps the exact cancellation reason when cancellation arrives during descriptor close", async () => {
const path = await createExecutable(
"selected executable cancelled during close",
@@ -58,7 +58,9 @@ import { parseProcessScenario } from "../../src/domain/process/processScenario.j
import {
hashFile,
snapshotRoots,
ProcessCaptureSnapshotFailure,
} from "../../src/process/capture/FilesystemSnapshot.js";
import { ProcessCaptureReadFailure } from "../../src/process/capture/ProcessCaptureRead.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
afterEach(() => {
@@ -99,7 +101,29 @@ it.each(["hash", "snapshot"] as const)(
}),
controller.signal,
);
await expect(result).rejects.toBe(reason);
const failure = await result.catch((cause: unknown) => cause);
if (operation === "hash") {
expect(failure).toBeInstanceOf(ProcessCaptureReadFailure);
const read = failure as ProcessCaptureReadFailure;
expect(read.cause).toBe(reason);
expect(read.userCategory).toBe("cancelled");
expect(read.outcome).toMatchObject({ kind: "failed", cause: reason });
expect(read.cleanupLeases).toEqual([]);
expect(read.cleanupIncomplete).toBe(false);
} else {
expect(failure).toBeInstanceOf(ProcessCaptureSnapshotFailure);
const snapshot = failure as ProcessCaptureSnapshotFailure;
expect(snapshot.cause).toBe(reason);
expect(snapshot.userCategory).toBe("cancelled");
expect(snapshot.snapshot).toMatchObject({
files: [],
completeRoots: [],
truncated: true,
coverage: { enumeration_truncated: true },
});
expect(snapshot.cleanupLeases).toEqual([]);
expect(snapshot.cleanupIncomplete).toBe(false);
}
expect(controller.signal.aborted).toBe(true);
expect(observation.handles).toHaveLength(1);
await expect(observation.handles[0]?.stat()).rejects.toMatchObject({
@@ -17,7 +17,6 @@ const race = vi.hoisted(() => ({
| "cancel_directory_close"
| "",
target: "",
followup: false,
opened: false,
closeCompleted: false,
controller: undefined as AbortController | undefined,
@@ -61,14 +60,12 @@ vi.mock("node:fs/promises", async (importOriginal) => {
const target = await original.readlink(...args);
if (path === race.target) {
if (race.mode === "lstat_missing") {
race.followup = true;
await original.rm(path);
} else if (race.mode === "lstat_identity_changed") {
const replacement = `${path}.replacement`;
await original.symlink("replacement-target", replacement);
await original.rm(path);
await original.rename(replacement, path);
race.followup = true;
} else if (race.mode === "cancel_readlink") {
race.controller?.abort(race.reason);
}
@@ -77,15 +74,6 @@ vi.mock("node:fs/promises", async (importOriginal) => {
},
lstat: async (...args: Parameters<typeof original.lstat>) => {
const path = args[0];
if (
path === race.target &&
(race.mode === "lstat_missing" ||
race.mode === "lstat_identity_changed") &&
race.followup
) {
race.followup = false;
return original.lstat(...args);
}
if (
path === race.target &&
race.mode === "directory_missing" &&
@@ -103,44 +91,11 @@ vi.mock("node:fs/promises", async (importOriginal) => {
if (args[0] === race.target && race.mode === "directory_missing")
race.opened = true;
if (args[0] === race.target && race.mode === "cancel_directory_close") {
const iterator = directory[Symbol.asyncIterator]();
let closed = false;
const closeBeforeCancel = async () => {
if (closed) return;
closed = true;
try {
await directory.close();
} catch (cause: unknown) {
if (
!(cause instanceof Error) ||
!("code" in cause) ||
cause.code !== "ERR_DIR_CLOSED"
)
throw cause;
}
const close = directory.close.bind(directory);
vi.spyOn(directory, "close").mockImplementation(async () => {
await close();
race.closeCompleted = true;
race.controller?.abort(race.reason);
};
const wrappedIterator = {
[Symbol.asyncIterator]() {
return this;
},
async next() {
const next = await iterator.next();
if (next.done) await closeBeforeCancel();
return next;
},
async return() {
const result = await iterator.return?.();
await closeBeforeCancel();
return result ?? { done: true, value: undefined };
},
};
return new Proxy(directory, {
get(target, property, receiver) {
if (property === Symbol.asyncIterator) return () => wrappedIterator;
return Reflect.get(target, property, receiver);
},
});
}
// The selected directory's next lstat is snapshotRoots' post-enumeration
@@ -150,22 +105,54 @@ vi.mock("node:fs/promises", async (importOriginal) => {
};
});
import { parseProcessScenario } from "../../src/domain/process/processScenario.js";
import {
parseProcessScenario,
type ProcessScenario,
} from "../../src/domain/process/processScenario.js";
import { classifyFilesystemEffects } from "../../src/process/capture/ProcessFilesystemEffects.js";
import { snapshotRoots } from "../../src/process/capture/FilesystemSnapshot.js";
import {
snapshotRoots,
ProcessCaptureSnapshotFailure,
} from "../../src/process/capture/FilesystemSnapshot.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const resetRace = () => {
race.mode = "";
race.target = "";
race.followup = false;
race.opened = false;
race.closeCompleted = false;
race.controller = undefined;
race.reason = undefined;
};
afterEach(resetRace);
afterEach(() => {
resetRace();
vi.restoreAllMocks();
});
const expectCancelledSnapshot = async (
scenario: ProcessScenario,
controller: AbortController,
): Promise<void> => {
const cause = await snapshotRoots(scenario, controller.signal).catch(
(failure: unknown) => failure,
);
expect(controller.signal.aborted).toBe(true);
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
const failure = cause as ProcessCaptureSnapshotFailure;
expect(failure.cause).toBe(controller.signal.reason);
expect(failure.userCategory).toBe("cancelled");
expect(failure.snapshot).toMatchObject({
truncated: true,
completeRoots: [],
coverage: { enumeration_truncated: true },
});
expect(failure.snapshot?.files).toContainEqual(
expect.objectContaining({ path: "root_0:.", type: "directory" }),
);
expect(failure.cleanupLeases).toEqual([]);
expect(failure.cleanupIncomplete).toBe(false);
};
const withTemporaryRoot = async <Value>(
prefix: string,
@@ -335,9 +322,7 @@ it.skipIf(process.platform === "win32")(
race.target = link;
race.controller = controller;
race.reason = reason;
await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe(
reason,
);
await expectCancelledSnapshot(scenario, controller);
}),
);
@@ -358,13 +343,11 @@ it.skipIf(process.platform === "win32")(
race.target = link;
race.controller = controller;
race.reason = reason;
await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe(
reason,
);
await expectCancelledSnapshot(scenario, controller);
}),
);
it("observes cancellation after the selected directory iterator closes", async () =>
it("observes cancellation after the selected directory handle closes", async () =>
withTemporaryRoot("rea-snapshot-directory-close-cancel-", async (root) => {
const directory = join(root, "selected");
await mkdir(directory);
@@ -375,14 +358,12 @@ it("observes cancellation after the selected directory iterator closes", async (
filesystem_observation_paths: [directory],
});
const controller = new AbortController();
const reason = new Error("cancel after directory iterator close");
const reason = new Error("cancel after directory handle close");
race.mode = "cancel_directory_close";
race.target = directory;
race.controller = controller;
race.reason = reason;
await expect(snapshotRoots(scenario, controller.signal)).rejects.toBe(
reason,
);
await expectCancelledSnapshot(scenario, controller);
expect(race.closeCompleted).toBe(true);
}));
@@ -426,8 +407,17 @@ it.skipIf(process.platform === "win32")(
});
race.mode = "readlink_eio";
race.target = link;
await expect(snapshotRoots(scenario)).rejects.toMatchObject({
code: "EIO",
const cause = await snapshotRoots(scenario).catch(
(failure: unknown) => failure,
);
expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure);
const failure = cause as ProcessCaptureSnapshotFailure;
expect(failure.cause).toMatchObject({ code: "EIO" });
expect(failure.userCategory).toBeUndefined();
expect(failure.snapshot).toMatchObject({
truncated: true,
completeRoots: [],
coverage: { enumeration_omissions: [] },
});
}),
);
@@ -1,28 +1,27 @@
import { execFile } from "node:child_process";
import { rm, writeFile } from "node:fs/promises";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { Cli } from "incur";
import { afterEach, describe, expect, test, vi } from "vitest";
import { observeJavaScriptRuntime } from "../../../src/application/javascript/JavaScriptRuntimeObservationService.js";
import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js";
import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js";
import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js";
import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { registerJavaScriptRuntimeObservationCommands } from "../../../src/cliJavaScriptRuntimeCommands.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { ok } from "../../../src/domain/result.js";
import { silentLogger } from "../../../src/logger.js";
import { observeJavaScriptRuntime } from "../../src/application/javascript/JavaScriptRuntimeObservationService.js";
import type { JavaScriptRuntimeObservationPort } from "../../src/application/javascript/JavaScriptRuntimeObservationPort.js";
import { V8InspectorProvider } from "../../src/inspector/V8InspectorProvider.js";
import { observeJavaScriptRuntimeInputSchema } from "../../src/domain/javascript/javascriptRuntimeObservation.js";
import { startFakeV8Inspector } from "../fixtures/inspector/fakeV8Inspector.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { registerJavaScriptRuntimeObservationCommands } from "../../src/cliJavaScriptRuntimeCommands.js";
import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js";
import { ok } from "../../src/domain/result.js";
import { silentLogger } from "../../src/logger.js";
import { cliTest } from "../support/cli/cliFixture.js";
const runtimeFactory = vi.hoisted(() => ({
provider: undefined as JavaScriptRuntimeObservationPort | undefined,
}));
vi.mock("../../../src/composition/javascriptRuntimeObservation.js", () => ({
vi.mock("../../src/composition/javascriptRuntimeObservation.js", () => ({
createJavaScriptRuntimeObservationProvider: () => {
if (runtimeFactory.provider === undefined)
throw new Error("No runtime observation provider was configured");
@@ -30,28 +29,20 @@ vi.mock("../../../src/composition/javascriptRuntimeObservation.js", () => ({
},
}));
const execute = promisify(execFile);
const TIMEOUT_MS = 20_000;
describe("JavaScript runtime observation CLI parity", () => {
const resources: Array<{ close(): Promise<unknown> }> = [];
const temporary: string[] = [];
afterEach(async () => {
runtimeFactory.provider = undefined;
await Promise.all(resources.splice(0).map((item) => item.close()));
await Promise.all(
temporary
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
test(
cliTest(
"returns the same passive Inspector Evidence contracts",
async () => {
async ({ cli }) => {
const root = await createTestTempDirectory("rea-v8-cli-");
temporary.push(root);
const entry = join(root, "entry.js");
await writeFile(entry, "export const value = 1;\n");
const inspector = await startFakeV8Inspector({
@@ -60,11 +51,16 @@ describe("JavaScript runtime observation CLI parity", () => {
resources.push(inspector);
const environment = { ...process.env };
const listed = await runCli(
["list-javascript-runtime-targets", inspector.endpoint, "--json"],
const listed = await cli.run({
arguments: [
"list-javascript-runtime-targets",
inspector.endpoint,
"--json",
],
environment,
);
expect(listed).toMatchObject({
});
expect(listed.exitCode, listed.stderr).toBe(0);
expect(listed.json).toMatchObject({
operation: "list_javascript_runtime_targets",
provider: { id: "rea-v8-inspector" },
normalized_result: {
@@ -72,8 +68,8 @@ describe("JavaScript runtime observation CLI parity", () => {
},
});
const observed = await runCli(
[
const observed = await cli.run({
arguments: [
"observe-javascript-runtime",
inspector.endpoint,
inspector.targetId,
@@ -84,8 +80,9 @@ describe("JavaScript runtime observation CLI parity", () => {
"--json",
],
environment,
);
expect(observed).toMatchObject({
});
expect(observed.exitCode, observed.stderr).toBe(0);
expect(observed.json).toMatchObject({
operation: "observe_javascript_runtime",
provider: { id: "rea-v8-inspector" },
normalized_result: {
@@ -99,18 +96,23 @@ describe("JavaScript runtime observation CLI parity", () => {
},
},
});
const direct = await observeJavaScriptRuntime(
new V8InspectorProvider(),
observeJavaScriptRuntimeInputSchema.parse({
inspector_endpoint: inspector.endpoint,
target_id: inspector.targetId,
runtime_kind: "node",
observation_ms: 10,
}),
);
expect(direct.ok).toBe(true);
if (direct.ok)
expect(evidenceIdFrom(observed)).toBe(direct.value.evidence_id);
const provider = new V8InspectorProvider();
try {
const direct = await observeJavaScriptRuntime(
provider,
observeJavaScriptRuntimeInputSchema.parse({
inspector_endpoint: inspector.endpoint,
target_id: inspector.targetId,
runtime_kind: "node",
observation_ms: 10,
}),
);
expect(direct.ok).toBe(true);
if (direct.ok)
expect(evidenceIdFrom(observed.json)).toBe(direct.value.evidence_id);
} finally {
await provider.close();
}
},
TIMEOUT_MS,
);
@@ -172,22 +174,6 @@ describe("JavaScript runtime observation CLI parity", () => {
});
});
const runCli = async (
arguments_: readonly string[],
environment: NodeJS.ProcessEnv,
): Promise<unknown> => {
const { stdout } = await execute(
process.execPath,
["scripts/rea.mjs", ...arguments_],
{
cwd: process.cwd(),
env: environment,
maxBuffer: 16 * 1_024 * 1_024,
},
);
return JSON.parse(stdout);
};
const evidenceIdFrom = (value: unknown): string => {
if (typeof value !== "object" || value === null)
throw new TypeError("Missing CLI Evidence");
@@ -49,12 +49,17 @@ vi.mock("node:fs/promises", async (load) => {
control.handle = handle;
const nativeStat = handle.stat.bind(handle);
const nativeClose = handle.close.bind(handle);
const nativeStream = handle.createReadStream.bind(handle);
handle.createReadStream = (options) => {
const stream = nativeStream(options);
stream.once("end", () => (control.sampled = true));
return stream;
};
const nativeRead = handle.read.bind(handle);
handle.read = (async (
buffer: Buffer,
offset: number,
length: number,
position: number | bigint | null,
) => {
const result = await nativeRead(buffer, offset, length, position);
if (result.bytesRead === 0) control.sampled = true;
return result;
}) as typeof handle.read;
handle.stat = (async (...parameters: Parameters<typeof handle.stat>) => {
const metadata = await nativeStat(...parameters);
if (control.sampled && control.stage === "final-stat")
@@ -125,7 +130,6 @@ it.each(["none", "before", "open", "final-stat", "close"] as const)(
},
);
expect(control.launches).toBe(stage === "none" ? 1 : 0);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected sentinel failure or cancellation");
if (stage === "none")
expect(String(result.error.cause)).toContain("PTY launch sentinel");
@@ -10,7 +10,6 @@ const injected = vi.hoisted(() => ({
failClose: false,
opens: 0,
handle: undefined as Awaited<ReturnType<typeof open>> | undefined,
nativeClose: undefined as (() => Promise<void>) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
@@ -23,7 +22,6 @@ vi.mock("node:fs/promises", async (importOriginal) => {
injected.opens += 1;
injected.handle = handle;
const nativeClose = handle.close.bind(handle);
injected.nativeClose = nativeClose;
handle.close = async () => {
if (injected.failClose)
throw new Error("controlled file close failure");
@@ -34,14 +32,14 @@ vi.mock("node:fs/promises", async (importOriginal) => {
};
});
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js";
import { observed } from "../../fixtures/analysisExecution.js";
import { createServer } from "../../../src/server/createServer.js";
import { silentLogger } from "../../../src/logger.js";
import { ToolResultDelivery } from "../../../src/server/toolResult.js";
import { ok } from "../../../src/domain/result.js";
import { createTestBinarySession } from "../fixtures/binarySession.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import type { AnalysisClient } from "../../src/application/AnalysisProvider.js";
import { observed } from "../fixtures/analysisExecution.js";
import { createServer } from "../../src/server/createServer.js";
import { silentLogger } from "../../src/logger.js";
import { ToolResultDelivery } from "../../src/server/toolResult.js";
import { ok } from "../../src/domain/result.js";
const connectSession = async () => {
const session = createTestBinarySession(
@@ -172,6 +170,5 @@ it("retains and retries the exact snapshot file handle through public MCP captur
await rm(root, { recursive: true, force: true });
injected.path = "";
injected.handle = undefined;
injected.nativeClose = undefined;
}
});
+15 -15
View File
@@ -16,6 +16,7 @@ import {
systemUpdateHost,
} from "../../src/application/UpdateRuntime.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
const entryPoint = resolve("scripts/rea.mjs");
let home: string;
@@ -381,21 +382,20 @@ describe("updated executable maintenance planning", () => {
});
describe("update subprocess boundaries", () => {
it("exposes update as the sole CLI command", async () => {
const help = await runUpdateCommand(
[process.execPath, entryPoint, "--help"],
process.env,
);
expect(help.ok).toBe(true);
if (help.ok) {
expect(help.value).toMatch(/^\s{2}update\s/mu);
expect(help.value).not.toMatch(/^\s{2}upgrade\s/mu);
}
const removed = await runUpdateCommand(
[process.execPath, entryPoint, "upgrade", "--json"],
process.env,
);
expect(removed.ok).toBe(false);
cliTest("exposes update as the sole CLI command", async ({ cli }) => {
const help = await cli.run({
arguments: ["--help"],
environment: process.env,
});
expect(help.exitCode, help.stderr).toBe(0);
expect(help.stdout).toMatch(/^\s{2}update\s/mu);
expect(help.stdout).not.toMatch(/^\s{2}upgrade\s/mu);
const removed = await cli.run({
arguments: ["upgrade", "--json"],
environment: process.env,
});
expect(removed.exitCode, removed.stderr).toBe(1);
expect(removed.json).toMatchObject({ code: "COMMAND_NOT_FOUND" });
});
it("captures output through stream closure and keeps arguments literal", async () => {
const literal = 'space and $(echo bad); & | "quotes"';