Files
iptvnator/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts
T
4grayandClaude Opus 5 9b7776a901 chore(lint): hold tests to their own max-lines ceiling (#1306)
* 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>
2026-07-29 08:08:04 +02:00

461 lines
17 KiB
TypeScript

import type {
CancellationBenchmarkManifest,
CancellationBenchmarkSummary,
CancellationIterationResult,
CancellationPhaseMetrics,
MainCaptureMetrics,
MainTimelineRecord,
NumericDistribution,
PerformanceWorkerKind,
RendererCaptureMetrics,
WorkerRequestPerformanceMetrics,
} from './m3u-refresh-cancellation-contract';
import {
PERFORMANCE_ITERATION_KIND,
PERFORMANCE_WORKER_KIND,
} from './m3u-refresh-cancellation-contract';
import {
nonNegativeDifference,
summarizeNumbers,
} from './performance-statistics';
import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity';
import { assessRendererRssValidity } from './renderer-rss-validity';
export function createCancellationIterationResult(input: {
readonly kind: CancellationIterationResult['kind'];
readonly main: MainCaptureMetrics;
readonly renderer: RendererCaptureMetrics;
readonly runId: string;
}): CancellationIterationResult {
const { main, renderer } = input;
const phases = derivePhases(main, renderer);
const cancellationEffectObserved = renderer.probe.events.some(
(event) => event.status === 'cancelled'
);
return Object.freeze({
cancellationEffectObserved,
kind: input.kind,
main,
phases,
renderer,
runId: input.runId,
});
}
export function createCancellationBenchmarkSummary(
manifest: CancellationBenchmarkManifest,
iterations: readonly CancellationIterationResult[]
): CancellationBenchmarkSummary {
const measured = iterations.filter(
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
);
const flattenRenderer = (
select: (iteration: CancellationIterationResult) => readonly number[]
): number[] => measured.flatMap((iteration) => [...select(iteration)]);
const workerMetric = (
kind: PerformanceWorkerKind,
select: (worker: MainCaptureMetrics['workers'][number]) => number | null
): NumericDistribution =>
summarizeNumbers(
measured.flatMap((iteration) =>
iteration.main.workers
.filter((worker) => worker.kind === kind)
.map((worker) => select(worker))
)
);
const workerRequestMetric = (
kind: PerformanceWorkerKind,
select: (request: WorkerRequestPerformanceMetrics) => number | null
): NumericDistribution =>
summarizeNumbers(
measured.flatMap((iteration) =>
iteration.main.workers
.filter((worker) => worker.kind === kind)
.flatMap((worker) =>
worker.requests.map((request) => select(request))
)
)
);
const workerRequestEventLoopDelayMetric = (
kind: PerformanceWorkerKind,
percentile: keyof NonNullable<
WorkerRequestPerformanceMetrics['eventLoopDelay']
>
): NumericDistribution =>
workerRequestMetric(
kind,
(request) => request.eventLoopDelay?.[percentile] ?? null
);
return Object.freeze({
cancellationEffectRate:
measured.length === 0
? 0
: measured.filter(
(iteration) => iteration.cancellationEffectObserved
).length / measured.length,
iterations: Object.freeze([...iterations]),
manifest,
measured: Object.freeze({
cancelAcknowledgementLatencyMs: summarizeNumbers(
measured.map(
(iteration) =>
iteration.phases.cancelAcknowledgementLatencyMs
)
),
cancelToDurableTerminalMs: summarizeNumbers(
measured.map(
(iteration) => iteration.phases.cancelToDurableTerminalMs
)
),
cancelToWorkerTerminatedMs: summarizeNumbers(
measured.map(
(iteration) => iteration.phases.cancelToWorkerTerminatedMs
)
),
cancelTransportLatencyMs: summarizeNumbers(
measured.map(
(iteration) => iteration.phases.cancelTransportLatencyMs
)
),
databaseWorkerExternalPeakBytes: workerMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(worker) => worker.peakExternalBytes
),
databaseWorkerHeapPeakBytes: workerMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(worker) => worker.peakHeapUsedBytes
),
databaseWorkerPostGcHeapBytes: workerMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(worker) => worker.postGcHeapUsedBytes
),
databaseWorkerRequestEventLoopDelayMaxMs:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'maxMs'
),
databaseWorkerRequestEventLoopDelayP95Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'p95Ms'
),
databaseWorkerRequestEventLoopDelayP99Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'p99Ms'
),
databaseWorkerRequestEventLoopUtilization: workerRequestMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(request) => request.eventLoopUtilization
),
databaseWorkerRequestThreadCpuSystemMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(request) => request.threadCpuSystemMicros
),
databaseWorkerRequestThreadCpuUserMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(request) => request.threadCpuUserMicros
),
dataFetchMs: summarizeNumbers(
measured.map((iteration) => iteration.phases.dataFetchMs)
),
dbCommitProxyMs: summarizeNumbers(
measured.map((iteration) => iteration.phases.dbCommitProxyMs)
),
ipcStoreDispatchProxyMs: summarizeNumbers(
measured.map(
(iteration) => iteration.phases.ipcStoreDispatchProxyMs
)
),
mainEventLoopDelayMaxMs: summarizeNumbers(
measured.map((iteration) => iteration.main.eventLoopDelay.maxMs)
),
mainEventLoopDelayP95Ms: summarizeNumbers(
measured.map((iteration) => iteration.main.eventLoopDelay.p95Ms)
),
mainEventLoopDelayP99Ms: summarizeNumbers(
measured.map((iteration) => iteration.main.eventLoopDelay.p99Ms)
),
mainEventLoopUtilization: summarizeNumbers(
measured.map((iteration) => iteration.main.eventLoopUtilization)
),
mainHeapPeakBytes: summarizeNumbers(
measured.map(
(iteration) => iteration.main.memory.peakHeapUsedBytes
)
),
mainHeapPostGcBytes: summarizeNumbers(
measured.map(
(iteration) => iteration.main.memory.postGcHeapUsedBytes
)
),
mainRssPeakBytes: summarizeNumbers(
measured.map((iteration) => iteration.main.memory.peakRssBytes)
),
mainRssPostGcBytes: summarizeNumbers(
measured.map(
(iteration) => iteration.main.memory.postGcRssBytes
)
),
parseNormalizeCloneProxyMs: summarizeNumbers(
measured.map(
(iteration) => iteration.phases.parseNormalizeCloneProxyMs
)
),
persistencePreparationProxyMs: summarizeNumbers(
measured.map(
(iteration) =>
iteration.phases.persistencePreparationProxyMs
)
),
playlistWorkerExternalPeakBytes: workerMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(worker) => worker.peakExternalBytes
),
playlistWorkerHeapPeakBytes: workerMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(worker) => worker.peakHeapUsedBytes
),
playlistWorkerPostGcHeapBytes: workerMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(worker) => worker.postGcHeapUsedBytes
),
playlistWorkerRequestEventLoopDelayMaxMs:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'maxMs'
),
playlistWorkerRequestEventLoopDelayP95Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'p95Ms'
),
playlistWorkerRequestEventLoopDelayP99Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'p99Ms'
),
playlistWorkerRequestEventLoopUtilization: workerRequestMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(request) => request.eventLoopUtilization
),
playlistWorkerRequestThreadCpuSystemMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(request) => request.threadCpuSystemMicros
),
playlistWorkerRequestThreadCpuUserMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(request) => request.threadCpuUserMicros
),
rendererFrameGapMs: summarizeNumbers(
flattenRenderer(
(iteration) => iteration.renderer.probe.frameGapsMs
)
),
rendererHeartbeatDelayMs: summarizeNumbers(
flattenRenderer(
(iteration) => iteration.renderer.probe.heartbeatDelaysMs
)
),
rendererHeapPeakBytes: summarizeNumbers(
measured.map(
(iteration) => iteration.renderer.peakHeapUsedBytes
)
),
rendererHeapPostGcBytes: summarizeNumbers(
measured.map(
(iteration) => iteration.renderer.postGcHeapUsedBytes
)
),
rendererLongTaskCount: measured.reduce(
(sum, iteration) =>
sum + iteration.renderer.probe.longTasksMs.length,
0
),
rendererLongTaskMs: summarizeNumbers(
flattenRenderer(
(iteration) => iteration.renderer.probe.longTasksMs
)
),
rendererRssPeakBytes: summarizeNumbers(
measured.map(
(iteration) =>
iteration.main.rendererWindow.rss.peakRssBytes
)
),
responsiveEvents: measured.reduce(
(sum, iteration) =>
sum + iteration.main.rendererWindow.responsiveEvents,
0
),
totalMs: summarizeNumbers(
measured.map((iteration) => iteration.phases.totalMs)
),
uiSettlementToPaintMs: summarizeNumbers(
measured.map(
(iteration) => iteration.phases.uiSettlementToPaintMs
)
),
unresponsiveEvents: measured.reduce(
(sum, iteration) =>
sum + iteration.main.rendererWindow.unresponsiveEvents,
0
),
visibleTotalMs: summarizeNumbers(
measured.map((iteration) => iteration.phases.visibleTotalMs)
),
}),
validity: Object.freeze({
databaseWorkerPostGc:
assessDatabaseWorkerPostGcValidity(iterations),
rendererRss: assessRendererRssValidity(iterations),
}),
});
}
function derivePhases(
main: MainCaptureMetrics,
renderer: RendererCaptureMetrics
): CancellationPhaseMetrics {
const started = findTimeline(
main.timeline,
'playlist-event:started:fetching'
);
const parsing = findTimeline(
main.timeline,
'playlist-event:progress:parsing'
);
const response = findTimeline(main.timeline, 'playlist-response');
const playlistRequest = findTimeline(main.timeline, 'playlist-request');
const playlistId = playlistRequest?.playlistId;
const dbGetRequest = findPlaylistDbRecord(
main.timeline,
'db-request',
response?.epochMs,
'DB_GET_APP_PLAYLIST',
playlistId
);
const dbGetResponse = findResponse(main.timeline, dbGetRequest);
const dbUpsertRequest = findPlaylistDbRecord(
main.timeline,
'db-request',
dbGetResponse?.epochMs ?? dbGetRequest?.epochMs ?? response?.epochMs,
'DB_UPSERT_APP_PLAYLIST',
playlistId
);
const dbUpsertResponse = findResponse(main.timeline, dbUpsertRequest);
const playlistWorker = main.workers.find(
(worker) => worker.kind === 'playlist-refresh.worker'
);
const cancelClick = renderer.probe.cancelClickEpochMs;
const uiPainted =
renderer.probe.uiPaintedEpochMs ?? renderer.probe.terminalEpochMs;
const cancelledEvent = renderer.probe.events.find(
(event) => event.status === 'cancelled'
);
const terminal = maxEpoch(
uiPainted,
dbUpsertResponse?.epochMs,
cancelledEvent?.receivedEpochMs,
response?.epochMs,
playlistWorker?.terminatedEpochMs
);
return Object.freeze({
cancelAcknowledgementLatencyMs: nonNegativeDifference(
cancelledEvent?.receivedEpochMs,
cancelClick
),
cancelToDurableTerminalMs: nonNegativeDifference(terminal, cancelClick),
cancelToWorkerTerminatedMs: nonNegativeDifference(
playlistWorker?.terminatedEpochMs,
cancelClick
),
cancelTransportLatencyMs: nonNegativeDifference(
playlistWorker?.cancelPostedEpochMs,
cancelClick
),
dataFetchMs: nonNegativeDifference(parsing?.epochMs, started?.epochMs),
dbCommitProxyMs: nonNegativeDifference(
dbUpsertResponse?.epochMs,
dbUpsertRequest?.epochMs
),
ipcStoreDispatchProxyMs: nonNegativeDifference(
dbGetRequest?.epochMs,
response?.epochMs
),
parseNormalizeCloneProxyMs: nonNegativeDifference(
response?.epochMs,
parsing?.epochMs
),
persistencePreparationProxyMs: nonNegativeDifference(
dbUpsertRequest?.epochMs,
dbGetResponse?.epochMs
),
totalMs: nonNegativeDifference(
terminal,
renderer.probe.operationStartEpochMs
),
uiSettlementToPaintMs: nonNegativeDifference(
uiPainted,
renderer.probe.uiSettledEpochMs
),
visibleTotalMs: nonNegativeDifference(
uiPainted,
renderer.probe.operationStartEpochMs
),
});
}
function findTimeline(
timeline: readonly MainTimelineRecord[],
type: string
): MainTimelineRecord | undefined {
return timeline.find((record) => record.type === type);
}
function findPlaylistDbRecord(
timeline: readonly MainTimelineRecord[],
type: string,
afterEpochMs: number | undefined,
operation: string,
playlistId: string | undefined
): MainTimelineRecord | undefined {
if (afterEpochMs === undefined) {
return undefined;
}
return timeline.find(
(record) =>
record.type === type &&
record.epochMs >= afterEpochMs &&
record.operation === operation &&
(playlistId === undefined || record.playlistId === playlistId)
);
}
function findResponse(
timeline: readonly MainTimelineRecord[],
request: MainTimelineRecord | undefined
): MainTimelineRecord | undefined {
if (request?.requestId === undefined) {
return undefined;
}
return timeline.find(
(record) =>
record.type === 'db-response' &&
record.requestId === request.requestId
);
}
function maxEpoch(
...values: readonly (number | null | undefined)[]
): number | null {
const finite = values.filter(
(value): value is number =>
typeof value === 'number' && Number.isFinite(value)
);
return finite.length === 0 ? null : Math.max(...finite);
}