mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(web): retain completed file identities across acquisition failures
This commit is contained in:
1 parent
620690c923
commit
82af5b5f52
9 files changed
+478
-97
No files matched your search
@@ -2,7 +2,6 @@ import { lstat, realpath } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
|
||||
import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
@@ -16,7 +15,7 @@ import type { SelectedWebScriptInput } from "../../domain/webScriptArtifacts.js"
|
||||
import { webScriptExportManifestSchema } from "../../domain/webScriptExport.js";
|
||||
import {
|
||||
decodeWebArtifact,
|
||||
readWebArtifactJson,
|
||||
WebArtifactAcquisition,
|
||||
webArtifactReadError,
|
||||
type WebArtifactReadContext,
|
||||
} from "./WebArtifactReads.js";
|
||||
@@ -53,47 +52,62 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
}
|
||||
|
||||
async #load(input: SelectedWebScriptInput, options?: ExecutionOptions) {
|
||||
const acquisition = new WebArtifactAcquisition();
|
||||
let field: readonly (string | number)[] = ["manifest_path"];
|
||||
let targetPath = input.manifest_path;
|
||||
let callerSelected = true;
|
||||
try {
|
||||
const manifestBytes = await readWebArtifactJson(
|
||||
const manifestBytes = await acquisition.readJson(
|
||||
"manifest",
|
||||
input.manifest_path,
|
||||
32 * 1024 * 1024,
|
||||
options?.signal,
|
||||
this.resources,
|
||||
);
|
||||
const manifest = webScriptExportManifestSchema.parse(manifestBytes.value);
|
||||
acquisition.record(
|
||||
"manifest",
|
||||
{
|
||||
path: input.manifest_path,
|
||||
sha256: manifestBytes.sha256,
|
||||
bytes: manifestBytes.bytes.length,
|
||||
},
|
||||
"manifest-validated",
|
||||
);
|
||||
field = ["script_index"];
|
||||
const selected = manifest.scripts[input.script_index];
|
||||
// A valid manifest without this index is a caller selection fault.
|
||||
if (selected === undefined)
|
||||
return err(
|
||||
new AnalysisInputError(this.operation, undefined, [
|
||||
manifest.scripts.length === 0
|
||||
? {
|
||||
path: ["script_index"],
|
||||
reason: "invalid_value",
|
||||
message: "The selected manifest contains no scripts.",
|
||||
}
|
||||
: {
|
||||
path: ["script_index"],
|
||||
reason: "out_of_range",
|
||||
minimum: 0,
|
||||
maximum: manifest.scripts.length - 1,
|
||||
message: `No script at index ${String(input.script_index)}; the manifest contains ${String(manifest.scripts.length)} scripts.`,
|
||||
},
|
||||
]),
|
||||
acquisition.retain(
|
||||
new AnalysisInputError(this.operation, undefined, [
|
||||
manifest.scripts.length === 0
|
||||
? {
|
||||
path: ["script_index"],
|
||||
reason: "invalid_value",
|
||||
message: "The selected manifest contains no scripts.",
|
||||
}
|
||||
: {
|
||||
path: ["script_index"],
|
||||
reason: "out_of_range",
|
||||
minimum: 0,
|
||||
maximum: manifest.scripts.length - 1,
|
||||
message: `No script at index ${String(input.script_index)}; the manifest contains ${String(manifest.scripts.length)} scripts.`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
if (selected.content.state !== "exported")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"web-module-artifacts",
|
||||
this.operation,
|
||||
"selected_source_unavailable",
|
||||
{
|
||||
userMessage: `Selected script ${String(input.script_index)} has unavailable source bytes (${selected.content.reason}): ${selected.content.message} Capture source bytes before tracing this script.`,
|
||||
},
|
||||
acquisition.retain(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"web-module-artifacts",
|
||||
this.operation,
|
||||
"selected_source_unavailable",
|
||||
{
|
||||
userMessage: `Selected script ${String(input.script_index)} has unavailable source bytes (${selected.content.reason}): ${selected.content.message} Capture source bytes before tracing this script.`,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
// The index has selected a script. Its source metadata belongs to the
|
||||
@@ -116,11 +130,11 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
const sourcePath = join(root, "files", portable);
|
||||
targetPath = sourcePath;
|
||||
await assertContained(root, sourcePath);
|
||||
const sourceBytes = await readStableArtifact(
|
||||
const sourceBytes = await acquisition.read(
|
||||
"source",
|
||||
sourcePath,
|
||||
16 * 1024 * 1024,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.resources,
|
||||
);
|
||||
await assertContained(root, sourcePath);
|
||||
@@ -139,6 +153,15 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
unpacked: false,
|
||||
},
|
||||
);
|
||||
acquisition.record(
|
||||
"source",
|
||||
{
|
||||
path: sourcePath,
|
||||
sha256: sourceBytes.sha256,
|
||||
bytes: sourceBytes.bytes.length,
|
||||
},
|
||||
"source-verified",
|
||||
);
|
||||
const source = decodeWebArtifact(sourceBytes.bytes);
|
||||
return ok({
|
||||
manifest,
|
||||
@@ -156,10 +179,12 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort {
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{ operation: this.operation, field, targetPath, callerSelected },
|
||||
options?.signal,
|
||||
acquisition.retain(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{ operation: this.operation, field, targetPath, callerSelected },
|
||||
options?.signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { WebArtifactAcquisition as Acquisition } from "../../domain/webArtifactAcquisition.js";
|
||||
import { z } from "zod";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
@@ -7,9 +8,14 @@ import {
|
||||
type ArtifactFilePrimaryFacts,
|
||||
} from "../../artifacts/ArtifactFileOperation.js";
|
||||
import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
completedStableArtifact,
|
||||
readStableArtifact,
|
||||
} from "../../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
primaryAnalysisError,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCancelledError,
|
||||
@@ -47,24 +53,83 @@ export const decodeWebArtifact = (bytes: Buffer): string => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Read one stable JSON artifact with a documented byte budget. */
|
||||
export const readWebArtifactJson = async (
|
||||
path: string,
|
||||
maximumBytes: number,
|
||||
signal?: AbortSignal,
|
||||
resourceScope?: ArtifactResourceScope,
|
||||
) => {
|
||||
const data = await readStableArtifact(
|
||||
path,
|
||||
maximumBytes,
|
||||
signal,
|
||||
undefined,
|
||||
resourceScope,
|
||||
);
|
||||
const value = safeParseJson(decodeWebArtifact(data.bytes));
|
||||
if (!value.ok) throw new WebArtifactFormatFailure(value.error);
|
||||
return { ...data, value: value.value };
|
||||
};
|
||||
/** Own the independently completed physical reads throughout web acquisition. */
|
||||
export class WebArtifactAcquisition {
|
||||
readonly files: Acquisition["files"] = [];
|
||||
|
||||
record(
|
||||
role: Acquisition["files"][number]["role"],
|
||||
file: Acquisition["files"][number]["file"],
|
||||
admission: Acquisition["files"][number]["admission"] = "read",
|
||||
): void {
|
||||
const previous = this.files.findIndex((entry) => entry.role === role);
|
||||
const receipt = { role, file, admission };
|
||||
if (previous < 0) this.files.push(receipt);
|
||||
else this.files[previous] = receipt;
|
||||
}
|
||||
|
||||
async read(
|
||||
role: Acquisition["files"][number]["role"],
|
||||
path: string,
|
||||
maximumBytes: number,
|
||||
signal?: AbortSignal,
|
||||
scope?: ArtifactResourceScope,
|
||||
) {
|
||||
try {
|
||||
const data = await readStableArtifact(
|
||||
path,
|
||||
maximumBytes,
|
||||
signal,
|
||||
undefined,
|
||||
scope,
|
||||
);
|
||||
this.record(role, {
|
||||
path,
|
||||
sha256: data.sha256,
|
||||
bytes: data.bytes.length,
|
||||
});
|
||||
return data;
|
||||
} catch (cause: unknown) {
|
||||
const completed = completedStableArtifact(cause);
|
||||
if (completed !== undefined)
|
||||
this.record(role, {
|
||||
path,
|
||||
sha256: completed.sha256,
|
||||
bytes: completed.bytes.length,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async readJson(
|
||||
role: Acquisition["files"][number]["role"],
|
||||
path: string,
|
||||
maximumBytes: number,
|
||||
signal?: AbortSignal,
|
||||
scope?: ArtifactResourceScope,
|
||||
) {
|
||||
const data = await this.read(role, path, maximumBytes, signal, scope);
|
||||
const value = safeParseJson(decodeWebArtifact(data.bytes));
|
||||
if (!value.ok) throw new WebArtifactFormatFailure(value.error);
|
||||
return { ...data, value: value.value };
|
||||
}
|
||||
|
||||
retain(error: AnalysisError): AnalysisError {
|
||||
if (this.files.length > 0) {
|
||||
const observation: Acquisition = {
|
||||
kind: "web-artifact-acquisition",
|
||||
analysis: "not-performed",
|
||||
files: this.files.map((entry) => ({
|
||||
...entry,
|
||||
file: { ...entry.file },
|
||||
})),
|
||||
};
|
||||
error.partialObservation = observation;
|
||||
primaryAnalysisError(error).partialObservation = observation;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve artifact format, integrity, OS I/O, and cancellation reasons. */
|
||||
export const webArtifactReadError = (
|
||||
@@ -77,11 +142,6 @@ export const webArtifactReadError = (
|
||||
context,
|
||||
signal,
|
||||
);
|
||||
const observation = completedStableArtifactObservation(
|
||||
cause,
|
||||
context.targetPath,
|
||||
);
|
||||
if (observation !== undefined) primary.retainPartialObservation(observation);
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) {
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"captured-web-artifacts",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { WebSourceLocationArtifactPort } from "../../application/WebSourceL
|
||||
import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
|
||||
import {
|
||||
WEB_SOURCE_MAP_LIMITS,
|
||||
type WebSourceLocationInput,
|
||||
@@ -10,7 +9,11 @@ import {
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { WebArtifactCleanup } from "../WebArtifactCleanup.js";
|
||||
import { LocalWebScriptArtifacts } from "./LocalWebScriptArtifacts.js";
|
||||
import { decodeWebArtifact, webArtifactReadError } from "./WebArtifactReads.js";
|
||||
import {
|
||||
decodeWebArtifact,
|
||||
WebArtifactAcquisition,
|
||||
webArtifactReadError,
|
||||
} from "./WebArtifactReads.js";
|
||||
|
||||
/** Read one verified capture source and the caller-selected map, without fetching. */
|
||||
export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifactPort {
|
||||
@@ -41,32 +44,47 @@ export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifac
|
||||
|
||||
/** Preserve actual map identity separately from the selected deployment URL. */
|
||||
async load(input: WebSourceLocationInput, options?: ExecutionOptions) {
|
||||
const acquisition = new WebArtifactAcquisition();
|
||||
try {
|
||||
return await this.#resources.run(() => this.#load(input, options));
|
||||
return await this.#resources.run(() =>
|
||||
this.#load(input, acquisition, options),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{
|
||||
operation: "trace_web_source_location",
|
||||
field: ["source_map", "path"],
|
||||
targetPath: input.source_map.path,
|
||||
callerSelected: true,
|
||||
},
|
||||
options?.signal,
|
||||
acquisition.retain(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{
|
||||
operation: "trace_web_source_location",
|
||||
field: ["source_map", "path"],
|
||||
targetPath: input.source_map.path,
|
||||
callerSelected: true,
|
||||
},
|
||||
options?.signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #load(input: WebSourceLocationInput, options?: ExecutionOptions) {
|
||||
async #load(
|
||||
input: WebSourceLocationInput,
|
||||
acquisition: WebArtifactAcquisition,
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
const loaded = await this.scripts.load(input, options);
|
||||
if (!loaded.ok) return loaded;
|
||||
const data = await readStableArtifact(
|
||||
acquisition.record(
|
||||
"manifest",
|
||||
loaded.value.manifestFile,
|
||||
"manifest-validated",
|
||||
);
|
||||
acquisition.record("source", loaded.value.sourceFile, "source-verified");
|
||||
const data = await acquisition.read(
|
||||
"source-map",
|
||||
input.source_map.path,
|
||||
WEB_SOURCE_MAP_LIMITS.mapBytes,
|
||||
options?.signal,
|
||||
undefined,
|
||||
this.#resources,
|
||||
);
|
||||
return ok({
|
||||
|
||||
@@ -11,7 +11,7 @@ import { WebArtifactCleanup } from "../WebArtifactCleanup.js";
|
||||
import type { WebModuleTraceInput } from "../../domain/webModuleTrace.js";
|
||||
import { LocalWebScriptArtifacts } from "../assets/LocalWebScriptArtifacts.js";
|
||||
import {
|
||||
readWebArtifactJson,
|
||||
WebArtifactAcquisition,
|
||||
webArtifactReadError,
|
||||
} from "../assets/WebArtifactReads.js";
|
||||
|
||||
@@ -44,34 +44,50 @@ export class LocalWebModuleArtifacts implements WebModuleArtifactPort {
|
||||
|
||||
/** Read selected source and optional map without website requests. */
|
||||
async load(input: WebModuleTraceInput, options?: ExecutionOptions) {
|
||||
const acquisition = new WebArtifactAcquisition();
|
||||
try {
|
||||
return await this.#resources.run(() => this.#load(input, options));
|
||||
return await this.#resources.run(() =>
|
||||
this.#load(input, acquisition, options),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{
|
||||
operation: "trace_web_module_imports",
|
||||
field:
|
||||
input.import_map === undefined
|
||||
? ["manifest_path"]
|
||||
: ["import_map", "path"],
|
||||
targetPath: input.import_map?.path ?? input.manifest_path,
|
||||
callerSelected: input.import_map !== undefined,
|
||||
},
|
||||
options?.signal,
|
||||
acquisition.retain(
|
||||
webArtifactReadError(
|
||||
cause,
|
||||
{
|
||||
operation: "trace_web_module_imports",
|
||||
field:
|
||||
input.import_map === undefined
|
||||
? ["manifest_path"]
|
||||
: ["import_map", "path"],
|
||||
targetPath: input.import_map?.path ?? input.manifest_path,
|
||||
callerSelected: input.import_map !== undefined,
|
||||
},
|
||||
options?.signal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #load(input: WebModuleTraceInput, options?: ExecutionOptions) {
|
||||
async #load(
|
||||
input: WebModuleTraceInput,
|
||||
acquisition: WebArtifactAcquisition,
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
const loaded = await this.scripts.load(input, options);
|
||||
if (!loaded.ok) return loaded;
|
||||
acquisition.record(
|
||||
"manifest",
|
||||
loaded.value.manifestFile,
|
||||
"manifest-validated",
|
||||
);
|
||||
acquisition.record("source", loaded.value.sourceFile, "source-verified");
|
||||
if (input.import_map === undefined)
|
||||
return ok({ ...loaded.value, importMap: null });
|
||||
const selected = input.import_map;
|
||||
const data = await readWebArtifactJson(
|
||||
const data = await acquisition.readJson(
|
||||
"import-map",
|
||||
selected.path,
|
||||
4 * 1024 * 1024,
|
||||
options?.signal,
|
||||
|
||||
@@ -295,8 +295,11 @@ import type { WebNetworkCapture } from "./webNetworkCapture.js";
|
||||
import type { FridaDeviceList, FridaProcessList } from "./fridaDiscovery.js";
|
||||
import type { CutterBridgeDiscovery } from "./cutterDiscovery.js";
|
||||
|
||||
import type { WebArtifactAcquisition } from "./webArtifactAcquisition.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| WebArtifactAcquisition
|
||||
| CutterBridgeDiscovery
|
||||
| FridaDeviceList
|
||||
| FridaProcessList
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { webArtifactFileSchema } from "./webScriptArtifacts.js";
|
||||
|
||||
/** Independently completed reads; these facts never claim a completed analysis. */
|
||||
export const webArtifactAcquisitionSchema = z.strictObject({
|
||||
kind: z.literal("web-artifact-acquisition"),
|
||||
analysis: z.literal("not-performed"),
|
||||
files: z.array(
|
||||
z.strictObject({
|
||||
role: z.enum(["manifest", "source", "source-map", "import-map"]),
|
||||
file: webArtifactFileSchema,
|
||||
// Source verification establishes declared digest/length, not text decoding.
|
||||
admission: z.enum(["read", "manifest-validated", "source-verified"]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type WebArtifactAcquisition = z.infer<
|
||||
typeof webArtifactAcquisitionSchema
|
||||
>;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { webSourceLocationFixture } from "../fixtures/webSourceLocation.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"delivers completed web acquisition identities through CLI and stdio MCP when a later map is missing",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-web-acquisition-cli-");
|
||||
const fixture = webSourceLocationFixture();
|
||||
const manifestPath = join(root, "manifest.json");
|
||||
const sourcePath = join(root, "files", "modules", "main.js");
|
||||
const mapPath = join(root, "missing.map");
|
||||
const manifest = JSON.stringify(fixture.manifest);
|
||||
await mkdir(join(root, "files", "modules"), { recursive: true });
|
||||
await writeFile(manifestPath, manifest);
|
||||
await writeFile(sourcePath, fixture.source);
|
||||
const environment = {
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_BROWSER_EXECUTABLE: "relative-unconfigured-browser",
|
||||
};
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"trace-web-source-location",
|
||||
manifestPath,
|
||||
"0",
|
||||
mapPath,
|
||||
fixture.sourceMap.url,
|
||||
"1",
|
||||
"0",
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "invalid_request",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "web-artifact-acquisition",
|
||||
analysis: "not-performed",
|
||||
files: [
|
||||
{
|
||||
role: "manifest",
|
||||
file: {
|
||||
path: manifestPath,
|
||||
sha256: createHash("sha256").update(manifest).digest("hex"),
|
||||
bytes: Buffer.byteLength(manifest),
|
||||
},
|
||||
admission: "manifest-validated",
|
||||
},
|
||||
{
|
||||
role: "source",
|
||||
file: {
|
||||
path: sourcePath,
|
||||
sha256: createHash("sha256")
|
||||
.update(fixture.source)
|
||||
.digest("hex"),
|
||||
bytes: Buffer.byteLength(fixture.source),
|
||||
},
|
||||
admission: "source-verified",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const { call } = await connectLocalToolsMcp(environment);
|
||||
const response = await call("trace_web_source_location", {
|
||||
manifest_path: manifestPath,
|
||||
script_index: 0,
|
||||
source_map: { path: mapPath, url: fixture.sourceMap.url },
|
||||
generated_position: { line: 1, column: 0 },
|
||||
});
|
||||
expect(parseMcpToolError(response).error).toEqual(result.json);
|
||||
expect(response.structuredContent).toBeUndefined();
|
||||
expect(result.json).not.toHaveProperty("normalized_result");
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { LocalWebScriptArtifacts } from "../../../src/browser/assets/LocalWebScriptArtifacts.js";
|
||||
import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js";
|
||||
import { LocalWebSourceLocationArtifacts } from "../../../src/browser/assets/WebSourceLocationArtifacts.js";
|
||||
import { webArtifactAcquisitionSchema } from "../../../src/domain/webArtifactAcquisition.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js";
|
||||
|
||||
const identity = (path: string, bytes: Buffer) => ({
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
});
|
||||
|
||||
it.each([
|
||||
"invalid-json",
|
||||
"invalid-utf8",
|
||||
"invalid-schema",
|
||||
"missing-selection",
|
||||
"source-mismatch",
|
||||
"missing-map",
|
||||
"invalid-import-map",
|
||||
])(
|
||||
"retains independently read web file identities when %s prevents analysis",
|
||||
async (variant) => {
|
||||
const root = await createTestTempDirectory("rea-web-acquisition-");
|
||||
const fixture = webModuleArtifactsFixture();
|
||||
const manifestPath = join(root, "manifest.json");
|
||||
const sourcePath = join(root, "files", "modules", "main.js");
|
||||
await mkdir(join(root, "files", "modules"), { recursive: true });
|
||||
const source = Buffer.from(
|
||||
variant === "source-mismatch" ? "changed" : fixture.source,
|
||||
);
|
||||
await writeFile(sourcePath, source);
|
||||
const manifest =
|
||||
variant === "invalid-json"
|
||||
? Buffer.from("{")
|
||||
: variant === "invalid-utf8"
|
||||
? Buffer.from([255])
|
||||
: variant === "invalid-schema"
|
||||
? Buffer.from("{}")
|
||||
: Buffer.from(JSON.stringify(fixture.manifest));
|
||||
await writeFile(manifestPath, manifest);
|
||||
const reader =
|
||||
variant === "missing-map"
|
||||
? new LocalWebSourceLocationArtifacts()
|
||||
: variant === "invalid-import-map"
|
||||
? new LocalWebModuleArtifacts()
|
||||
: new LocalWebScriptArtifacts("trace_web_module_imports");
|
||||
onTestFinished(() => reader.close());
|
||||
const input = {
|
||||
manifest_path: manifestPath,
|
||||
script_index: variant === "missing-selection" ? 100 : 0,
|
||||
};
|
||||
const mapPath = join(root, "selected.map");
|
||||
if (variant === "invalid-import-map") await writeFile(mapPath, "[]");
|
||||
const result =
|
||||
reader instanceof LocalWebSourceLocationArtifacts
|
||||
? await reader.load({
|
||||
...input,
|
||||
source_map: { path: mapPath, url: "https://app.test/app.map" },
|
||||
generated_position: { line: 1, column: 0 },
|
||||
})
|
||||
: reader instanceof LocalWebModuleArtifacts
|
||||
? await reader.load({
|
||||
...input,
|
||||
import_map: { path: mapPath, base_url: "https://app.test/" },
|
||||
})
|
||||
: await reader.load(input);
|
||||
if (result.ok) throw new Error("Expected acquisition failure");
|
||||
const observation = webArtifactAcquisitionSchema.parse(
|
||||
projectAnalysisError(result.error).details?.partial_observation,
|
||||
);
|
||||
expect(observation.files).toHaveLength(
|
||||
variant === "invalid-import-map"
|
||||
? 3
|
||||
: ["source-mismatch", "missing-map"].includes(variant)
|
||||
? 2
|
||||
: 1,
|
||||
);
|
||||
expect(observation).toMatchObject({
|
||||
kind: "web-artifact-acquisition",
|
||||
analysis: "not-performed",
|
||||
files: expect.arrayContaining([
|
||||
{
|
||||
role: "manifest",
|
||||
file: identity(manifestPath, manifest),
|
||||
admission:
|
||||
variant.startsWith("invalid-") && variant !== "invalid-import-map"
|
||||
? "read"
|
||||
: "manifest-validated",
|
||||
},
|
||||
]),
|
||||
});
|
||||
if (
|
||||
["source-mismatch", "missing-map", "invalid-import-map"].includes(variant)
|
||||
)
|
||||
expect(observation).toMatchObject({
|
||||
files: expect.arrayContaining([
|
||||
{
|
||||
role: "source",
|
||||
file: identity(sourcePath, source),
|
||||
admission:
|
||||
variant === "source-mismatch" ? "read" : "source-verified",
|
||||
},
|
||||
]),
|
||||
});
|
||||
if (variant === "invalid-import-map")
|
||||
expect(observation).toMatchObject({
|
||||
files: expect.arrayContaining([
|
||||
{
|
||||
role: "import-map",
|
||||
file: identity(mapPath, Buffer.from("[]")),
|
||||
admission: "read",
|
||||
},
|
||||
]),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -64,7 +64,10 @@ vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
|
||||
import { RustBinaryProvider } from "../../src/rust/RustBinaryProvider.js";
|
||||
import { RustBinaryService } from "../../src/application/rust/RustBinaryService.js";
|
||||
import { AnalysisError } from "../../src/domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
resolvedAnalysisError,
|
||||
} from "../../src/domain/analysisErrorBase.js";
|
||||
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js";
|
||||
import {
|
||||
@@ -213,16 +216,40 @@ it.each(
|
||||
if (result.ok)
|
||||
throw new Error("Expected descriptor finalization failure");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
const snapshot = {
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
};
|
||||
expect(result.error.partialObservation).toEqual(
|
||||
outcome === "physical failure"
|
||||
? undefined
|
||||
: {
|
||||
kind: "file-snapshot-hash",
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
},
|
||||
consumer === "source map"
|
||||
? {
|
||||
kind: "web-artifact-acquisition",
|
||||
analysis: "not-performed",
|
||||
files: [
|
||||
{
|
||||
role: "manifest",
|
||||
file: script.manifestFile,
|
||||
admission: "manifest-validated",
|
||||
},
|
||||
{
|
||||
role: "source",
|
||||
file: script.sourceFile,
|
||||
admission: "source-verified",
|
||||
},
|
||||
...(outcome === "physical failure"
|
||||
? []
|
||||
: [
|
||||
{ role: "source-map", file: snapshot, admission: "read" },
|
||||
]),
|
||||
],
|
||||
}
|
||||
: outcome === "physical failure"
|
||||
? undefined
|
||||
: { kind: "file-snapshot-hash", ...snapshot },
|
||||
);
|
||||
const beforeCleanup = projectAnalysisError(result.error).details
|
||||
?.partial_observation;
|
||||
if (outcome === "physical failure")
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(physical);
|
||||
if (outcome === "close cancellation")
|
||||
@@ -232,6 +259,11 @@ it.each(
|
||||
fault.blocked.clear();
|
||||
await close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
if (consumer === "source map")
|
||||
expect(
|
||||
projectAnalysisError(resolvedAnalysisError(result.error)).details
|
||||
?.partial_observation,
|
||||
).toEqual(beforeCleanup);
|
||||
expect(fault.handles).toHaveLength(1);
|
||||
expect(fault.handles[0]?.fd).toBe(-1);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user