test(electron): retain process handle after application exit

This commit is contained in:
4gray committed 2026-09-22 23:09:55 +02:00
1 parent f48be2ac63
commit a5bc36699c
5 files changed
+102 -41

No files matched your search

@@ -8,10 +8,10 @@ type ElectronChildProcess = Pick<
export interface ClosableElectronApplication {
close(): Promise<void>;
process(): ElectronChildProcess;
}
export interface ElectronExitConfirmationOptions {
readonly childProcess: ElectronChildProcess;
readonly closeTimeoutMs: number;
readonly exitTimeoutMs: number;
}
@@ -58,7 +58,10 @@ export async function closeElectronApplicationAndConfirmExit(
): Promise<void> {
assertTimeout(options.closeTimeoutMs);
assertTimeout(options.exitTimeoutMs);
const child = application.process();
// Playwright discards its process dispatcher when Electron exits. Keep
// using the Node handle captured immediately after launch, including when
// the application's last window already caused a normal process exit.
const child = options.childProcess;
if (hasExited(child)) return;
const exit = observeProcessExit(child);
@@ -7,7 +7,7 @@ import {
Page,
test as base,
} from '@playwright/test';
import { spawn } from 'child_process';
import { spawn, type ChildProcess } from 'child_process';
import { createServer, Server } from 'http';
import {
accessSync,
@@ -136,6 +136,7 @@ declare global {
export type LaunchedElectronApp = {
electronApp: ElectronApplication;
electronProcess: ChildProcess;
mainWindow: Page;
};
@@ -234,15 +235,17 @@ export async function launchElectronApp(
args,
env: buildElectronLaunchEnvironment(dataDir, options),
});
const electronProcess = electronApp.process();
return prepareElectronApplication({
application: electronApp,
dispose: (application) =>
closeElectronApplicationAndConfirmExit(application, {
childProcess: electronProcess,
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
}),
prepare: async (application) => {
attachElectronProcessDiagnostics(application);
attachElectronProcessDiagnostics(electronProcess);
const mainWindow = await findMainWindow(application);
await waitForAppReady(mainWindow);
await startPortalDebugCapture(mainWindow);
@@ -250,6 +253,7 @@ export async function launchElectronApp(
await startRendererFrameCapture(mainWindow);
return {
electronApp: application,
electronProcess,
mainWindow,
};
},
@@ -458,26 +462,33 @@ export async function launchPackagedElectronApp(
NODE_ENV: 'test',
},
});
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
return {
electronApp,
mainWindow,
};
const electronProcess = electronApp.process();
return prepareElectronApplication({
application: electronApp,
dispose: (application) =>
closeElectronApplicationAndConfirmExit(application, {
childProcess: electronProcess,
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
}),
prepare: async (application) => {
attachElectronProcessDiagnostics(electronProcess);
const mainWindow = await findMainWindow(application);
await waitForAppReady(mainWindow);
return {
electronApp: application,
electronProcess,
mainWindow,
};
},
});
}
function attachElectronProcessDiagnostics(
electronApp: ElectronApplication
): void {
function attachElectronProcessDiagnostics(childProcess: ChildProcess): void {
if (!process.env['CI']) {
return;
}
const childProcess = electronApp.process();
childProcess.stdout?.on('data', (chunk: Buffer) => {
console.log(`[electron stdout] ${chunk.toString().trimEnd()}`);
});
@@ -571,31 +582,12 @@ export async function closeElectronAppAndConfirmExit(
app: LaunchedElectronApp
): Promise<void> {
await closeElectronApplicationAndConfirmExit(app.electronApp, {
childProcess: app.electronProcess,
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
});
}
async function waitForPromiseWithTimeout(
promise: Promise<unknown>,
timeoutMs: number
): Promise<boolean> {
let timeoutId: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function assertPackagedRendererBuildIsElectronSafe(): void {
if (!existsSync(packagedRendererIndexPath)) {
throw new Error(
@@ -39,17 +39,43 @@ class FakeChildProcess extends EventEmitter {
}
describe('Electron process lifecycle', () => {
it('confirms an already exited process after Playwright disposes its dispatcher', async () => {
const child = new FakeChildProcess();
child.exitCode = 0;
const app = {
electronProcess: child,
electronApp: {
close: async () => {
assert.fail(
'an exited application must not be closed again'
);
},
process: () => {
throw new TypeError(
"Cannot read properties of undefined (reading '_object')"
);
},
},
} as unknown as LaunchedElectronApp;
await closeElectronApp(app);
assert.equal(child.killCalls, 0);
});
it('does not return from public cleanup when close and termination both fail', async () => {
const child = new FakeChildProcess();
child.kill = () => {
throw new Error('termination failed');
};
const app = {
electronProcess: child,
electronApp: {
close: async () => {
throw new Error('CDP disconnected');
},
process: () => child,
process: () => {
assert.fail('cleanup must use the retained child process');
},
},
} as unknown as LaunchedElectronApp;
@@ -76,6 +102,7 @@ describe('Electron process lifecycle', () => {
application,
dispose: (app) =>
closeElectronApplicationAndConfirmExit(app, {
childProcess: child,
closeTimeoutMs: 10,
exitTimeoutMs: 10,
}),
@@ -89,6 +116,37 @@ describe('Electron process lifecycle', () => {
assert.equal(child.exitCode, 0);
});
it('preserves preparation failure when Electron has already exited', async () => {
const child = new FakeChildProcess();
const launchFailure = new Error('renderer closed before readiness');
const application = {
close: async () => {
assert.fail('an exited application must not be closed again');
},
process: () => {
assert.fail('the Playwright dispatcher is already disposed');
},
};
await assert.rejects(
prepareElectronApplication({
application,
dispose: (app) =>
closeElectronApplicationAndConfirmExit(app, {
childProcess: child,
closeTimeoutMs: 10,
exitTimeoutMs: 10,
}),
prepare: async () => {
child.exitCode = 0;
throw launchFailure;
},
}),
(error: unknown) => error === launchFailure
);
assert.equal(child.killCalls, 0);
});
it('fails when process exit remains unconfirmed after forced teardown', async () => {
const child = new FakeChildProcess();
child.shouldExitOnKill = false;
@@ -99,6 +157,7 @@ describe('Electron process lifecycle', () => {
await assert.rejects(
closeElectronApplicationAndConfirmExit(application, {
childProcess: child,
closeTimeoutMs: 1,
exitTimeoutMs: 1,
}),
@@ -143,6 +202,7 @@ describe('Electron process lifecycle', () => {
await Promise.race([
closeElectronApplicationAndConfirmExit(application, {
childProcess: child,
closeTimeoutMs: 1_000,
exitTimeoutMs: 10,
}),
@@ -164,6 +224,7 @@ describe('Electron process lifecycle', () => {
};
await closeElectronApplicationAndConfirmExit(application, {
childProcess: child,
closeTimeoutMs: 1,
exitTimeoutMs: 10,
});
@@ -177,9 +238,8 @@ describe('Electron process lifecycle', () => {
await closeApplication(
{
close: () => new Promise<void>(() => undefined),
process: () => child,
},
{ closeTimeoutMs: 1, exitTimeoutMs: 1 },
{ childProcess: child, closeTimeoutMs: 1, exitTimeoutMs: 1 },
(_child, signal) => {
signals.push(signal);
if (signal === 'SIGTERM') throw new Error('termination failed');