mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user