diff --git a/src/browser/PlaywrightElectronActiveProvider.test.ts b/src/browser/PlaywrightElectronActiveProvider.test.ts index 2ddece3a6c..4c7b738b6c 100644 --- a/src/browser/PlaywrightElectronActiveProvider.test.ts +++ b/src/browser/PlaywrightElectronActiveProvider.test.ts @@ -1,9 +1,13 @@ import { afterEach, expect, it, vi } from "vitest"; import * as processOwnership from "../process/ProcessOwnership.js"; -import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js"; import * as electronActions from "./PlaywrightElectronActiveActions.js"; -import { PlaywrightElectronActiveProvider } from "./PlaywrightElectronActiveProvider.js"; +import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js"; +import type { OwnedProcessGroup } from "../process/ProcessOwnership.js"; +import { + cleanupElectronProcesses, + PlaywrightElectronActiveProvider, +} from "./PlaywrightElectronActiveProvider.js"; afterEach(() => { vi.restoreAllMocks(); @@ -39,6 +43,71 @@ it("passes the selected environment and ownership token to Electron launch", asy }); }); +const ownership = { + runId: "run", + leaderPid: 4242, + processGroupId: 4242, + expectedParentPid: 1, +} satisfies OwnedProcessGroup; + +it("does not signal a Windows Electron PID when lineage is unavailable", async () => { + const terminateTree = vi.fn(async () => ({ + cleaned: true as const, + signaled: true, + })); + const result = await cleanupElectronProcesses( + ownership, + { + status: "unavailable", + observedAt: "2026-10-10T00:00:00.000Z", + runId: ownership.runId, + launcherPid: ownership.leaderPid, + processGroupId: ownership.processGroupId, + reason: "owned launcher is not live", + }, + { platform: "win32", terminateTree }, + ); + + expect(terminateTree).not.toHaveBeenCalled(); + expect(result).toEqual({ + cleaned: false, + reason: + "owned Electron lineage was unavailable; helper cleanup was not proven", + }); +}); + +it("signals a Windows Electron tree only after lineage is verified", async () => { + const terminateTree = vi.fn(async () => ({ + cleaned: true as const, + signaled: true, + })); + const result = await cleanupElectronProcesses( + ownership, + { + status: "verified", + observedAt: "2026-10-10T00:00:00.000Z", + lineage: { + runId: ownership.runId, + launcherPid: ownership.leaderPid, + launcherParentPid: 1, + processGroupId: ownership.processGroupId, + descendants: [ + { + pid: 4343, + parentPid: ownership.leaderPid, + processGroupId: ownership.processGroupId, + }, + ], + }, + }, + { platform: "win32", terminateTree }, + ); + + expect(terminateTree).toHaveBeenNthCalledWith(1, 4242); + expect(terminateTree).toHaveBeenNthCalledWith(2, 4343); + expect(result).toEqual({ cleaned: true, signaled: true }); +}); + it("retains the partial capture without taskkill when Windows lineage is unavailable", async () => { // Exercise the Windows decision branch; this is not native Windows coverage. const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); diff --git a/src/browser/PlaywrightElectronActiveProvider.ts b/src/browser/PlaywrightElectronActiveProvider.ts index 8faf1249fd..5c730e2fdf 100644 --- a/src/browser/PlaywrightElectronActiveProvider.ts +++ b/src/browser/PlaywrightElectronActiveProvider.ts @@ -259,21 +259,30 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati } } -const cleanupElectronProcesses = async ( +/** Clean up an Electron launch. Windows signals a process only after lineage is verified. */ +export const cleanupElectronProcesses = async ( ownership: OwnedProcessGroup, lineage: ProcessLineageObservation | undefined, + host: { + readonly platform: NodeJS.Platform; + readonly terminateTree: typeof cleanupWindowsProcessTree; + } = { + platform: process.platform, + terminateTree: cleanupWindowsProcessTree, + }, ): Promise => { - if (process.platform === "win32") { + if (host.platform === "win32") { + // An empty Windows process table is not ownership proof for this PID. if (lineage?.status !== "verified") return { cleaned: false, reason: "owned Electron lineage was unavailable; helper cleanup was not proven", }; - const root = await cleanupWindowsProcessTree(ownership.leaderPid); + const root = await host.terminateTree(ownership.leaderPid); if (!root.cleaned) return root; for (const descendant of lineage.lineage.descendants) { - const result = await cleanupWindowsProcessTree(descendant.pid); + const result = await host.terminateTree(descendant.pid); if (!result.cleaned) return result; } return root;