mirror of
https://github.com/morluto/rea.git
synced 2026-11-02 21:06:04 +00:00
368 lines
13 KiB
TypeScript
368 lines
13 KiB
TypeScript
import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { expect, it, onTestFinished, vi } from "vitest";
|
|
|
|
const injected = vi.hoisted(() => ({
|
|
sourcePath: undefined as string | undefined,
|
|
abortController: undefined as AbortController | undefined,
|
|
abortDuringClose: false,
|
|
abortReason: undefined as unknown,
|
|
readFailure: undefined as Error | undefined,
|
|
failClose: false,
|
|
closeAttempts: 0,
|
|
sourceHandle: undefined as import("node:fs/promises").FileHandle | undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...actual,
|
|
open: async (...args: Parameters<typeof actual.open>) => {
|
|
const handle = await actual.open(...args);
|
|
if (args[0] !== injected.sourcePath) return handle;
|
|
injected.sourceHandle = handle;
|
|
const read = handle.read.bind(handle);
|
|
Object.defineProperty(handle, "read", {
|
|
value: async (
|
|
buffer: Buffer,
|
|
offset: number,
|
|
length: number,
|
|
position: number,
|
|
) => {
|
|
if (injected.readFailure !== undefined) throw injected.readFailure;
|
|
const result = await read(buffer, offset, length, position);
|
|
if (!injected.abortDuringClose)
|
|
injected.abortController?.abort(injected.abortReason);
|
|
return result;
|
|
},
|
|
});
|
|
const close = handle.close.bind(handle);
|
|
handle.close = async () => {
|
|
injected.closeAttempts += 1;
|
|
if (injected.abortDuringClose)
|
|
injected.abortController?.abort(injected.abortReason);
|
|
if (injected.failClose) throw new Error("fixture close refusal");
|
|
await close();
|
|
};
|
|
return handle;
|
|
},
|
|
};
|
|
});
|
|
|
|
import { WebModuleTraceService } from "../../../src/application/WebModuleTraceService.js";
|
|
import { finishAnalysisCommand } from "../../../src/cli/finishAnalysisCommand.js";
|
|
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
|
import {
|
|
webArtifactAcquisitionSchema,
|
|
type WebArtifactAcquisition,
|
|
} from "../../../src/domain/webArtifactAcquisition.js";
|
|
import { primaryAnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
|
import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const sourceFixture = async (source: string) => {
|
|
const root = await createTestTempDirectory("rea-web-artifact-read-owner-");
|
|
const fixture = webModuleArtifactsFixture(source);
|
|
const manifestPath = join(root, "manifest.json");
|
|
const sourcePath = join(root, "files", "modules", "main.js");
|
|
await mkdir(join(root, "files", "modules"), { recursive: true });
|
|
const sourceBytes = Buffer.from(source);
|
|
const manifestBytes = Buffer.from(JSON.stringify(fixture.manifest));
|
|
await writeFile(sourcePath, sourceBytes);
|
|
await writeFile(manifestPath, manifestBytes);
|
|
return {
|
|
input: { manifest_path: manifestPath, script_index: 0 },
|
|
sourcePath,
|
|
sourceBytes,
|
|
manifestBytes,
|
|
};
|
|
};
|
|
|
|
const fileIdentity = (path: string, bytes: Buffer) => ({
|
|
path,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
bytes: bytes.length,
|
|
});
|
|
|
|
it("retains cancelled source read ownership until service close retries it", async () => {
|
|
onTestFinished(() => {
|
|
injected.sourcePath = undefined;
|
|
injected.abortController = undefined;
|
|
injected.abortDuringClose = false;
|
|
injected.abortReason = undefined;
|
|
injected.readFailure = undefined;
|
|
injected.failClose = false;
|
|
injected.closeAttempts = 0;
|
|
injected.sourceHandle = undefined;
|
|
});
|
|
const { input, sourcePath, manifestBytes } =
|
|
await sourceFixture('import "./dep.js";');
|
|
|
|
injected.sourcePath = sourcePath;
|
|
injected.failClose = true;
|
|
const controller = new AbortController();
|
|
injected.abortController = controller;
|
|
injected.abortReason = { request: "cancel after source read" };
|
|
const resolver = {
|
|
resolve: vi.fn(),
|
|
close: vi.fn(async () => undefined),
|
|
};
|
|
const service = new WebModuleTraceService(
|
|
new LocalWebModuleArtifacts(),
|
|
resolver,
|
|
);
|
|
onTestFinished(async () => {
|
|
injected.failClose = false;
|
|
injected.abortController = undefined;
|
|
await service.close();
|
|
});
|
|
|
|
const cancelled = await service.trace(input, { signal: controller.signal });
|
|
expect(cancelled.ok).toBe(false);
|
|
if (cancelled.ok) throw new Error("Expected cancelled source read");
|
|
const expectedAcquisition: WebArtifactAcquisition = {
|
|
kind: "web-artifact-acquisition",
|
|
analysis: "not-performed",
|
|
files: [
|
|
{
|
|
role: "manifest",
|
|
file: fileIdentity(input.manifest_path, manifestBytes),
|
|
admission: "manifest-validated",
|
|
},
|
|
],
|
|
};
|
|
expect(
|
|
webArtifactAcquisitionSchema.parse(cancelled.error.partialObservation),
|
|
).toEqual(expectedAcquisition);
|
|
expect(
|
|
projectAnalysisError(cancelled.error).details?.partial_observation,
|
|
).toEqual(expectedAcquisition);
|
|
expect(cancelled.error.cleanupIncomplete).toBe(true);
|
|
expect(cancelled.error.cleanupResources).toContain(sourcePath);
|
|
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
expect(primaryAnalysisError(cancelled.error).cause).toBe(
|
|
injected.abortReason,
|
|
);
|
|
|
|
injected.abortController = undefined;
|
|
const blocked = await service.trace(input);
|
|
expect(blocked.ok).toBe(false);
|
|
if (blocked.ok) throw new Error("Expected retained cleanup to block load");
|
|
expect(blocked.error.cleanupIncomplete).toBe(true);
|
|
expect(injected.closeAttempts).toBe(2);
|
|
|
|
let cleanupFailure: unknown;
|
|
try {
|
|
await service.close();
|
|
} catch (cause) {
|
|
cleanupFailure = cause;
|
|
}
|
|
expect(cleanupFailure).toBeInstanceOf(ProviderCleanupError);
|
|
expect(cleanupFailure).toMatchObject({
|
|
operation: "trace_web_module_imports",
|
|
cleanupResources: [sourcePath],
|
|
diagnostics: { reason: expect.stringContaining("fixture close refusal") },
|
|
});
|
|
expect((cleanupFailure as ProviderCleanupError).cause).toBeInstanceOf(Error);
|
|
expect(resolver.close).toHaveBeenCalledTimes(1);
|
|
expect(injected.closeAttempts).toBe(3);
|
|
|
|
const finalized = await finishAnalysisCommand(
|
|
() => service.trace(input),
|
|
service,
|
|
"web-module-artifacts",
|
|
"trace_web_module_imports",
|
|
new AbortController().signal,
|
|
).then(projectCliAnalysisResult);
|
|
expect(finalized).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: { resources: [sourcePath] },
|
|
});
|
|
expect(resolver.close).toHaveBeenCalledTimes(2);
|
|
|
|
injected.failClose = false;
|
|
const attemptsBeforeRetry = injected.closeAttempts;
|
|
await service.close();
|
|
expect(injected.closeAttempts).toBeGreaterThan(attemptsBeforeRetry);
|
|
expect(resolver.close).toHaveBeenCalledTimes(3);
|
|
expect(injected.sourceHandle?.fd).toBe(-1);
|
|
expect(primaryAnalysisError(cancelled.error).partialObservation).toEqual(
|
|
expectedAcquisition,
|
|
);
|
|
expect(
|
|
projectAnalysisError(primaryAnalysisError(cancelled.error)).details
|
|
?.partial_observation,
|
|
).toEqual(expectedAcquisition);
|
|
for (const error of [cancelled.error, blocked.error, cleanupFailure]) {
|
|
expect(error).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanupResources: [],
|
|
});
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ readFails: true, closeFails: false, cancel: true },
|
|
{ readFails: true, closeFails: true, cancel: true },
|
|
{ readFails: true, closeFails: true, cancel: false },
|
|
{ readFails: false, closeFails: false, cancel: true },
|
|
{ readFails: false, closeFails: true, cancel: true },
|
|
{ readFails: false, closeFails: false, cancel: false },
|
|
])(
|
|
"keeps physical source read failure and exact cleanup ownership %j",
|
|
async ({ readFails, closeFails, cancel }) => {
|
|
const { input, sourcePath, sourceBytes, manifestBytes } =
|
|
await sourceFixture("export const value = 1;");
|
|
const physical = Object.assign(new Error("fixture descriptor EIO"), {
|
|
code: "EIO",
|
|
syscall: "read",
|
|
});
|
|
const reason = { request: "cancel during failed source finalization" };
|
|
const controller = new AbortController();
|
|
injected.sourcePath = sourcePath;
|
|
injected.readFailure = readFails ? physical : undefined;
|
|
injected.failClose = closeFails;
|
|
injected.abortController = cancel ? controller : undefined;
|
|
injected.abortDuringClose = true;
|
|
injected.abortReason = reason;
|
|
injected.closeAttempts = 0;
|
|
const service = new WebModuleTraceService(new LocalWebModuleArtifacts(), {
|
|
resolve: vi.fn(),
|
|
});
|
|
onTestFinished(async () => {
|
|
injected.failClose = false;
|
|
injected.abortController = undefined;
|
|
await service.close();
|
|
injected.sourcePath = undefined;
|
|
injected.readFailure = undefined;
|
|
injected.abortDuringClose = false;
|
|
injected.abortReason = undefined;
|
|
injected.closeAttempts = 0;
|
|
injected.sourceHandle = undefined;
|
|
});
|
|
const result = await service.trace(input, { signal: controller.signal });
|
|
expect(controller.signal.aborted).toBe(cancel);
|
|
if (cancel) expect(controller.signal.reason).toBe(reason);
|
|
if (!readFails && !cancel) {
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.normalized_result).toMatchObject({
|
|
source: {
|
|
path: sourcePath,
|
|
sha256: fileIdentity(sourcePath, sourceBytes).sha256,
|
|
},
|
|
manifest: { path: input.manifest_path },
|
|
});
|
|
await service.close();
|
|
expect(injected.sourceHandle?.fd).toBe(-1);
|
|
expect(injected.closeAttempts).toBe(1);
|
|
return;
|
|
}
|
|
if (result.ok) throw new Error("Expected source read failure");
|
|
const primary = primaryAnalysisError(result.error);
|
|
if (readFails)
|
|
expect(primary).toMatchObject({
|
|
_tag: "ArtifactOperationError",
|
|
reason: "io",
|
|
});
|
|
else expect(primary).toBeInstanceOf(AnalysisCancelledError);
|
|
expect(primary.cause).toBe(readFails ? physical : reason);
|
|
const expectedAcquisition: WebArtifactAcquisition = {
|
|
kind: "web-artifact-acquisition",
|
|
analysis: "not-performed",
|
|
files: [
|
|
{
|
|
role: "manifest",
|
|
file: fileIdentity(input.manifest_path, manifestBytes),
|
|
admission: "manifest-validated",
|
|
},
|
|
// Descriptor-finalization cancellation precedes selected-source digest/length admission.
|
|
...(!readFails
|
|
? [
|
|
{
|
|
role: "source" as const,
|
|
file: fileIdentity(sourcePath, sourceBytes),
|
|
admission: "read" as const,
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
};
|
|
expect(
|
|
webArtifactAcquisitionSchema.parse(result.error.partialObservation),
|
|
).toEqual(expectedAcquisition);
|
|
expect(primary.partialObservation).toEqual(expectedAcquisition);
|
|
expect(
|
|
projectAnalysisError(result.error).details?.partial_observation,
|
|
).toEqual(expectedAcquisition);
|
|
expect(result.error.cleanupIncomplete).toBe(closeFails);
|
|
if (closeFails) {
|
|
expect(result.error.cleanupResources).toEqual([sourcePath]);
|
|
expect(injected.sourceHandle?.fd).toBeGreaterThanOrEqual(0);
|
|
}
|
|
injected.failClose = false;
|
|
await service.close();
|
|
expect(injected.sourceHandle?.fd).toBe(-1);
|
|
expect(injected.closeAttempts).toBe(closeFails ? 2 : 1);
|
|
expect(result.error.cleanupIncomplete).toBe(false);
|
|
expect(result.error.cleanupResources).toEqual([]);
|
|
expect(primaryAnalysisError(result.error)).toBe(primary);
|
|
expect(primary.partialObservation).toEqual(expectedAcquisition);
|
|
expect(projectAnalysisError(primary).details?.partial_observation).toEqual(
|
|
expectedAcquisition,
|
|
);
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: readFails ? "artifact_operation_failed" : "cancelled",
|
|
...(readFails
|
|
? {
|
|
details: {
|
|
reason: "io",
|
|
detail: expect.stringContaining("EIO"),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves both web cleanup owners' resources, diagnostics and recovery actions", async () => {
|
|
const artifacts = new ProviderCleanupError(
|
|
"web-artifacts",
|
|
["artifact-root", "shared-resource"],
|
|
{ reason: "artifact cleanup denied" },
|
|
{ remediationAction: "Release the retained artifact lease." },
|
|
);
|
|
const resolver = new AnalysisCancelledError("web-resolution", {
|
|
cleanup: {
|
|
reason: "resolver shutdown unconfirmed",
|
|
resources: ["resolver-root", "shared-resource"],
|
|
},
|
|
});
|
|
const service = new WebModuleTraceService(
|
|
{
|
|
load: vi.fn(),
|
|
close: async () => {
|
|
throw artifacts;
|
|
},
|
|
},
|
|
{
|
|
resolve: vi.fn(),
|
|
close: async () => {
|
|
throw resolver;
|
|
},
|
|
},
|
|
);
|
|
const projected = [artifacts, resolver].map(projectAnalysisError);
|
|
await expect(service.close()).rejects.toMatchObject({
|
|
cleanupResources: ["artifact-root", "shared-resource", "resolver-root"],
|
|
diagnostics: { failures: projected },
|
|
remediationAction: projected
|
|
.map(({ remediation }) => remediation.action)
|
|
.join("; "),
|
|
});
|
|
});
|