test(electron): fix Windows cleanup and retain packaged smoke diagnostics (#1665)

* test(electron): reap Windows process trees and retain smoke diagnostics

* test(electron): require confirmed process exit before relaunch

* test(electron): retain process handle after application exit

* test(electron): bind captured processes to application instances
This commit is contained in:
4gray authored and GitHub committed 2026-09-23 22:55:48 +02:00
1 parent dc4b33991d
commit 0ecfc06494
10 files changed
+402 -95

No files matched your search

@@ -1,13 +1,28 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { describe, it } from 'node:test';
import {
closeElectronApp,
type LaunchedElectronApp,
} from '../electron-test-fixtures';
import {
closeElectronApplicationAndConfirmExit,
captureElectronProcess,
closeElectronApplicationAndConfirmExit as closeApplication,
ElectronApplicationDisposalError,
prepareElectronApplication,
} from '../electron-process-lifecycle';
// Fake children have no OS PID; exercise lifecycle decisions independently
// of the platform process-tree integration test.
const closeElectronApplicationAndConfirmExit: typeof closeApplication = (
app,
options
) =>
closeApplication(app, options, (child, signal) => {
child.kill(signal);
});
class FakeChildProcess extends EventEmitter {
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
@@ -25,6 +40,94 @@ class FakeChildProcess extends EventEmitter {
}
describe('Electron process lifecycle', () => {
it('rejects cleanup when no process was captured for that application', async () => {
await assert.rejects(
closeApplication(
{ close: async () => assert.fail('exit cannot be confirmed') },
{ closeTimeoutMs: 1, exitTimeoutMs: 1 }
),
/electron-process-handle-not-captured/
);
});
it('cleans the replacement application after a partial restart assignment', async () => {
const oldChild = new FakeChildProcess();
const newChild = new FakeChildProcess();
const oldApplication = {
close: async () => {
oldChild.exitCode = 0;
oldChild.emit('exit', 0, null);
},
process: () => oldChild,
};
const newApplication = {
close: async () => {
newChild.exitCode = 0;
newChild.emit('exit', 0, null);
},
process: () => newChild,
};
const app = {
electronApp: oldApplication,
} as unknown as LaunchedElectronApp;
captureElectronProcess(oldApplication);
captureElectronProcess(newApplication);
await closeElectronApp(app);
// Existing E2E callers replace the application/window fields only.
app.electronApp =
newApplication as unknown as LaunchedElectronApp['electronApp'];
await closeElectronApp(app);
assert.equal(newChild.exitCode, 0);
});
it('confirms an already exited process after Playwright disposes its dispatcher', async () => {
const child = new FakeChildProcess();
const app = {
electronApp: {
close: async () => {
assert.fail(
'an exited application must not be closed again'
);
},
process: () => child,
},
} as unknown as LaunchedElectronApp;
captureElectronProcess(app.electronApp);
child.exitCode = 0;
app.electronApp.process = () => {
throw new TypeError(
"Cannot read properties of undefined (reading '_object')"
);
};
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 = {
electronApp: {
close: async () => {
throw new Error('CDP disconnected');
},
process: () => child,
},
} as unknown as LaunchedElectronApp;
captureElectronProcess(app.electronApp);
app.electronApp.process = () => {
assert.fail('cleanup must use the retained child process');
};
await assert.rejects(
closeElectronApp(app),
/electron-process-exit-unconfirmed/
);
});
it('closes and confirms exit when post-spawn launch preparation fails', async () => {
const child = new FakeChildProcess();
const launchFailure = new Error('renderer readiness failed');
@@ -37,6 +140,7 @@ describe('Electron process lifecycle', () => {
},
process: () => child,
};
captureElectronProcess(application);
await assert.rejects(
prepareElectronApplication({
@@ -56,6 +160,40 @@ 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: () => child,
};
captureElectronProcess(application);
await assert.rejects(
prepareElectronApplication({
application,
dispose: (app) =>
closeElectronApplicationAndConfirmExit(app, {
closeTimeoutMs: 10,
exitTimeoutMs: 10,
}),
prepare: async () => {
child.exitCode = 0;
application.process = () => {
assert.fail(
'the Playwright dispatcher is already disposed'
);
};
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;
@@ -63,6 +201,7 @@ describe('Electron process lifecycle', () => {
close: () => new Promise<void>(() => undefined),
process: () => child,
};
captureElectronProcess(application);
await assert.rejects(
closeElectronApplicationAndConfirmExit(application, {
@@ -71,7 +210,7 @@ describe('Electron process lifecycle', () => {
}),
/electron-process-exit-unconfirmed/
);
assert.equal(child.killCalls, 1);
assert.equal(child.killCalls, 2);
});
it('preserves both preparation and unconfirmed-disposal failures', async () => {
@@ -107,6 +246,7 @@ describe('Electron process lifecycle', () => {
},
process: () => child,
};
captureElectronProcess(application);
await Promise.race([
closeElectronApplicationAndConfirmExit(application, {
@@ -129,6 +269,7 @@ describe('Electron process lifecycle', () => {
close: () => new Promise<void>(() => undefined),
process: () => child,
};
captureElectronProcess(application);
await closeElectronApplicationAndConfirmExit(application, {
closeTimeoutMs: 1,
@@ -137,4 +278,25 @@ describe('Electron process lifecycle', () => {
assert.equal(child.killCalls, 1);
assert.equal(child.signalCode, 'SIGTERM');
});
it('still observes exit after the first termination attempt throws', async () => {
const child = new FakeChildProcess();
const signals: NodeJS.Signals[] = [];
const application = {
close: () => new Promise<void>(() => undefined),
process: () => child,
};
captureElectronProcess(application);
await closeApplication(
application,
{ closeTimeoutMs: 1, exitTimeoutMs: 1 },
(_child, signal) => {
signals.push(signal);
if (signal === 'SIGTERM') throw new Error('termination failed');
child.kill();
}
);
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
assert.equal(child.signalCode, 'SIGTERM');
});
});
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { spawn, type execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { describe, it } from 'node:test';
import { terminateElectronProcess } from '../electron-process-termination';
describe('Electron forced termination', () => {
it('terminates the Windows shell and its Electron descendants together', () => {
let shellAlive = true;
let electronAlive = true;
const child = {
pid: 1234,
kill: () => {
shellAlive = false;
return true;
},
};
const run = ((file, args, options) => {
assert.equal(file, 'taskkill.exe');
assert.deepEqual(args, ['/pid', '1234', '/T', '/F']);
assert.equal(options?.timeout, 5000);
shellAlive = false;
electronAlive = false;
return Buffer.alloc(0);
}) as typeof execFileSync;
terminateElectronProcess(child, 'SIGTERM', 'win32', run);
assert.equal(shellAlive, false);
assert.equal(
electronAlive,
false,
'Electron must release the profile before relaunch'
);
});
it('preserves signal escalation on Unix', () => {
const signals: NodeJS.Signals[] = [];
const child = {
pid: 1234,
kill: (signal: NodeJS.Signals) => {
signals.push(signal);
return true;
},
};
terminateElectronProcess(child, 'SIGTERM', 'linux');
terminateElectronProcess(child, 'SIGKILL', 'darwin');
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
});
it('does not fall back to killing only the shell when tree termination fails', () => {
const failure = new Error('taskkill failed');
const child = {
pid: 1234,
kill: () => assert.fail('would orphan the Electron descendant'),
};
const run = (() => {
throw failure;
}) as typeof execFileSync;
assert.throws(
() => terminateElectronProcess(child, 'SIGTERM', 'win32', run),
(error) => error === failure
);
});
it(
'reaps a real Windows shell child instead of orphaning its descendant',
{
skip: process.platform !== 'win32',
timeout: 15000,
},
async () => {
// Match Playwright's Windows launch topology: cmd.exe -> application.
const shell = spawn(
`"${process.execPath}"`,
[
'-e',
'"setInterval(() => {}, 1000); console.log(process.pid)"',
],
{ shell: true, stdio: ['ignore', 'pipe', 'pipe'] }
);
try {
const [output] = await once(shell.stdout, 'data', {
signal: AbortSignal.timeout(5000),
});
const descendantPid = Number(String(output).trim());
assert.ok(
Number.isSafeInteger(descendantPid) && descendantPid > 0
);
assert.notEqual(descendantPid, shell.pid);
process.kill(descendantPid, 0);
terminateElectronProcess(shell);
await assert.rejects(
async () => process.kill(descendantPid, 0),
{
code: 'ESRCH',
}
);
} finally {
if (shell.exitCode === null && shell.signalCode === null) {
// The successful taskkill can precede Node's exit event.
try {
terminateElectronProcess(shell);
} catch {
/* already exited */
}
}
}
}
);
});