Files
rea/src/browser/ElectronFileScope.cleanup.test.ts
T

440 lines
15 KiB
TypeScript

import { createServer } from "node:http";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, expect, it, vi } from "vitest";
const handleState = vi.hoisted(() => ({
archive: undefined as string | undefined,
failCloseCount: 0,
closeController: undefined as AbortController | undefined,
closeReason: undefined as unknown,
readController: undefined as AbortController | undefined,
readReason: undefined as unknown,
statController: undefined as AbortController | undefined,
statReason: undefined as unknown,
readFailure: undefined as Error | undefined,
events: [] as string[],
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (String(args[0]) !== handleState.archive) return handle;
handleState.events.push("open");
return new Proxy(handle, {
get(target, property, receiver) {
if (property === "stat" && handleState.statController !== undefined)
return async () => {
const result = await target.stat();
handleState.statController?.abort(handleState.statReason);
handleState.statController = undefined;
return result;
};
if (property === "read" && handleState.readFailure !== undefined)
return async () => {
const failure = handleState.readFailure;
handleState.readFailure = undefined;
throw failure;
};
if (property === "read" && handleState.readController !== undefined)
return async (...args: Parameters<typeof target.read>) => {
const response = await target.read(...args);
handleState.readController?.abort(handleState.readReason);
handleState.readController = undefined;
return response;
};
if (property === "close")
return async () => {
handleState.events.push("close");
if (handleState.closeController !== undefined) {
handleState.closeController.abort(handleState.closeReason);
handleState.closeController = undefined;
}
if (handleState.failCloseCount > 0) {
handleState.failCloseCount -= 1;
throw new Error("controlled ASAR descriptor close failure");
}
return await target.close();
};
return Reflect.get(target, property, receiver) as unknown;
},
});
},
};
});
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { CdpElectronProvider } from "./CdpElectronProvider.js";
import { V8InspectorProvider } from "../inspector/V8InspectorProvider.js";
import { observeJavaScriptRuntime } from "../application/javascript/JavaScriptRuntimeObservationService.js";
import { startFakeV8Inspector } from "../../tests/fixtures/inspector/fakeV8Inspector.js";
import { prependAsarJsonWhitespace } from "../../tests/fixtures/asarHeader.js";
import {
authorizedElectronFile,
mapElectronFileAuthorizationFailure,
retryElectronFileCleanup,
} from "./ElectronFileScope.js";
const roots: string[] = [];
afterEach(async () => {
await retryElectronFileCleanup();
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
handleState.archive = undefined;
handleState.failCloseCount = 0;
handleState.closeController = undefined;
handleState.closeReason = undefined;
handleState.readController = undefined;
handleState.readReason = undefined;
handleState.statController = undefined;
handleState.statReason = undefined;
handleState.readFailure = undefined;
handleState.events = [];
});
const asarPage = async (): Promise<string> => {
const root = await realpath(
await mkdtemp(join(tmpdir(), "rea-electron-file-scope-")),
);
roots.push(root);
const archive = join(root, "app.asar");
const json = Buffer.from(
JSON.stringify({ files: { "index.html": { size: 1, offset: "0" } } }),
);
const archiveBytes = Buffer.alloc(16 + json.length + 1);
const headerBytes = 8 + json.length;
archiveBytes.writeUInt32LE(4, 0);
archiveBytes.writeUInt32LE(headerBytes, 4);
archiveBytes.writeUInt32LE(headerBytes - 4, 8);
archiveBytes.writeUInt32LE(json.length, 12);
json.copy(archiveBytes, 16);
archiveBytes[16 + json.length] = 0;
await writeFile(archive, archiveBytes);
handleState.archive = archive;
return pathToFileURL(join(archive, "index.html")).href;
};
it("retains Inspector scripts and contexts when final ASAR authorization cannot close its descriptor", async () => {
const scriptUrl = await asarPage();
const archive = handleState.archive;
if (archive === undefined) throw new Error("Missing fixture ASAR");
const inspector = await startFakeV8Inspector({
targetUrl: "https://example.test/entry.js",
scriptUrls: [scriptUrl],
});
const provider = new V8InspectorProvider();
const input = {
inspector_endpoint: inspector.endpoint,
target_id: inspector.targetId,
observation_ms: 0,
};
try {
const control = await observeJavaScriptRuntime(provider, input);
expect(control.ok).toBe(true);
if (!control.ok) throw control.error;
expect(control.value.normalized_result).toMatchObject({
scripts: { observed_total: 1 },
execution_contexts: [{ name: "node[fixture]" }],
});
await prependAsarJsonWhitespace(archive, " ");
handleState.failCloseCount = 1;
const result = await observeJavaScriptRuntime(provider, input);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected descriptor close failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
scripts: {
observed_total: 1,
items: [
{ location: { kind: "unresolved", reported_url: scriptUrl } },
],
},
execution_contexts: [{ name: "node[fixture]" }],
},
});
await provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
} finally {
handleState.failCloseCount = 0;
await provider.close();
await inspector.close();
}
});
it("retains a failed ASAR close through denial and retries before reopening", async () => {
const location = await asarPage();
const missing = pathToFileURL(
join(dirname(fileURLToPath(location)), "missing.js"),
).href;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(missing, undefined, "list_electron_targets");
} catch (cause: unknown) {
failure = cause;
}
if (!(failure instanceof ArtifactReaderFailure))
throw new Error("Expected an ASAR descriptor cleanup failure");
expect(failure.cleanup?.resources).toEqual([handleState.archive]);
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"list_electron_targets",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeUndefined();
await expect(
authorizedElectronFile(missing, undefined, "list_electron_targets"),
).resolves.toBeUndefined();
expect(handleState.events).toEqual([
"open",
"close",
"close",
"open",
"close",
]);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("keeps cancellation primary when ASAR close fails, then retries the same handle", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = new Error("cancel during ASAR close");
handleState.closeController = controller;
handleState.closeReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(
location,
controller.signal,
"inspect_electron_page",
);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(ArtifactReaderFailure);
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(handleState.events).toEqual([
"open",
"close",
"close",
"open",
"close",
]);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("keeps an earlier read failure primary over a later abort and close failure", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = new Error("late cancellation after read failure");
const readFailure = new Error("controlled ASAR header read failure");
handleState.closeController = controller;
handleState.closeReason = reason;
handleState.failCloseCount = 1;
handleState.readFailure = readFailure;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).not.toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(readFailure);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("preserves an arbitrary cancellation reason observed during ASAR read", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = { source: "caller", token: 17 };
handleState.readController = controller;
handleState.readReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("preserves cancellation observed after ASAR stat when final close fails", async () => {
const location = await asarPage();
const controller = new AbortController();
const reason = { source: "caller", stage: "descriptor-stat" };
handleState.statController = controller;
handleState.statReason = reason;
handleState.failCloseCount = 1;
let failure: unknown;
try {
await authorizedElectronFile(location, controller.signal);
} catch (cause: unknown) {
failure = cause;
}
const mapped = mapElectronFileAuthorizationFailure(
failure,
"rea-cdp-electron",
"inspect_electron_page",
);
expect(mapped?.cleanupIncomplete).toBe(true);
expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(mapped?.cleanupPrimary?.cause).toBe(reason);
await expect(authorizedElectronFile(location)).resolves.toBe(
`${handleState.archive}/index.html`,
);
expect(mapped?.cleanupIncomplete).toBe(false);
});
it("retries a failed ASAR descriptor lease during provider shutdown", async () => {
const location = await asarPage();
handleState.failCloseCount = 1;
await expect(authorizedElectronFile(location)).rejects.toBeInstanceOf(
ArtifactReaderFailure,
);
await new CdpElectronProvider().close();
expect(handleState.events).toEqual(["open", "close", "close"]);
});
it.each(["completed", "read-failed", "cancelled"] as const)(
"retains the completed listing prefix after ASAR finalization: %s",
async (mode) => {
const archiveUrl = await asarPage();
const page = join(roots.at(-1)!, "first.html");
await writeFile(page, "first");
const controller = new AbortController();
handleState.failCloseCount = 1;
if (mode === "read-failed")
handleState.readFailure = new Error("controlled ASAR read denial");
if (mode === "cancelled") {
handleState.closeController = controller;
handleState.closeReason = { request: "ASAR descriptor close" };
}
let endpoint = "";
const server = createServer((request, response) => {
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify(
request.url === "/json/version"
? {
Browser: "Electron/fixture",
"Protocol-Version": "1.3",
"WebKit-Version": "revision",
"User-Agent": "fixture",
"V8-Version": "V8",
webSocketDebuggerUrl:
endpoint.replace("http:", "ws:") +
"/devtools/browser/fixture",
}
: [pathToFileURL(page).href, archiveUrl].map((url, index) => ({
id: `page-${index}`,
type: "page",
title: `page ${index}`,
url,
attached: false,
webSocketDebuggerUrl:
endpoint.replace("http:", "ws:") +
`/devtools/page/page-${index}`,
})),
),
);
});
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("Expected discovery port");
endpoint = `http://127.0.0.1:${address.port}`;
const provider = new CdpElectronProvider();
try {
const result = await provider.listTargets(
{ cdp_endpoint: endpoint },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected ASAR finalization failure");
const origin = result.error.cleanupPrimary ?? result.error;
expect(origin.diagnostics).toMatchObject({
completed_prefix: {
examined_targets: 1,
allowed_targets: [{ target_id: "page-0", file_path: page }],
coverage: expect.stringContaining("remaining targets are unknown"),
},
});
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
"completed_prefix",
);
expect(result.error.cleanupIncomplete).toBe(true);
expect(handleState.events).toEqual(["open", "close"]);
await provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(handleState.events).toEqual(["open", "close", "close"]);
expect(origin.diagnostics).toHaveProperty("completed_prefix");
} finally {
handleState.failCloseCount = 0;
await provider.close();
await new Promise<void>((resolve, reject) =>
server.close((cause) =>
cause === undefined ? resolve() : reject(cause),
),
);
}
},
);