mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user