Files
iptvnator/apps/electron-backend-e2e/src/electron-process-lifecycle.ts
T
4grayandClaude Opus 5.5 078b91bfdf test(perf): type-check the whole electron-backend-e2e project (#1869)
* test(e2e): fix type errors in four Electron E2E specs

- category-management: the poll's message and 15 s timeout were passed to
  toBe(), which takes one argument, so the poll ran with the default 10 s
  timeout and message. Pass them to expect.poll().
- settings: evaluate<void, HTMLVideoElement> set the argument type, not the
  element type; type the callback parameter instead.
- remote-control: keep the poll callback's writes visible to the return and
  fail with a clear error instead of casting null.
- downloads: read the dataset key through its index signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): fix type errors in the benchmark harness

None of these changes behaviour for a capture the harness produces today:

- cancellation report: a null playlist id on the request record now means
  unknown, like undefined, instead of matching only null DB records.
- main capture: type the worker transport map so its unavailable-reason
  literals stay narrow.
- worker request performance: a parsed capture carries non-null work
  timestamps; only the unavailable fallback reports null.
- Xtream iteration: hand the teardown closure the narrowed app and data
  directory.
- Xtream exact schema: return early when the identity is not an exact
  record instead of re-checking it three times.
- Xtream assembly: reject an unpainted UI explicitly; the null-to-0
  comparison already rejected it.
- Xtream scenario driver: drop the always-null cancellation observer from
  prepare; trigger() installs it.
- Electron process lifecycle: type the child process by the exit
  subscription it uses, so a test double need not return a ChildProcess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): fix type errors in the benchmark harness specs

The dynamically imported helpers were checked with
assert.equal(typeof fn, 'function'), which does not narrow, so later calls
used fn?.(). assert.ok(typeof fn === 'function') narrows them. The rest
types fixtures by the module under test, keeps closure-assigned callbacks
visible after their callbacks run, and narrows nullable fixture values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): type-check the whole electron-backend-e2e project

Widen the spec config's include from the journey harness to src/**/*.ts
and the root Playwright configs, with no baseline or ignore list, and
update the validation map and the journey checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): build a complete renderer fixture in the request-performance spec

The fixture was cast through unknown, so the type check accepted an
incomplete RendererCaptureMetrics. Fill every required field with neutral
values; the summary reads only the probe arrays and heap fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:33:15 +02:00

203 lines
6.6 KiB
TypeScript

import type { ChildProcess } from 'node:child_process';
import { terminateElectronProcess } from './electron-process-termination';
// Only the exit subscription is used; picking ChildProcess's own once and
// removeListener would demand that every listener host returns a ChildProcess.
interface ElectronChildProcess extends Pick<
ChildProcess,
'exitCode' | 'kill' | 'pid' | 'signalCode'
> {
once(event: 'exit', listener: () => void): unknown;
removeListener(event: 'exit', listener: () => void): unknown;
}
export interface ClosableElectronApplication {
close(): Promise<void>;
}
export interface ElectronExitConfirmationOptions {
readonly closeTimeoutMs: number;
readonly exitTimeoutMs: number;
}
const electronProcesses = new WeakMap<
ClosableElectronApplication,
ElectronChildProcess
>();
export function captureElectronProcess<Process extends ElectronChildProcess>(
application: ClosableElectronApplication & { process(): Process }
): Process {
const child = application.process();
electronProcesses.set(application, child);
return child;
}
export interface PrepareElectronApplicationOptions<Application, Prepared> {
readonly application: Application;
readonly dispose: (application: Application) => Promise<void>;
readonly prepare: (application: Application) => Promise<Prepared>;
}
export class ElectronApplicationDisposalError extends AggregateError {
constructor(failure: unknown, disposeFailure: unknown) {
super(
[failure, disposeFailure],
'Electron launch preparation and disposal both failed',
{ cause: failure }
);
this.name = 'ElectronApplicationDisposalError';
}
}
export async function prepareElectronApplication<Application, Prepared>(
options: PrepareElectronApplicationOptions<Application, Prepared>
): Promise<Prepared> {
try {
return await options.prepare(options.application);
} catch (failure) {
try {
await options.dispose(options.application);
} catch (disposeFailure) {
throw new ElectronApplicationDisposalError(failure, disposeFailure);
}
throw failure;
}
}
export async function closeElectronApplicationAndConfirmExit(
application: ClosableElectronApplication,
options: ElectronExitConfirmationOptions,
terminate: (
child: ElectronChildProcess,
signal: NodeJS.Signals
) => void = terminateElectronProcess
): Promise<void> {
assertTimeout(options.closeTimeoutMs);
assertTimeout(options.exitTimeoutMs);
// 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 = electronProcesses.get(application);
if (!child) throw new Error('electron-process-handle-not-captured');
if (hasExited(child)) return;
const exit = observeProcessExit(child);
let failure: unknown;
try {
const close = Promise.resolve()
.then(() => application.close())
.then(
() => ({ kind: 'close-resolved' as const }),
(closeFailure: unknown) => ({
failure: closeFailure,
kind: 'close-rejected' as const,
})
);
const first = await raceCloseAndExit(
close,
exit.promise,
options.closeTimeoutMs
);
if (first.kind === 'exit') return;
if (
first.kind === 'close-resolved' &&
(await resolvesWithin(exit.promise, options.exitTimeoutMs))
) {
return;
}
if (first.kind === 'close-rejected') failure = first.failure;
// A failed termination must not skip exit observation or let a
// caller restart against a profile that is still owned by Electron.
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
try {
terminate(child, signal);
} catch (killFailure) {
failure = failure
? new AggregateError([failure, killFailure])
: killFailure;
}
if (await resolvesWithin(exit.promise, options.exitTimeoutMs))
return;
}
} finally {
exit.cancel();
}
throw new Error('electron-process-exit-unconfirmed', { cause: failure });
}
function hasExited(child: ElectronChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
function observeProcessExit(child: ElectronChildProcess): {
readonly cancel: () => void;
readonly promise: Promise<void>;
} {
let resolveExit: (() => void) | undefined;
const onExit = () => resolveExit?.();
const promise = new Promise<void>((resolvePromise) => {
resolveExit = resolvePromise;
child.once('exit', onExit);
if (hasExited(child)) resolvePromise();
});
return {
cancel: () => child.removeListener('exit', onExit),
promise,
};
}
async function resolvesWithin(
promise: Promise<unknown>,
timeoutMs: number
): Promise<boolean> {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeout = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
async function raceCloseAndExit(
close: Promise<
| { readonly kind: 'close-resolved' }
| { readonly failure: unknown; readonly kind: 'close-rejected' }
>,
exit: Promise<void>,
timeoutMs: number
): Promise<
| { readonly kind: 'close-resolved' }
| { readonly failure: unknown; readonly kind: 'close-rejected' }
| { readonly kind: 'exit' }
| { readonly kind: 'timeout' }
> {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
close,
exit.then(() => ({ kind: 'exit' as const })),
new Promise<{ readonly kind: 'timeout' }>((resolvePromise) => {
timeout = setTimeout(
() => resolvePromise({ kind: 'timeout' }),
timeoutMs
);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
}
function assertTimeout(value: number): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error('electron-process-exit-timeout-invalid');
}
}