test(e2e): harden the unautomated Electron launch

Review follow-ups for the window-visibility E2E:

- Resolve `electron` in the main process through a require created from
  the `node:module` builtin instead of `process.mainModule`, which only
  exists when the app entry is CommonJS.
- Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM,
  then SIGKILL, 5 s each, then fail instead of waiting forever.
- Surface CDP protocol errors from Runtime.evaluate instead of returning
  undefined.
- Wait until the window is actually shown before hiding it. The app shows
  its window on ready-to-show, and a hide() that lands earlier is undone
  by that show(); this was the first-attempt failure on the macOS shard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 20:48:56 +02:00
1 parent 53fa5027e6
commit 560c2c2a4b
2 files changed
+69 -7

No files matched your search

@@ -26,6 +26,13 @@ interface CdpSocket {
close(): void;
}
const EXIT_WAIT_MS = 5_000;
/**
* `electron` for main-process evaluation. `process.mainModule` exists only
* when the app entry is CommonJS; a require created from the core `module`
* builtin resolves Electron's built-in module either way.
*/
const MAIN_PROCESS_ELECTRON = `process.getBuiltinModule('node:module').createRequire(process.execPath)('electron')`;
const DEVTOOLS_PATTERN = /DevTools listening on (ws:\/\/[^\s]+)/;
const INSPECTOR_PATTERN = /Debugger listening on (ws:\/\/[^\s]+)/;
const STARTUP_TIMEOUT_MS = 30_000;
@@ -115,6 +122,12 @@ async function evaluate<T>(socket: CdpSocket, expression: string): Promise<T> {
awaitPromise: true,
returnByValue: true,
});
const protocolError = response['error'] as { message?: string } | undefined;
if (protocolError) {
throw new Error(
`CDP Runtime.evaluate: ${protocolError.message ?? 'failed'}`
);
}
const result = response['result'] as {
result?: { value?: T };
exceptionDetails?: { text?: string };
@@ -125,6 +138,40 @@ async function evaluate<T>(socket: CdpSocket, expression: string): Promise<T> {
return result?.result?.value as T;
}
function waitForExit(
child: ChildProcess,
exited: Promise<void>,
timeoutMs: number
): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve(true);
}
let timer: ReturnType<typeof setTimeout> | undefined;
return Promise.race([
exited.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]).finally(() => clearTimeout(timer));
}
/**
* Bounded like `closeElectronApplicationAndConfirmExit`: a stuck Electron
* must neither stall the worker nor keep holding the test profile.
*/
async function stopElectron(
child: ChildProcess,
exited: Promise<void>
): Promise<void> {
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
terminateElectronProcess(child, signal);
if (await waitForExit(child, exited, EXIT_WAIT_MS)) return;
}
throw new Error(
`Electron (pid ${child.pid}) did not exit after SIGTERM and SIGKILL`
);
}
export async function launchUnautomatedElectronApp(
dataDir: string
): Promise<UnautomatedElectronApp> {
@@ -154,19 +201,17 @@ export async function launchUnautomatedElectronApp(
evaluateInMain: (body) =>
evaluate(
main,
`(async (electron) => { ${body} })(process.mainModule.require('electron'))`
`(async (electron) => { ${body} })(${MAIN_PROCESS_ELECTRON})`
),
evaluateInPage: (expression) => evaluate(page, expression),
close: async () => {
page.close();
main.close();
terminateElectronProcess(child);
await exited;
await stopElectron(child, exited);
},
};
} catch (error) {
terminateElectronProcess(child, 'SIGKILL');
await exited;
await stopElectron(child, exited);
throw error;
}
}
@@ -20,6 +20,23 @@ const setWindowState = (
`electron.BrowserWindow.getAllWindows()[0].${action}();`
);
/**
* The app creates its window with `show: false` and shows it on
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
*/
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
await expect
.poll(
() =>
app.evaluateInMain<boolean>(
'return electron.BrowserWindow.getAllWindows()[0]?.isVisible() ?? false;'
),
{ timeout: 30_000 }
)
.toBe(true);
await expect.poll(() => visibility(app)).toBe('visible');
}
/**
* The renderer must see a hidden or minimized window as hidden: idle timers
* pause on `visibilitychange`, and the playback keep-awake gate releases the
@@ -35,7 +52,7 @@ test.describe('Main window visibility', () => {
}) => {
const app = await launchUnautomatedElectronApp(dataDir);
try {
await expect.poll(() => visibility(app)).toBe('visible');
await waitUntilShown(app);
await setWindowState(app, 'hide');
await expect
@@ -60,7 +77,7 @@ test.describe('Main window visibility', () => {
);
const app = await launchUnautomatedElectronApp(dataDir);
try {
await expect.poll(() => visibility(app)).toBe('visible');
await waitUntilShown(app);
await setWindowState(app, 'minimize');
await expect