Files
rea/src/browser/PlaywrightElectronActiveProvider.test.ts
T

1064 lines
33 KiB
TypeScript

import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { cleanupElectronProcesses } from "./ElectronProcessCleanup.js";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
OBSIDIAN_FUSE_BYTES,
thinFuseImage,
} from "../../tests/fixtures/electron/fuseImage.js";
import * as processOwnership from "../process/ProcessOwnership.js";
import { ProcessOwnershipEvidenceOwner } from "../process/ProcessOwnershipEvidence.js";
import * as ownershipObservation from "../process/ProcessOwnershipObservation.js";
import * as electronActions from "./PlaywrightElectronActiveActions.js";
import { electronActiveObservationInputSchema } from "../domain/javascript/electronActiveObservation.js";
import type { PersistentOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
PlaywrightElectronActiveProvider,
safeElectronEnvironment,
} from "./PlaywrightElectronActiveProvider.js";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("passes the selected environment and ownership token to Electron launch", async () => {
vi.stubEnv("LANG", "ambient-language");
vi.stubEnv("XAUTHORITY", "/ambient/.Xauthority");
let launchedEnvironment: unknown;
const provider = new PlaywrightElectronActiveProvider(
{
LANG: "selected-language",
PATH: "/selected/bin",
DISPLAY: ":selected-display",
XAUTHORITY: "/selected/display-authorization",
WAYLAND_DISPLAY: "selected-wayland",
REA_PRIVATE_VALUE: "not-a-launch-setting",
},
async (options) => {
launchedEnvironment = options?.env;
throw new Error("launch boundary reached");
},
);
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
}),
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
expect(launchedEnvironment).toEqual({
LANG: "selected-language",
PATH: "/selected/bin",
DISPLAY: ":selected-display",
XAUTHORITY: "/selected/display-authorization",
WAYLAND_DISPLAY: "selected-wayland",
REA_PROCESS_RUN_ID: expect.any(String),
});
});
it("passes required Windows runtime variables without inheriting unrelated values", () => {
expect(
safeElectronEnvironment(
{
SYSTEMROOT: "C:\\Windows",
LOCALAPPDATA: "C:\\Users\\analyst\\AppData\\Local",
USERPROFILE: "C:\\Users\\analyst",
APP_SECRET: "must-not-pass",
},
"run-id",
"win32",
),
).toEqual({
SYSTEMROOT: "C:\\Windows",
LOCALAPPDATA: "C:\\Users\\analyst\\AppData\\Local",
USERPROFILE: "C:\\Users\\analyst",
REA_PROCESS_RUN_ID: "run-id",
});
});
let ownership: PersistentOwnedProcessGroup;
beforeEach(() => {
ownership = {
runId: "run",
leaderPid: 4242,
processGroupId: 4242,
expectedParentPid: 1,
evidenceOwner: new ProcessOwnershipEvidenceOwner(),
};
});
const emptyHookSnapshot = () => ({
events: [],
retention_budget_bytes: 0,
estimated_retained_bytes: 0,
event_serialized_byte_upper_bound: 0,
retained: 0,
dropped: 0,
dropped_ipc: 0,
dropped_runtime: 0,
dropped_event_families: [],
dropped_event_roles: [],
observed: 0,
observed_ipc: 0,
observed_runtime: 0,
hook_error: false,
});
it.each([
"owned launcher is not live",
"process table permission denied",
"process ownership did not match",
])(
"does not signal a Windows Electron PID when lineage is unavailable: %s",
async (reason) => {
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockResolvedValue(
{ cleaned: false, reason: "authored census unavailable" },
);
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,
},
{ platform: "win32", terminateTree },
);
expect(terminateTree).not.toHaveBeenCalled();
expect(result).toEqual({
cleaned: false,
reason: expect.stringContaining(
`${reason}; helper cleanup was not proven`,
),
});
},
);
it("does not signal an unobserved Windows Electron lineage", async () => {
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockResolvedValue({
cleaned: false,
reason: "authored census unavailable",
});
const terminateTree = vi.fn();
expect(
await cleanupElectronProcesses(ownership, undefined, {
platform: "win32",
terminateTree,
}),
).toEqual({
cleaned: false,
reason: expect.stringContaining(
"owned Electron lineage was not observed; helper cleanup was not proven",
),
});
expect(terminateTree).not.toHaveBeenCalled();
});
it("preserves a Windows root termination failure without signaling cached descendants", async () => {
const failure = { cleaned: false as const, reason: "termination denied" };
const terminateTree = vi
.fn<typeof processOwnership.cleanupWindowsProcessTree>()
.mockResolvedValue({ cleaned: true, signaled: true });
terminateTree.mockResolvedValueOnce(failure);
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(result).toBe(failure);
expect(terminateTree).toHaveBeenCalledOnce();
});
it("signals a Windows Electron tree only after lineage is verified", async () => {
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockResolvedValue({
cleaned: true,
signaled: false,
});
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).toHaveBeenCalledOnce();
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.
// The injected unavailable observation keeps this independent of host PID reuse.
vi.spyOn(
ownershipObservation,
"observeOwnedProcessLineage",
).mockResolvedValue({
status: "unavailable",
observedAt: "2026-10-10T00:00:00.000Z",
runId: "fixture-run",
launcherPid: 424242,
processGroupId: 424242,
reason: "process table permission denied",
});
vi.spyOn(processOwnership, "verifyNoTokenOwnedProcesses").mockResolvedValue({
cleaned: false,
reason: "authored census unavailable",
});
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32" });
const terminateTree = vi
.spyOn(processOwnership, "cleanupWindowsProcessTree")
.mockResolvedValue({ cleaned: true, signaled: true });
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const provider = new PlaywrightElectronActiveProvider(
{},
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
try {
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
}),
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected unverified cleanup to fail");
expect(terminateTree).not.toHaveBeenCalled();
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cleanup_failed",
cleanup: {
reason: expect.stringContaining(
"process table permission denied; helper cleanup was not proven",
),
},
partialObservation: {
kind: "electron-active-observation",
capture: {
application: {
cleanup: "unverified",
},
},
},
});
} finally {
if (originalPlatform !== undefined)
Object.defineProperty(process, "platform", originalPlatform);
}
});
it("retains the initial snapshot when the final state read fails", async () => {
const initialState = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValueOnce(
initialState,
);
vi.spyOn(electronActions, "readFinalApplicationState").mockRejectedValueOnce(
new Error("final renderer state unavailable"),
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const provider = new PlaywrightElectronActiveProvider(
{},
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
actions: [],
}),
application_root: dirname(process.execPath),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected final state capture to fail");
const partialObservation = result.error.partialObservation;
expect(partialObservation).toMatchObject({
kind: "electron-active-observation",
capture: {
application: { electron_version: "44.0.0" },
limitations: expect.arrayContaining([
expect.stringContaining("initial pre-action snapshot"),
]),
},
});
});
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");
await writeFile(disabledBinary, thinFuseImage(OBSIDIAN_FUSE_BYTES).bytes);
const launchSpy = vi.fn(async () => {
throw new Error("launch should not be called when fuse is disabled");
});
const provider = new PlaywrightElectronActiveProvider({}, launchSpy);
const result = await provider.capture({
...electronActiveObservationInputSchema.parse({
executable_path: disabledBinary,
application_path: disabledBinary,
}),
application_root: root,
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected capture to fail");
expect(launchSpy).not.toHaveBeenCalled();
expect(result.error).toMatchObject({
_tag: "AnalysisUnsupportedTargetError",
operation: "capture_electron_scenario",
path: disabledBinary,
message: expect.stringContaining("EnableNodeCliInspectArguments"),
remediationAction: expect.stringContaining("inspect_electron_page"),
});
});
const stubVerifiedElectronCleanup = () => {
vi.spyOn(
ownershipObservation,
"observeOwnedProcessLineage",
).mockImplementation(async (group) => ({
status: "verified",
observedAt: "2026-10-10T00:00:00.000Z",
lineage: {
runId: group.runId,
launcherPid: group.leaderPid,
launcherParentPid: process.pid,
processGroupId: group.processGroupId,
descendants: [],
},
}));
return [
vi
.spyOn(processOwnership, "cleanupWindowsProcessTree")
.mockResolvedValue({ cleaned: true, signaled: true }),
vi
.spyOn(processOwnership, "cleanupOwnedProcessGroup")
.mockResolvedValue({ cleaned: true, signaled: true }),
vi
.spyOn(processOwnership, "verifyNoTokenOwnedProcesses")
.mockResolvedValue({ cleaned: true, signaled: false }),
];
};
/** Run a capture whose test doubles abort `controller` at a chosen phase. */
const captureWithController = async (
controller: AbortController,
application: Record<string, unknown>,
actions: unknown[],
) => {
const provider = new PlaywrightElectronActiveProvider(
{},
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
return provider.capture(
{
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
actions,
}),
application_root: dirname(process.execPath),
},
{ signal: controller.signal },
);
};
it("cancels a windowless wait action and cleans up the owned application", async () => {
const cleanupSpies = stubVerifiedElectronCleanup();
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
const controller = new AbortController();
const reason = new Error("caller-reason");
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
firstWindow: vi.fn(() => {
queueMicrotask(() => controller.abort(reason));
return new Promise<never>(() => undefined);
}),
};
const result = await captureWithController(controller, application, [
{ step_id: "settle", kind: "wait", duration_ms: 10 },
]);
expect(application.firstWindow).toHaveBeenCalledOnce();
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: {
kind: "electron-active-observation",
capture: {
actions: [{ step_id: "settle", kind: "wait", status: "cancelled" }],
},
},
});
expect(result.error.cause).toBe(reason);
});
it("records actions after a failed action as not executed and keeps the capture", async () => {
stubVerifiedElectronCleanup();
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
const click = vi.fn(async () => {
throw new Error("strict mode violation");
});
const waitForTimeout = vi.fn(async () => undefined);
const page = { locator: () => ({ click }), waitForTimeout };
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [page],
browserWindow: async () => ({
evaluate: async (read: (window: { id: number }) => unknown) =>
read({ id: 42 }),
dispose: async () => {},
}),
firstWindow: async () => page,
};
const result = await captureWithController(
new AbortController(),
application,
[
{ step_id: "fail", kind: "click", selector: "#duplicate" },
{ step_id: "settle", kind: "wait", duration_ms: 0, window_index: 0 },
{
step_id: "link",
kind: "deep-link",
delivery: "open-url",
url: "rea-test://open",
},
],
);
if (!result.ok) throw result.error;
expect(click).toHaveBeenCalledOnce();
expect(waitForTimeout).not.toHaveBeenCalled();
expect(application.close).toHaveBeenCalledOnce();
expect(result.value.actions).toEqual([
{
step_id: "fail",
kind: "click",
window_index: 0,
target: "window:42",
status: "failed",
elapsed_ms: expect.any(Number),
error: "strict mode violation",
},
{
step_id: "settle",
kind: "wait",
window_index: 0,
target: null,
status: "cancelled",
elapsed_ms: 0,
error: "not executed after an earlier action failure",
},
{
step_id: "link",
kind: "deep-link",
window_index: null,
target: null,
status: "cancelled",
elapsed_ms: 0,
error: "not executed after an earlier action failure",
},
]);
});
it("cancels a pending initial state read and cleans up the owned application", async () => {
const cleanupSpies = stubVerifiedElectronCleanup();
const controller = new AbortController();
const reason = new Error("caller-reason");
vi.spyOn(electronActions, "readApplicationState").mockImplementation(() => {
queueMicrotask(() => controller.abort(reason));
return new Promise<never>(() => undefined);
});
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
});
expect(result.error.cause).toBe(reason);
});
it("observes an initial state read started after cancellation and rejected by cleanup", async () => {
const cleanupSpies = stubVerifiedElectronCleanup();
const unhandled: unknown[] = [];
const recordUnhandled = (reason: unknown) => {
unhandled.push(reason);
};
process.on("unhandledRejection", recordUnhandled);
try {
const controller = new AbortController();
const reason = new Error("caller-reason");
let rejectReads = (_cause: Error): void => undefined;
const closed = new Promise<never>((_, reject) => {
rejectReads = reject;
});
// Plain functions keep spy bookkeeping from observing the abandoned read.
const application = {
process: () => ({ pid: 424242 }),
evaluate: () => {
controller.abort(reason);
return closed;
},
close: vi.fn(async () => {
rejectReads(
new Error("Target page, context or browser has been closed"),
);
}),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
expect(application.close).toHaveBeenCalledOnce();
expect(cleanupSpies.some((spy) => spy.mock.calls.length > 0)).toBe(true);
expect(unhandled).toEqual([]);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
});
expect(result.error.cause).toBe(reason);
} finally {
process.off("unhandledRejection", recordUnhandled);
}
});
it("keeps a failure observed with cancellation as the cancellation cause", async () => {
stubVerifiedElectronCleanup();
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
const controller = new AbortController();
const failure = new Error("final state read failed");
vi.spyOn(electronActions, "readFinalApplicationState").mockImplementation(
async () => {
controller.abort(new Error("caller-reason"));
throw failure;
},
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: { kind: "electron-active-observation" },
});
expect(result.error.cause).toBe(failure);
});
it("reports an expired final state grace period as the caller's cancellation", async () => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
try {
stubVerifiedElectronCleanup();
const controller = new AbortController();
const reason = new Error("caller-reason");
vi.spyOn(electronActions, "readApplicationState").mockResolvedValueOnce({
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
});
// The real final read reaches these evaluations, which never settle.
const evaluate = vi.fn(() => {
queueMicrotask(() => controller.abort(reason));
return new Promise<never>(() => undefined);
});
const application = {
process: () => ({ pid: 424242 }),
evaluate,
close: vi.fn(async () => undefined),
windows: () => [],
};
const capture = captureWithController(controller, application, []);
await vi.waitFor(() => expect(evaluate).toHaveBeenCalled());
await vi.advanceTimersByTimeAsync(5_000);
const result = await capture;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
diagnostics: {
final_state_read: "cancellation_grace_expired",
cancellation_grace_ms: 5_000,
},
partialObservation: {
kind: "electron-active-observation",
capture: {
limitations: expect.arrayContaining([
expect.stringContaining("initial pre-action snapshot"),
]),
},
},
});
expect(result.error.cause).toBe(reason);
} finally {
vi.useRealTimers();
}
});
const stubCompletedCapture = () => {
const state = {
windows: [],
metrics: [],
electronVersion: "44.0.0",
hookSnapshot: emptyHookSnapshot(),
};
vi.spyOn(electronActions, "readApplicationState").mockResolvedValue(state);
vi.spyOn(electronActions, "readFinalApplicationState").mockResolvedValue(
state,
);
};
it("cancels a completed capture when cancellation arrives during application close", async () => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM during close");
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => {
controller.abort(reason);
}),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(application.close).toHaveBeenCalledOnce();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: {
kind: "electron-active-observation",
capture: { application: { electron_version: "44.0.0" } },
},
});
expect(result.error.cause).toBe(reason);
});
it("cancels a completed capture when cancellation arrives during process cleanup", async () => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM during process cleanup");
// POSIX cleanup owns the final sweep; Windows uses tree termination.
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
async () => {
controller.abort(reason);
return {
cleaned: true,
signaled: false,
unverified: [{ pid: 777, diagnostic: "command line unavailable" }],
};
},
);
vi.spyOn(processOwnership, "cleanupWindowsProcessTree").mockImplementation(
async () => {
controller.abort(reason);
return { cleaned: true, signaled: true };
},
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected the capture to be cancelled");
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
partialObservation: {
kind: "electron-active-observation",
capture: { application: { electron_version: "44.0.0" } },
},
});
expect(result.error.cause).toBe(reason);
});
it("keeps cleanup failure precedence over cancellation observed during cleanup", async () => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const failed = async () => {
controller.abort(new Error("SIGTERM during process cleanup"));
return { cleaned: false as const, reason: "process group still alive" };
};
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
failed,
);
vi.spyOn(processOwnership, "cleanupWindowsProcessTree").mockImplementation(
failed,
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => undefined),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cleanup to fail");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
partialObservation: {
kind: "electron-active-observation",
capture: { application: { cleanup: "unverified" } },
},
});
expect(result.error.cleanupPrimary).toMatchObject({
_tag: "BrowserObservationError",
reason: "cancelled",
});
});
it.each(["process", "transport"] as const)(
"preserves cancellation that preceded a %s cleanup failure",
async (failure) => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = new Error("SIGTERM before cleanup");
vi.spyOn(electronActions, "runElectronActions").mockImplementation(
async () => {
controller.abort(reason);
return { actions: [] };
},
);
if (failure === "process") {
const failed = async () => ({
cleaned: false as const,
reason: "process group still alive",
});
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
failed,
);
vi.spyOn(
processOwnership,
"cleanupWindowsProcessTree",
).mockImplementation(failed);
}
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => {
if (failure === "transport")
throw new Error("transport still connected");
}),
windows: () => [],
};
const result = await captureWithController(controller, application, []);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected incomplete cleanup");
expect(result.error).toMatchObject({
cleanupIncomplete: true,
cleanupPrimary: {
_tag: "BrowserObservationError",
reason: "cancelled",
cause: reason,
},
partialObservation: {
kind: "electron-active-observation",
},
});
expect(JSON.stringify(result.error.diagnostics)).toContain(
failure === "process"
? "process group still alive"
: "transport still connected",
);
expect(application.close).toHaveBeenCalledOnce();
},
);
it.each(["process", "transport", "both"] as const)(
"retains %s cleanup and blocks replacement capture until recovery",
async (failure) => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
let denyProcess = failure !== "transport";
let denyTransport = failure !== "process";
const processCleanup = vi.fn(async () =>
denyProcess
? { cleaned: false as const, reason: "authored process cleanup denial" }
: { cleaned: true as const, signaled: false },
);
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
processCleanup,
);
vi.spyOn(processOwnership, "cleanupWindowsProcessTree").mockImplementation(
processCleanup,
);
const application = {
process: () => ({ pid: 424242 }),
close: vi.fn(async () => {
if (denyTransport) throw new Error("authored transport cleanup denial");
}),
windows: () => [],
};
const launch = vi.fn(
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
const provider = new PlaywrightElectronActiveProvider({}, launch);
const input = {
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
}),
application_root: dirname(process.execPath),
};
try {
const first = await provider.capture(input);
expect(first).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
const blocked = await provider.capture(input);
expect(blocked).toMatchObject({
ok: false,
error: { cleanupIncomplete: true },
});
expect(launch).toHaveBeenCalledOnce();
denyProcess = false;
denyTransport = false;
expect((await provider.capture(input)).ok).toBe(true);
expect(first).toMatchObject({
error: { cleanupIncomplete: false, cleanupResources: [] },
});
expect(blocked).toMatchObject({
error: { cleanupIncomplete: false, cleanupResources: [] },
});
expect(launch).toHaveBeenCalledTimes(2);
} finally {
denyProcess = false;
denyTransport = false;
await provider.close();
}
},
);
it.each(["process", "transport"] as const)(
"retains cancellation first observed during failed %s cleanup through retry",
async (phase) => {
stubVerifiedElectronCleanup();
stubCompletedCapture();
const controller = new AbortController();
const reason = { caller: "during final cleanup", phase };
let denied = true;
const processCleanup = async () => {
if (phase === "process" && denied) {
controller.abort(reason);
return {
cleaned: false as const,
reason: "authored late process failure",
};
}
return { cleaned: true as const, signaled: false };
};
vi.spyOn(processOwnership, "cleanupOwnedProcessGroup").mockImplementation(
processCleanup,
);
vi.spyOn(processOwnership, "cleanupWindowsProcessTree").mockImplementation(
processCleanup,
);
const application = {
process: () => ({ pid: 424242 }),
windows: () => [],
close: vi.fn(async () => {
if (phase === "transport" && denied) {
controller.abort(reason);
throw new Error("authored late transport failure");
}
}),
};
const provider = new PlaywrightElectronActiveProvider(
{},
async () =>
application as unknown as Awaited<
ReturnType<typeof import("playwright-core")._electron.launch>
>,
);
try {
const result = await provider.capture(
{
...electronActiveObservationInputSchema.parse({
executable_path: process.execPath,
application_path: process.execPath,
}),
application_root: dirname(process.execPath),
},
{ signal: controller.signal },
);
if (result.ok)
throw new Error("Expected cleanup failure over cancellation");
expect(projectAnalysisError(result.error).code).toBe(
"cleanup_incomplete",
);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
kind: "electron-active-observation",
});
denied = false;
await provider.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(result.error.cleanupPrimary?.cause).toBe(reason);
} finally {
denied = false;
await provider.close();
}
},
);