mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
test(electron): retain process handle after application exit
This commit is contained in:
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');
|
||||
|
||||
@@ -78,6 +78,11 @@ target only its own launch PID, never all Electron or IPTVnator processes.
|
||||
Cleanup confirms process exit even if Playwright's close promise fails or
|
||||
never settles. If termination fails, it retries and then throws instead of
|
||||
allowing a relaunch against a potentially locked profile.
|
||||
Capture the Node child-process handle immediately after launch and retain it
|
||||
for cleanup and preparation failures. After the last window closes on Linux,
|
||||
Electron can exit before cleanup begins; Playwright disposes its dispatcher,
|
||||
so calling `electronApp.process()` at that point can throw even after a clean
|
||||
exit. The retained handle still provides the actual exit code and signal.
|
||||
|
||||
The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces
|
||||
even when the smoke fails. Check the paused-frame screenshot and trace before
|
||||
|
||||
@@ -13,6 +13,7 @@ const PUBLISH_ACTION_ALLOWLIST = Object.freeze([
|
||||
PINNED_UPLOAD_ARTIFACT_ACTION,
|
||||
]);
|
||||
const BUILD_ACTION_ALLOWLIST = Object.freeze([
|
||||
PINNED_UPLOAD_ARTIFACT_ACTION,
|
||||
'actions/cache/restore@v6',
|
||||
'actions/cache/save@v6',
|
||||
'actions/cache@v6',
|
||||
|
||||
Reference in new issue
Block a user