diff --git a/docs/guides/javascript/electron-observation.md b/docs/guides/javascript/electron-observation.md index bce4add26c..496f07fece 100644 --- a/docs/guides/javascript/electron-observation.md +++ b/docs/guides/javascript/electron-observation.md @@ -167,6 +167,16 @@ This operation actively launches and interacts with the selected target. It is not a passive CDP observation. The owned process keeps normal host filesystem and network privileges, so lifecycle ownership does not make it a sandbox. +The app runs with the operator's real profile. If the app is already running, +or another capture of the same app is in progress, the launched instance can +fail to obtain the app's single-instance lock. Electron then hands the launch, +including its arguments, to the running instance. The active hook stops the +launched app at that call, and the capture fails with +`application_already_running` without observing or driving the running +instance. If the app requests the lock while actions run, the pending actions +stop and the capture fails the same way, keeping its final snapshot. Close the +running app, or wait for the other capture, and retry. + The CLI and MCP surfaces accept the same schema. For a JSON request file: ```bash diff --git a/scripts/electron-active-hook.cjs b/scripts/electron-active-hook.cjs index 1ed521da9b..ea0b651416 100644 --- a/scripts/electron-active-hook.cjs +++ b/scripts/electron-active-hook.cjs @@ -1195,6 +1195,47 @@ const patchWebContents = (webContents, windowId) => { boundaries.patchWindowOpenHandler(webContents, contentsId); }; +// When another instance holds the app's single-instance lock, Electron has +// already handed this launch's arguments to that instance, and the launched +// process is no longer the capture target. Stop the application's calling path +// and keep the process alive so the provider reads the refusal, fails the +// capture, and terminates the process it owns. +let singleInstanceLockRefused = false; +// Resolves when the lock is refused so the provider can stop actions that +// wait for a window the stopped path would have opened. The snapshot's +// `single_instance_lock_refused` remains the fact the provider reports. +let announceSingleInstanceLockRefusal = () => undefined; +globalThis.__reaElectronSingleInstanceLockRefusal = new Promise((resolve) => { + announceSingleInstanceLockRefusal = () => resolve(true); +}); +const singleInstanceLockRefusal = new Error( + "Another instance holds this application's single-instance lock", +); +const patchSingleInstanceLock = (app) => { + const request = app?.requestSingleInstanceLock; + if (typeof request !== "function") return; + app.requestSingleInstanceLock = function requestSingleInstanceLock() { + const held = applyFunction(request, this, arguments); + if (held !== false) return held; + if (!singleInstanceLockRefused) { + singleInstanceLockRefused = true; + recordRuntime("app-lifecycle", "requestSingleInstanceLock", "blocked", { + target: "app", + process_type: "main", + error: true, + }); + // The stopped path surfaces as an uncaught exception; without a + // listener, Electron would show a modal error dialog or exit first. + applyFunction(nodeEventEmitterMethods.on ?? process.on, process, [ + "uncaughtException", + () => undefined, + ]); + announceSingleInstanceLockRefusal(); + } + throw singleInstanceLockRefusal; + }; +}; + const patchWindows = (app) => { if (app === null || app === undefined) return; app.on("browser-window-created", (_event, window) => { @@ -1318,6 +1359,7 @@ try { boundaries.patchIpcRenderer(electron.ipcRenderer); boundaries.patchContextBridge(electron.contextBridge); patchWindows(electron.app); + patchSingleInstanceLock(electron.app); boundaries.patchUtilityProcess(electron.utilityProcess); boundaries.patchShell(electron.shell); boundaries.patchSessionManager(electron.session, electron.app); @@ -1388,4 +1430,5 @@ globalThis.__reaElectronActiveSnapshot = () => ({ observed_ipc: observedIpc, observed_runtime: observedRuntime, hook_error: globalThis.__reaElectronActiveHookError === true, + single_instance_lock_refused: singleInstanceLockRefused, }); diff --git a/src/browser/PlaywrightElectronActiveActions.test.ts b/src/browser/PlaywrightElectronActiveActions.test.ts index 65b34439c7..1c7e14d1ab 100644 --- a/src/browser/PlaywrightElectronActiveActions.test.ts +++ b/src/browser/PlaywrightElectronActiveActions.test.ts @@ -599,6 +599,7 @@ const finalState = { observed_ipc: 0, observed_runtime: 0, hook_error: false, + single_instance_lock_refused: false, }, }; diff --git a/src/browser/PlaywrightElectronActiveActions.ts b/src/browser/PlaywrightElectronActiveActions.ts index a8e67c9576..8f87653e9b 100644 --- a/src/browser/PlaywrightElectronActiveActions.ts +++ b/src/browser/PlaywrightElectronActiveActions.ts @@ -50,6 +50,8 @@ const hookSnapshotSchema = z.strictObject({ observed_ipc: retention.observed_ipc, observed_runtime: retention.observed_runtime, hook_error: z.boolean(), + /** Null when the hook did not run, so the lock outcome is unknown. */ + single_instance_lock_refused: z.boolean().nullable(), }); type ElectronAction = ElectronActiveObservationInput["actions"][number]; @@ -382,6 +384,40 @@ const emitDeepLink = ( { delivery: action.delivery, url: action.url }, ); +/** + * Abort when the hook announces a refused single-instance lock. The hook + * stops the application path that requested the lock, so a window that path + * would have opened never appears; aborting lets pending actions stop. The + * hook snapshot's `single_instance_lock_refused` remains the reported fact. + */ +export const watchSingleInstanceLockRefusal = ( + application: ElectronApplication, +): AbortSignal => { + const refusal = new AbortController(); + void application + .evaluate(() => { + const announced = Reflect.get( + globalThis, + "__reaElectronSingleInstanceLockRefusal", + ); + return announced instanceof Promise ? announced : false; + }) + .then( + (refused) => { + if (refused === true) + refusal.abort( + new BrowserObservationError( + OPERATION, + "application_already_running", + ), + ); + }, + // The pending read settles when the application closes; nothing to report. + () => undefined, + ); + return refusal.signal; +}; + /** Read the current Electron windows, metrics, and hook snapshot. */ export const readApplicationState = async ( application: ElectronApplication, @@ -456,6 +492,7 @@ export const readApplicationState = async ( observed_ipc: 0, observed_runtime: 0, hook_error: true, + single_instance_lock_refused: null, }; }) .then((value) => hookSnapshotSchema.parse(value)); diff --git a/src/browser/PlaywrightElectronActiveProjection.test.ts b/src/browser/PlaywrightElectronActiveProjection.test.ts index 6c69dc34d3..ea9f2489ad 100644 --- a/src/browser/PlaywrightElectronActiveProjection.test.ts +++ b/src/browser/PlaywrightElectronActiveProjection.test.ts @@ -65,6 +65,7 @@ it.each([ observed_ipc: 0, observed_runtime: 3, hook_error: false, + single_instance_lock_refused: false, }, }, ); @@ -117,6 +118,7 @@ it("projects hook retention and dropped event families through the result schema observed_ipc: 1, observed_runtime: 1, hook_error: false, + single_instance_lock_refused: false, }, }, ); @@ -199,6 +201,7 @@ it("keeps renderer coverage unavailable when only the main process was observed" observed_ipc: 0, observed_runtime: 1, hook_error: false, + single_instance_lock_refused: false, }, }, ); @@ -239,6 +242,7 @@ it("does not correlate legacy dropped families and roles", () => { observed_ipc: 1, observed_runtime: 1, hook_error: false, + single_instance_lock_refused: false, }, }, ); diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 0964018764..b9b2c84270 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -415,6 +415,10 @@ it("passes required Windows runtime variables without inheriting unrelated value let ownership: PersistentOwnedProcessGroup; beforeEach(() => { + // Fake applications have no hook to announce a refused lock. + vi.spyOn(electronActions, "watchSingleInstanceLockRefusal").mockReturnValue( + new AbortController().signal, + ); ownership = { runId: "run", leaderPid: 4242, @@ -439,6 +443,7 @@ const emptyHookSnapshot = () => ({ observed_ipc: 0, observed_runtime: 0, hook_error: false, + single_instance_lock_refused: false, }); it.each([ @@ -693,6 +698,148 @@ it("retains the initial snapshot when the final state read fails", async () => { }); }); +it.each(["initial", "final"] as const)( + "refuses an application handed off to a running instance at the %s snapshot", + async (stage) => { + const state = (refused: boolean) => ({ + windows: [], + metrics: [], + electronVersion: "44.0.0", + hookSnapshot: { + ...emptyHookSnapshot(), + single_instance_lock_refused: refused, + }, + }); + vi.spyOn(electronActions, "readApplicationState").mockResolvedValueOnce( + state(stage === "initial"), + ); + vi.spyOn( + electronActions, + "readFinalApplicationState", + ).mockResolvedValueOnce(state(stage === "final")); + const runActions = vi + .spyOn(electronActions, "runElectronActions") + .mockResolvedValue({ actions: [] }); + const application = { + process: () => ({ pid: 424242 }), + close: vi.fn(async () => undefined), + windows: () => [], + }; + const provider = new PlaywrightElectronActiveProvider( + {}, + async () => + application as unknown as Awaited< + ReturnType + >, + ); + + const result = await provider.capture({ + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + }), + application_root: dirname(process.execPath), + }); + + if (result.ok) throw new Error("Expected the handed-off launch to fail"); + expect(runActions).toHaveBeenCalledTimes(stage === "initial" ? 0 : 1); + expect(application.close).toHaveBeenCalled(); + expect(result.error).toMatchObject({ + _tag: "BrowserObservationError", + reason: "application_already_running", + }); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + category: "unavailable", + message: expect.stringContaining("The application is already running"), + remediation: { + action: expect.stringContaining("Close the running application"), + }, + }); + // A refusal first seen in the final snapshot keeps that snapshot, which + // holds the blocked lock request and the action effects. + expect(result.error.partialObservation).toMatchObject({ + kind: "electron-active-observation", + capture: { + limitations: expect.arrayContaining([ + expect.stringContaining( + stage === "initial" + ? "initial pre-action snapshot" + : "failed after its final snapshot", + ), + ]), + }, + }); + }, +); + +it("stops pending actions when the lock is refused while they wait", async () => { + const state = (refused: boolean) => ({ + windows: [], + metrics: [], + electronVersion: "44.0.0", + hookSnapshot: { + ...emptyHookSnapshot(), + single_instance_lock_refused: refused, + }, + }); + vi.spyOn(electronActions, "readApplicationState").mockResolvedValueOnce( + state(false), + ); + vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValueOnce( + state(true), + ); + // The refused path never opens the window the action waits for. + const refusal = new AbortController(); + vi.spyOn(electronActions, "watchSingleInstanceLockRefusal").mockReturnValue( + refusal.signal, + ); + const windows = vi.fn(() => []); + const application = { + process: () => ({ pid: 424242 }), + close: vi.fn(async () => undefined), + windows, + }; + const provider = new PlaywrightElectronActiveProvider( + {}, + async () => + application as unknown as Awaited< + ReturnType + >, + ); + + const capture = provider.capture({ + ...electronActiveObservationInputSchema.parse({ + executable_path: process.execPath, + application_path: process.execPath, + actions: [ + { step_id: "open", kind: "click", window_index: 0, selector: "#go" }, + ], + }), + application_root: dirname(process.execPath), + }); + let result: Awaited; + try { + await waitFor(() => expect(windows).toHaveBeenCalled()); + refusal.abort(); + result = await capture; + } finally { + // A failed wait still stops and joins the capture before mocks restore. + await provider.close(); + await capture; + } + + if (result.ok) throw new Error("Expected the handed-off launch to fail"); + expect(application.close).toHaveBeenCalled(); + expect(result.error).toMatchObject({ + _tag: "BrowserObservationError", + reason: "application_already_running", + partialObservation: { + capture: { actions: [{ step_id: "open", status: "cancelled" }] }, + }, + }); +}); + it("rejects active capture before launch when EnableNodeCliInspectArguments fuse is disabled", async () => { const root = await createTestTempDirectory("rea-active-provider-fuse-"); const disabledBinary = join(root, "disabled-electron"); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index ec1d42dcdd..d3c432dff9 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -50,6 +50,8 @@ import { runElectronActions, readApplicationState, readFinalApplicationState, + watchSingleInstanceLockRefusal, + type ElectronHookSnapshot, } from "./PlaywrightElectronActiveActions.js"; import { projectElectronActiveCapture } from "./PlaywrightElectronActiveProjection.js"; import { readElectronFuseImage } from "../electron/ElectronFuseReader.js"; @@ -124,8 +126,12 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati let lineage: ProcessLineageObservation | undefined; let capture: ElectronActiveObservationResult | undefined; let paths: ElectronPaths | undefined; - let initialState: - | Awaited> + // The latest completed snapshot, retained when a later stage fails. + let retained: + | { + readonly stage: "initial" | "final"; + readonly state: Awaited>; + } | undefined; let actions: ElectronActiveObservationResult["actions"] = []; let outcome: Result; @@ -224,15 +230,27 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati ...(captureBaseline === undefined ? {} : { captureBaseline }), }; lineage = await observeOwnedProcessLineage(ownership); - initialState = await runWithExecutionLimits( + const initialState = await runWithExecutionLimits( readApplicationState(application), options.signal, startupDeadline, ); + retained = { stage: "initial", state: initialState }; + refuseHandedOffInstance(initialState.hookSnapshot); + // A lock refused while actions run stops the path that would open their + // windows, so the refusal also ends pending actions; the final snapshot + // then reports it. + const handedOff = watchSingleInstanceLockRefusal(application); const actionOutcome = await runElectronActions( application, input, - options, + { + ...options, + signal: + options.signal === undefined + ? handedOff + : AbortSignal.any([options.signal, handedOff]), + }, this.#resources, ); actions = actionOutcome.actions; @@ -242,20 +260,26 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati application, options.signal, ); - capture = projectElectronActiveCapture(paths, actions, state); + const finalCapture = projectElectronActiveCapture(paths, actions, state); + retained = { stage: "final", state }; + refuseHandedOffInstance(state.hookSnapshot); + capture = finalCapture; outcome = ok(capture); } catch (cause: unknown) { - if (paths !== undefined && initialState !== undefined) { + if (paths !== undefined && retained !== undefined) { const retainedCapture = projectElectronActiveCapture( paths, actions, - initialState, + retained.state, ); + const reason = cause instanceof Error ? cause.message : String(cause); capture = { ...retainedCapture, limitations: [ ...retainedCapture.limitations, - `Capture stopped after the initial pre-action snapshot: ${cause instanceof Error ? cause.message : String(cause)}. Final Windows, metrics, IPC, and timeline state is unknown; the retained snapshot may omit action effects.`, + retained.stage === "initial" + ? `Capture stopped after the initial pre-action snapshot: ${reason}. Final Windows, metrics, IPC, and timeline state is unknown; the retained snapshot may omit action effects.` + : `Capture failed after its final snapshot: ${reason}. The retained final snapshot includes action effects.`, ], }; } @@ -346,6 +370,24 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati } } +/** + * A launch that was refused the app's single-instance lock was handed to the + * instance holding it; the launched process is not the target, so stop. + */ +const refuseHandedOffInstance = (snapshot: ElectronHookSnapshot): void => { + if (snapshot.single_instance_lock_refused === true) + throw new BrowserObservationError( + OPERATION, + "application_already_running", + { + detail: + "The application is already running: the launched instance did not obtain the app's single-instance lock, so Electron handed this launch and its arguments to the running instance. REA stopped the launched instance at that call and failed the capture; it did not observe or drive the running instance.", + remediationAction: + "Close the running application, or wait for another capture of the same application to finish, then retry.", + }, + ); +}; + /** Normalize only explicit facts emitted by the inspection owner. */ const inspectOwnership = async ( inspect: () => Promise | undefined, diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index b0bb439ff8..0f9e608b75 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -403,6 +403,7 @@ const browserErrorCategory = ( if ( reason === "target_not_found" || reason === "target_not_allowed" || + reason === "application_already_running" || reason === "service_worker_blocked" || reason === "target_changed" || reason === "target_session_detached" || diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index a00f2c5d27..8159b3ded8 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -619,6 +619,7 @@ const BROWSER_ERROR_CODES: Readonly< window_not_found: "unreadable_output", window_metadata_missing: "unreadable_output", process_ownership_unavailable: "unreadable_output", + application_already_running: "target_unavailable", cleanup_failed: "execution_failure", cancelled: "cancelled", timeout: "provider_timeout", diff --git a/src/domain/browserObservationErrors.ts b/src/domain/browserObservationErrors.ts index c13928bd02..7de214f060 100644 --- a/src/domain/browserObservationErrors.ts +++ b/src/domain/browserObservationErrors.ts @@ -33,6 +33,7 @@ export type BrowserObservationFailureReason = | "window_not_found" | "window_metadata_missing" | "process_ownership_unavailable" + | "application_already_running" | "cleanup_failed" | "cancelled" | "timeout"; diff --git a/tests/boundary/browser/electronActiveHook.test.ts b/tests/boundary/browser/electronActiveHook.test.ts index 360126a76d..cd275a777f 100644 --- a/tests/boundary/browser/electronActiveHook.test.ts +++ b/tests/boundary/browser/electronActiveHook.test.ts @@ -1,23 +1,22 @@ import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { createTestProcessRegistry } from "../../support/process/processFixture.js"; -import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; -import { afterEach, expect, it, onTestFinished } from "vitest"; +import { expect, it, onTestFinished } from "vitest"; import { electronActiveTimelineEventSchema } from "../../../src/domain/javascript/electronActiveObservation.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -const temporary: string[] = []; - const electronModuleSource = `const { EventEmitter } = require("node:events"); const app = new EventEmitter(); app.isReady = () => true; app.relaunch = () => undefined; app.setAsDefaultProtocolClient = () => true; app.setLoginItemSettings = () => undefined; +app.requestSingleInstanceLock = () => globalThis.__singleInstanceLockHeld !== false; app.moveToApplicationsFolder = () => { throw new Error("moveToApplicationsFolder must not run"); }; @@ -141,17 +140,8 @@ process.stdout.write(JSON.stringify({ snapshot, default_popup_decision: defaultP })(); `; -afterEach(async () => { - await Promise.all( - temporary - .splice(0) - .map(async (path) => rm(path, { force: true, recursive: true })), - ); -}); - it("retains active hook evidence within its event-retention budget", async () => { const root = await createTestTempDirectory("rea-electron-hook-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "exercise.cjs"); await writeFile(script, activeCaptureSource); @@ -265,7 +255,6 @@ it.each([ "denies permissions before app registration and after replacement for %s sessions", async (admission) => { const root = await createTestTempDirectory("rea-electron-session-policy-"); - temporary.push(root); await writeElectronFixture( root, admission === "deferred-default" @@ -341,7 +330,6 @@ console.log(JSON.stringify({ initial, replaced, cleared, applicationCalls, it("blocks synchronous shell effects with their native return contracts and retains argument shapes only", async () => { const root = await createTestTempDirectory("rea-electron-hook-shell-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "shell.cjs"); await writeFile( @@ -393,9 +381,66 @@ process.stdout.write(JSON.stringify({ electronActiveTimelineEventSchema.array().parse(result.snapshot.events); }); +it.each([ + { held: true, refused: false }, + { held: false, refused: true }, +])( + "stops an application handed off by its single-instance lock (held: $held)", + async ({ held, refused }) => { + const root = await createTestTempDirectory("rea-electron-hook-lock-"); + await writeElectronFixture(root); + const script = join(root, "lock.cjs"); + await writeFile( + script, + `const { app } = require("electron"); +globalThis.__singleInstanceLockHeld = ${String(held)}; +setImmediate(async () => { + const snapshot = globalThis.__reaElectronActiveSnapshot(); + // An already-announced refusal settles first; otherwise false wins. + const announced = await Promise.race([ + globalThis.__reaElectronSingleInstanceLockRefusal, + Promise.resolve(false), + ]); + process.stdout.write(JSON.stringify({ + refused: snapshot.single_instance_lock_refused, + announced, + events: snapshot.events.filter((event) => event.event === "requestSingleInstanceLock"), + }) + "\\n"); +}); +const primary = app.requestSingleInstanceLock(); +process.stdout.write(JSON.stringify({ application_continued: primary }) + "\\n"); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const lines = execution.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + // A refused launch belongs to the running instance: application code + // after the lock request must not run, and the process stays readable. + expect(lines).toEqual([ + ...(held ? [{ application_continued: true }] : []), + { + refused, + announced: refused, + events: refused + ? [ + expect.objectContaining({ + kind: "app-lifecycle", + phase: "blocked", + target: "app", + error: true, + }), + ] + : [], + }, + ]); + }, +); + it("blocks trashItem asynchronously, records the attempt, and preserves the selected file", async () => { const root = await createTestTempDirectory("rea-electron-hook-trash-"); - temporary.push(root); await writeElectronFixture(root); const selected = join(root, "retained.txt"); await writeFile(selected, "must remain"); @@ -433,7 +478,6 @@ it("classifies a dropped browser preload as preload configuration at capture", a const root = await createTestTempDirectory( "rea-electron-hook-browser-preload-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "browser-preload.cjs"); await writeFile( @@ -471,7 +515,6 @@ process.stdout.write(JSON.stringify(snapshot) + "\\n"); it("removes observed ipcMain listeners through their original function", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-removal-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-removal.cjs"); await writeFile( @@ -515,7 +558,6 @@ process.stdout.write(JSON.stringify({ it("removes the newest matching ipcMain registration across unpatched aliases", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-aliases-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-aliases.cjs"); await writeFile( @@ -558,7 +600,6 @@ process.stdout.write(JSON.stringify({ it("records ipcMain listeners registered by a newListener callback during once", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-nested-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-nested.cjs"); await writeFile( @@ -589,7 +630,6 @@ process.stdout.write(JSON.stringify({ it("keeps removal working after many fired ipcMain once registrations", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-repeat-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-repeat.cjs"); await writeFile( @@ -624,7 +664,6 @@ process.stdout.write(JSON.stringify({ it("removes the newest registration of an ipcMain listener that has its own listener property", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-wrapped-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-wrapped.cjs"); await writeFile( @@ -656,7 +695,6 @@ process.stdout.write(JSON.stringify({ it("reports original ipcMain listeners to newListener and removeListener observers", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-meta-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-meta.cjs"); await writeFile( @@ -703,7 +741,6 @@ process.stdout.write(JSON.stringify({ it("registers and removes ipcMain once listeners through decorators installed after the hook", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-decor-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-decorators.cjs"); await writeFile( @@ -752,7 +789,6 @@ process.stdout.write(JSON.stringify({ it("keeps hooked ipcMain listener semantics identical to an unhooked EventEmitter", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-diff-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-differential.cjs"); await writeFile( @@ -836,7 +872,6 @@ process.stdout.write(JSON.stringify({ it("keeps a replacement registered by a removeListener observer during ipcMain removeAllListeners", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-replace-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-replacement.cjs"); await writeFile( @@ -912,7 +947,6 @@ it("does not infer hook dispatch from an application's listener metadata", async const root = await createTestTempDirectory( "rea-electron-hook-indirect-owner-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "indirect-owner.cjs"); await writeFile( @@ -942,7 +976,6 @@ process.stdout.write(JSON.stringify({calls,remaining:ipcMain.listenerCount("oute it("keeps ipcMain once self-removal through an on decorator that wraps the hook", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-wrap-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-wrapping-decorator.cjs"); await writeFile( @@ -1016,7 +1049,6 @@ it.each(["stable", "replaced", "readonly", "getter", "proxy"])( const root = await createTestTempDirectory( "rea-electron-hook-ipc-remove-decorator-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-removal-decorator.cjs"); await writeFile( @@ -1089,7 +1121,6 @@ it("preserves native removal before mutated raw listener metadata throws", async const root = await createTestTempDirectory( "rea-electron-hook-ipc-mutated-metadata-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-mutated-listener-metadata.cjs"); await writeFile( @@ -1124,7 +1155,6 @@ process.stdout.write(JSON.stringify({ plain: scenario(new EventEmitter()), hooke it("matches native once dispatch and retains only established invocations", async () => { const root = await createTestTempDirectory("rea-electron-hook-native-once-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "electron-once-runtime.mjs"); await copyFile( @@ -1192,7 +1222,6 @@ it.each(["single-on", "multiple-on", "empty-once", "event-once"])( const root = await createTestTempDirectory( "rea-electron-hook-ipc-dispatch-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-native-dispatch.cjs"); await writeFile( @@ -1249,7 +1278,6 @@ it.each([ "primitive", ])("preserves raw callback apply with %s argument lists", async (mode) => { const root = await createTestTempDirectory("rea-electron-hook-ipc-apply-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-apply.cjs"); await writeFile( @@ -1303,7 +1331,6 @@ it("preserves the native once argument-list object passed to custom apply", asyn const root = await createTestTempDirectory( "rea-electron-hook-ipc-once-args-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-once-arguments.cjs"); await writeFile( @@ -1354,7 +1381,6 @@ it.each([ const root = await createTestTempDirectory( "rea-electron-hook-ipc-prototype-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-inherited-listener.cjs"); await writeFile( @@ -1398,7 +1424,6 @@ it.each(["on", "multiple-on", "once"])( const root = await createTestTempDirectory( "rea-electron-hook-ipc-species-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-array-species.cjs"); await writeFile( @@ -1456,7 +1481,6 @@ it.each(["array", "arguments", "array-like", "null"])( const root = await createTestTempDirectory( "rea-electron-hook-custom-apply-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-custom-apply.cjs"); await writeFile( @@ -1523,7 +1547,6 @@ it.each([false, true])( const root = await createTestTempDirectory( "rea-electron-hook-borrowed-apply-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-borrowed-apply.cjs"); await writeFile( @@ -1567,7 +1590,6 @@ it("uses the captured Array.isArray intrinsic while selecting a native listener const root = await createTestTempDirectory( "rea-electron-hook-captured-array-is-array-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "captured-array-is-array.cjs"); await writeFile( @@ -1625,7 +1647,6 @@ it.each([ const root = await createTestTempDirectory( "rea-electron-hook-late-intrinsic-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-late-intrinsic.cjs"); await writeFile( @@ -1764,7 +1785,6 @@ it.each([ const root = await createTestTempDirectory( "rea-electron-hook-chain-coverage-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-chain-coverage.cjs"); await writeFile( @@ -1833,7 +1853,6 @@ it.each(["array", "map"])( const root = await createTestTempDirectory( "rea-electron-hook-snapshot-iterator-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-snapshot-iterator.cjs"); await writeFile( @@ -1870,7 +1889,6 @@ console.log(JSON.stringify({ calls, reads, failure, copied: !!copied, retained: it("invokes handle callbacks without reading callable method properties", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-callable-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-callable-properties.cjs"); await writeFile( @@ -1931,7 +1949,6 @@ const scenario = async (emitter, observed) => { it("keeps native removal traversal when an ipcMain observer prepends a listener", async () => { const root = await createTestTempDirectory("rea-electron-hook-ipc-prepend-"); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-prepend-during-removal.cjs"); await writeFile( @@ -1981,7 +1998,6 @@ it.each(["accessor", "proxy"])( const root = await createTestTempDirectory( "rea-electron-hook-ipc-accessor-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "ipc-listener-accessor.cjs"); await writeFile( @@ -2034,7 +2050,6 @@ process.stdout.write(JSON.stringify({ plain: scenario(new EventEmitter()), hooke it("isolates hook and exercise from ancestor Electron packages", async () => { const root = await createTestTempDirectory("rea-electron-hook-ancestor-"); - temporary.push(root); await writeElectronFixture(root, 'module.exports = "/ambient/Electron";'); const ambientHook = await stageHook(join(root, "producer", "scripts")); expect(createRequire(ambientHook)("electron")).toBe("/ambient/Electron"); @@ -2064,7 +2079,6 @@ it("does not read an application rawListeners accessor during hook setup", async const root = await createTestTempDirectory( "rea-electron-hook-raw-listeners-accessor-", ); - temporary.push(root); const source = electronModuleSource.replace( "const ipcMain = new EventEmitter();", `const ipcMain = new EventEmitter(); @@ -2101,7 +2115,6 @@ it("leaves an accessor removal method untouched and suppresses uncertain invocat const root = await createTestTempDirectory( "rea-electron-hook-removal-method-accessor-", ); - temporary.push(root); const source = electronModuleSource.replace( "const ipcMain = new EventEmitter();", `const ipcMain = new EventEmitter(); @@ -2155,7 +2168,6 @@ it("preserves native last-registration order when the last raw listener link is const root = await createTestTempDirectory( "rea-electron-hook-removal-mutated-lifo-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-mutated-lifo.cjs"); await writeFile( @@ -2226,7 +2238,6 @@ it("lets a later data listener link win over an earlier owned callback", async ( const root = await createTestTempDirectory( "rea-electron-hook-removal-later-data-link-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-later-data-link.cjs"); await writeFile( @@ -2282,7 +2293,6 @@ it("normalizes a later raw link to an earlier owned registration", async () => { const root = await createTestTempDirectory( "rea-electron-hook-removal-owned-link-chain-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-owned-link-chain.cjs"); await writeFile( @@ -2338,7 +2348,6 @@ it("preserves native direct-removal cleanup with throwing listener metadata", as const root = await createTestTempDirectory( "rea-electron-hook-removal-accessor-gap-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-accessor-gap.cjs"); await writeFile( @@ -2385,7 +2394,6 @@ it("forwards native direct-removal bookkeeping to the real emitter", async () => const root = await createTestTempDirectory( "rea-electron-hook-removal-bookkeeping-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-bookkeeping.cjs"); await writeFile( @@ -2451,7 +2459,6 @@ it.each([1, 2] as const)( const root = await createTestTempDirectory( "rea-electron-hook-removal-meta-accessor-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-meta-accessor.cjs"); await writeFile( @@ -2522,7 +2529,6 @@ it("keeps listener ownership when native function prototype metadata changes", a const root = await createTestTempDirectory( "rea-electron-hook-wrapper-constructor-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "wrapper-constructor.cjs"); await writeFile( @@ -2559,7 +2565,6 @@ it("matches native custom inspection that mutates the raw listener", async () => const root = await createTestTempDirectory( "rea-electron-hook-removal-inspect-mutation-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-inspect-mutation.cjs"); await writeFile( @@ -2603,7 +2608,6 @@ it("normalizes an earlier raw owner in a later removal meta payload", async () = const root = await createTestTempDirectory( "rea-electron-hook-removal-owned-meta-link-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-owned-meta-link.cjs"); await writeFile( @@ -2643,7 +2647,6 @@ it("preserves the native argument that proves a chained removal match", async () const root = await createTestTempDirectory( "rea-electron-hook-removal-chain-argument-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-chain-argument.cjs"); await writeFile( @@ -2688,7 +2691,6 @@ it("falls back to native errors for a revoked listener-array proxy", async () => const root = await createTestTempDirectory( "rea-electron-hook-revoked-listener-array-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "revoked-listener-array.cjs"); await writeFile( @@ -2741,7 +2743,6 @@ it("records a gap when an opaque later registration blocks ownership ordering", const root = await createTestTempDirectory( "rea-electron-hook-removal-opaque-gap-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-opaque-gap.cjs"); await writeFile( @@ -2789,7 +2790,6 @@ it("preserves pre-hook removeListener decorator receiver and callback identity", const root = await createTestTempDirectory( "rea-electron-hook-preinstalled-removal-decorator-", ); - temporary.push(root); const source = electronModuleSource.replace( "const ipcMain = new EventEmitter();", `const ipcMain = new EventEmitter(); @@ -2862,7 +2862,6 @@ it("forwards pre-hook on and once decorators exact registrations with an explici const root = await createTestTempDirectory( "rea-electron-hook-preinstalled-registration-decorators-", ); - temporary.push(root); const source = electronModuleSource.replace( "const ipcMain = new EventEmitter();", `const ipcMain = new EventEmitter(); @@ -2980,7 +2979,6 @@ it("forwards borrowed on and once registrations without attributing calls to ipc const root = await createTestTempDirectory( "rea-electron-hook-borrowed-registration-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "borrowed-registration.cjs"); await writeFile( @@ -3076,7 +3074,6 @@ it("does not inspect object event channels while recording registration gaps", a const root = await createTestTempDirectory( "rea-electron-hook-object-channel-gap-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "object-channel-gap.cjs"); await writeFile( @@ -3120,7 +3117,6 @@ it("preserves borrowed removeAllListeners callback identity", async () => { const root = await createTestTempDirectory( "rea-electron-hook-borrowed-remove-all-identity-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "borrowed-remove-all-identity.cjs"); await writeFile( @@ -3179,7 +3175,6 @@ it("does not coerce object channels before native listener removal", async () => const root = await createTestTempDirectory( "rea-electron-hook-object-channel-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "object-channel.cjs"); await writeFile( @@ -3223,7 +3218,6 @@ it("removes observed ipcMain registrations after the public listener link is del const root = await createTestTempDirectory( "rea-electron-hook-removal-deleted-link-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-deleted-link.cjs"); await writeFile( @@ -3283,7 +3277,6 @@ it("passes canonical owned callbacks through post-hook removeAll decorators desp const root = await createTestTempDirectory( "rea-electron-hook-remove-all-decorator-accessor-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "remove-all-decorator-accessor.cjs"); await writeFile( @@ -3357,7 +3350,6 @@ it.each(["removeListener", "off"] as const)( const root = await createTestTempDirectory( "rea-electron-hook-removal-direct-link-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "removal-direct-link.cjs"); await writeFile( @@ -3433,7 +3425,6 @@ it("preserves warning-only rejections and records later blocked shell rejections const root = await createTestTempDirectory( "rea-electron-hook-rejection-warn-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "warnings.cjs"); await writeFile( @@ -3473,7 +3464,6 @@ it("preserves application rejection listeners and borrowed process emit behavior const root = await createTestTempDirectory( "rea-electron-hook-rejection-listener-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "listeners.cjs"); await writeFile( @@ -3514,7 +3504,6 @@ it("delivers revoked proxy rejections to the application's original handler", as const root = await createTestTempDirectory( "rea-electron-hook-revoked-rejection-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "revoked-rejections.cjs"); await writeFile( @@ -3555,7 +3544,6 @@ it("preserves an Electron-style uncaught exception listener policy", async () => const root = await createTestTempDirectory( "rea-electron-hook-uncaught-policy-", ); - temporary.push(root); await writeElectronFixture( root, electronModuleSource + @@ -3584,7 +3572,6 @@ it.each(["throw", "strict"])( const root = await createTestTempDirectory( "rea-electron-hook-rejection-fatal-", ); - temporary.push(root); await writeElectronFixture(root); const script = join(root, "fatal-rejection.cjs"); await writeFile( @@ -3613,7 +3600,6 @@ Promise.reject(new Error("controlled fatal rejection")); it("preserves fatal process termination after recording uncaught exceptions", async () => { const root = await createTestTempDirectory("rea-electron-hook-fatal-"); - temporary.push(root); await writeElectronFixture( root, 'throw new Error("controlled Electron unavailable");', @@ -3634,7 +3620,6 @@ it.each([{ sandboxPrefix: [] }, { sandboxPrefix: ["--no-sandbox"] }])( "restores the exact application argv with Playwright prefix %j", async ({ sandboxPrefix }) => { const root = await createTestTempDirectory("rea-electron-argv-"); - temporary.push(root); await writeElectronFixture(root); const hook = join(root, "electron-active-hook.cjs"); const callerArguments = [ @@ -3688,7 +3673,6 @@ it.each([ "no-entry", ])("preserves observed argv for an %s capture handoff", async (mode) => { const root = await createTestTempDirectory("rea-electron-argv-guard-"); - temporary.push(root); await writeElectronFixture(root); const hook = join(root, "electron-active-hook.cjs"); const plan = ["-r", hook, "/selected/app", "caller-document"];