mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): retain module resolution through finalization
This commit is contained in:
@@ -3,7 +3,8 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type {
|
||||
WebModuleFile,
|
||||
WebModuleResolution,
|
||||
WebModuleResolutionBatch,
|
||||
WebModuleResolutionRequest,
|
||||
WebModuleTraceInput,
|
||||
} from "../domain/webModuleTrace.js";
|
||||
import type { SelectedWebScriptArtifacts } from "../domain/webScriptArtifacts.js";
|
||||
@@ -29,26 +30,11 @@ export interface WebModuleArtifactPort {
|
||||
): Promise<Result<WebModuleArtifacts, AnalysisError>>;
|
||||
}
|
||||
|
||||
/** Resolve literal specifiers in a selected URL/map context without executing the selected application. */
|
||||
export interface WebModuleResolutionBatch {
|
||||
readonly engine: { readonly id: string; readonly version: string };
|
||||
readonly resolutions: readonly WebModuleResolution[];
|
||||
readonly diagnostics: readonly string[];
|
||||
readonly rawResult: JsonObject;
|
||||
}
|
||||
|
||||
/** Replaceable native resolution engine, with no provider protocol in callers. */
|
||||
export interface WebModuleResolutionPort {
|
||||
close?(): Promise<void>;
|
||||
resolve(
|
||||
input: {
|
||||
readonly importerUrl: string;
|
||||
readonly importMap: {
|
||||
readonly baseUrl: string;
|
||||
readonly value: JsonObject;
|
||||
} | null;
|
||||
readonly specifiers: readonly string[];
|
||||
},
|
||||
input: WebModuleResolutionRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<WebModuleResolutionBatch, AnalysisError>>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WebModuleTraceService } from "./WebModuleTraceService.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import {
|
||||
webModuleTraceResultSchema,
|
||||
type WebModuleResolution,
|
||||
type WebModuleResolutionRequest,
|
||||
} from "../domain/webModuleTrace.js";
|
||||
import {
|
||||
webModuleArtifactsFixture,
|
||||
webModuleResolverFixture,
|
||||
} from "../../tests/fixtures/webModuleTrace.js";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { parseEvidence } from "../domain/evidence.js";
|
||||
|
||||
const args = { manifest_path: "/analysis/manifest.json", script_index: 0 };
|
||||
it.each([
|
||||
{ ...args, importer_url: "file:///tmp/main.js" },
|
||||
@@ -135,7 +142,7 @@ describe("selected module trace workflow", () => {
|
||||
});
|
||||
it("honors cancellation before any artifacts are acquired", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
controller.abort(null);
|
||||
const service = new WebModuleTraceService(
|
||||
{
|
||||
load: () => {
|
||||
@@ -146,6 +153,219 @@ describe("selected module trace workflow", () => {
|
||||
);
|
||||
const result = await service.trace(args, { signal: controller.signal });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, { request: "opaque cancellation" }])(
|
||||
"retains completed module Evidence and the exact late abort reason: %j",
|
||||
async (reason) => {
|
||||
const controller = new AbortController();
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const service = new WebModuleTraceService(
|
||||
{ load: async () => ok(artifacts) },
|
||||
{
|
||||
resolve: async (input) => {
|
||||
const result = await webModuleResolverFixture.resolve(input);
|
||||
controller.abort(reason);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
);
|
||||
const result = await service.trace(args, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected cancelled completion");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "trace_web_module_imports",
|
||||
confidence: "derived",
|
||||
normalized_result: {
|
||||
source: artifacts.sourceFile,
|
||||
imports: [
|
||||
{
|
||||
resolution: { state: "resolved", url: "https://app.test/dep.js" },
|
||||
execution: "unknown",
|
||||
},
|
||||
{
|
||||
resolution: { state: "unknown", reason: "computed-specifier" },
|
||||
execution: "unknown",
|
||||
},
|
||||
],
|
||||
},
|
||||
raw_result: { fixture: true },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not start the native resolver after cancellation during artifact admission", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancelled local artifact read" };
|
||||
const result = await new WebModuleTraceService(
|
||||
{
|
||||
load: async () => {
|
||||
controller.abort(reason);
|
||||
return ok(webModuleArtifactsFixture());
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: async () => {
|
||||
throw new Error("must not start resolver");
|
||||
},
|
||||
},
|
||||
).trace(args, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected cancelled admission");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it.each(["incomplete", "provider-failure"])(
|
||||
"keeps resolver %s ahead of late cancellation",
|
||||
async (kind) => {
|
||||
const controller = new AbortController();
|
||||
const primary = new AnalysisOutputError(
|
||||
"trace_web_module_imports",
|
||||
"controlled resolver failure",
|
||||
);
|
||||
const result = await new WebModuleTraceService(
|
||||
{ load: async () => ok(webModuleArtifactsFixture()) },
|
||||
{
|
||||
resolve: async () => {
|
||||
controller.abort({ request: "late cancellation" });
|
||||
return kind === "provider-failure"
|
||||
? err(primary)
|
||||
: ok({
|
||||
engine: { id: "fixture", version: "1" },
|
||||
resolutions: [],
|
||||
diagnostics: [],
|
||||
rawResult: {},
|
||||
});
|
||||
},
|
||||
},
|
||||
).trace(args, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected original failure");
|
||||
expect(result.error).toBeInstanceOf(AnalysisOutputError);
|
||||
if (kind === "provider-failure") expect(result.error).toBe(primary);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"matching",
|
||||
"importer",
|
||||
"map-base",
|
||||
"map-value",
|
||||
"specifiers",
|
||||
"missing-resolution",
|
||||
])(
|
||||
"adopts only a complete resolution for the exact selected request: %s",
|
||||
async (kind) => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const importMap = {
|
||||
file: { path: "/analysis/map.json", sha256: "c".repeat(64), bytes: 2 },
|
||||
baseUrl: "https://app.test/maps/",
|
||||
value: { imports: { dep: "./dep.js" } },
|
||||
};
|
||||
let failure: AnalysisCancelledError | undefined;
|
||||
const result = await new WebModuleTraceService(
|
||||
{ load: async () => ok({ ...artifacts, importMap }) },
|
||||
{
|
||||
resolve: async (request) => {
|
||||
const resolved = await webModuleResolverFixture.resolve(request);
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
let observed: WebModuleResolutionRequest = request;
|
||||
if (kind === "importer")
|
||||
observed = { ...request, importerUrl: "https://other.test/" };
|
||||
if (kind === "map-base")
|
||||
observed = {
|
||||
...request,
|
||||
importMap: {
|
||||
baseUrl: "https://other.test/",
|
||||
value: importMap.value,
|
||||
},
|
||||
};
|
||||
if (kind === "map-value")
|
||||
observed = {
|
||||
...request,
|
||||
importMap: {
|
||||
baseUrl: importMap.baseUrl,
|
||||
value: { imports: { dep: "./other.js" } },
|
||||
},
|
||||
};
|
||||
if (kind === "specifiers")
|
||||
observed = { ...request, specifiers: ["./other.js"] };
|
||||
failure = new AnalysisCancelledError("trace_web_module_imports", {
|
||||
cause: null,
|
||||
partialObservation: {
|
||||
kind: "web-module-resolution",
|
||||
request: observed,
|
||||
batch:
|
||||
kind === "missing-resolution"
|
||||
? { ...resolved.value, resolutions: [] }
|
||||
: resolved.value,
|
||||
},
|
||||
});
|
||||
return err(failure);
|
||||
},
|
||||
},
|
||||
).trace({
|
||||
...args,
|
||||
import_map: { path: importMap.file.path, base_url: importMap.baseUrl },
|
||||
});
|
||||
if (result.ok) throw new Error("expected original cancellation");
|
||||
expect(result.error).toBe(failure);
|
||||
expect(result.error.cause).toBeNull();
|
||||
if (kind === "matching") {
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "trace_web_module_imports",
|
||||
normalized_result: {
|
||||
import_map: {
|
||||
path: importMap.file.path,
|
||||
base_url: importMap.baseUrl,
|
||||
},
|
||||
imports: [
|
||||
{
|
||||
resolution: { state: "resolved", url: "https://app.test/dep.js" },
|
||||
},
|
||||
{ resolution: { state: "unknown", reason: "computed-specifier" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
} else
|
||||
expect(result.error.partialObservation).toHaveProperty(
|
||||
"kind",
|
||||
"web-module-resolution",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains a completed engine-free trace when artifact loading finishes under cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "engine-free completion" };
|
||||
const result = await new WebModuleTraceService(
|
||||
{
|
||||
load: async () => {
|
||||
controller.abort(reason);
|
||||
return ok(webModuleArtifactsFixture("import(name)"));
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: async () => {
|
||||
throw new Error("no literal imports to resolve");
|
||||
},
|
||||
},
|
||||
).trace(args, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected cancelled completion");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(parseEvidence(result.error.partialObservation)).toMatchObject({
|
||||
operation: "trace_web_module_imports",
|
||||
normalized_result: {
|
||||
engine: null,
|
||||
imports: [
|
||||
{ resolution: { state: "unknown", reason: "computed-specifier" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { closeOwnedWebResources } from "./closeOwnedWebResources.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -19,7 +20,9 @@ import {
|
||||
import {
|
||||
webModuleTraceInputSchema,
|
||||
webModuleFileSchema,
|
||||
webModuleResolutionSchema,
|
||||
webModuleResolutionBatchSchema,
|
||||
webModuleResolutionObservationSchema,
|
||||
type WebModuleResolutionBatch,
|
||||
webModuleTraceResultSchema,
|
||||
type WebModuleResolution,
|
||||
type WebModuleTraceResult,
|
||||
@@ -33,9 +36,10 @@ import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import type {
|
||||
WebModuleArtifactPort,
|
||||
WebModuleResolutionPort,
|
||||
WebModuleResolutionBatch,
|
||||
} from "./WebModulePorts.js";
|
||||
|
||||
import { completeProviderEvidence } from "./ProviderOperationExecution.js";
|
||||
|
||||
const OPERATION = "trace_web_module_imports";
|
||||
const artifactsSchema = selectedWebScriptArtifactsSchema.extend({
|
||||
importMap: z
|
||||
@@ -46,12 +50,6 @@ const artifactsSchema = selectedWebScriptArtifactsSchema.extend({
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
const resolutionBatchSchema = z.strictObject({
|
||||
engine: z.strictObject({ id: z.string().min(1), version: z.string().min(1) }),
|
||||
resolutions: z.array(webModuleResolutionSchema),
|
||||
diagnostics: z.array(z.string()),
|
||||
rawResult: jsonObjectSchema,
|
||||
});
|
||||
const LIMITATIONS = [
|
||||
"Resolution is derived under the selected importer URL and optional import map; it does not establish that the map was installed in the captured page.",
|
||||
"Reported source URLs do not establish an inline script's document base. Select importer_url explicitly when that context differs or is unknown.",
|
||||
@@ -84,8 +82,10 @@ export class WebModuleTraceService {
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (isAborted(options?.signal))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const parsed = webModuleTraceInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
@@ -144,31 +144,51 @@ export class WebModuleTraceService {
|
||||
.filter((reference) => reference.specifier !== null)
|
||||
.map((reference) => reference.specifier ?? "");
|
||||
let resolution: WebModuleResolutionBatch | null = null;
|
||||
let resolutionFailure: AnalysisError | undefined;
|
||||
if (literals.length > 0) {
|
||||
const execution = await this.resolver.resolve(
|
||||
{
|
||||
importerUrl,
|
||||
importMap:
|
||||
data.importMap === null
|
||||
? null
|
||||
: {
|
||||
baseUrl: data.importMap.baseUrl,
|
||||
value: data.importMap.value,
|
||||
},
|
||||
specifiers: literals,
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (!execution.ok) return execution;
|
||||
const report = resolutionBatchSchema.safeParse(execution.value);
|
||||
if (!report.success)
|
||||
if (options?.signal?.aborted)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Resolver returned malformed or incomplete native evidence.",
|
||||
),
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
}),
|
||||
);
|
||||
resolution = report.data;
|
||||
const request = {
|
||||
importerUrl,
|
||||
importMap:
|
||||
data.importMap === null
|
||||
? null
|
||||
: {
|
||||
baseUrl: data.importMap.baseUrl,
|
||||
value: data.importMap.value,
|
||||
},
|
||||
specifiers: literals,
|
||||
};
|
||||
const execution = await this.resolver.resolve(request, options);
|
||||
if (execution.ok) {
|
||||
const report = webModuleResolutionBatchSchema.safeParse(
|
||||
execution.value,
|
||||
);
|
||||
if (!report.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Resolver returned malformed or incomplete native evidence.",
|
||||
),
|
||||
);
|
||||
resolution = report.data;
|
||||
} else {
|
||||
const observation = webModuleResolutionObservationSchema.safeParse(
|
||||
execution.error.partialObservation,
|
||||
);
|
||||
if (
|
||||
!observation.success ||
|
||||
!isDeepStrictEqual(observation.data.request, request) ||
|
||||
observation.data.batch.resolutions.length !== literals.length
|
||||
)
|
||||
return execution;
|
||||
resolution = observation.data.batch;
|
||||
resolutionFailure = execution.error;
|
||||
}
|
||||
if (resolution.resolutions.length !== literals.length)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
@@ -177,8 +197,6 @@ export class WebModuleTraceService {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isAborted(options?.signal))
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
let literalIndex = 0;
|
||||
const imports: WebModuleTraceResult["imports"] = [];
|
||||
for (const reference of syntax.imports) {
|
||||
@@ -238,37 +256,40 @@ export class WebModuleTraceService {
|
||||
});
|
||||
if (!normalized.success)
|
||||
return err(new AnalysisOutputError(OPERATION, normalized.error.message));
|
||||
return ok(
|
||||
createEvidence(
|
||||
{
|
||||
path: data.sourceFile.path,
|
||||
sha256: data.sourceFile.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{
|
||||
id: "rea-web-module-trace",
|
||||
name: "REA captured module relationship trace",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: normalized.data,
|
||||
rawResult: resolution?.rawResult ?? null,
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations: LIMITATIONS,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: data.sourceFile.path },
|
||||
{ kind: "artifact-path", path: input.manifest_path },
|
||||
],
|
||||
evidenceLinks:
|
||||
data.manifest.source_evidence_id === null
|
||||
? []
|
||||
: [data.manifest.source_evidence_id],
|
||||
},
|
||||
),
|
||||
const evidence = createEvidence(
|
||||
{
|
||||
path: data.sourceFile.path,
|
||||
sha256: data.sourceFile.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{
|
||||
id: "rea-web-module-trace",
|
||||
name: "REA captured module relationship trace",
|
||||
version: "1",
|
||||
},
|
||||
{
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(input),
|
||||
result: normalized.data,
|
||||
rawResult: resolution?.rawResult ?? null,
|
||||
confidence: "derived",
|
||||
authority: "historical-reference",
|
||||
limitations: LIMITATIONS,
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: data.sourceFile.path },
|
||||
{ kind: "artifact-path", path: input.manifest_path },
|
||||
],
|
||||
evidenceLinks:
|
||||
data.manifest.source_evidence_id === null
|
||||
? []
|
||||
: [data.manifest.source_evidence_id],
|
||||
},
|
||||
);
|
||||
if (resolutionFailure !== undefined) {
|
||||
resolutionFailure.partialObservation = evidence;
|
||||
return err(resolutionFailure);
|
||||
}
|
||||
return completeProviderEvidence(evidence, options?.signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +325,6 @@ const admitContext = (
|
||||
);
|
||||
return ok(null);
|
||||
};
|
||||
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
|
||||
const invalid = (path: readonly string[], message: string) =>
|
||||
err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NativeModuleResolver } from "./NativeModuleResolver.js";
|
||||
import { webModuleResolutionObservationSchema } from "../../domain/webModuleTrace.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
|
||||
import { completedModuleResolverLauncher } from "../../../tests/fixtures/webModuleTrace.js";
|
||||
|
||||
const input = {
|
||||
importerUrl: "https://app.test/main.js",
|
||||
importMap: null,
|
||||
@@ -95,6 +98,9 @@ describe("native module resolver cleanup ownership", () => {
|
||||
await provider.close();
|
||||
expect(closes).toBe(4);
|
||||
expect(launches).toBe(1);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.cleanupResources).toEqual([]);
|
||||
if (!blocked.ok) expect(blocked.error.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
it("awaits acquisition and cleanup while refusing queued work during shutdown", async () => {
|
||||
let acquired: () => void = () => {
|
||||
@@ -163,7 +169,8 @@ describe("optional native resolution acquisition", () => {
|
||||
});
|
||||
it("does not acquire a browser after caller cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const reason = { request: "opaque admission cancellation" };
|
||||
controller.abort(reason);
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
() => {
|
||||
@@ -173,6 +180,7 @@ describe("optional native resolution acquisition", () => {
|
||||
const result = await provider.resolve(input, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
it("retains the selected executable and actual startup diagnostic", async () => {
|
||||
const provider = new NativeModuleResolver(
|
||||
@@ -189,3 +197,155 @@ describe("optional native resolution acquisition", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("completed native resolution finalization", () => {
|
||||
it.each([false, true])(
|
||||
"retains the completed batch when final close is cancelled (close fails: %s)",
|
||||
async (cleanupFails) => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancelled during final browser close" };
|
||||
let allowClose = !cleanupFails;
|
||||
let closes = 0;
|
||||
const report = {
|
||||
importer_url: input.importerUrl,
|
||||
entries: [
|
||||
{
|
||||
specifier: "./dep.js",
|
||||
resolution: { state: "resolved", url: "https://app.test/dep.js" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const launcher = completedModuleResolverLauncher(input, async () => {
|
||||
closes += 1;
|
||||
controller.abort(reason);
|
||||
if (!allowClose) throw new Error("controlled close refusal");
|
||||
});
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
launcher,
|
||||
);
|
||||
const result = await provider.resolve(input, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (result.ok) throw new Error("expected cancelled finalization");
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
cleanupFails ? "cleanup_incomplete" : "cancelled",
|
||||
);
|
||||
expect((result.error.cleanupPrimary ?? result.error).cause).toBe(reason);
|
||||
expect(
|
||||
webModuleResolutionObservationSchema.parse(
|
||||
result.error.partialObservation,
|
||||
),
|
||||
).toMatchObject({
|
||||
kind: "web-module-resolution",
|
||||
request: input,
|
||||
batch: {
|
||||
engine: { version: "controlled-browser" },
|
||||
resolutions: report.entries.map((entry) => entry.resolution),
|
||||
rawResult: { native_report: report, executable_path: "/browser" },
|
||||
},
|
||||
});
|
||||
if (cleanupFails) {
|
||||
await expect(provider.close()).rejects.toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
});
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
allowClose = true;
|
||||
}
|
||||
await provider.close();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.cleanupResources).toEqual([]);
|
||||
expect(projectAnalysisError(result.error).code).toBe("cancelled");
|
||||
expect(closes).toBe(cleanupFails ? 3 : 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ stage: "launch", reason: null },
|
||||
{ stage: "launch", reason: new Error("opaque cancellation reason") },
|
||||
{ stage: "context", reason: null },
|
||||
{ stage: "context", reason: new Error("opaque cancellation reason") },
|
||||
])(
|
||||
"preserves an independent $stage rejection when cancellation arrives before catch: $reason",
|
||||
async ({ stage, reason }) => {
|
||||
const controller = new AbortController();
|
||||
const physicalFailure = new Error("controlled SDK EIO");
|
||||
let closes = 0;
|
||||
const reject = () => {
|
||||
const rejected = Promise.reject(physicalFailure);
|
||||
queueMicrotask(() => controller.abort(reason));
|
||||
return rejected;
|
||||
};
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
() =>
|
||||
stage === "launch"
|
||||
? reject()
|
||||
: Promise.resolve({
|
||||
version: () => "controlled-browser",
|
||||
newContext: reject,
|
||||
close: async () => {
|
||||
closes += 1;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const result = await provider.resolve(input, { signal: controller.signal });
|
||||
if (result.ok) throw new Error("expected independent rejection");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
cause: physicalFailure,
|
||||
});
|
||||
expect(projectAnalysisError(result.error).code).not.toBe("cancelled");
|
||||
await provider.close();
|
||||
expect(closes).toBe(stage === "launch" ? 0 : 1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["caller", "deadline"] as const)(
|
||||
"attributes an in-flight SDK rejection to the owned %s stop",
|
||||
async (control) => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel active browser operation" };
|
||||
let started!: () => void;
|
||||
const active = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let rejectCall!: (cause: unknown) => void;
|
||||
let closes = 0;
|
||||
const provider = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
async () => ({
|
||||
version: () => "controlled-browser",
|
||||
newContext: () => {
|
||||
started();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectCall = reject;
|
||||
});
|
||||
},
|
||||
close: async () => {
|
||||
closes += 1;
|
||||
rejectCall(new Error("controlled SDK target closed"));
|
||||
},
|
||||
}),
|
||||
);
|
||||
const pending = provider.resolve(input, { signal: controller.signal });
|
||||
await active;
|
||||
if (control === "caller") controller.abort(reason);
|
||||
else await vi.advanceTimersByTimeAsync(20001);
|
||||
const result = await pending;
|
||||
if (result.ok) throw new Error("expected interrupted active call");
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
control === "caller" ? "cancelled" : "provider_timeout",
|
||||
);
|
||||
if (control === "caller") expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBeUndefined();
|
||||
await provider.close();
|
||||
expect(closes).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { Browser, LaunchOptions } from "playwright-core";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import type {
|
||||
WebModuleResolutionPort,
|
||||
WebModuleResolutionBatch,
|
||||
} from "../../application/WebModulePorts.js";
|
||||
Browser,
|
||||
BrowserContext,
|
||||
Page,
|
||||
ConsoleMessage,
|
||||
LaunchOptions,
|
||||
} from "playwright-core";
|
||||
import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
||||
import type { WebModuleResolutionPort } from "../../application/WebModulePorts.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import {
|
||||
@@ -19,6 +22,14 @@ import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
AnalysisCleanupLease,
|
||||
AnalysisCleanupLeaseGroup,
|
||||
} from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import type {
|
||||
WebModuleResolutionBatch,
|
||||
WebModuleResolutionObservation,
|
||||
} from "../../domain/webModuleTrace.js";
|
||||
import { webModuleResolutionSchema } from "../../domain/webModuleTrace.js";
|
||||
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
|
||||
|
||||
@@ -34,7 +45,25 @@ const reportSchema = z.strictObject({
|
||||
),
|
||||
});
|
||||
|
||||
type ModuleResolverBrowser = Pick<Browser, "newContext" | "version" | "close">;
|
||||
type ObserveResolverCall = <T>(call: () => Promise<T>) => Promise<T>;
|
||||
|
||||
interface ModuleResolverPage {
|
||||
on(event: "console", listener: (message: ConsoleMessage) => void): unknown;
|
||||
goto: Page["goto"];
|
||||
evaluate(expression: string | (() => unknown)): Promise<unknown>;
|
||||
waitForFunction(
|
||||
expression: () => unknown,
|
||||
argument: undefined,
|
||||
options: { timeout: number },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
interface ModuleResolverBrowser extends Pick<Browser, "version" | "close"> {
|
||||
newContext(
|
||||
options: Parameters<Browser["newContext"]>[0],
|
||||
): Promise<
|
||||
Pick<BrowserContext, "route"> & { newPage(): Promise<ModuleResolverPage> }
|
||||
>;
|
||||
}
|
||||
/** Production launch seam: only the adapter knows the browser API. */
|
||||
export type ModuleResolverLauncher = (
|
||||
options: LaunchOptions,
|
||||
@@ -47,7 +76,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#pendingBrowser: ModuleResolverBrowser | undefined;
|
||||
#pendingCleanup: AnalysisCleanupLease | undefined;
|
||||
|
||||
constructor(
|
||||
readonly environment: Readonly<Record<string, string | undefined>>,
|
||||
@@ -74,7 +103,9 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
options?: ExecutionOptions,
|
||||
): ReturnType<WebModuleResolutionPort["resolve"]> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return err(
|
||||
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
|
||||
);
|
||||
const pending = await this.#retryCleanup();
|
||||
if (pending !== undefined) return err(pending);
|
||||
const executable = this.environment.REA_BROWSER_EXECUTABLE;
|
||||
@@ -91,86 +122,153 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
),
|
||||
);
|
||||
const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal);
|
||||
let browser: ModuleResolverBrowser | undefined;
|
||||
let activeCall:
|
||||
| { settled: boolean; interruption?: AnalysisError }
|
||||
| undefined;
|
||||
let interruption: AnalysisError | undefined;
|
||||
const interrupted = (): AnalysisError =>
|
||||
deadline.interruption === "cancelled"
|
||||
? new AnalysisCancelledError(OPERATION, {
|
||||
cause: deadline.signal.reason,
|
||||
})
|
||||
: new AnalysisTimeoutError(OPERATION, TIMEOUT_MS);
|
||||
const assertActive = (): void => {
|
||||
if (!deadline.signal.aborted) return;
|
||||
interruption = interrupted();
|
||||
throw interruption;
|
||||
};
|
||||
const observe: ObserveResolverCall = (call) => {
|
||||
assertActive();
|
||||
const observed: NonNullable<typeof activeCall> = { settled: false };
|
||||
activeCall = observed;
|
||||
return call().then(
|
||||
(value) => {
|
||||
observed.settled = true;
|
||||
return value;
|
||||
},
|
||||
(cause: unknown) => {
|
||||
observed.settled = true;
|
||||
throw observed.interruption === undefined
|
||||
? cause
|
||||
: observed.interruption;
|
||||
},
|
||||
);
|
||||
};
|
||||
let lease: AnalysisCleanupLease | undefined;
|
||||
let cleanup: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
if (cleanup === undefined && browser !== undefined) {
|
||||
const owned = browser;
|
||||
if (cleanup === undefined && lease !== undefined) {
|
||||
const owned = lease;
|
||||
cleanup = Promise.resolve().then(async () => {
|
||||
await owned.close();
|
||||
this.#pendingBrowser = undefined;
|
||||
await owned.release();
|
||||
this.#pendingCleanup = undefined;
|
||||
});
|
||||
}
|
||||
return cleanup ?? Promise.resolve();
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
if (browser !== undefined) {
|
||||
if (lease !== undefined) {
|
||||
const pending = close();
|
||||
void pending.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
deadline.signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
let result: Result<WebModuleResolutionBatch, AnalysisError>;
|
||||
try {
|
||||
deadline.signal.throwIfAborted();
|
||||
browser = await this.launcher({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
args: [
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=128",
|
||||
...(this.environment.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
this.#pendingBrowser = browser;
|
||||
deadline.signal.throwIfAborted();
|
||||
result = ok(await resolveInBrowser(browser, input, deadline, executable));
|
||||
assertActive();
|
||||
const browser = await observe(() =>
|
||||
this.launcher({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
handleSIGINT: false,
|
||||
handleSIGTERM: false,
|
||||
handleSIGHUP: false,
|
||||
args: [
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-background-networking",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=128",
|
||||
...(this.environment.REA_BROWSER_NO_SANDBOX === "true"
|
||||
? ["--no-sandbox"]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
lease = new AnalysisCleanupLease(async () => {
|
||||
if (
|
||||
deadline.signal.aborted &&
|
||||
activeCall !== undefined &&
|
||||
!activeCall.settled
|
||||
) {
|
||||
interruption = interrupted();
|
||||
activeCall.interruption = interruption;
|
||||
}
|
||||
await browser.close();
|
||||
}, ["chromium-browser"]);
|
||||
this.#pendingCleanup = lease;
|
||||
assertActive();
|
||||
result = ok(
|
||||
await resolveInBrowser(
|
||||
browser,
|
||||
input,
|
||||
deadline,
|
||||
executable,
|
||||
assertActive,
|
||||
observe,
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
deadline.interruption === "cancelled"
|
||||
? new AnalysisCancelledError(OPERATION)
|
||||
: deadline.interruption === "timeout"
|
||||
? new AnalysisTimeoutError(OPERATION, TIMEOUT_MS)
|
||||
: cause instanceof AnalysisOutputError ||
|
||||
cause instanceof AnalysisCapabilityUnavailableError ||
|
||||
cause instanceof BrowserObservationError
|
||||
? cause
|
||||
: new ProviderAdapterError("native-module-resolver", OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
executable,
|
||||
error_message:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
cause === interruption && interruption !== undefined
|
||||
? interruption
|
||||
: cause instanceof AnalysisOutputError ||
|
||||
cause instanceof AnalysisCapabilityUnavailableError ||
|
||||
cause instanceof BrowserObservationError
|
||||
? cause
|
||||
: new ProviderAdapterError("native-module-resolver", OPERATION, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
executable,
|
||||
error_message:
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
deadline.signal.removeEventListener("abort", onAbort);
|
||||
deadline.dispose();
|
||||
}
|
||||
let cleanupError: AnalysisError | undefined;
|
||||
try {
|
||||
await close();
|
||||
} catch (cause: unknown) {
|
||||
const failure = cleanupFailure(
|
||||
cleanupError = cleanupFailure(
|
||||
cause,
|
||||
result.ok ? result.value.rawResult : undefined,
|
||||
);
|
||||
return err(
|
||||
result.ok
|
||||
? failure
|
||||
: analysisErrorWithCleanupFailure(result.error, failure, OPERATION),
|
||||
result.ok ? completedObservation(input, result.value) : undefined,
|
||||
);
|
||||
if (lease !== undefined)
|
||||
new AnalysisCleanupLeaseGroup(cleanupError, [lease]);
|
||||
}
|
||||
if (result.ok && options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
result = err(
|
||||
new AnalysisCancelledError(OPERATION, {
|
||||
cause: options.signal.reason,
|
||||
partialObservation: completedObservation(input, result.value),
|
||||
}),
|
||||
);
|
||||
if (cleanupError !== undefined)
|
||||
return err(
|
||||
result.ok
|
||||
? cleanupError
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanupError,
|
||||
OPERATION,
|
||||
).bindCleanupDependencies([result.error, cleanupError]),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -195,14 +293,16 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
}
|
||||
|
||||
async #retryCleanup(): Promise<AnalysisError | undefined> {
|
||||
const browser = this.#pendingBrowser;
|
||||
if (browser === undefined) return undefined;
|
||||
const lease = this.#pendingCleanup;
|
||||
if (lease === undefined) return undefined;
|
||||
try {
|
||||
await browser.close();
|
||||
this.#pendingBrowser = undefined;
|
||||
await lease.release();
|
||||
this.#pendingCleanup = undefined;
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return cleanupFailure(cause);
|
||||
const failure = cleanupFailure(cause);
|
||||
new AnalysisCleanupLeaseGroup(failure, [lease]);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,12 +312,16 @@ const resolveInBrowser = async (
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
deadline: ProviderStartupDeadline,
|
||||
executable: string,
|
||||
assertActive: () => void,
|
||||
observe: ObserveResolverCall,
|
||||
): Promise<WebModuleResolutionBatch> => {
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const context = await observe(() =>
|
||||
browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
}),
|
||||
);
|
||||
const page = await observe(() => context.newPage());
|
||||
const diagnostics: string[] = [];
|
||||
let diagnosticBytes = 0;
|
||||
page.on("console", (message) => {
|
||||
@@ -237,64 +341,74 @@ const resolveInBrowser = async (
|
||||
const transportUrl = new URL(importerUrl);
|
||||
transportUrl.hash = "";
|
||||
let stubSent = false;
|
||||
await context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.resourceType() === "document" &&
|
||||
request.url() === documentUrl.split("#")[0]
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "document",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
|
||||
});
|
||||
} else if (
|
||||
!stubSent &&
|
||||
request.resourceType() === "script" &&
|
||||
request.url() === transportUrl.href
|
||||
) {
|
||||
stubSent = true;
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "script",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/javascript",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`,
|
||||
});
|
||||
} else {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: request.resourceType(),
|
||||
effect: "blocked",
|
||||
});
|
||||
await route.abort();
|
||||
}
|
||||
});
|
||||
await page.goto(documentUrl, {
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
});
|
||||
await page.evaluate(
|
||||
`{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`,
|
||||
await observe(() =>
|
||||
context.route("**/*", async (route) => {
|
||||
const request = route.request();
|
||||
if (
|
||||
request.resourceType() === "document" &&
|
||||
request.url() === documentUrl.split("#")[0]
|
||||
) {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "document",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
|
||||
});
|
||||
} else if (
|
||||
!stubSent &&
|
||||
request.resourceType() === "script" &&
|
||||
request.url() === transportUrl.href
|
||||
) {
|
||||
stubSent = true;
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: "script",
|
||||
effect: "fulfilled",
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/javascript",
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`,
|
||||
});
|
||||
} else {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
resource_type: request.resourceType(),
|
||||
effect: "blocked",
|
||||
});
|
||||
await route.abort();
|
||||
}
|
||||
}),
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
Reflect.has(globalThis, "__reaModuleResult") ||
|
||||
Reflect.has(globalThis, "__reaModuleUnsupported") ||
|
||||
Reflect.has(globalThis, "__reaModuleFailure"),
|
||||
undefined,
|
||||
{ timeout: Math.max(1, deadline.remainingMs()) },
|
||||
await observe(() =>
|
||||
page.goto(documentUrl, {
|
||||
timeout: Math.max(1, deadline.remainingMs()),
|
||||
}),
|
||||
);
|
||||
const unsupported: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
await observe(() =>
|
||||
page.evaluate(
|
||||
`{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`,
|
||||
),
|
||||
);
|
||||
await observe(() =>
|
||||
page.waitForFunction(
|
||||
() =>
|
||||
Reflect.has(globalThis, "__reaModuleResult") ||
|
||||
Reflect.has(globalThis, "__reaModuleUnsupported") ||
|
||||
Reflect.has(globalThis, "__reaModuleFailure"),
|
||||
undefined,
|
||||
{ timeout: Math.max(1, deadline.remainingMs()) },
|
||||
),
|
||||
);
|
||||
const unsupported: unknown = await observe(() =>
|
||||
page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
),
|
||||
);
|
||||
if (unsupported === true)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
@@ -305,11 +419,15 @@ const resolveInBrowser = async (
|
||||
userMessage: `Configured Chromium ${browser.version()} does not supply import.meta.resolve; use a browser supporting native module resolution.`,
|
||||
},
|
||||
);
|
||||
const raw: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleResult") as unknown,
|
||||
const raw: unknown = await observe(() =>
|
||||
page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleResult") as unknown,
|
||||
),
|
||||
);
|
||||
const failure: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
const failure: unknown = await observe(() =>
|
||||
page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
),
|
||||
);
|
||||
if (typeof failure === "string")
|
||||
throw new AnalysisOutputError(
|
||||
@@ -331,7 +449,7 @@ const resolveInBrowser = async (
|
||||
);
|
||||
if (diagnosticBytes > 1024 * 1024)
|
||||
throw new BrowserObservationError(OPERATION, "payload_limit");
|
||||
deadline.signal.throwIfAborted();
|
||||
assertActive();
|
||||
const engine = {
|
||||
id: "chromium-native-module-resolver",
|
||||
version: browser.version(),
|
||||
@@ -350,20 +468,30 @@ const resolveInBrowser = async (
|
||||
};
|
||||
};
|
||||
|
||||
const completedObservation = (
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
batch: WebModuleResolutionBatch,
|
||||
): WebModuleResolutionObservation => ({
|
||||
kind: "web-module-resolution",
|
||||
request: input,
|
||||
batch,
|
||||
});
|
||||
|
||||
const cleanupFailure = (
|
||||
cause: unknown,
|
||||
observation?: WebModuleResolutionBatch["rawResult"],
|
||||
observation?: WebModuleResolutionObservation,
|
||||
): ProviderCleanupError =>
|
||||
new ProviderCleanupError(
|
||||
"native-module-resolver",
|
||||
["chromium-browser"],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
...(observation === undefined
|
||||
? {}
|
||||
: { partial_observation: observation }),
|
||||
},
|
||||
{ cause, operation: OPERATION },
|
||||
{
|
||||
cause,
|
||||
operation: OPERATION,
|
||||
...(observation === undefined ? {} : { partialObservation: observation }),
|
||||
},
|
||||
);
|
||||
|
||||
const scriptJson = (value: unknown): string =>
|
||||
|
||||
@@ -236,8 +236,11 @@ import type { NativeLoadImagePartialObservation } from "./native/nativeLoadImage
|
||||
import type { NativeAotSnapshotObservation } from "./native/nativeAotSnapshotObservation.js";
|
||||
import type { FileSnapshotPartialObservation } from "./fileSnapshotObservation.js";
|
||||
|
||||
import type { WebModuleResolutionObservation } from "./webModuleTrace.js";
|
||||
|
||||
/** Provider-neutral evidence collected before a typed analysis failure. */
|
||||
export type AnalysisPartialObservation =
|
||||
| WebModuleResolutionObservation
|
||||
| NativeLoadImagePartialObservation
|
||||
| NativeAotSnapshotObservation
|
||||
| FileSnapshotPartialObservation
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonObjectSchema } from "./jsonValue.js";
|
||||
import { digestSchema } from "./digests.js";
|
||||
import { localPathStringSchema } from "./localPath.js";
|
||||
import {
|
||||
@@ -69,6 +70,39 @@ export const webModuleResolutionSchema = z.discriminatedUnion("state", [
|
||||
]);
|
||||
export type WebModuleResolution = z.output<typeof webModuleResolutionSchema>;
|
||||
|
||||
/** Completed native resolution, shared by success and interrupted finalization. */
|
||||
export const webModuleResolutionBatchSchema = z.strictObject({
|
||||
engine: z.strictObject({ id: z.string().min(1), version: z.string().min(1) }),
|
||||
resolutions: z.array(webModuleResolutionSchema),
|
||||
diagnostics: z.array(z.string()),
|
||||
rawResult: jsonObjectSchema,
|
||||
});
|
||||
export type WebModuleResolutionBatch = z.output<
|
||||
typeof webModuleResolutionBatchSchema
|
||||
>;
|
||||
|
||||
/** Result-affecting context of one local native resolver request. */
|
||||
export const webModuleResolutionRequestSchema = z.strictObject({
|
||||
importerUrl: z.string().min(1),
|
||||
importMap: z
|
||||
.strictObject({ baseUrl: z.string().min(1), value: jsonObjectSchema })
|
||||
.nullable(),
|
||||
specifiers: z.array(z.string()).readonly(),
|
||||
});
|
||||
export type WebModuleResolutionRequest = z.output<
|
||||
typeof webModuleResolutionRequestSchema
|
||||
>;
|
||||
|
||||
/** A completed batch remains bound to the exact resolver request on failure. */
|
||||
export const webModuleResolutionObservationSchema = z.strictObject({
|
||||
kind: z.literal("web-module-resolution"),
|
||||
request: webModuleResolutionRequestSchema,
|
||||
batch: webModuleResolutionBatchSchema,
|
||||
});
|
||||
export type WebModuleResolutionObservation = z.output<
|
||||
typeof webModuleResolutionObservationSchema
|
||||
>;
|
||||
|
||||
const candidateSchema = z.strictObject({
|
||||
script_index: z.number().int().min(0),
|
||||
match: z.enum(["exact-reported-url", "response-url-without-fragment"]),
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import type { ExecutionOptions } from "../../../src/application/AnalysisProvider.js";
|
||||
import { NativeModuleResolver } from "../../../src/browser/modules/NativeModuleResolver.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
@@ -9,6 +14,7 @@ import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
webModuleArtifactsFixture,
|
||||
completedModuleResolverLauncher,
|
||||
webModuleResolverFixture,
|
||||
} from "../../fixtures/webModuleTrace.js";
|
||||
|
||||
@@ -110,3 +116,114 @@ it("retains the module resolver cleanup owner across failed server shutdown", as
|
||||
await server.close();
|
||||
expect(closes).toBe(2);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains completed module Evidence over SDK MCP through final close cancellation (close fails: %s)",
|
||||
async (cleanupFails) => {
|
||||
const artifacts = webModuleArtifactsFixture();
|
||||
const importerUrl = artifacts.manifest.scripts[0]?.url;
|
||||
if (importerUrl === undefined)
|
||||
throw new Error("fixture source URL missing");
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "controlled finalization cancellation" };
|
||||
let allowClose = !cleanupFails;
|
||||
let launches = 0;
|
||||
const launcher = completedModuleResolverLauncher(
|
||||
{ importerUrl, importMap: null, specifiers: ["./dep.js"] },
|
||||
async () => {
|
||||
controller.abort(reason);
|
||||
if (!allowClose) throw new Error("controlled close refusal");
|
||||
},
|
||||
);
|
||||
const resolver = new NativeModuleResolver(
|
||||
{ REA_BROWSER_EXECUTABLE: "/browser" },
|
||||
(options) => {
|
||||
launches += 1;
|
||||
return launcher(options);
|
||||
},
|
||||
);
|
||||
let original: AnalysisError | undefined;
|
||||
class ControlledCancellationService extends WebModuleTraceService {
|
||||
override async trace(input: unknown, options?: ExecutionOptions) {
|
||||
const result = await super.trace(input, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!result.ok) original = result.error;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
const service = new ControlledCancellationService(
|
||||
{ load: async () => ok(artifacts) },
|
||||
resolver,
|
||||
);
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("no binary acquisition");
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ webModuleTrace: service },
|
||||
);
|
||||
const client = new Client({ name: "module-finalization", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
allowClose = true;
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const response = await client.callTool({
|
||||
name: "trace_web_module_imports",
|
||||
arguments: {
|
||||
manifest_path: "/analysis/manifest.json",
|
||||
script_index: 0,
|
||||
},
|
||||
});
|
||||
expect(response.isError).toBe(true);
|
||||
if (original === undefined) throw new Error("expected original failure");
|
||||
const evidence = parseEvidence(original.partialObservation);
|
||||
expect(parseMcpToolError(response)).toMatchObject({
|
||||
error: {
|
||||
code: cleanupFails ? "cleanup_incomplete" : "cancelled",
|
||||
details: {
|
||||
partial_observation: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: evidence.evidence_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(evidence).toMatchObject({
|
||||
operation: "trace_web_module_imports",
|
||||
confidence: "derived",
|
||||
normalized_result: {
|
||||
source: artifacts.sourceFile,
|
||||
imports: [
|
||||
{
|
||||
resolution: { state: "resolved", url: "https://app.test/dep.js" },
|
||||
execution: "unknown",
|
||||
},
|
||||
{
|
||||
resolution: { state: "unknown", reason: "computed-specifier" },
|
||||
execution: "unknown",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
expect((original.cleanupPrimary ?? original).cause).toBe(reason);
|
||||
if (cleanupFails) {
|
||||
await expect(server.close()).rejects.toBeInstanceOf(AggregateError);
|
||||
expect(original.cleanupIncomplete).toBe(true);
|
||||
allowClose = true;
|
||||
}
|
||||
await server.close();
|
||||
expect(original.cleanupIncomplete).toBe(false);
|
||||
expect(original.cleanupResources).toEqual([]);
|
||||
expect(projectAnalysisError(original).code).toBe("cancelled");
|
||||
expect(launches).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
Vendored
+33
@@ -79,3 +79,36 @@ export const webModuleResolverFixture: WebModuleResolutionPort = {
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
/** Controlled SDK seam for provider finalization, not real Chromium qualification. */
|
||||
export const completedModuleResolverLauncher =
|
||||
(
|
||||
request: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
close: () => Promise<void>,
|
||||
): import("../../src/browser/modules/NativeModuleResolver.js").ModuleResolverLauncher =>
|
||||
async () => {
|
||||
const report = {
|
||||
importer_url: request.importerUrl,
|
||||
entries: request.specifiers.map((specifier) => ({
|
||||
specifier,
|
||||
resolution: {
|
||||
state: "resolved",
|
||||
url: new URL(specifier, request.importerUrl).href,
|
||||
},
|
||||
})),
|
||||
};
|
||||
const evaluations: unknown[] = [undefined, false, report, undefined];
|
||||
return {
|
||||
version: () => "controlled-browser",
|
||||
newContext: async () => ({
|
||||
route: async () => {},
|
||||
newPage: async () => ({
|
||||
on: () => {},
|
||||
goto: async () => null,
|
||||
evaluate: async () => evaluations.shift(),
|
||||
waitForFunction: async () => {},
|
||||
}),
|
||||
}),
|
||||
close,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user