mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
* 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>
203 lines
6.6 KiB
TypeScript
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');
|
|
}
|
|
}
|