mirror of
https://github.com/morluto/rea.git
synced 2026-10-21 21:32:26 -05:00
test(environment): verify export failures through the real CLI
Remove shared-module mocks for input reads, heap statistics, and tarball streaming. Exercise actual file changes and real V8 heap admission in fresh CLI processes; keep only selected-descriptor syscall fault injection for the pathless EIO case. Reproduced the four hosted source failures with a preloaded module graph and found the same flaw in the adjacent tarball test. The preloaded control now passes 17 cases; 32 focused tests, all 1,739 service tests (2 skips), and check:ci pass. Snapshot, cleanup, identity, and error projection coverage is retained.
This commit is contained in:
1 parent
5eebe2fdf8
commit
c324d31b45
4 files changed
+235
-157
No files matched your search
@@ -9,7 +9,6 @@ import {
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
writeEnvironmentServeFiles,
|
||||
} from "../../../tests/fixtures/environmentServe.js";
|
||||
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
|
||||
import * as fileChunks from "../../filesystem/readFileHandleChunks.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
@@ -30,61 +28,6 @@ import {
|
||||
readEnvironmentExportInputs,
|
||||
} from "./environmentExport.js";
|
||||
|
||||
vi.mock("../../filesystem/readFileHandleChunks.js", { spy: true });
|
||||
|
||||
it("attributes a pathless tarball read failure during the copy to the tarball", async () => {
|
||||
const root = await createTestTempDirectory("rea-tarball-read-failure-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
const inputs = await readEnvironmentExportInputs(
|
||||
files.manifestPath,
|
||||
files.capturePath,
|
||||
);
|
||||
if (!inputs.ok) throw inputs.error;
|
||||
const tarball = join(root, "package.tgz");
|
||||
await writeFile(tarball, "admitted package");
|
||||
const directory = join(root, "bundle");
|
||||
const actual = await vi.importActual<typeof fileChunks>(
|
||||
"../../filesystem/readFileHandleChunks.js",
|
||||
);
|
||||
// Only the tarball descriptor is read twice: once to hash, once to copy.
|
||||
const reads = new Map<unknown, number>();
|
||||
const reader = vi
|
||||
.mocked(fileChunks.readFileHandleChunks)
|
||||
.mockImplementation((handle, options) => {
|
||||
const count = (reads.get(handle) ?? 0) + 1;
|
||||
reads.set(handle, count);
|
||||
if (count < 2) return actual.readFileHandleChunks(handle, options);
|
||||
const failure = Object.assign(new Error("EIO: i/o error, read"), {
|
||||
code: "EIO",
|
||||
syscall: "read",
|
||||
});
|
||||
return Object.assign(
|
||||
new Readable({
|
||||
read() {
|
||||
this.destroy(failure);
|
||||
},
|
||||
}),
|
||||
{ readCoverage: () => ({ status: "unknown" as const }) },
|
||||
) as unknown as ReturnType<typeof fileChunks.readFileHandleChunks>;
|
||||
});
|
||||
try {
|
||||
const result = await exportEnvironmentBundle({
|
||||
...inputs.value,
|
||||
source: environmentServeSource(),
|
||||
directory,
|
||||
packageTarball: tarball,
|
||||
});
|
||||
if (result.ok || !(result.error instanceof AnalysisOutputError))
|
||||
throw new Error("expected an output failure");
|
||||
expect(result.error.reason).toContain(`reading ${tarball}`);
|
||||
expect(result.error.remediationAction).toContain(tarball);
|
||||
expect(result.error.remediationAction).not.toContain("disk space");
|
||||
expect(result.error.diagnostics).toEqual({ output_directory: directory });
|
||||
} finally {
|
||||
reader.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the admitted tarball descriptor and refuses a pathname replacement during output", async () => {
|
||||
const root = await createTestTempDirectory("rea-tarball-instance-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import * as v8 from "node:v8";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
environmentServeFixture,
|
||||
@@ -12,17 +11,11 @@ import {
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { PACKAGE_METADATA } from "../../generatedPackageMetadata.js";
|
||||
import { RegularFileChangedError } from "../../filesystem/RegularFile.js";
|
||||
import { nativeJsonParseRequiredBytes } from "../JsonInputFile.js";
|
||||
import * as regularFileRead from "../RegularFileRead.js";
|
||||
import {
|
||||
exportEnvironmentBundle,
|
||||
readEnvironmentExportInputs,
|
||||
} from "./environmentExport.js";
|
||||
|
||||
vi.mock("node:v8", { spy: true });
|
||||
vi.mock("../RegularFileRead.js", { spy: true });
|
||||
|
||||
const listFiles = async (root: string): Promise<string[]> =>
|
||||
(await readdir(root, { recursive: true, withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile())
|
||||
@@ -52,41 +45,6 @@ const prepare = async (prefix: string) => {
|
||||
};
|
||||
|
||||
describe("readEnvironmentExportInputs", () => {
|
||||
it.each(["manifest", "evidence"] as const)(
|
||||
"returns a tagged input error when the %s snapshot changes",
|
||||
async (role) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-environment-input-changed-",
|
||||
);
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
const changedPath =
|
||||
role === "manifest" ? files.manifestPath : files.capturePath;
|
||||
const actual = await vi.importActual<typeof regularFileRead>(
|
||||
"../RegularFileRead.js",
|
||||
);
|
||||
const reader = vi.mocked(regularFileRead.readRegularFile);
|
||||
reader.mockImplementation(async (path, options) => {
|
||||
if (path === changedPath) throw new RegularFileChangedError(path);
|
||||
return actual.readRegularFile(path, options);
|
||||
});
|
||||
try {
|
||||
const result = await readEnvironmentExportInputs(
|
||||
files.manifestPath,
|
||||
files.capturePath,
|
||||
);
|
||||
if (result.ok) throw new Error("Expected changed input refusal");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "environment-export",
|
||||
issues: [{ path: [role], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
reader.mockImplementation(actual.readRegularFile);
|
||||
}
|
||||
},
|
||||
);
|
||||
it("reads each file once as the bytes on disk and the value they parse to", async () => {
|
||||
const root = await createTestTempDirectory("rea-environment-inputs-");
|
||||
const fixture = environmentServeFixture();
|
||||
@@ -107,32 +65,6 @@ describe("readEnvironmentExportInputs", () => {
|
||||
expect(inputs.value.evidence.value).toEqual(fixture.capture);
|
||||
});
|
||||
|
||||
it("refuses an input whose whole read and parse would not fit live heap headroom, before reading it", async () => {
|
||||
const root = await createTestTempDirectory("rea-environment-export-heap-");
|
||||
const files = await writeEnvironmentServeFiles(
|
||||
root,
|
||||
environmentServeFixture(),
|
||||
);
|
||||
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
|
||||
...v8.getHeapStatistics(),
|
||||
total_available_size: 1024,
|
||||
});
|
||||
const refused = await readEnvironmentExportInputs(
|
||||
files.manifestPath,
|
||||
files.capturePath,
|
||||
);
|
||||
expect(refused.ok).toBe(false);
|
||||
if (refused.ok) return;
|
||||
expect(refused.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
resource: "memory",
|
||||
reportedLimits: {
|
||||
input_path: files.manifestPath,
|
||||
available_heap_bytes: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses inline JSON, a directory and a file that is not JSON by role", async () => {
|
||||
const root = await createTestTempDirectory("rea-environment-inputs-bad-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
@@ -314,34 +246,3 @@ describe("exportEnvironmentBundle file modes", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("readEnvironmentExportInputs heap admission", () => {
|
||||
it("reserves headroom for snapshot sealing after the native parse", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-environment-export-copies-",
|
||||
);
|
||||
const files = await writeEnvironmentServeFiles(
|
||||
root,
|
||||
environmentServeFixture(),
|
||||
);
|
||||
const { size } = await stat(files.manifestPath);
|
||||
// Room for parsing, but not for the owned snapshot traversal that follows it.
|
||||
vi.mocked(v8.getHeapStatistics).mockReturnValueOnce({
|
||||
...v8.getHeapStatistics(),
|
||||
total_available_size: nativeJsonParseRequiredBytes(size) * 1.5,
|
||||
});
|
||||
const refused = await readEnvironmentExportInputs(
|
||||
files.manifestPath,
|
||||
files.capturePath,
|
||||
);
|
||||
expect(refused.ok).toBe(false);
|
||||
if (refused.ok) return;
|
||||
expect(refused.error).toMatchObject({
|
||||
_tag: "AnalysisResourceConstraintError",
|
||||
reportedLimits: {
|
||||
input_path: files.manifestPath,
|
||||
required_heap_bytes: nativeJsonParseRequiredBytes(size, true),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
appendFile,
|
||||
readFile,
|
||||
stat,
|
||||
truncate,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { nativeJsonParseRequiredBytes } from "../../../src/application/JsonInputFile.js";
|
||||
import { writeEnvironmentServeFiles } from "../../fixtures/environmentServe.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const probe = pathToFileURL(
|
||||
resolve("tests/fixtures/environmentExportInputProbe.mjs"),
|
||||
).href;
|
||||
|
||||
for (const role of ["manifest", "evidence"] as const) {
|
||||
cliTest(
|
||||
`refuses a real ${role} file changed during its read`,
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-export-input-race-cli-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
const input =
|
||||
role === "manifest" ? files.manifestPath : files.capturePath;
|
||||
const before = await readFile(input);
|
||||
const marker = join(root, "read-marker");
|
||||
const directory = join(root, "bundle");
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"environment-export",
|
||||
files.manifestPath,
|
||||
"--evidence",
|
||||
files.capturePath,
|
||||
directory,
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`,
|
||||
REA_TEST_EXPORT_INPUT: input,
|
||||
REA_TEST_EXPORT_READ_MARKER: marker,
|
||||
REA_TEST_EXPORT_READ_ACTION: "append",
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(await readFile(marker, "utf8")).toBe("read");
|
||||
expect(await readFile(input)).toEqual(
|
||||
Buffer.concat([before, Buffer.from(" ")]),
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
operation: "environment-export",
|
||||
issues: [{ path: [role], reason: "invalid_value" }],
|
||||
},
|
||||
});
|
||||
await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const heapRefusalSchema = z.object({
|
||||
code: z.literal("resource_constraint"),
|
||||
details: z.object({
|
||||
operation: z.literal("environment-export"),
|
||||
resource: z.literal("memory"),
|
||||
reported_limits: z.object({
|
||||
input_path: z.string(),
|
||||
input_file_bytes: z.number(),
|
||||
required_heap_bytes: z.number(),
|
||||
available_heap_bytes: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"admits neither the whole read nor an unbudgeted snapshot under a real heap limit",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-export-input-heap-cli-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
const originalBytes = (await stat(files.manifestPath)).size;
|
||||
const marker = join(root, "read-marker");
|
||||
const directory = join(root, "bundle");
|
||||
// Whitespace preserves a valid manifest while making the actual file large.
|
||||
await appendFile(files.manifestPath, Buffer.alloc(8 * 1024 * 1024, " "));
|
||||
let available: number | undefined;
|
||||
for (const phase of ["whole-read", "snapshot"] as const) {
|
||||
if (available !== undefined) {
|
||||
// Fit the native parse in two thirds of the previous child's real
|
||||
// headroom, while the additional snapshot reservation cannot fit.
|
||||
const overhead = nativeJsonParseRequiredBytes(0);
|
||||
const perByte = nativeJsonParseRequiredBytes(1) - overhead;
|
||||
await truncate(
|
||||
files.manifestPath,
|
||||
Math.max(
|
||||
originalBytes,
|
||||
Math.floor(((available * 2) / 3 - overhead) / perByte),
|
||||
),
|
||||
);
|
||||
}
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"environment-export",
|
||||
files.manifestPath,
|
||||
"--evidence",
|
||||
files.capturePath,
|
||||
directory,
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`,
|
||||
REA_TEST_EXPORT_INPUT: files.manifestPath,
|
||||
REA_TEST_EXPORT_READ_MARKER: marker,
|
||||
REA_TEST_EXPORT_READ_ACTION: "record",
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
const limits = heapRefusalSchema.parse(result.json).details
|
||||
.reported_limits;
|
||||
expect(limits.input_path).toBe(files.manifestPath);
|
||||
expect(limits.input_file_bytes).toBe(
|
||||
(await stat(files.manifestPath)).size,
|
||||
);
|
||||
expect(limits.required_heap_bytes).toBe(
|
||||
nativeJsonParseRequiredBytes(limits.input_file_bytes, true),
|
||||
);
|
||||
expect(limits.required_heap_bytes).toBeGreaterThan(
|
||||
limits.available_heap_bytes,
|
||||
);
|
||||
if (phase === "snapshot")
|
||||
expect(
|
||||
nativeJsonParseRequiredBytes(limits.input_file_bytes),
|
||||
).toBeLessThan(limits.available_heap_bytes);
|
||||
else
|
||||
expect(
|
||||
nativeJsonParseRequiredBytes(limits.input_file_bytes),
|
||||
).toBeGreaterThan(limits.available_heap_bytes);
|
||||
available = limits.available_heap_bytes;
|
||||
await expect(stat(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"attributes a pathless descriptor failure during the real tarball copy",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-export-copy-read-cli-");
|
||||
const files = await writeEnvironmentServeFiles(root);
|
||||
const tarball = join(root, "package.tgz");
|
||||
await writeFile(tarball, "admitted package");
|
||||
const marker = join(root, "read-marker");
|
||||
const directory = join(root, "bundle");
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"environment-export",
|
||||
files.manifestPath,
|
||||
"--evidence",
|
||||
files.capturePath,
|
||||
directory,
|
||||
"--package-tarball",
|
||||
tarball,
|
||||
"--json",
|
||||
],
|
||||
environment: {
|
||||
NODE_OPTIONS: `--import=${probe} --max-old-space-size=256`,
|
||||
REA_TEST_EXPORT_INPUT: tarball,
|
||||
REA_TEST_EXPORT_READ_MARKER: marker,
|
||||
REA_TEST_EXPORT_READ_ACTION: "fail-copy",
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(await readFile(marker, "utf8")).toBe("copy");
|
||||
expect(await readFile(tarball, "utf8")).toBe("admitted package");
|
||||
expect(result.exitCode, result.stderr).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: {
|
||||
reason: expect.stringContaining(`reading ${tarball}`),
|
||||
diagnostics: { output_directory: directory },
|
||||
},
|
||||
remediation: { action: expect.stringContaining(tarball) },
|
||||
});
|
||||
expect(JSON.stringify(result.json)).not.toContain("disk space");
|
||||
},
|
||||
);
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
|
||||
const path = process.env.REA_TEST_EXPORT_INPUT;
|
||||
const marker = process.env.REA_TEST_EXPORT_READ_MARKER;
|
||||
const action = process.env.REA_TEST_EXPORT_READ_ACTION;
|
||||
if (!path || !marker || !["append", "record", "fail-copy"].includes(action))
|
||||
throw new Error("Expected an explicit export input, read marker and action");
|
||||
|
||||
// Instrument only the selected real descriptor in this disposable CLI process.
|
||||
// Reads and metadata checks remain the production filesystem operations.
|
||||
const open = fs.open;
|
||||
fs.open = async (...args) => {
|
||||
const handle = await open(...args);
|
||||
if (args[0] !== path) return handle;
|
||||
const read = handle.read.bind(handle);
|
||||
let recorded = false;
|
||||
let hashed = false;
|
||||
handle.read = async (...readArgs) => {
|
||||
if (action === "fail-copy" && hashed) {
|
||||
await fs.writeFile(marker, "copy");
|
||||
throw Object.assign(new Error("controlled pathless descriptor failure"), {
|
||||
code: "EIO",
|
||||
syscall: "read",
|
||||
});
|
||||
}
|
||||
const result = await read(...readArgs);
|
||||
if (action === "fail-copy") {
|
||||
hashed = result.bytesRead === 0;
|
||||
return result;
|
||||
}
|
||||
if (!recorded) {
|
||||
recorded = true;
|
||||
await fs.writeFile(marker, "read");
|
||||
if (action === "append") await fs.appendFile(path, " ");
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return handle;
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
Reference in new issue
Block a user