Files
rea/src/browser/IsolatedWebBundleAnalyzer.test.ts
T

384 lines
13 KiB
TypeScript

import { expect, it } from "vitest";
import { createWebPageInspection } from "../../tests/support/browser/webPageInspection.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
AnalysisResourceConstraintError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import type {
AnalyzeWebBundleInput,
WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok } from "../domain/result.js";
import {
IsolatedWebBundleAnalyzer,
type WebBundleAnalysisWorkerPort,
} from "./IsolatedWebBundleAnalyzer.js";
const origin = "https://app.example.test";
const noSourceMaps: WebBundleAnalysis["observations"]["source_maps"] = {
status: "not_requested",
requested: 0,
processed: 0,
items: [],
};
const input: AnalyzeWebBundleInput = analyzeWebBundleInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
target_id: "page-1",
include_script_sources: true,
});
it("preserves completed prefix and suffix facts when one script times out", async () => {
const captured = inspection([
"import './prefix.js'; fetch('/prefix');",
"function stalled() {}",
"import './suffix.js'; fetch('/suffix');",
]);
const calls: string[] = [];
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script) => {
calls.push(script.script_key);
if (calls.length === 2)
return err({
error: new AnalysisTimeoutError("analyze_web_bundle", 5),
completed: null,
});
return ok(analyzeCapturedWebScript(script));
},
close: async () => {},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, input);
analyzer.releaseReservation();
expect(calls).toEqual(
captured.scripts.items.map(({ script_key }) => script_key),
);
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(
result.value.observations.chunks.edges.map(({ specifier }) => specifier),
).toEqual(["./prefix.js", "./suffix.js"]);
expect(result.value.observations.endpoints.map(({ value }) => value)).toEqual(
["/prefix", "/suffix"],
);
expect(result.value.completeness).toMatchObject({
status: "partial",
parsed_scripts: 2,
parse_failures: 0,
});
expect(result.value.unknowns).toContainEqual({
dimension: "javascript_analysis",
reason: expect.stringContaining("deadline"),
affected_script_keys: [captured.scripts.items[1]?.script_key],
});
});
it("marks the unstarted suffix when the aggregate deadline expires", async () => {
const captured = inspection([
"fetch('/prefix');",
"fetch('/stalled');",
"fetch('/not-started');",
]);
const calls: string[] = [];
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script, _timeout, signal) => {
calls.push(script.script_key);
if (calls.length === 1) return ok(analyzeCapturedWebScript(script));
await waitForAbort(signal);
return err({
error: new AnalysisTimeoutError("analyze_web_bundle", 50),
completed: null,
});
},
close: async () => {},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, {
...input,
total_analysis_timeout_ms: 50,
});
analyzer.releaseReservation();
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected total deadline failure");
expect(result.error._tag).toBe("AnalysisTimeoutError");
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 1 },
observations: { endpoints: [{ value: "/prefix" }] },
});
expect(result.error.partialObservation).toMatchObject({
unknowns: expect.arrayContaining([
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[1]?.script_key],
}),
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[2]?.script_key],
}),
]),
});
});
it("stops after a middle-script result-capacity failure and retains prefix facts", async () => {
const captured = inspection([
"import './prefix.js'; fetch('/prefix');",
"fetch('/capacity-blocked');",
"fetch('/must-not-start');",
]);
const calls: string[] = [];
const caller = new AbortController();
const primary = new AnalysisResourceConstraintError(
"analyze_web_bundle",
"memory",
"No result capacity remains",
{ result_bytes: 0 },
);
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script) => {
calls.push(script.script_key);
if (calls.length === 1) return ok(analyzeCapturedWebScript(script));
return err({
error: primary,
completed: null,
continuation: "result_capacity_exhausted",
});
},
close: async () => {
caller.abort(new Error("later cancellation during final close"));
},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(
captured,
noSourceMaps,
input,
caller.signal,
);
analyzer.releaseReservation();
expect(calls).toEqual(
captured.scripts.items.slice(0, 2).map(({ script_key }) => script_key),
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected result-capacity failure");
expect(result.error).toBe(primary);
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 1 },
observations: {
chunks: { edges: [{ specifier: "./prefix.js" }] },
endpoints: [{ value: "/prefix" }],
},
unknowns: expect.arrayContaining([
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[1]?.script_key],
}),
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[2]?.script_key],
}),
]),
});
});
it("carries the exact caller cancellation reason and completed prefix facts", async () => {
const captured = inspection([
"fetch('/prefix');",
"fetch('/cancelled');",
"fetch('/cancelled-suffix');",
]);
const started = deferred<void>();
const controller = new AbortController();
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script, _timeout, signal) => {
if (script.script_key === captured.scripts.items[0]?.script_key)
return ok(analyzeCapturedWebScript(script));
started.resolve();
await waitForAbort(signal);
return err({
error: new AnalysisCancelledError("analyze_web_bundle", {
cause: signal?.reason,
}),
completed: null,
});
},
close: async () => {},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const exactReason = new Error("caller stopped analysis");
const pending = analyzer.analyze(
captured,
noSourceMaps,
input,
controller.signal,
);
await started.promise;
controller.abort(exactReason);
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(exactReason);
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 1 },
observations: { endpoints: [{ value: "/prefix" }] },
unknowns: expect.arrayContaining([
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[1]?.script_key],
}),
expect.objectContaining({
dimension: "javascript_analysis",
affected_script_keys: [captured.scripts.items[2]?.script_key],
}),
]),
});
});
it("retains all completed facts when cancellation arrives during worker close", async () => {
const captured = inspection(["fetch('/one');", "fetch('/two');"]);
const closeStarted = deferred<void>();
let closeWorker: (() => void) | undefined;
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async (script) => ok(analyzeCapturedWebScript(script)),
close: async () => {
closeStarted.resolve();
await new Promise<void>((resolve) => {
closeWorker = resolve;
});
},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const pending = analyzer.analyze(captured, noSourceMaps, input);
await closeStarted.promise;
const closing = analyzer.close();
closeWorker?.();
await closing;
const result = await pending;
analyzer.releaseReservation();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation during finalization");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "complete", parsed_scripts: 2 },
observations: { endpoints: [{ value: "/one" }, { value: "/two" }] },
});
});
it("retains cleanup ownership after mixed worker/close failure and resolves it on retry", async () => {
const captured = inspection(["fetch('/partial');"]);
const primary = new ProviderCleanupError(
"web-bundle-static-analysis",
["worker-job"],
{ reason: "analysis cleanup pending" },
{ operation: "analyze_web_bundle" },
);
const closeFailure = new ProviderCleanupError(
"web-bundle-static-analysis",
["worker-root"],
{ reason: "worker close pending" },
{ operation: "analyze_web_bundle" },
);
let closeAttempts = 0;
const analyzer = new IsolatedWebBundleAnalyzer(() => ({
analyze: async () => err({ error: primary, completed: null }),
close: async () => {
closeAttempts += 1;
if (closeAttempts <= 2) throw closeFailure;
closeFailure.resolveCleanup();
primary.resolveCleanup();
},
}));
expect((await analyzer.reserve()).ok).toBe(true);
const result = await analyzer.analyze(captured, noSourceMaps, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected mixed failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toBe(primary);
expect(result.error.cleanupResources).toEqual(["worker-job", "worker-root"]);
expect(result.error.partialObservation).toMatchObject({
completeness: { status: "partial", parsed_scripts: 0 },
});
const replacement = new IsolatedWebBundleAnalyzer(() => inertWorker());
analyzer.releaseReservation();
const blocked = await replacement.reserve();
expect(blocked.ok).toBe(false);
await analyzer.close();
expect(closeAttempts).toBe(3);
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupResources).toEqual([]);
expect((await replacement.reserve()).ok).toBe(true);
replacement.releaseReservation();
});
it("enforces one process-wide capture reservation across controller instances", async () => {
const first = new IsolatedWebBundleAnalyzer(() => inertWorker());
const second = new IsolatedWebBundleAnalyzer(() => inertWorker());
expect((await first.reserve()).ok).toBe(true);
const blocked = await second.reserve();
expect(blocked.ok).toBe(false);
if (blocked.ok) throw new Error("Expected process-wide admission refusal");
expect(blocked.error._tag).toBe("AnalysisResourceConstraintError");
first.releaseReservation();
expect((await second.reserve()).ok).toBe(true);
second.releaseReservation();
});
const inertWorker = (): WebBundleAnalysisWorkerPort => ({
analyze: async () =>
err({
error: new AnalysisOutputError("analyze_web_bundle", "unused"),
completed: null,
}),
close: async () => {},
});
const waitForAbort = (signal: AbortSignal | undefined): Promise<void> => {
if (signal?.aborted) return Promise.resolve();
return new Promise((resolve) => {
signal?.addEventListener("abort", () => resolve(), { once: true });
});
};
const deferred = <T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
};
const inspection = (sources: readonly string[]) =>
createWebPageInspection(
sources.map((source, index) => ({
source,
scriptKey: `scr_${String(index + 1).repeat(64)}`,
url: `${origin}/assets/${String(index)}.js`,
origin,
cdpHash: `hash-${String(index)}`,
})),
);