fix(inspector): retain observations after capture failures

This commit is contained in:
morluto 2026-10-09 23:16:42 +08:00
parent c267e99227
commit dea3498b2b
9 changed files with 532 additions and 45 deletions

View File

@ -27,7 +27,6 @@ const observedJavaScriptRuntimeKindSchema = javascriptRuntimeKindSchema.or(
z.literal("unknown"),
);
/** Input for one bounded, attach-only V8 Inspector observation. */
/** Input for one passive attach-only V8 Inspector observation. */
export const observeJavaScriptRuntimeInputSchema = z.strictObject({
...runtimeEndpoint,
@ -49,7 +48,6 @@ export const javascriptRuntimeLocationSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("file"),
file_path: z.string().min(1),
authority: z.literal("scope-fallback").optional(),
}),
z.strictObject({
kind: z.literal("url"),
@ -68,8 +66,15 @@ export type JavaScriptRuntimeLocation = z.infer<
/** Discovery metadata can identify an endpoint target without verifying its file location. */
export const javascriptRuntimeUnresolvedLocationSchema = z.strictObject({
kind: z.literal("unresolved"),
reported_url: z.string().min(1),
reason: z.literal("unverifiable-file-location"),
reported_url: z
.string()
.describe(
"Raw Inspector URL, which may be empty for anonymous or evaluated code.",
),
reason: z.enum([
"unverifiable-file-location",
"location-authorization-not-attempted",
]),
});
/** Verified locations and explicitly unresolved discovery metadata. */
@ -104,7 +109,7 @@ export type JavaScriptRuntimeTargetList = z.infer<
const javascriptRuntimeScriptSchema = z.strictObject({
script_key: prefixedDigestSchema("v8_script"),
location: javascriptRuntimeLocationSchema,
location: javascriptRuntimeTargetLocationSchema,
execution_context_key: z.string().nullable(),
cdp_hash: z.string().nullable(),
length: z

View File

@ -20,7 +20,7 @@ import {
javascriptRuntimeObservationSchema,
javascriptRuntimeKindSchema,
type JavaScriptRuntimeObservation,
type JavaScriptRuntimeLocation,
type JavaScriptRuntimeTargetLocation,
} from "./javascriptRuntimeObservation.js";
import { canonicalDigest } from "../comparisonSemantics.js";
import { compareCodePoints } from "../canonicalOrdering.js";
@ -89,7 +89,16 @@ interface NormalizedV8Inspection {
language: null;
source: { readonly included: false; readonly reason: string };
}> &
({ readonly file_path: string } | { readonly url: string }))[];
(
| { readonly file_path: string }
| { readonly url: string }
| {
readonly unresolved_location: Extract<
JavaScriptRuntimeObservation["scripts"]["items"][number]["location"],
{ kind: "unresolved" }
>;
}
))[];
};
readonly workers: readonly [];
readonly completeness: BrowserCompleteness;
@ -367,8 +376,17 @@ const normalizeV8Inspection = (
});
const runtimeLocation = (
location: JavaScriptRuntimeLocation,
): { readonly file_path: string } | { readonly url: string } => {
location: JavaScriptRuntimeTargetLocation,
):
| { readonly file_path: string }
| { readonly url: string }
| {
readonly unresolved_location: Extract<
JavaScriptRuntimeTargetLocation,
{ kind: "unresolved" }
>;
} => {
if (location.kind === "unresolved") return { unresolved_location: location };
if (location.kind === "file") return { file_path: location.file_path };
if (location.kind === "url") return { url: location.sanitized_url };
return { url: location.specifier };

View File

@ -144,9 +144,14 @@ const scriptEntities = (
): RuntimeReconciliationEntity[] =>
capture.inspection.scripts.items.map((script) => {
const location =
"url" in script
? ({ kind: "url", value: script.url } as const)
: ({ kind: "file", value: script.file_path } as const);
"unresolved_location" in script
? ({
kind: "unresolved",
value: script.unresolved_location.reported_url,
} as const)
: "url" in script
? ({ kind: "url", value: script.url } as const)
: ({ kind: "file", value: script.file_path } as const);
return runtimeEntity(capture, {
kind: "script",
nodeKind: "runtime-script-instance",
@ -161,7 +166,13 @@ const scriptEntities = (
: null,
label: location.value,
properties: {
location: location.value,
...("unresolved_location" in script
? {
location_kind: "unresolved",
reported_url: script.unresolved_location.reported_url,
location_reason: script.unresolved_location.reason,
}
: { location: location.value }),
cdp_hash: script.cdp_hash,
length: script.length,
is_module: script.is_module,

View File

@ -47,11 +47,6 @@ export const authorizeRuntimeLocation = async (
signal?: AbortSignal,
): Promise<RuntimeLocationDecision> => {
throwIfRuntimeObservationCancelled(signal);
if (value.startsWith("node:") && value.length > 0)
return {
allowed: true,
location: { kind: "builtin", specifier: value },
};
if (value.startsWith("file:")) {
const filePath = await authorizedElectronFile(value);
throwIfRuntimeObservationCancelled(signal);
@ -59,6 +54,18 @@ export const authorizeRuntimeLocation = async (
? { allowed: false, reason: "not_approved" }
: { allowed: true, location: { kind: "file", file_path: filePath } };
}
return projectReportedRuntimeUrl(value);
};
/** Project non-file Inspector URLs without filesystem access. */
export const projectReportedRuntimeUrl = (
value: string,
): RuntimeLocationDecision => {
if (value.startsWith("node:") && value.length > 0)
return {
allowed: true,
location: { kind: "builtin", specifier: value },
};
let url: URL;
try {
url = new URL(value);

View File

@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { afterAll, beforeAll, expect, it, vi } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { finalizeInspectorCapture } from "./V8InspectorCaptureProjection.js";
@ -159,6 +159,108 @@ it("authorizes each unique script location through a bounded worker pool", async
});
});
it("preserves raw script facts without authorizing locations in partial projection", async () => {
const state = stateFor([
{
rawUrl: "file:///slow-or-unavailable.js?token=retained",
executionContextKey: "1",
cdpHash: "observed",
length: 7,
isModule: false,
},
{
rawUrl: "https://example.test/script.js?caller-value=kept",
executionContextKey: "1",
cdpHash: "remote",
length: 12,
isModule: true,
},
{
rawUrl: "",
executionContextKey: "1",
cdpHash: "anonymous",
length: 0,
isModule: false,
},
]);
state.eventsObserved += 1;
state.eventsRetained += 1;
state.contexts.set("1", {
contextKey: "1",
state: "created",
name: "main",
origin: "file:///slow-or-unavailable.js?token=retained",
});
const authorizeLocation = vi.fn(async () => {
throw new Error("must not run after cancellation");
});
const result = await finalizeInspectorCapture({
input: {
inspector_endpoint: "http://127.0.0.1:9222",
target_id: target.id,
observation_ms: 100,
},
runtime: {
product: "Node.js/v24.18.0",
protocol_version: "1.3",
v8_version: null,
},
target,
state,
locationMode: "reported",
authorizeLocation,
});
expect(authorizeLocation).not.toHaveBeenCalled();
expect(result.capture.events_observed).toBe(4);
expect(result.capture.events_retained).toBe(4);
expect(result.scripts.items).toHaveLength(3);
expect(
result.scripts.items.find(({ cdp_hash }) => cdp_hash === "observed"),
).toMatchObject({
location: {
kind: "unresolved",
reported_url: "file:///slow-or-unavailable.js?token=retained",
reason: "location-authorization-not-attempted",
},
length: 7,
});
expect(
result.scripts.items.find(({ cdp_hash }) => cdp_hash === "remote"),
).toMatchObject({
location: {
kind: "url",
origin: "https://example.test",
sanitized_url: "https://example.test/script.js?caller-value=kept",
},
length: 12,
is_module: true,
});
expect(
result.scripts.items.find(({ cdp_hash }) => cdp_hash === "anonymous"),
).toMatchObject({
location: {
kind: "unresolved",
reported_url: "",
reason: "location-authorization-not-attempted",
},
length: 0,
});
expect(result.scripts.observed_total).toBe(3);
expect(result.execution_contexts).toEqual([
{
context_key: "1",
state: "created",
name: "main",
origin: "file:///slow-or-unavailable.js?token=retained",
},
]);
expect(result.unknowns).toContain(
"2 observed script locations remain unverified; raw URLs and script metadata are retained.",
);
});
it("waits for active authorization workers to settle before returning a failure", async () => {
const scripts = Array.from({ length: 12 }, (_, index) => ({
rawUrl: `file:///fixture/failure-${String(index)}.js`,

View File

@ -1,14 +1,15 @@
import { createHash } from "node:crypto";
import type {
JavaScriptRuntimeLocation,
JavaScriptRuntimeObservation,
JavaScriptRuntimeTargetLocation,
JavaScriptRuntimeTargetList,
ObserveJavaScriptRuntimeInput,
} from "../domain/javascript/javascriptRuntimeObservation.js";
import {
authorizeRuntimeLocation,
inspectorExclusionKey,
projectReportedRuntimeUrl,
throwIfRuntimeObservationCancelled,
} from "./JavaScriptRuntimeScope.js";
import type { CaptureState, ScriptDraft } from "./V8InspectorProvider.js";
@ -43,6 +44,7 @@ interface FinalizeCaptureInput {
rawUrl: string,
signal?: AbortSignal,
) => ReturnType<typeof authorizeRuntimeLocation>;
readonly locationMode?: "verify" | "reported";
readonly signal?: AbortSignal;
}
@ -60,29 +62,32 @@ export const finalizeInspectorCapture = async ({
target,
state,
authorizeLocation = authorizeRuntimeLocation,
locationMode = "verify",
signal,
}: FinalizeCaptureInput): Promise<JavaScriptRuntimeObservation> => {
throwIfRuntimeObservationCancelled(signal);
if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal);
const exclusions = createInspectorExclusionCounts();
const scripts = new Map<
string,
JavaScriptRuntimeObservation["scripts"]["items"][number]
>();
const draftsByUrl = new Map<string, ScriptDraft[]>();
for (const draft of state.scripts) {
const drafts = draftsByUrl.get(draft.rawUrl) ?? [];
drafts.push(draft);
draftsByUrl.set(draft.rawUrl, drafts);
}
if (locationMode === "verify")
for (const draft of state.scripts) {
const drafts = draftsByUrl.get(draft.rawUrl) ?? [];
drafts.push(draft);
draftsByUrl.set(draft.rawUrl, drafts);
}
const groups = [...draftsByUrl];
const authorizedGroups: AuthorizedLocationGroup[] = new Array(groups.length);
let nextGroup = 0;
let stopScheduling = false;
let unresolvedScripts = 0;
const worker = async (): Promise<void> => {
try {
for (;;) {
throwIfRuntimeObservationCancelled(signal);
if (stopScheduling) return;
throwIfRuntimeObservationCancelled(signal);
const index = nextGroup;
nextGroup += 1;
const group = groups[index];
@ -99,18 +104,23 @@ export const finalizeInspectorCapture = async ({
throw cause;
}
};
const workerResults = await Promise.allSettled(
Array.from(
{ length: Math.min(LOCATION_AUTHORIZATION_WORKERS, groups.length) },
worker,
),
);
const workerResults =
locationMode === "verify"
? await Promise.allSettled(
Array.from(
{ length: Math.min(LOCATION_AUTHORIZATION_WORKERS, groups.length) },
worker,
),
)
: [];
const failedWorker = workerResults.find(
(result) => result.status === "rejected",
);
throwIfRuntimeObservationCancelled(signal);
if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal);
if (failedWorker?.status === "rejected") throw failedWorker.reason;
for (const { drafts, decision } of authorizedGroups) {
for (const group of authorizedGroups) {
if (group === undefined) continue;
const { drafts, decision } = group;
for (const draft of drafts) {
if (!decision.allowed) {
exclusions[inspectorExclusionKey(decision.reason)] += 1;
@ -120,6 +130,13 @@ export const finalizeInspectorCapture = async ({
scripts.set(script.script_key, script);
}
}
if (locationMode === "reported")
for (const draft of state.scripts) {
const location = partialScriptLocation(draft.rawUrl);
if (location.kind === "unresolved") unresolvedScripts += 1;
const script = scriptFromDraft(draft, location);
scripts.set(script.script_key, script);
}
const items = [...scripts.values()].sort((left, right) =>
left.script_key < right.script_key
? -1
@ -141,7 +158,7 @@ export const finalizeInspectorCapture = async ({
? 1
: 0,
);
throwIfRuntimeObservationCancelled(signal);
if (locationMode === "verify") throwIfRuntimeObservationCancelled(signal);
return {
runtime,
target: {
@ -189,6 +206,11 @@ export const finalizeInspectorCapture = async ({
"The discovery-reported file location cannot be verified; loaded script locations are resolved independently from Debugger.scriptParsed.",
]
: []),
...(unresolvedScripts === 0
? []
: [
`${String(unresolvedScripts)} observed script location${unresolvedScripts === 1 ? " remains" : "s remain"} unverified; raw URLs and script metadata are retained.`,
]),
"Scripts collected before attachment may have been garbage-collected and therefore omitted.",
"A bounded observation window cannot establish that an unobserved script or behavior never occurs.",
"The declared Node/Electron process role is not authenticated by the Inspector protocol.",
@ -199,7 +221,7 @@ export const finalizeInspectorCapture = async ({
const scriptFromDraft = (
draft: ScriptDraft,
location: JavaScriptRuntimeLocation,
location: JavaScriptRuntimeTargetLocation,
): JavaScriptRuntimeObservation["scripts"]["items"][number] => {
const stable = JSON.stringify({
location,
@ -218,3 +240,15 @@ const scriptFromDraft = (
status: "observed-loaded",
};
};
const partialScriptLocation = (
reportedUrl: string,
): JavaScriptRuntimeTargetLocation => {
const projection = projectReportedRuntimeUrl(reportedUrl);
if (projection.allowed) return projection.location;
return {
kind: "unresolved",
reported_url: reportedUrl,
reason: "location-authorization-not-attempted",
};
};

View File

@ -0,0 +1,175 @@
import { createServer } from "node:http";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { expect, it } from "vitest";
import { WebSocketServer } from "ws";
import { z } from "zod";
import { safeParseJson } from "../domain/safeJson.js";
import { V8InspectorProvider } from "./V8InspectorProvider.js";
const failureCases = [
["disconnect", "disconnected"],
["protocol failure", "protocol_error"],
["cancellation", "cancelled"],
] as const;
it.each(failureCases)(
"preserves captured scripts and contexts after %s",
async (failure, reason) => {
const root = await mkdtemp(join(tmpdir(), "rea-inspector-partial-"));
const scriptPath = join(root, "target.js");
const scriptUrl = pathToFileURL(scriptPath).href;
const reportedScriptUrl = failure === "cancellation" ? "" : scriptUrl;
await writeFile(scriptPath, "globalThis.ready = true;\n");
let signalScriptSent: (() => void) | undefined;
const scriptSent = new Promise<void>((resolve) => {
signalScriptSent = resolve;
});
const server = createServer((request, response) => {
response.setHeader("content-type", "application/json");
if (request.url === "/json/version") {
response.end(
JSON.stringify({
Browser: "Node.js/v24.18.0",
"Protocol-Version": "1.3",
"V8-Version": "12.0",
}),
);
return;
}
if (request.url === "/json/list") {
const address = server.address();
if (address === null || typeof address === "string") {
response.writeHead(500).end();
return;
}
response.end(
JSON.stringify([
{
id: "target-1",
type: "node",
url: scriptUrl,
attached: false,
webSocketDebuggerUrl: `ws://127.0.0.1:${String(address.port)}/target-1`,
},
]),
);
return;
}
response.writeHead(404).end();
});
const sockets = new WebSocketServer({ server, path: "/target-1" });
sockets.on("connection", (socket) => {
socket.on("message", (raw) => {
const parsed = safeParseJson(raw.toString());
if (!parsed.ok) throw parsed.cause;
const command = z
.object({ id: z.number(), method: z.string() })
.parse(parsed.value);
if (command.method === "Runtime.enable") {
socket.send(
JSON.stringify({
method: "Runtime.executionContextCreated",
params: {
context: { id: 1, name: "main", origin: reportedScriptUrl },
},
}),
);
socket.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: "1",
url: reportedScriptUrl,
executionContextId: 1,
hash: "captured-before-failure",
length: 24,
isModule: false,
},
}),
);
socket.send(JSON.stringify({ id: command.id, result: {} }));
return;
}
if (command.method === "Debugger.enable") {
// Receiving the next command proves the preceding events and reply
// were consumed; cancellation needs no timing assumption.
signalScriptSent?.();
if (failure === "protocol failure") {
socket.send(
JSON.stringify({ id: command.id, result: "malformed" }),
);
} else {
socket.send(JSON.stringify({ id: command.id, result: {} }));
if (failure === "disconnect") socket.close();
}
return;
}
socket.send(JSON.stringify({ id: command.id, result: {} }));
});
});
try {
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const address = server.address();
if (address === null || typeof address === "string")
throw new Error("Inspector fixture did not bind a TCP port");
const controller = new AbortController();
const executionOptions =
failure === "cancellation" ? { signal: controller.signal } : {};
const observing = new V8InspectorProvider().observe(
{
inspector_endpoint: `http://127.0.0.1:${String(address.port)}`,
target_id: "target-1",
observation_ms: 10_000,
},
executionOptions,
);
if (failure === "cancellation") {
await scriptSent;
controller.abort();
}
const result = await observing;
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Expected the Inspector operation to fail");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason,
partialObservation: {
capture: { events_observed: 2 },
scripts: {
observed_total: 1,
items: [
{
location: {
kind: "unresolved",
reported_url: reportedScriptUrl,
reason: "location-authorization-not-attempted",
},
cdp_hash: "captured-before-failure",
length: 24,
},
],
},
execution_contexts: [
{ context_key: "1", name: "main", origin: reportedScriptUrl },
],
},
});
} finally {
await new Promise<void>((resolve) => sockets.close(() => resolve()));
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
},
);

View File

@ -130,20 +130,32 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
options: ExecutionOptions = {},
): Promise<Result<JavaScriptRuntimeObservation, AnalysisError>> {
let connection: CdpConnection | undefined;
let discovery: Awaited<ReturnType<typeof discoverV8Inspector>> | undefined;
let target: AuthorizedV8InspectorTarget | undefined;
let state: CaptureState | undefined;
let primaryFailure: unknown;
let failed = false;
let cleanupFailure: unknown;
let cleanupFailed = false;
let connectionClosePromise: Promise<void> | undefined;
let outcome:
| Result<JavaScriptRuntimeObservation, AnalysisError>
| undefined;
const closeConnection = (): Promise<void> => {
if (connection === undefined) return Promise.resolve();
connectionClosePromise ??= closeInspectorConnection(
connection,
options.signal,
);
return connectionClosePromise;
};
try {
const discovery = await discoverV8Inspector(
discovery = await discoverV8Inspector(
input.inspector_endpoint,
"observe_javascript_runtime",
options.signal,
);
const target = await authorizedTarget(
target = await authorizedTarget(
discovery.targets,
input,
discovery.runtime.product,
@ -156,9 +168,10 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
options.signal,
{ maxPayloadBytes: INSPECTOR_MAX_PAYLOAD_BYTES },
);
const state = emptyCaptureState();
const captureState = emptyCaptureState();
state = captureState;
const removeListener = connection.onEvent((event) =>
ingestEvent(event, state),
ingestEvent(event, captureState),
);
try {
await connection.send("Runtime.enable", {}, undefined, options.signal);
@ -171,18 +184,71 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
input,
runtime: discovery.runtime,
target,
state,
state: captureState,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
outcome = ok(javascriptRuntimeObservationSchema.parse(result));
} catch (cause: unknown) {
failed = true;
primaryFailure = cause;
outcome = err(providerError(cause, "observe_javascript_runtime"));
let error = providerError(cause, "observe_javascript_runtime");
if (
state !== undefined &&
discovery !== undefined &&
target !== undefined &&
(cause instanceof BrowserObservationError ||
options.signal?.aborted === true)
) {
const reason =
options.signal?.aborted === true
? "cancelled"
: cause instanceof BrowserObservationError
? cause.reason
: "protocol_error";
try {
await closeConnection();
} catch (cause: unknown) {
cleanupFailed = true;
cleanupFailure = cause;
}
try {
const partialObservation = await finalizeInspectorCapture({
input,
runtime: discovery.runtime,
target,
state,
locationMode: "reported",
});
error = new BrowserObservationError(
"observe_javascript_runtime",
reason,
{
cause: error,
...(error.userMessage === undefined
? {}
: { detail: error.userMessage }),
partialObservation,
},
);
} catch (projectionFailure: unknown) {
error = new BrowserObservationError(
"observe_javascript_runtime",
reason,
{
cause: new AggregateError(
[error, projectionFailure],
"Inspector failure and partial-observation projection both failed",
),
detail: `${error.message} Partial observations could not be projected.`,
},
);
}
}
outcome = err(error);
} finally {
if (connection !== undefined)
try {
await closeInspectorConnection(connection, options.signal);
await closeConnection();
} catch (cause: unknown) {
cleanupFailed = true;
cleanupFailure = cause;
@ -356,7 +422,7 @@ const ingestScript = (event: CdpEvent, state: CaptureState): void => {
state.scriptsObserved += 1;
const value = recordValue(event.params);
const rawUrl = cdpStringValue(value?.url);
if (rawUrl === undefined || rawUrl === "") {
if (rawUrl === undefined) {
state.invalidScripts += 1;
retainEvent(state, 0);
return;

View File

@ -475,6 +475,75 @@ describe("passive V8 Inspector evidence", () => {
expect(result.summary.runtime_scripts).toBe(1);
expect(result.summary.matched).toBeGreaterThan(0);
});
test("keeps unverified partial script URLs unresolved during reconciliation", () => {
const rawUrl = "file:///tmp/not-verified.js?caller-value=kept";
const resolvedObservation = runtimeObservation(
"/Applications/Example.app/Contents/Resources/app/index.html",
"/Applications/Example.app/Contents/Resources/app/renderer.js",
);
const resolvedScript = resolvedObservation.scripts.items[0];
if (resolvedScript === undefined)
throw new Error("Expected the runtime fixture to have one script");
const observation: JavaScriptRuntimeObservation = {
...resolvedObservation,
scripts: {
...resolvedObservation.scripts,
items: [
...resolvedObservation.scripts.items.map((script) => ({
...script,
location: {
kind: "unresolved" as const,
reported_url: rawUrl,
reason: "location-authorization-not-attempted" as const,
},
})),
{
...resolvedScript,
script_key: `v8_script_${"5".repeat(64)}`,
location: {
kind: "unresolved",
reported_url: "",
reason: "location-authorization-not-attempted",
},
cdp_hash: null,
length: 0,
},
],
},
};
const runtimeEvidence = createJavaScriptRuntimeObservationEvidence(
"observe_javascript_runtime",
{
inspector_endpoint: "http://127.0.0.1:9229",
target_id: "example-v8-target",
runtime_kind: "electron-main",
observation_ms: 100,
},
observation,
V8_INSPECTOR_PROVIDER_IDENTITY,
);
const reconciled = reconcileJavaScriptRuntimeEvidence({
static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers,
runtime_observations: [runtimeEvidence],
});
if (!reconciled.ok) throw reconciled.error;
const result = javascriptRuntimeReconciliationResultSchema.parse(
reconciled.value.normalized_result,
);
expect(result.summary.runtime_scripts).toBe(2);
expect(result.reconciliations).toContainEqual(
expect.objectContaining({
entity_kind: "script",
status: "unknown",
reason: "runtime-location-unresolved",
}),
);
expect(JSON.stringify(result.graph)).toContain(rawUrl);
expect(JSON.stringify(result.graph)).toContain("v8-hash");
expect(JSON.stringify(result.graph)).toContain('"reported_url":""');
});
});
const runtimeFixture = async () => {