mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
* chore(lint): hold tests to their own max-lines ceiling The flat 400-line cap treated a spec like a component. A spec is a flat list of independent cases, so hitting the cap there produces arbitrary `-2.spec.ts` splits and hides coverage instead of surfacing design debt — 65 of the 138 files over the limit were tests. Production code keeps 400. Tests (`**/*.spec.ts`, `**/*.e2e.ts`, and everything under `apps/*-e2e/**`) get 1200. Blank lines and comments no longer count, so a docblock can't be the reason a file must be split. Both limits now live in tools/eslint/max-lines-config.mjs, imported by eslint.config.mjs and the baseline generator alike. The generator decides who belongs on the list by running ESLint's own max-lines rule instead of counting lines itself — a private reimplementation would disagree with the rule the moment either side changed (a `//` inside a template literal is enough) and yield a baseline that turns CI red while looking correct. The baseline drops 126 -> 68 entries with nothing added, and six now-dead `eslint-disable max-lines` directives are removed. A new eslint-tools test asserts the committed baseline still matches what the generator produces, so a stale entry or a forgotten regeneration fails CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(lint): classify eslint-tools in the coverage policy A project with a `test` target must be assigned a coverage tier, so adding eslint-tools broke `coverage:policy:check` before the unit suite even ran. Tier B alongside packaging and release-tools: these are Node tests over lint tooling, and a coverage percentage across a generated list would not mean anything. CI runs Tier B/C through its own `--run-non-tier-a` step, so the baseline-consistency test executes there rather than being skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
508 lines
19 KiB
TypeScript
508 lines
19 KiB
TypeScript
import { createWriteStream } from 'node:fs';
|
|
import { writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { finished } from 'node:stream/promises';
|
|
|
|
import type { CDPSession, Page } from '@playwright/test';
|
|
|
|
import type {
|
|
RendererCaptureMetrics,
|
|
RendererProbeMetrics,
|
|
} from './m3u-refresh-cancellation-contract';
|
|
import { summarizeNumbers } from './performance-statistics';
|
|
|
|
const RENDERER_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshRendererCapture';
|
|
const HEARTBEAT_INTERVAL_MS = 50;
|
|
|
|
export interface RendererCaptureStartOptions {
|
|
readonly diagnostic: boolean;
|
|
readonly outputDirectory: string;
|
|
readonly sourceTitle: string;
|
|
}
|
|
|
|
export interface RunningRendererCapture {
|
|
readonly session: CDPSession;
|
|
markOperationStart(): Promise<number>;
|
|
stop(): Promise<RendererCaptureMetrics>;
|
|
waitForCancelClick(): Promise<void>;
|
|
waitForTerminal(): Promise<void>;
|
|
}
|
|
|
|
interface HeapUsageResult {
|
|
readonly usedSize: number;
|
|
}
|
|
|
|
interface TraceData {
|
|
readonly value: readonly unknown[];
|
|
}
|
|
|
|
export async function startRendererCapture(
|
|
page: Page,
|
|
options: RendererCaptureStartOptions
|
|
): Promise<RunningRendererCapture> {
|
|
const session = await page.context().newCDPSession(page);
|
|
await session.send('Runtime.enable');
|
|
await session.send('Performance.enable');
|
|
await installRendererProbe(page, options.sourceTitle);
|
|
|
|
const heapSamples: number[] = [];
|
|
let heapSampleBusy = false;
|
|
let heapSampleError: unknown = null;
|
|
const sampleHeap = async (): Promise<void> => {
|
|
if (heapSampleBusy) {
|
|
return;
|
|
}
|
|
heapSampleBusy = true;
|
|
try {
|
|
const result = (await session.send(
|
|
'Runtime.getHeapUsage'
|
|
)) as HeapUsageResult;
|
|
if (Number.isFinite(result.usedSize)) {
|
|
heapSamples.push(result.usedSize);
|
|
}
|
|
} catch (error) {
|
|
heapSampleError ??= error;
|
|
} finally {
|
|
heapSampleBusy = false;
|
|
}
|
|
};
|
|
await sampleHeap();
|
|
throwHeapSampleError(heapSampleError);
|
|
const heapSampleTimer = setInterval(() => void sampleHeap(), 20);
|
|
|
|
const cpuProfilePath = options.diagnostic
|
|
? join(options.outputDirectory, 'renderer.cpuprofile')
|
|
: null;
|
|
const heapSnapshotPath = options.diagnostic
|
|
? join(options.outputDirectory, 'renderer.heapsnapshot')
|
|
: null;
|
|
const tracePath = options.diagnostic
|
|
? join(options.outputDirectory, 'renderer.trace.json')
|
|
: null;
|
|
let traceOutput: ReturnType<typeof createWriteStream> | null = null;
|
|
let traceFirstEvent = true;
|
|
let resolveTraceComplete: (() => void) | null = null;
|
|
const traceComplete = new Promise<void>((resolve) => {
|
|
resolveTraceComplete = resolve;
|
|
});
|
|
const onTraceData = (data: TraceData): void => {
|
|
if (!traceOutput) {
|
|
return;
|
|
}
|
|
for (const event of data.value) {
|
|
traceOutput.write(
|
|
`${traceFirstEvent ? '' : ','}${JSON.stringify(event)}`
|
|
);
|
|
traceFirstEvent = false;
|
|
}
|
|
};
|
|
const onTraceComplete = (): void => {
|
|
resolveTraceComplete?.();
|
|
resolveTraceComplete = null;
|
|
};
|
|
|
|
if (options.diagnostic) {
|
|
await session.send('Profiler.enable');
|
|
await session.send('Profiler.start');
|
|
traceOutput = createWriteStream(
|
|
requireArtifactPath(tracePath, 'renderer trace'),
|
|
{ flags: 'wx', mode: 0o600 }
|
|
);
|
|
traceOutput.write('{"traceEvents":[');
|
|
session.on('Tracing.dataCollected', onTraceData);
|
|
session.on('Tracing.tracingComplete', onTraceComplete);
|
|
await session.send('Tracing.start', {
|
|
categories: [
|
|
'blink.user_timing',
|
|
'devtools.timeline',
|
|
'disabled-by-default-devtools.timeline',
|
|
'disabled-by-default-v8.cpu_profiler',
|
|
'v8',
|
|
].join(','),
|
|
options: 'sampling-frequency=1000',
|
|
transferMode: 'ReportEvents',
|
|
});
|
|
}
|
|
|
|
let stopped = false;
|
|
return Object.freeze({
|
|
session,
|
|
markOperationStart: () => markRendererOperationStart(page),
|
|
stop: async () => {
|
|
if (stopped) {
|
|
throw new Error('Renderer capture was already stopped');
|
|
}
|
|
stopped = true;
|
|
const probe = await stopRendererProbe(page);
|
|
clearInterval(heapSampleTimer);
|
|
await sampleHeap();
|
|
throwHeapSampleError(heapSampleError);
|
|
|
|
if (options.diagnostic) {
|
|
const cpuProfile = await session.send('Profiler.stop');
|
|
await writeFile(
|
|
requireArtifactPath(cpuProfilePath, 'renderer CPU profile'),
|
|
JSON.stringify(cpuProfile['profile']),
|
|
{ encoding: 'utf8', flag: 'wx', mode: 0o600 }
|
|
);
|
|
await session.send('Tracing.end');
|
|
await traceComplete;
|
|
session.off('Tracing.dataCollected', onTraceData);
|
|
session.off('Tracing.tracingComplete', onTraceComplete);
|
|
traceOutput?.write(']}');
|
|
traceOutput?.end();
|
|
if (traceOutput) {
|
|
await finished(traceOutput);
|
|
}
|
|
}
|
|
|
|
await session.send('HeapProfiler.enable');
|
|
await session.send('HeapProfiler.collectGarbage');
|
|
const postGc = (await session.send(
|
|
'Runtime.getHeapUsage'
|
|
)) as HeapUsageResult;
|
|
|
|
if (options.diagnostic) {
|
|
const output = createWriteStream(
|
|
requireArtifactPath(
|
|
heapSnapshotPath,
|
|
'renderer heap snapshot'
|
|
),
|
|
{ flags: 'wx', mode: 0o600 }
|
|
);
|
|
const onSnapshotChunk = (message: {
|
|
readonly chunk: string;
|
|
}): void => {
|
|
output.write(message.chunk);
|
|
};
|
|
session.on(
|
|
'HeapProfiler.addHeapSnapshotChunk',
|
|
onSnapshotChunk
|
|
);
|
|
await session.send('HeapProfiler.takeHeapSnapshot', {
|
|
reportProgress: false,
|
|
});
|
|
session.off(
|
|
'HeapProfiler.addHeapSnapshotChunk',
|
|
onSnapshotChunk
|
|
);
|
|
output.end();
|
|
await finished(output);
|
|
}
|
|
|
|
await session.detach();
|
|
return Object.freeze({
|
|
cpuProfilePath,
|
|
frameGap: summarizeNumbers(probe.frameGapsMs),
|
|
heapSnapshotPath,
|
|
heartbeatDelay: summarizeNumbers(probe.heartbeatDelaysMs),
|
|
longTask: summarizeNumbers(probe.longTasksMs),
|
|
peakHeapUsedBytes: Math.max(0, ...heapSamples),
|
|
postGcHeapUsedBytes: postGc.usedSize,
|
|
probe,
|
|
tracePath,
|
|
});
|
|
},
|
|
waitForCancelClick: () =>
|
|
page
|
|
.waitForFunction(
|
|
(stateKey) => {
|
|
const target = globalThis as unknown as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const state = target[stateKey] as
|
|
{ cancelClickEpochMs: number | null } | undefined;
|
|
return typeof state?.cancelClickEpochMs === 'number';
|
|
},
|
|
RENDERER_CAPTURE_STATE_KEY,
|
|
{ timeout: 60_000 }
|
|
)
|
|
.then(() => undefined),
|
|
waitForTerminal: () =>
|
|
page
|
|
.waitForFunction(
|
|
(stateKey) => {
|
|
const target = globalThis as unknown as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
const state = target[stateKey] as
|
|
{ uiPaintedEpochMs: number | null } | undefined;
|
|
return typeof state?.uiPaintedEpochMs === 'number';
|
|
},
|
|
RENDERER_CAPTURE_STATE_KEY,
|
|
{ timeout: 120_000 }
|
|
)
|
|
.then(() => undefined),
|
|
});
|
|
}
|
|
|
|
function throwHeapSampleError(error: unknown): void {
|
|
if (error instanceof Error) {
|
|
throw error;
|
|
}
|
|
if (error !== null) {
|
|
throw new Error(`Renderer heap sampling failed: ${String(error)}`);
|
|
}
|
|
}
|
|
|
|
function requireArtifactPath(value: string | null, label: string): string {
|
|
if (value === null) {
|
|
throw new Error(`${label} path is unavailable`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
async function installRendererProbe(
|
|
page: Page,
|
|
sourceTitle: string
|
|
): Promise<void> {
|
|
await page.evaluate(
|
|
({ heartbeatIntervalMs, sourceTitle: title, stateKey }) => {
|
|
interface PlaylistRefreshEvent {
|
|
readonly operationId: string;
|
|
readonly phase?: string;
|
|
readonly status: string;
|
|
}
|
|
interface ElectronApi {
|
|
onPlaylistRefreshEvent(
|
|
callback: (event: PlaylistRefreshEvent) => void
|
|
): (() => void) | undefined;
|
|
}
|
|
interface ProbeState {
|
|
cancelButtonFound: boolean;
|
|
cancelClickEpochMs: number | null;
|
|
events: {
|
|
operationId: string;
|
|
phase: string | null;
|
|
receivedEpochMs: number;
|
|
status: string;
|
|
}[];
|
|
frameGapsMs: number[];
|
|
frameRequestId: number;
|
|
heartbeatDelaysMs: number[];
|
|
heartbeatTimer: number;
|
|
lastFrameAt: number | null;
|
|
lastHeartbeatAt: number;
|
|
longTaskObserver: PerformanceObserver | null;
|
|
longTasksMs: number[];
|
|
mutationObserver: MutationObserver;
|
|
operationId: string | null;
|
|
operationStartEpochMs: number;
|
|
terminalEpochMs: number | null;
|
|
uiPaintFrameRequestId: number | null;
|
|
uiPaintedEpochMs: number | null;
|
|
uiPhaseEpochMs: Record<string, number>;
|
|
uiSettledEpochMs: number | null;
|
|
unsubscribe: () => void;
|
|
}
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const epoch = (): number =>
|
|
performance.timeOrigin + performance.now();
|
|
const sourceRow = (): Element | undefined =>
|
|
Array.from(document.querySelectorAll('app-playlist-item')).find(
|
|
(element) => element.textContent?.includes(title)
|
|
);
|
|
const state = {
|
|
cancelButtonFound: false,
|
|
cancelClickEpochMs: null,
|
|
events: [],
|
|
frameGapsMs: [],
|
|
frameRequestId: 0,
|
|
heartbeatDelaysMs: [],
|
|
heartbeatTimer: 0,
|
|
lastFrameAt: null,
|
|
lastHeartbeatAt: performance.now(),
|
|
longTaskObserver: null,
|
|
longTasksMs: [],
|
|
mutationObserver: null,
|
|
operationId: null,
|
|
operationStartEpochMs: 0,
|
|
terminalEpochMs: null,
|
|
uiPaintFrameRequestId: null,
|
|
uiPaintedEpochMs: null,
|
|
uiPhaseEpochMs: Object.create(null) as Record<string, number>,
|
|
uiSettledEpochMs: null,
|
|
unsubscribe: () => undefined,
|
|
} as unknown as ProbeState;
|
|
const inspectUi = (): void => {
|
|
const row = sourceRow();
|
|
const message =
|
|
row
|
|
?.querySelector('.busy-state__message')
|
|
?.textContent?.trim()
|
|
.toLowerCase() ?? '';
|
|
for (const phase of ['fetch', 'pars', 'sav']) {
|
|
if (
|
|
message.includes(phase) &&
|
|
state.uiPhaseEpochMs[phase] === undefined
|
|
) {
|
|
state.uiPhaseEpochMs[phase] = epoch();
|
|
}
|
|
}
|
|
if (
|
|
state.cancelClickEpochMs !== null &&
|
|
state.uiSettledEpochMs === null &&
|
|
row !== undefined &&
|
|
row.querySelector('.busy-state__message') === null &&
|
|
row.querySelector('.action-spinner') === null &&
|
|
row.querySelector('.cancel-btn') === null
|
|
) {
|
|
state.uiSettledEpochMs = epoch();
|
|
state.uiPaintFrameRequestId = requestAnimationFrame(() => {
|
|
state.uiPaintFrameRequestId = requestAnimationFrame(
|
|
() => {
|
|
state.uiPaintedEpochMs = epoch();
|
|
state.terminalEpochMs = state.uiPaintedEpochMs;
|
|
state.uiPaintFrameRequestId = null;
|
|
}
|
|
);
|
|
});
|
|
}
|
|
};
|
|
const frame = (timestamp: number): void => {
|
|
if (state.lastFrameAt !== null) {
|
|
state.frameGapsMs.push(timestamp - state.lastFrameAt);
|
|
}
|
|
state.lastFrameAt = timestamp;
|
|
state.frameRequestId = requestAnimationFrame(frame);
|
|
};
|
|
state.frameRequestId = requestAnimationFrame(frame);
|
|
state.heartbeatTimer = window.setInterval(() => {
|
|
const now = performance.now();
|
|
state.heartbeatDelaysMs.push(
|
|
Math.max(
|
|
0,
|
|
now - state.lastHeartbeatAt - heartbeatIntervalMs
|
|
)
|
|
);
|
|
state.lastHeartbeatAt = now;
|
|
}, heartbeatIntervalMs);
|
|
if (
|
|
typeof PerformanceObserver === 'function' &&
|
|
PerformanceObserver.supportedEntryTypes.includes('longtask')
|
|
) {
|
|
state.longTaskObserver = new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
state.longTasksMs.push(entry.duration);
|
|
}
|
|
});
|
|
state.longTaskObserver.observe({ entryTypes: ['longtask'] });
|
|
}
|
|
state.mutationObserver = new MutationObserver(inspectUi);
|
|
state.mutationObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
const electron = (
|
|
globalThis as unknown as { electron?: ElectronApi }
|
|
).electron;
|
|
if (!electron?.onPlaylistRefreshEvent) {
|
|
throw new Error('Playlist refresh event bridge is unavailable');
|
|
}
|
|
const unsubscribe = electron.onPlaylistRefreshEvent((event) => {
|
|
const receivedEpochMs = epoch();
|
|
if (state.operationId === null && event.status === 'started') {
|
|
state.operationId = event.operationId;
|
|
}
|
|
if (state.operationId !== event.operationId) {
|
|
return;
|
|
}
|
|
state.events.push({
|
|
operationId: event.operationId,
|
|
phase: event.phase ?? null,
|
|
receivedEpochMs,
|
|
status: event.status,
|
|
});
|
|
if (
|
|
event.status === 'progress' &&
|
|
event.phase === 'parsing' &&
|
|
state.cancelClickEpochMs === null
|
|
) {
|
|
const button = sourceRow()?.querySelector('.cancel-btn');
|
|
state.cancelButtonFound =
|
|
button instanceof HTMLButtonElement;
|
|
if (button instanceof HTMLButtonElement) {
|
|
state.cancelClickEpochMs = epoch();
|
|
button.click();
|
|
}
|
|
}
|
|
});
|
|
state.unsubscribe =
|
|
typeof unsubscribe === 'function'
|
|
? unsubscribe
|
|
: () => undefined;
|
|
target[stateKey] = state;
|
|
inspectUi();
|
|
},
|
|
{
|
|
heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS,
|
|
sourceTitle,
|
|
stateKey: RENDERER_CAPTURE_STATE_KEY,
|
|
}
|
|
);
|
|
}
|
|
|
|
async function markRendererOperationStart(page: Page): Promise<number> {
|
|
return page.evaluate((stateKey) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const state = target[stateKey] as { operationStartEpochMs: number };
|
|
state.operationStartEpochMs =
|
|
performance.timeOrigin + performance.now();
|
|
return state.operationStartEpochMs;
|
|
}, RENDERER_CAPTURE_STATE_KEY);
|
|
}
|
|
|
|
async function stopRendererProbe(page: Page): Promise<RendererProbeMetrics> {
|
|
return page.evaluate((stateKey) => {
|
|
const target = globalThis as unknown as Record<string, unknown>;
|
|
const state = target[stateKey] as {
|
|
cancelButtonFound: boolean;
|
|
cancelClickEpochMs: number | null;
|
|
events: RendererProbeMetrics['events'];
|
|
frameGapsMs: number[];
|
|
frameRequestId: number;
|
|
heartbeatDelaysMs: number[];
|
|
heartbeatTimer: number;
|
|
longTaskObserver: PerformanceObserver | null;
|
|
longTasksMs: number[];
|
|
mutationObserver: MutationObserver;
|
|
operationStartEpochMs: number;
|
|
terminalEpochMs: number | null;
|
|
uiPaintFrameRequestId: number | null;
|
|
uiPaintedEpochMs: number | null;
|
|
uiPhaseEpochMs: Record<string, number>;
|
|
uiSettledEpochMs: number | null;
|
|
unsubscribe: () => void;
|
|
};
|
|
cancelAnimationFrame(state.frameRequestId);
|
|
if (state.uiPaintFrameRequestId !== null) {
|
|
cancelAnimationFrame(state.uiPaintFrameRequestId);
|
|
}
|
|
clearInterval(state.heartbeatTimer);
|
|
for (const entry of state.longTaskObserver?.takeRecords() ?? []) {
|
|
state.longTasksMs.push(entry.duration);
|
|
}
|
|
state.longTaskObserver?.disconnect();
|
|
state.mutationObserver.disconnect();
|
|
state.unsubscribe();
|
|
const result: RendererProbeMetrics = {
|
|
cancelButtonFound: state.cancelButtonFound,
|
|
cancelClickEpochMs: state.cancelClickEpochMs,
|
|
events: [...state.events],
|
|
frameGapsMs: [...state.frameGapsMs],
|
|
heartbeatDelaysMs: [...state.heartbeatDelaysMs],
|
|
longTasksMs: [...state.longTasksMs],
|
|
operationStartEpochMs: state.operationStartEpochMs,
|
|
terminalEpochMs: state.terminalEpochMs,
|
|
uiPaintedEpochMs: state.uiPaintedEpochMs,
|
|
uiPhaseEpochMs: { ...state.uiPhaseEpochMs },
|
|
uiSettledEpochMs: state.uiSettledEpochMs,
|
|
};
|
|
delete target[stateKey];
|
|
return result;
|
|
}, RENDERER_CAPTURE_STATE_KEY);
|
|
}
|