mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 10:12:50 +00:00
fix(capture): retain Electron observations and HAR resource failures
This commit is contained in:
1 parent
6c13fc8e0a
commit
44fd18305f
27 files changed
+1462
-145
No files matched your search
@@ -195,9 +195,11 @@ const createElectronActiveBoundaryPatches = ({
|
||||
};
|
||||
};
|
||||
|
||||
const patchSessionManager = (sessionModule) => {
|
||||
const patchSessionManager = (sessionModule, app) => {
|
||||
if (sessionModule === null || sessionModule === undefined) return;
|
||||
patchSession(sessionModule.defaultSession);
|
||||
if (app === undefined || app.isReady())
|
||||
patchSession(sessionModule.defaultSession);
|
||||
else app.once("ready", () => patchSession(sessionModule.defaultSession));
|
||||
for (const method of ["fromPartition", "fromPath"]) {
|
||||
const original = sessionModule[method];
|
||||
if (typeof original !== "function") continue;
|
||||
|
||||
@@ -543,31 +543,44 @@ const patchWebContents = (webContents, windowId) => {
|
||||
boundaries.patchWindowOpenHandler(webContents, contentsId);
|
||||
};
|
||||
|
||||
const patchBrowserWindow = (electron) => {
|
||||
const original = electron.BrowserWindow;
|
||||
if (typeof original !== "function") return;
|
||||
electron.BrowserWindow = new Proxy(original, {
|
||||
construct(target, args, newTarget) {
|
||||
const window = Reflect.construct(target, args, newTarget);
|
||||
const targetId = identity(window, "window");
|
||||
recordRuntime("window-lifecycle", "created", "completed", {
|
||||
target: targetId,
|
||||
argument_shape_values: args,
|
||||
});
|
||||
const preload = args[0]?.webPreferences?.preload;
|
||||
const patchWindows = (app) => {
|
||||
if (app === null || app === undefined) return;
|
||||
app.on("browser-window-created", (_event, window) => {
|
||||
const targetId = identity(window, "window");
|
||||
recordRuntime("window-lifecycle", "created", "completed", {
|
||||
target: targetId,
|
||||
capture_method: "event-emitter",
|
||||
});
|
||||
try {
|
||||
// Electron 44 separates the preload descriptor from WebPreferences.
|
||||
// Read the engine's descriptor, never infer a path from application code.
|
||||
const preferences = window.webContents.getLastWebPreferences?.();
|
||||
const preload =
|
||||
typeof preferences?.preload === "string"
|
||||
? preferences.preload
|
||||
: window.webContents._getPreloadScript?.()?.filePath;
|
||||
if (typeof preload === "string")
|
||||
recordRuntime("preload", "configured", "completed", {
|
||||
target: targetId,
|
||||
artifact_path: preload,
|
||||
});
|
||||
patchEmitter(window, (event) =>
|
||||
recordRuntime("window-lifecycle", event, "observed", {
|
||||
} catch (cause) {
|
||||
recordRuntime(
|
||||
"error",
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
"failed",
|
||||
{
|
||||
target: targetId,
|
||||
}),
|
||||
error: true,
|
||||
},
|
||||
);
|
||||
patchWebContents(window.webContents, targetId);
|
||||
return window;
|
||||
},
|
||||
}
|
||||
patchEmitter(window, (event) =>
|
||||
recordRuntime("window-lifecycle", event, "observed", {
|
||||
target: targetId,
|
||||
}),
|
||||
);
|
||||
patchWebContents(window.webContents, targetId);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -599,10 +612,10 @@ try {
|
||||
patchIpcMain(electron.ipcMain);
|
||||
boundaries.patchIpcRenderer(electron.ipcRenderer);
|
||||
boundaries.patchContextBridge(electron.contextBridge);
|
||||
patchBrowserWindow(electron);
|
||||
patchWindows(electron.app);
|
||||
boundaries.patchUtilityProcess(electron.utilityProcess);
|
||||
boundaries.patchShell(electron.shell);
|
||||
boundaries.patchSessionManager(electron.session);
|
||||
boundaries.patchSessionManager(electron.session, electron.app);
|
||||
boundaries.patchApplicationEffects(electron.app);
|
||||
boundaries.patchNativeAddonLoading();
|
||||
boundaries.patchChildProcess();
|
||||
@@ -620,8 +633,17 @@ try {
|
||||
});
|
||||
throw reason;
|
||||
});
|
||||
} catch {
|
||||
} catch (cause) {
|
||||
globalThis.__reaElectronActiveHookError = true;
|
||||
recordRuntime(
|
||||
"error",
|
||||
cause instanceof Error ? cause.message : String(cause),
|
||||
"failed",
|
||||
{
|
||||
target: "active-hook-installation",
|
||||
error: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
globalThis.__reaElectronActiveSnapshot = () => ({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { PlaywrightElectronActiveProvider } from "../dist/browser/PlaywrightElectronActiveProvider.js";
|
||||
import { electronActiveObservationInputSchema } from "../dist/domain/javascript/electronActiveObservation.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import { verifyElectronToolContracts } from "./verify/electron/tool-contracts.mjs";
|
||||
|
||||
const executable = process.env.REA_ELECTRON_EXECUTABLE;
|
||||
if (executable === undefined || executable.length === 0)
|
||||
@@ -14,6 +15,7 @@ if (executable === undefined || executable.length === 0)
|
||||
);
|
||||
|
||||
const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
await verifyElectronToolContracts({ executable, repositoryRoot });
|
||||
const applicationRoot =
|
||||
process.env.REA_ELECTRON_APPLICATION_ROOT ??
|
||||
join(repositoryRoot, "tests/conformance/readiness/electron");
|
||||
@@ -60,6 +62,7 @@ const output = {
|
||||
timeline: result.value.timeline,
|
||||
windows: result.value.windows,
|
||||
verified:
|
||||
result.value.coverage.status === "partial_attach" &&
|
||||
result.value.actions.every(({ status }) => status === "completed") &&
|
||||
result.value.actions.some(
|
||||
({ kind, window_index }) =>
|
||||
@@ -104,8 +107,8 @@ const output = {
|
||||
kind === "process-lifecycle" && event === "child.spawn",
|
||||
),
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(output)}\n`);
|
||||
if (!output.verified)
|
||||
throw new Error(
|
||||
"real Electron verifier did not observe the readiness IPC round trip",
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(output)}\n`);
|
||||
@@ -0,0 +1,364 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import Ajv2020 from "ajv/dist/2020.js";
|
||||
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
readProcessRunId,
|
||||
} from "../../../dist/process/ProcessOwnership.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
/** Exercise public Electron tools and cancellation using a real owned application. */
|
||||
export async function verifyElectronToolContracts({
|
||||
executable,
|
||||
repositoryRoot,
|
||||
}) {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), "rea-electron-e2e-")),
|
||||
);
|
||||
const main = join(root, "main.cjs");
|
||||
const ready = join(root, "ready");
|
||||
const entrypoint = join(repositoryRoot, "scripts", "rea.mjs");
|
||||
const runId = randomUUID();
|
||||
await writeFile(
|
||||
main,
|
||||
`const {app,BrowserWindow}=require("electron");
|
||||
app.whenReady().then(()=>{const window=new BrowserWindow();
|
||||
window.loadFile("renderer.html");require("node:fs").writeFileSync(${JSON.stringify(ready)},"ready");});`,
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "renderer.html"),
|
||||
'<!doctype html><button id="run">Run</button><script src="renderer.js"></script>',
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "renderer.js"),
|
||||
'document.querySelector("#run").addEventListener("click",()=>console.log("fixture-click"));',
|
||||
);
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
HOPPER_LAUNCHER_PATH: "/rea-unconfigured/hopper",
|
||||
},
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "electron-real-e2e", version: "1" });
|
||||
let serverStderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
serverStderr += chunk.toString();
|
||||
});
|
||||
let application;
|
||||
let stderr = "";
|
||||
try {
|
||||
await readProcessRunId(process.pid);
|
||||
application = spawn(
|
||||
executable,
|
||||
[
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port=0",
|
||||
`--user-data-dir=${join(root, "profile")}`,
|
||||
main,
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
},
|
||||
);
|
||||
application.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const endpoint = await waitForEndpoint(application, () => stderr);
|
||||
await client.connect(transport);
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const validators = new Map(
|
||||
(await client.listTools()).tools.map((tool) => {
|
||||
ajv.compile(tool.inputSchema);
|
||||
return [
|
||||
tool.name,
|
||||
tool.outputSchema ? ajv.compile(tool.outputSchema) : undefined,
|
||||
];
|
||||
}),
|
||||
);
|
||||
const call = async (name, arguments_) => {
|
||||
console.log(`PROBE ${name}`);
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: arguments_ },
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
const validate = validators.get(name);
|
||||
assert.ok(
|
||||
validate?.(result.structuredContent),
|
||||
JSON.stringify(validate?.errors),
|
||||
);
|
||||
return result.structuredContent;
|
||||
};
|
||||
let targets;
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
targets = await call("list_electron_targets", { cdp_endpoint: endpoint });
|
||||
if (targets.result.targets.length > 0) break;
|
||||
await pause(100);
|
||||
}
|
||||
const target = targets.result.targets.find(
|
||||
(item) => item.file_path === join(root, "renderer.html"),
|
||||
);
|
||||
assert.ok(target, JSON.stringify(targets));
|
||||
const page = await call("inspect_electron_page", {
|
||||
cdp_endpoint: endpoint,
|
||||
target_id: target.target_id,
|
||||
include_script_sources: true,
|
||||
observation_ms: 50,
|
||||
});
|
||||
assert.ok(
|
||||
page.result.scripts.items.some(
|
||||
(script) =>
|
||||
script.file_path === join(root, "renderer.js") &&
|
||||
script.source.included,
|
||||
),
|
||||
);
|
||||
const staticAnalysis = await call("analyze_javascript_application", {
|
||||
input_path: root,
|
||||
});
|
||||
const reconciled = await call("reconcile_javascript_runtime", {
|
||||
static_layers: [
|
||||
{
|
||||
analysis: staticAnalysis.evidence,
|
||||
runtime_mappings: [{ kind: "file-root", root }],
|
||||
},
|
||||
],
|
||||
runtime_observations: [page.evidence],
|
||||
});
|
||||
assert.ok(reconciled.result.runtime_captures.length > 0);
|
||||
const cli = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-electron-page",
|
||||
endpoint,
|
||||
target.target_id,
|
||||
"--include-script-sources",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, REA_LOG_LEVEL: "silent" },
|
||||
timeout: 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(cli.stdout).normalized_result.target.file_path,
|
||||
target.file_path,
|
||||
);
|
||||
const input = {
|
||||
executable_path: executable,
|
||||
application_path: main,
|
||||
application_root: root,
|
||||
actions: [{ step_id: "settle", kind: "wait", duration_ms: 50 }],
|
||||
};
|
||||
const active = await client.callTool(
|
||||
{ name: "capture_electron_scenario", arguments: input },
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
const capture = active.isError
|
||||
? active.structuredContent.error.details.partial_observation.capture
|
||||
: active.structuredContent.result;
|
||||
if (active.isError) {
|
||||
assert.equal(
|
||||
active.structuredContent.error.code,
|
||||
"cleanup_incomplete",
|
||||
JSON.stringify(active),
|
||||
);
|
||||
assert.equal(capture.application.cleanup, "unverified");
|
||||
} else {
|
||||
assert.ok(
|
||||
validators.get("capture_electron_scenario")(active.structuredContent),
|
||||
);
|
||||
assert.equal(capture.application.cleanup, "terminated-owned-process");
|
||||
}
|
||||
assert.equal(capture.actions[0].status, "completed");
|
||||
assert.equal(
|
||||
capture.coverage.status,
|
||||
"partial_attach",
|
||||
JSON.stringify(capture),
|
||||
);
|
||||
assert.ok(
|
||||
capture.timeline.events.some(
|
||||
(event) =>
|
||||
event.kind === "window-lifecycle" && event.event === "created",
|
||||
),
|
||||
);
|
||||
await client.ping();
|
||||
console.log(
|
||||
`PASS live Electron CLI/MCP discovery, inspection, reconciliation, and active observations; active cleanup=${capture.application.cleanup}`,
|
||||
);
|
||||
if (process.platform !== "win32")
|
||||
await verifyCancellation({ input, ready, entrypoint, root });
|
||||
} catch (cause) {
|
||||
throw new Error(
|
||||
`Electron public-tool verification failed: ${cause.message}\nMCP stderr:\n${serverStderr}`,
|
||||
{ cause },
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
if (application?.pid) {
|
||||
const cleanup = await cleanupOwnedProcessGroup({
|
||||
runId,
|
||||
leaderPid: application.pid,
|
||||
processGroupId: application.pid,
|
||||
expectedParentPid: process.pid,
|
||||
expectedCommand: executable,
|
||||
});
|
||||
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
|
||||
}
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const pause = (milliseconds) =>
|
||||
new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function waitForEndpoint(application, diagnostics) {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const match = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//u.exec(
|
||||
diagnostics(),
|
||||
);
|
||||
if (match) return `http://127.0.0.1:${match[1]}`;
|
||||
assert.equal(application.exitCode, null, diagnostics());
|
||||
await pause(100);
|
||||
}
|
||||
throw new Error(
|
||||
`Electron did not expose a loopback endpoint: ${diagnostics()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyCancellation({ input, ready, entrypoint, root }) {
|
||||
await rm(ready, { force: true });
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"capture-electron-scenario",
|
||||
JSON.stringify({
|
||||
...input,
|
||||
actions: [{ step_id: "wait", kind: "wait", duration_ms: 30_000 }],
|
||||
}),
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env, REA_LOG_LEVEL: "silent" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
let ownership;
|
||||
try {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const table = (
|
||||
await execute("ps", ["-eo", "pid=,ppid=,pgid=,args="], {
|
||||
timeout: 5_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
for (const row of table.split("\n")) {
|
||||
const entry = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/u.exec(row);
|
||||
if (
|
||||
entry &&
|
||||
Number(entry[2]) === child.pid &&
|
||||
entry[4].includes(input.application_path)
|
||||
) {
|
||||
const runId = await readProcessRunId(Number(entry[1]));
|
||||
assert.ok(runId, "Electron child lacked its ownership token");
|
||||
ownership = {
|
||||
runId,
|
||||
leaderPid: Number(entry[1]),
|
||||
processGroupId: Number(entry[3]),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (ownership) {
|
||||
try {
|
||||
await access(ready);
|
||||
break;
|
||||
} catch {
|
||||
/* wait for actual fixture readiness */
|
||||
}
|
||||
}
|
||||
assert.equal(child.exitCode, null, `${stdout}\n${stderr}`);
|
||||
await pause(100);
|
||||
}
|
||||
assert.ok(ownership, "CLI did not acquire an owned Electron application");
|
||||
await access(ready);
|
||||
// The fixture's readiness precedes Playwright's Inspector acquisition;
|
||||
// interrupt the long action rather than its separate startup boundary.
|
||||
await pause(1_000);
|
||||
child.kill("SIGTERM");
|
||||
let timer;
|
||||
const result = await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Electron CLI cancellation timed out")),
|
||||
15_000,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
assert.equal(result.code, 143, `${stdout}\n${stderr}`);
|
||||
assert.equal(result.signal, null);
|
||||
const error = JSON.parse(stdout);
|
||||
assert.ok(["cancelled", "cleanup_incomplete"].includes(error.code), stdout);
|
||||
assert.equal(error.category, "cancelled", stdout);
|
||||
assert.doesNotMatch(error.message, /rea doctor/u);
|
||||
assert.equal(
|
||||
error.details?.partial_observation?.kind,
|
||||
"electron-active-observation",
|
||||
stdout,
|
||||
);
|
||||
assert.ok(
|
||||
error.details.partial_observation.capture.actions.some(
|
||||
(action) => action.status === "cancelled",
|
||||
),
|
||||
);
|
||||
assert.throws(() => process.kill(-ownership.processGroupId, 0), {
|
||||
code: "ESRCH",
|
||||
});
|
||||
console.log(
|
||||
`PASS real Electron CLI SIGTERM, retained cancelled actions, and owned group termination; reported code=${error.code}`,
|
||||
);
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
await exited;
|
||||
}
|
||||
if (ownership) {
|
||||
const cleanup = await cleanupOwnedProcessGroup(ownership);
|
||||
assert.equal(cleanup.cleaned, true, JSON.stringify(cleanup));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user