test(performance): add the J1 launch-to-usable journey benchmark

Implements plan items A1, A3 (J1 only) and the minimal A4 from
.plans/2026-09-25-performance-journeys-ratchet.md.

- journey-renderer-probe.ts: init-script probe counting DOM mutations,
  layout shifts and long tasks until the first source card is visible on
  /workspace with the splash removed; unit-tested with jsdom fixtures.
- journey-main-ipc-capture.ts: counts bridge invocations from the preload's
  renderer-API trace channel up to a sentinel call the probe fires, so the
  IPC counter is exact without touching production code.
- launch.journey.ts + playwright.journeys.config.ts: seeded profile (one
  M3U source, one Xtream portal on the loopback mock), one warm-up and five
  measured iterations, fresh process and data directory each, writing
  dist/performance/journeys/<timestamp>/summary.json with exact counters
  and P50/P90 wall-clock.
- Nx target electron-backend-e2e:journeys and root script perf:journeys.
- docs/architecture/performance-journeys.md, README, context and
  validation map entries.

renderer.cdTicksToFirstCard and main.sqlStatementsBeforeReadyToShow are
reported as unavailable with the reason instead of being faked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 11:11:53 +02:00
1 parent 3ed612ba65
commit 8e63326c80
19 files changed
+2169 -3

No files matched your search

+11
View File
@@ -376,6 +376,17 @@ To run only the Angular app without Electron, use:
$ pnpm run serve:frontend
```
To benchmark the "launch to usable" journey (fresh Electron process on a
seeded profile, exact renderer counters plus wall-clock), run:
```
$ pnpm run perf:journeys
```
The journeys, their counters and the summary written under
`dist/performance/journeys/` are described in
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
## Disclaimer
**IPTVnator doesn't provide any playlists or other digital content.**
@@ -0,0 +1,35 @@
import { workspaceRoot } from '@nx/devkit';
import { defineConfig } from '@playwright/test';
/**
* Performance journeys (docs/architecture/performance-journeys.md). One
* worker, no retries: every journey spawns its own Electron processes and
* writes one summary per run. The Xtream mock serves both the M3U playlist
* and the portal on a dedicated loopback port so a normal E2E server on
* 3211 cannot be reused by accident.
*/
const xtreamMockPort =
process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231';
export default defineConfig({
fullyParallel: false,
reporter: [['list']],
retries: 0,
testDir: './src/journeys',
testMatch: '**/*.journey.ts',
timeout: 30 * 60 * 1_000,
use: {
testIdAttribute: 'data-test-id',
},
webServer: {
command: 'pnpm nx run xtream-mock-server:serve',
cwd: workspaceRoot,
env: {
HOST: '127.0.0.1',
PORT: xtreamMockPort,
},
reuseExistingServer: false,
url: `http://127.0.0.1:${xtreamMockPort}/health`,
},
workers: 1,
});
+10
View File
@@ -49,6 +49,16 @@
"command": "pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts"
}
},
"journeys": {
"dependsOn": ["electron-backend:build-performance"],
"executor": "nx:run-commands",
"cache": false,
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.journeys.config.ts"
}
},
"packaged-frame-copy-smoke": {
"dependsOn": ["test-packaged-frame-copy-fixtures"],
"executor": "nx:run-commands",
@@ -30,6 +30,7 @@ import {
import {
captureElectronProcess,
closeElectronApplicationAndConfirmExit,
type ElectronExitConfirmationOptions,
prepareElectronApplication,
} from './electron-process-lifecycle';
@@ -200,7 +201,7 @@ export { expect };
* helper that spawns the app itself has to use the same list. `appArgs` land
* after the entry point, which is where the OS puts an opened file's path.
*/
function buildElectronLaunchArgs(
export function buildElectronLaunchArgs(
extraArgs: readonly string[] = [],
appArgs: readonly string[] = [],
entryPoint = electronMainPath
@@ -577,10 +578,18 @@ export async function closeElectronApp(
export async function closeElectronAppAndConfirmExit(
app: LaunchedElectronApp
): Promise<void> {
await closeElectronApplicationAndConfirmExit(app.electronApp, {
await closeElectronApplicationAndConfirmExit(
app.electronApp,
electronAppExitConfirmationOptions()
);
}
/** The close/exit timeouts the shared fixture applies to every launch. */
export function electronAppExitConfirmationOptions(): ElectronExitConfirmationOptions {
return {
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
});
};
}
function assertPackagedRendererBuildIsElectronSafe(): void {
@@ -0,0 +1,168 @@
import { cp, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { _electron as electron } from '@playwright/test';
import { captureElectronProcess } from '../electron-process-lifecycle';
import {
addXtreamPortal,
buildElectronLaunchArgs,
buildElectronLaunchEnvironment,
closeElectronAppAndConfirmExit,
electronAppExitConfirmationOptions,
importM3uPlaylistFromUrl,
launchElectronApp,
waitForM3uCatalog,
waitForXtreamCatalog,
type LaunchElectronAppOptions,
} from '../electron-test-fixtures';
import { closeElectronApplicationAndConfirmExit } from '../electron-process-lifecycle';
import {
installJourneyMainIpcCapture,
JOURNEY_MAIN_IPC_STATE_KEY,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
readJourneyMainIpcCapture,
} from '../performance/journey-main-ipc-capture';
import {
createLaunchJourneyProbeOptions,
installJourneyRendererProbe,
waitForJourneyRendererProbe,
} from '../performance/journey-renderer-probe';
import type { LaunchJourneyMeasurement } from '../performance/launch-journey-record';
/**
* Process lifecycle for J1 "Launch to usable": one seeded profile template,
* then a fresh Electron process on a fresh copy of that template per
* iteration. Mirrors `xtream-benchmark-app-startup.ts` without the import
* scaffolding that journey does not need.
*/
export const LAUNCH_JOURNEY_XTREAM_MOCK_PORT =
process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231';
export const LAUNCH_JOURNEY_MOCK_ORIGIN = `http://127.0.0.1:${LAUNCH_JOURNEY_XTREAM_MOCK_PORT}`;
function launchOptions(
env: Record<string, string> = {}
): LaunchElectronAppOptions {
return {
env: { IPTVNATOR_TRACE_RENDERER_CONSOLE: '0', ...env },
environmentInheritance: 'runtime-only',
omitEnvKeys: [
'IPTVNATOR_XTREAM_MOCK_CONTROL',
'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN',
],
};
}
function removeDirectory(directory: string): Promise<void> {
return rm(directory, {
force: true,
maxRetries: 20,
recursive: true,
retryDelay: 250,
});
}
/**
* Seeds one M3U source and one Xtream portal through the app's own dialogs
* and returns the data directory to copy for every measured launch.
*/
export async function seedLaunchJourneyProfile(
mockOrigin: string
): Promise<string> {
const templateDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-journey-launch-seed-')
);
try {
const app = await launchElectronApp(templateDirectory, launchOptions());
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
`${mockOrigin}/playlist.m3u`
);
await waitForM3uCatalog(app.mainWindow);
await addXtreamPortal(app.mainWindow, { serverUrl: mockOrigin });
await waitForXtreamCatalog(app.mainWindow);
} finally {
await closeElectronAppAndConfirmExit(app);
}
return templateDirectory;
} catch (failure) {
await removeDirectory(templateDirectory);
throw failure;
}
}
export function removeLaunchJourneyProfile(directory: string): Promise<void> {
return removeDirectory(directory);
}
/**
* Spawns a fresh Electron process on a copy of the seeded profile, installs
* the renderer probe before the window exists and the main-process IPC
* capture before the renderer runs any script, then waits for the journey's
* terminal condition.
*/
export async function measureLaunchJourney(
templateDirectory: string,
timeoutMs: number
): Promise<LaunchJourneyMeasurement> {
const dataDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-journey-launch-')
);
try {
await cp(templateDirectory, dataDirectory, { recursive: true });
const env = buildElectronLaunchEnvironment(
dataDirectory,
launchOptions({ IPTVNATOR_TRACE_IPC: '1' })
);
const args = buildElectronLaunchArgs();
const spawnEpochMs = Date.now();
const electronApp = await electron.launch({ args, env });
captureElectronProcess(electronApp);
try {
const probeOptions = createLaunchJourneyProbeOptions();
await installJourneyRendererProbe(
electronApp.context(),
probeOptions
);
await installJourneyMainIpcCapture(electronApp, {
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
sentinelId: probeOptions.sentinelId,
sentinelMethod: probeOptions.sentinelMethod,
stateKey: JOURNEY_MAIN_IPC_STATE_KEY,
});
const mainWindow = await electronApp.firstWindow();
const renderer = await waitForJourneyRendererProbe(
mainWindow,
probeOptions.stateKey,
timeoutMs
);
const ipc = await readJourneyMainIpcCapture(
electronApp,
JOURNEY_MAIN_IPC_STATE_KEY,
10_000
);
if (ipc.installedEpochMs > renderer.installed.epochMs) {
throw new Error('journey-main-ipc-capture-installed-late');
}
const electronVersion = await electronApp.evaluate(
() => process.versions.electron
);
return {
electronVersion,
ipc,
pid: electronApp.process().pid ?? -1,
renderer,
spawnEpochMs,
};
} finally {
await closeElectronApplicationAndConfirmExit(
electronApp,
electronAppExitConfirmationOptions()
);
}
} finally {
await removeDirectory(dataDirectory);
}
}
@@ -0,0 +1,121 @@
import { relative } from 'node:path';
import { test } from '@playwright/test';
import {
electronMainPath,
packagedRendererIndexPath,
workspaceRoot,
} from '../electron-test-fixtures';
import {
JOURNEY_SUMMARY_SCHEMA_VERSION,
resolveJourneySummaryPath,
summarizeJourneyIterations,
writeJourneySummary,
type JourneyIterationRecord,
type JourneySummary,
} from '../performance/journey-summary';
import {
LAUNCH_JOURNEY_ID,
LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS,
toLaunchIterationRecord,
} from '../performance/launch-journey-record';
import {
LAUNCH_JOURNEY_MOCK_ORIGIN,
measureLaunchJourney,
removeLaunchJourneyProfile,
seedLaunchJourneyProfile,
} from './launch-journey-app';
/**
* J1 "Launch to usable": Electron process spawn until the first playlist or
* portal card is visible on /workspace with the inline splash removed.
* Contract: docs/architecture/performance-journeys.md.
*/
const WARMUP_ITERATIONS = 1;
const MEASURED_ITERATIONS = readPositiveInteger(
'IPTVNATOR_JOURNEY_MEASURED_ITERATIONS',
5
);
const ITERATION_TIMEOUT_MS = 120_000;
function readPositiveInteger(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw === '') {
return fallback;
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
test.describe.configure({ mode: 'serial' });
test('J1 launch to usable', async () => {
const templateDirectory = await seedLaunchJourneyProfile(
LAUNCH_JOURNEY_MOCK_ORIGIN
);
const iterations: JourneyIterationRecord[] = [];
let electronVersion = 'unknown';
try {
const total = WARMUP_ITERATIONS + MEASURED_ITERATIONS;
for (let index = 0; index < total; index += 1) {
const warmup = index < WARMUP_ITERATIONS;
const measurement = await measureLaunchJourney(
templateDirectory,
ITERATION_TIMEOUT_MS
);
electronVersion = measurement.electronVersion;
const record = toLaunchIterationRecord(index, warmup, measurement);
iterations.push(record);
console.log(
`[journey:launch] iteration ${index}${warmup ? ' (warm-up)' : ''} pid=${record.pid} ${JSON.stringify(
{ ...record.counters, ...record.wallClock }
)}`
);
}
} finally {
await removeLaunchJourneyProfile(templateDirectory);
}
const entry = summarizeJourneyIterations(
iterations,
LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS
);
const summary: JourneySummary = {
generatedAt: new Date().toISOString(),
harness: {
arch: process.arch,
ci: Boolean(process.env['CI']),
electron: electronVersion,
electronMain: relative(workspaceRoot, electronMainPath),
measuredIterations: MEASURED_ITERATIONS,
node: process.version,
platform: process.platform,
rendererIndex: relative(workspaceRoot, packagedRendererIndexPath),
warmupIterations: WARMUP_ITERATIONS,
},
journeys: { [LAUNCH_JOURNEY_ID]: entry },
schemaVersion: JOURNEY_SUMMARY_SCHEMA_VERSION,
};
const summaryPath = resolveJourneySummaryPath(workspaceRoot);
await writeJourneySummary(summaryPath, summary);
await test.info().attach('journey-summary', {
contentType: 'application/json',
path: summaryPath,
});
console.log(
`[journey:launch] summary ${relative(workspaceRoot, summaryPath)}\n${JSON.stringify(
{
counters: entry.counters,
counterStability: entry.counterStability,
unavailable: entry.unavailable,
wallClock: entry.wallClock,
},
null,
2
)}`
);
});
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import {
assertJourneyMainIpcCapture,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
type JourneyMainIpcCaptureState,
} from './journey-main-ipc-capture';
import { JOURNEY_IPC_SENTINEL_METHOD } from './journey-renderer-probe';
const electronBackendSource = resolve(
__dirname,
'../../../electron-backend/src/app'
);
function validCapture(
overrides: Partial<JourneyMainIpcCaptureState> = {}
): JourneyMainIpcCaptureState {
return {
callsAfterSentinel: 2,
callsBeforeSentinel: 7,
callsByMethod: { dbGetAppPlaylists: 1, getSettings: 6 },
installedEpochMs: 1,
malformedEvents: 0,
processStartEpochMs: 0,
senderIds: [1],
sentinel: { occurrences: 1, receivedEpochMs: 2 },
...overrides,
};
}
test('the trace channel literal matches the main-process constant', () => {
const source = readFileSync(
resolve(electronBackendSource, 'services/debug-trace.ts'),
'utf8'
);
assert.match(
source,
new RegExp(
`DEBUG_TRACE_EVENT_CHANNEL = '${JOURNEY_RENDERER_API_TRACE_CHANNEL}'`
)
);
});
test('the preload traces every bridge invocation on that channel when IPC tracing is on', () => {
const preload = readFileSync(
resolve(electronBackendSource, 'api/main.preload.ts'),
'utf8'
);
assert.match(
preload,
/ipcRenderer\.send\(DEBUG_TRACE_EVENT_CHANNEL, payload\)/
);
assert.match(preload, /name\.startsWith\('on'\)/);
assert.match(preload, /name\.startsWith\('remove'\)/);
assert.match(
preload,
new RegExp(`${JOURNEY_IPC_SENTINEL_METHOD}: \\(playlistId: string`)
);
const debugTrace = readFileSync(
resolve(electronBackendSource, 'services/debug-trace.ts'),
'utf8'
);
assert.match(debugTrace, /readFlag\('IPTVNATOR_TRACE_IPC'\)/);
});
test('accepts a capture with exactly one sentinel from one renderer', () => {
assert.equal(
assertJourneyMainIpcCapture(validCapture()).callsBeforeSentinel,
7
);
});
test('rejects captures that cannot bound the counter exactly', () => {
assert.throws(() => assertJourneyMainIpcCapture(null), /missing/);
assert.throws(
() =>
assertJourneyMainIpcCapture(
validCapture({
sentinel: { occurrences: 0, receivedEpochMs: null },
})
),
/sentinel-count-0/
);
assert.throws(
() =>
assertJourneyMainIpcCapture(
validCapture({
sentinel: { occurrences: 2, receivedEpochMs: 2 },
})
),
/sentinel-count-2/
);
assert.throws(
() => assertJourneyMainIpcCapture(validCapture({ senderIds: [1, 2] })),
/senders-2/
);
assert.throws(
() => assertJourneyMainIpcCapture(validCapture({ malformedEvents: 1 })),
/malformed/
);
});
@@ -0,0 +1,155 @@
import type { ElectronApplication } from '@playwright/test';
/**
* Main-process side of the journey IPC counter.
*
* With `IPTVNATOR_TRACE_IPC=1` the preload wraps every bridge method (except
* `on*` / `remove*` listener registrations) and sends one trace event per
* invocation on the renderer-API trace channel before forwarding the call
* (see `apps/electron-backend/src/app/api/main.preload.ts`). This capture
* subscribes to that channel from the test side and counts `start` events
* until the renderer probe's sentinel call arrives. Renderer-to-main IPC is
* delivered in order, so every call started before the sentinel is counted
* and nothing after it is.
*/
/** Literal of `DEBUG_TRACE_EVENT_CHANNEL` in `services/debug-trace.ts`. */
export const JOURNEY_RENDERER_API_TRACE_CHANNEL = 'IPTVNATOR_DEBUG_TRACE_EVENT';
export const JOURNEY_MAIN_IPC_STATE_KEY = '__iptvnatorJourneyMainIpcCapture';
export interface JourneyMainIpcCaptureOptions {
readonly channel: string;
readonly sentinelId: string;
readonly sentinelMethod: string;
readonly stateKey: string;
}
export interface JourneyMainIpcCaptureState {
readonly callsAfterSentinel: number;
readonly callsBeforeSentinel: number;
readonly callsByMethod: Record<string, number>;
readonly installedEpochMs: number;
readonly malformedEvents: number;
readonly processStartEpochMs: number;
readonly senderIds: number[];
readonly sentinel: {
readonly occurrences: number;
readonly receivedEpochMs: number | null;
};
}
export async function installJourneyMainIpcCapture(
electronApp: ElectronApplication,
options: JourneyMainIpcCaptureOptions
): Promise<void> {
await electronApp.evaluate(({ ipcMain }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
if (target[input.stateKey] !== undefined) {
throw new Error('journey-main-ipc-capture-already-installed');
}
const state = {
callsAfterSentinel: 0,
callsBeforeSentinel: 0,
callsByMethod: {} as Record<string, number>,
installedEpochMs: Date.now(),
malformedEvents: 0,
processStartEpochMs: Date.now() - process.uptime() * 1000,
senderIds: [] as number[],
sentinel: {
occurrences: 0,
receivedEpochMs: null as number | null,
},
};
target[input.stateKey] = state;
ipcMain.on(input.channel, (event, payload: unknown) => {
const record =
typeof payload === 'object' && payload !== null
? (payload as Record<string, unknown>)
: null;
if (!record || typeof record['method'] !== 'string') {
state.malformedEvents += 1;
return;
}
if (record['phase'] !== 'start') {
return;
}
const senderId = event.sender.id;
if (!state.senderIds.includes(senderId)) {
state.senderIds.push(senderId);
}
const method = record['method'];
let isSentinel = false;
if (method === input.sentinelMethod) {
try {
isSentinel = JSON.stringify(
record['args'] ?? null
).includes(input.sentinelId);
} catch {
isSentinel = false;
}
}
if (isSentinel) {
state.sentinel.occurrences += 1;
state.sentinel.receivedEpochMs ??= Date.now();
return;
}
if (state.sentinel.receivedEpochMs !== null) {
state.callsAfterSentinel += 1;
return;
}
state.callsBeforeSentinel += 1;
state.callsByMethod[method] =
(state.callsByMethod[method] ?? 0) + 1;
});
}, options);
}
export async function readJourneyMainIpcCapture(
electronApp: ElectronApplication,
stateKey: string,
timeoutMs: number
): Promise<JourneyMainIpcCaptureState> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const state = await electronApp.evaluate(
(_electron, key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as unknown,
stateKey
);
const capture = state as JourneyMainIpcCaptureState | null;
if (capture?.sentinel.receivedEpochMs !== null) {
return assertJourneyMainIpcCapture(capture);
}
if (Date.now() >= deadline) {
throw new Error('journey-main-ipc-capture-sentinel-timeout');
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
export function assertJourneyMainIpcCapture(
value: unknown
): JourneyMainIpcCaptureState {
const state = value as JourneyMainIpcCaptureState | null | undefined;
if (!state || typeof state.callsBeforeSentinel !== 'number') {
throw new Error('journey-main-ipc-capture-missing');
}
if (state.sentinel.occurrences !== 1) {
throw new Error(
`journey-main-ipc-capture-sentinel-count-${state.sentinel.occurrences}`
);
}
if (state.senderIds.length !== 1) {
throw new Error(
`journey-main-ipc-capture-senders-${state.senderIds.length}`
);
}
if (state.malformedEvents > 0) {
throw new Error('journey-main-ipc-capture-malformed-events');
}
return state;
}
@@ -0,0 +1,362 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import {
assertJourneyRendererProbeState,
createLaunchJourneyProbeOptions,
JOURNEY_IPC_SENTINEL_ID,
JOURNEY_IPC_SENTINEL_METHOD,
JOURNEY_PROBE_STATE_KEY,
journeyRendererProbeScript,
type JourneyRendererProbeOptions,
type JourneyRendererProbeState,
} from './journey-renderer-probe';
interface FakeEntry {
duration?: number;
entryType: string;
hadRecentInput?: boolean;
startTime: number;
value?: number;
}
interface FakeObserver {
disconnected: boolean;
emit(entries: FakeEntry[]): void;
queue: FakeEntry[];
type: string | null;
}
interface Fixture {
readonly bridgeCalls: unknown[];
readonly observers: FakeObserver[];
/** The live state object inside the jsdom realm. */
readonly rawState: () => JourneyRendererProbeState;
/** A JSON clone, so assertions compare values across realms. */
readonly state: () => JourneyRendererProbeState;
readonly window: JSDOM['window'];
}
const PAGE = `<!doctype html><html><head></head><body class="mat-app-background">
<div id="initial-splash" role="status"><span>IPTVnator</span></div>
<app-root></app-root></body></html>`;
function installFakePerformance(
window: JSDOM['window'],
observers: FakeObserver[]
): void {
class FakePerformanceObserver implements FakeObserver {
disconnected = false;
queue: FakeEntry[] = [];
type: string | null = null;
constructor(
private readonly callback: (list: {
getEntries(): FakeEntry[];
}) => void
) {
observers.push(this);
}
observe(options: { type: string }): void {
this.type = options.type;
}
takeRecords(): FakeEntry[] {
const queued = this.queue;
this.queue = [];
return queued;
}
disconnect(): void {
this.disconnected = true;
}
emit(entries: FakeEntry[]): void {
this.callback({ getEntries: () => entries });
}
}
Object.defineProperty(window, 'PerformanceObserver', {
configurable: true,
value: FakePerformanceObserver,
});
Object.defineProperty(window.performance, 'getEntriesByType', {
configurable: true,
value: (type: string) =>
type === 'navigation'
? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }]
: [],
});
// jsdom never lays out, so visibility is "connected to the document".
window.HTMLElement.prototype.getClientRects = function getClientRects(
this: HTMLElement
) {
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
};
}
function createFixture(
overrides: Partial<JourneyRendererProbeOptions> & {
bridge?: boolean;
url?: string;
} = {}
): Fixture {
const {
bridge = true,
url = 'file:///dist/apps/web/workspace/dashboard',
...optionOverrides
} = overrides;
const dom = new JSDOM(PAGE, {
pretendToBeVisual: true,
runScripts: 'outside-only',
url,
});
const { window } = dom;
const observers: FakeObserver[] = [];
const bridgeCalls: unknown[] = [];
installFakePerformance(window, observers);
// tsx (esbuild keepNames) rewrites named inner functions as
// `__name(fn, 'name')` when it transpiles the probe for this test runner.
// Playwright's Babel transform, which serializes the probe for the real
// browser, does not, so the shim is a test-runner concern only.
Object.defineProperty(window, '__name', {
configurable: true,
value: (target: unknown) => target,
});
if (bridge) {
Object.defineProperty(window, 'electron', {
configurable: true,
value: Object.freeze({
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
bridgeCalls.push(id);
return Promise.resolve(null);
},
onSomething: () => undefined,
}),
});
}
const options = {
...createLaunchJourneyProbeOptions(),
...optionOverrides,
};
window.eval(
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})`
);
const rawState = (): JourneyRendererProbeState =>
(window as unknown as Record<string, JourneyRendererProbeState>)[
options.stateKey
] as JourneyRendererProbeState;
return {
bridgeCalls,
observers,
rawState,
state: () =>
JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState,
window,
};
}
function settle(ms = 40): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function renderFirstCard(fixture: Fixture): void {
const { document } = fixture.window;
document.getElementById('initial-splash')?.remove();
const rail = document.createElement('section');
rail.setAttribute('data-test-id', 'dashboard-recent-sources-rail');
document.querySelector('app-root')?.append(rail);
const card = document.createElement('div');
card.setAttribute('data-test-id', 'dashboard-recent-sources-rail-card');
rail.append(card);
}
test('records install facts before any renderer script ran', () => {
const fixture = createFixture();
const state = fixture.state();
assert.equal(state.schemaVersion, 1);
assert.equal(state.journey, 'launch');
assert.equal(state.installed.readyState, 'loading');
assert.equal(state.installed.scriptCount, 0);
assert.equal(state.installed.bridgePresent, true);
assert.equal(state.capabilities.observedTarget, 'documentElement');
assert.equal(state.capabilities.layoutShift, true);
assert.equal(state.capabilities.longTask, true);
assert.deepEqual(state.invalidReasons, []);
assert.equal(state.terminal, null);
assert.deepEqual(
fixture.observers.map((observer) => observer.type),
['layout-shift', 'longtask']
);
});
test('installs once per document', () => {
const fixture = createFixture();
const first = fixture.rawState();
fixture.window.eval(
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(createLaunchJourneyProbeOptions())})`
);
assert.equal(fixture.rawState(), first);
});
test('counts mutation records until the first card is visible after the splash is gone', async () => {
const fixture = createFixture();
const { document } = fixture.window;
const appRoot = document.querySelector('app-root') as HTMLElement;
appRoot.append(document.createElement('div'));
appRoot.append(document.createElement('div'));
appRoot.setAttribute('data-ready', '1');
await settle();
assert.equal(fixture.state().terminal, null);
renderFirstCard(fixture);
await settle();
const state = fixture.state();
assert.ok(state.terminal, 'terminal must be recorded');
assert.equal(
state.terminal.cardTestId,
'dashboard-recent-sources-rail-card'
);
assert.equal(state.terminal.cardTag, 'div');
assert.match(state.terminal.pathname, /\/workspace\/dashboard$/);
// 2 appends + 1 attribute + splash removal + rail append + card append
assert.equal(state.counters.domMutations, 6);
assert.equal(state.final, true);
assert.equal(typeof state.firstCardPaintEpochMs, 'number');
assert.deepEqual(fixture.bridgeCalls, [JOURNEY_IPC_SENTINEL_ID]);
assert.equal(state.sentinel.status, 'sent');
assert.equal(
state.navigation?.loadEventEndEpochMs,
fixture.window.performance.timeOrigin + 120
);
assert.equal(
state.capabilities.changeDetectionTicks,
'unavailable-ng-global-not-published'
);
assert.ok(fixture.observers.every((observer) => observer.disconnected));
appRoot.append(document.createElement('div'));
await settle();
assert.equal(fixture.state().counters.domMutations, 6);
assert.doesNotThrow(() => assertJourneyRendererProbeState(fixture.state()));
});
test('sums layout shifts without recent input and counts long tasks over 50 ms until the first painted frame', async () => {
const fixture = createFixture();
const [layoutShift, longTask] = fixture.observers as [
FakeObserver,
FakeObserver,
];
const now = () => fixture.window.performance.now();
layoutShift.emit([
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 0.25,
},
{
entryType: 'layout-shift',
hadRecentInput: true,
startTime: now(),
value: 5,
},
]);
longTask.emit([
{ duration: 80, entryType: 'longtask', startTime: now() },
{ duration: 50, entryType: 'longtask', startTime: now() },
]);
// Entries still queued when the terminal frame closes the observers.
layoutShift.queue.push(
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 0.5,
},
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now() + 60_000,
value: 9,
}
);
longTask.queue.push(
{ duration: 120, entryType: 'longtask', startTime: now() },
{ duration: 300, entryType: 'longtask', startTime: now() + 60_000 }
);
renderFirstCard(fixture);
await settle();
const state = fixture.state();
assert.equal(state.final, true);
assert.equal(state.counters.layoutShiftScore, 0.75);
assert.equal(state.counters.longTasks, 2);
assert.deepEqual(state.longTaskDurationsMs, [80, 120]);
layoutShift.emit([
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 1,
},
]);
longTask.emit([{ duration: 99, entryType: 'longtask', startTime: now() }]);
assert.equal(fixture.state().counters.layoutShiftScore, 0.75);
assert.equal(fixture.state().counters.longTasks, 2);
});
test('does not end while the splash is present, off the workspace route, or before a card is visible', async () => {
const withSplash = createFixture();
const rail = withSplash.window.document.createElement('div');
rail.setAttribute('data-test-id', 'dashboard-recent-sources-rail-card');
withSplash.window.document.querySelector('app-root')?.append(rail);
await settle();
assert.equal(withSplash.state().terminal, null);
assert.deepEqual(withSplash.bridgeCalls, []);
const offRoute = createFixture({ url: 'file:///dist/apps/web/index.html' });
renderFirstCard(offRoute);
await settle();
assert.equal(offRoute.state().terminal, null);
const noCard = createFixture();
noCard.window.document.getElementById('initial-splash')?.remove();
await settle();
assert.equal(noCard.state().terminal, null);
assert.throws(
() => assertJourneyRendererProbeState(noCard.state()),
/incomplete/
);
});
test('reports a missing bridge instead of guessing the IPC boundary', async () => {
const fixture = createFixture({ bridge: false });
assert.equal(fixture.state().installed.bridgePresent, false);
renderFirstCard(fixture);
await settle();
assert.equal(fixture.state().sentinel.status, 'bridge-missing');
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/sentinel-bridge-missing/
);
});
test('rejects a probe that was installed after the document started', async () => {
const fixture = createFixture();
const state = fixture.rawState() as { invalidReasons: string[] };
state.invalidReasons.push('probe-installed-after-document-start');
renderFirstCard(fixture);
await settle();
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/probe-installed-after-document-start/
);
});
test('launch options target the workspace source cards and the shared sentinel', () => {
const options = createLaunchJourneyProbeOptions();
assert.equal(options.stateKey, JOURNEY_PROBE_STATE_KEY);
assert.equal(options.sentinelMethod, 'dbGetAppPlaylist');
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
});
@@ -0,0 +1,338 @@
import type { BrowserContext, Page } from '@playwright/test';
/**
* Renderer-side probe for the performance journeys (J1 "Launch to usable").
*
* The probe is injected from the test side through `addInitScript`, so it
* runs before any renderer script and never touches production code. It
* counts DOM mutations, layout shifts and long tasks until the journey's
* terminal condition and then emits one JSON blob under
* `window.__iptvnatorJourneyProbe`.
*
* IPC invocations are not counted here: the bridge object exposed by
* `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe
* fires one sentinel bridge call at the terminal moment; the main-process
* capture (`journey-main-ipc-capture.ts`) counts the preload's renderer-API
* trace events received before that sentinel. Renderer-to-main IPC is
* ordered, so the count is exact regardless of clock skew.
*/
export const JOURNEY_PROBE_STATE_KEY = '__iptvnatorJourneyProbe';
export const JOURNEY_PROBE_SCHEMA_VERSION = 1;
export const JOURNEY_IPC_SENTINEL_ID = '__iptvnator-journey-sentinel__';
/** Bridge method used for the sentinel: a read-only lookup by id. */
export const JOURNEY_IPC_SENTINEL_METHOD = 'dbGetAppPlaylist';
export interface JourneyRendererProbeOptions {
/** Selector for the element whose visibility ends the journey. */
readonly cardSelector: string;
readonly journey: string;
/** Pathname fragment the terminal route must contain. */
readonly routeFragment: string;
readonly sentinelId: string;
readonly sentinelMethod: string;
/** Element id of the inline splash that must be gone at the end. */
readonly splashId: string;
readonly stateKey: string;
}
export interface JourneyRendererProbeCounters {
domMutations: number;
layoutShiftScore: number;
longTasks: number;
}
export interface JourneyRendererProbeState {
readonly capabilities: {
changeDetectionTicks: string;
layoutShift: boolean;
longTask: boolean;
observedTarget: 'document' | 'documentElement';
};
readonly counters: JourneyRendererProbeCounters;
final: boolean;
firstCardPaintEpochMs: number | null;
readonly installed: {
readonly bridgePresent: boolean;
readonly documentElementPresent: boolean;
readonly epochMs: number;
readonly readyState: string;
readonly scriptCount: number;
};
readonly invalidReasons: string[];
readonly journey: string;
readonly longTaskDurationsMs: number[];
navigation: {
readonly domContentLoadedEpochMs: number;
readonly loadEventEndEpochMs: number;
} | null;
readonly schemaVersion: number;
sentinel: {
readonly epochMs: number | null;
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
};
terminal: {
readonly cardTag: string;
readonly cardTestId: string | null;
readonly epochMs: number;
readonly pathname: string;
} | null;
}
/**
* Page-side script. It must stay self-contained: Playwright serializes it
* with `toString()`, so it may only use its argument and browser globals.
*/
export function journeyRendererProbeScript(
options: JourneyRendererProbeOptions
): void {
const target = globalThis as unknown as Record<string, unknown>;
if (target[options.stateKey] !== undefined) {
return;
}
const epoch = (): number => performance.timeOrigin + performance.now();
const bridge = target['electron'] as Record<string, unknown> | undefined;
const state: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'pending',
layoutShift: false,
longTask: false,
observedTarget: document.documentElement
? 'documentElement'
: 'document',
},
counters: { domMutations: 0, layoutShiftScore: 0, longTasks: 0 },
final: false,
firstCardPaintEpochMs: null,
installed: {
bridgePresent: typeof bridge === 'object' && bridge !== null,
documentElementPresent: document.documentElement !== null,
epochMs: epoch(),
readyState: document.readyState,
scriptCount: document.scripts.length,
},
invalidReasons: [],
journey: options.journey,
longTaskDurationsMs: [],
navigation: null,
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
terminal: null,
};
target[options.stateKey] = state;
if (
state.installed.scriptCount > 0 ||
state.installed.readyState !== 'loading'
) {
state.invalidReasons.push('probe-installed-after-document-start');
}
const acceptLayoutShift = (
entries: readonly PerformanceEntry[],
untilEpochMs: number
): void => {
for (const entry of entries) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
value?: number;
};
if (
shift.hadRecentInput === true ||
typeof shift.value !== 'number' ||
performance.timeOrigin + shift.startTime > untilEpochMs
) {
continue;
}
state.counters.layoutShiftScore += shift.value;
}
};
const acceptLongTasks = (
entries: readonly PerformanceEntry[],
untilEpochMs: number
): void => {
for (const entry of entries) {
if (
entry.duration <= 50 ||
performance.timeOrigin + entry.startTime > untilEpochMs
) {
continue;
}
state.counters.longTasks += 1;
state.longTaskDurationsMs.push(entry.duration);
}
};
const observe = (
type: string,
accept: (entries: readonly PerformanceEntry[], until: number) => void
): PerformanceObserver | null => {
try {
const observer = new PerformanceObserver((list) => {
if (state.final) return;
accept(list.getEntries(), Number.POSITIVE_INFINITY);
});
observer.observe({ type, buffered: true });
return observer;
} catch {
return null;
}
};
const layoutShiftObserver = observe('layout-shift', acceptLayoutShift);
const longTaskObserver = observe('longtask', acceptLongTasks);
state.capabilities.layoutShift = layoutShiftObserver !== null;
state.capabilities.longTask = longTaskObserver !== null;
const finalize = (untilEpochMs: number): void => {
if (layoutShiftObserver) {
acceptLayoutShift(layoutShiftObserver.takeRecords(), untilEpochMs);
layoutShiftObserver.disconnect();
}
if (longTaskObserver) {
acceptLongTasks(longTaskObserver.takeRecords(), untilEpochMs);
longTaskObserver.disconnect();
}
state.firstCardPaintEpochMs = untilEpochMs;
state.final = true;
};
const sendSentinel = (): void => {
const method = bridge?.[options.sentinelMethod];
if (typeof method !== 'function') {
state.sentinel = { epochMs: null, status: 'bridge-missing' };
return;
}
try {
const result: unknown = method.call(bridge, options.sentinelId);
state.sentinel = { epochMs: epoch(), status: 'sent' };
void Promise.resolve(result).catch(() => undefined);
} catch {
state.sentinel = { epochMs: null, status: 'failed' };
}
};
const isVisible = (element: Element | null): element is HTMLElement =>
element instanceof HTMLElement && element.getClientRects().length > 0;
const readNavigation = (): JourneyRendererProbeState['navigation'] => {
if (typeof performance.getEntriesByType !== 'function') {
return null;
}
const entry = performance.getEntriesByType('navigation')[0] as
PerformanceNavigationTiming | undefined;
if (!entry || entry.loadEventEnd <= 0) {
return null;
}
return {
domContentLoadedEpochMs:
performance.timeOrigin + entry.domContentLoadedEventEnd,
loadEventEndEpochMs: performance.timeOrigin + entry.loadEventEnd,
};
};
const mutationObserver = new MutationObserver((records) => {
if (state.terminal !== null) return;
state.counters.domMutations += records.length;
if (
!location.pathname.includes(options.routeFragment) ||
document.getElementById(options.splashId) !== null
) {
return;
}
const card = document.querySelector(options.cardSelector);
if (!isVisible(card)) return;
state.terminal = {
cardTag: card.tagName.toLowerCase(),
cardTestId: card.getAttribute('data-test-id'),
epochMs: epoch(),
pathname: location.pathname,
};
mutationObserver.disconnect();
sendSentinel();
state.navigation = readNavigation();
if (state.navigation === null) {
state.invalidReasons.push('load-event-not-finished-at-first-card');
}
const ng = target['ng'] as Record<string, unknown> | undefined;
state.capabilities.changeDetectionTicks =
typeof ng?.['ɵsetProfiler'] === 'function'
? 'hook-present-not-counted'
: 'unavailable-ng-global-not-published';
// Let the frame that paints the card land, then close the
// performance observers at that frame's timestamp so the render
// task's own long task and layout shift are included.
requestAnimationFrame(() => {
const frameEpochMs = epoch();
setTimeout(() => finalize(frameEpochMs), 0);
});
});
mutationObserver.observe(document.documentElement ?? document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
}
export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
journey: 'launch',
routeFragment: '/workspace',
sentinelId: JOURNEY_IPC_SENTINEL_ID,
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
splashId: 'initial-splash',
stateKey: JOURNEY_PROBE_STATE_KEY,
};
}
export async function installJourneyRendererProbe(
context: BrowserContext,
options: JourneyRendererProbeOptions
): Promise<void> {
await context.addInitScript(journeyRendererProbeScript, options);
}
export async function waitForJourneyRendererProbe(
page: Page,
stateKey: string,
timeoutMs: number
): Promise<JourneyRendererProbeState> {
await page.waitForFunction(
(key) =>
(globalThis as unknown as Record<string, { final?: boolean }>)[key]
?.final === true,
stateKey,
{ polling: 50, timeout: timeoutMs }
);
const state = await page.evaluate(
(key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as unknown,
stateKey
);
return assertJourneyRendererProbeState(state);
}
export function assertJourneyRendererProbeState(
value: unknown
): JourneyRendererProbeState {
const state = value as JourneyRendererProbeState | null;
if (
!state ||
state.schemaVersion !== JOURNEY_PROBE_SCHEMA_VERSION ||
state.final !== true ||
state.terminal === null
) {
throw new Error('journey-renderer-probe-incomplete');
}
if (state.invalidReasons.length > 0) {
throw new Error(
`journey-renderer-probe-invalid: ${state.invalidReasons.join(', ')}`
);
}
if (state.sentinel.status !== 'sent') {
throw new Error(
`journey-renderer-probe-sentinel-${state.sentinel.status}`
);
}
return state;
}
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
formatJourneyOutputTimestamp,
JOURNEY_SUMMARY_SCHEMA_VERSION,
percentile,
resolveJourneySummaryPath,
summarizeJourneyIterations,
writeJourneySummary,
type JourneyIterationRecord,
type JourneySummary,
} from './journey-summary';
function iteration(
index: number,
counters: Record<string, number>,
wallClock: Record<string, number>,
warmup = false
): JourneyIterationRecord {
return {
counters,
evidence: {},
index,
pid: 100 + index,
wallClock,
warmup,
};
}
test('percentile interpolates linearly like the shared statistics helper', () => {
assert.equal(percentile([10], 90), 10);
assert.equal(percentile([30, 10, 20], 50), 20);
assert.equal(percentile([10, 20, 30, 40, 50], 90), 46);
assert.equal(percentile([10, 20, 30, 40, 50], 0), 10);
assert.throws(() => percentile([], 50), /percentile-input/);
assert.throws(() => percentile([1], 101), /percentile-input/);
});
test('summarizes exact counters and P50/P90 wall-clock from measured iterations only', () => {
const entry = summarizeJourneyIterations(
[
iteration(
0,
{ 'renderer.x': 99 },
{ spawnToFirstCardMs: 9_000 },
true
),
iteration(1, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_000 }),
iteration(2, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_200 }),
iteration(3, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_100 }),
iteration(4, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_300 }),
iteration(5, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_400 }),
],
{ 'renderer.y': 'not measurable' }
);
assert.deepEqual(entry.counters, { 'renderer.x': 12 });
assert.deepEqual(entry.counterStability, {
'renderer.x': { stable: true, values: [12, 12, 12, 12, 12] },
});
assert.deepEqual(entry.wallClock, {
'spawnToFirstCardMs.p50': 1_200,
'spawnToFirstCardMs.p90': 1_360,
});
assert.deepEqual(entry.unavailable, { 'renderer.y': 'not measurable' });
assert.equal(entry.iterations.length, 6);
});
test('reports the maximum and flags instability when measured counters disagree', () => {
const entry = summarizeJourneyIterations(
[
iteration(0, { a: 3, b: 0.5 }, { w: 1 }),
iteration(1, { a: 5, b: 0.5 }, { w: 2 }),
iteration(2, { a: 4, b: 0.5 }, { w: 3 }),
],
{}
);
assert.deepEqual(entry.counters, { a: 5, b: 0.5 });
assert.deepEqual(entry.counterStability['a'], {
stable: false,
values: [3, 5, 4],
});
assert.deepEqual(entry.counterStability['b'], {
stable: true,
values: [0.5, 0.5, 0.5],
});
assert.deepEqual(entry.wallClock, { 'w.p50': 2, 'w.p90': 2.8 });
});
test('rejects runs that cannot produce an exact summary', () => {
assert.throws(
() =>
summarizeJourneyIterations(
[iteration(0, { a: 1 }, { w: 1 }, true)],
{}
),
/no-measured-iterations/
);
assert.throws(
() =>
summarizeJourneyIterations(
[
iteration(0, { a: 1 }, { w: 1 }),
iteration(1, { b: 1 }, { w: 1 }),
],
{}
),
/counter-set-mismatch-iteration-1/
);
assert.throws(
() =>
summarizeJourneyIterations(
[
iteration(0, { a: 1 }, { w: 1 }),
iteration(1, { a: 1 }, { v: 1 }),
],
{}
),
/wall-clock-set-mismatch-iteration-1/
);
assert.throws(
() =>
summarizeJourneyIterations(
[iteration(0, { a: Number.NaN }, { w: 1 })],
{}
),
/counter-not-finite-a/
);
const duplicate = { ...iteration(1, { a: 1 }, { w: 1 }), pid: 100 };
assert.throws(
() =>
summarizeJourneyIterations(
[iteration(0, { a: 1 }, { w: 1 }), duplicate],
{}
),
/duplicate-pid/
);
});
test('writes the summary below dist/performance/journeys/<timestamp> and never overwrites', async () => {
const date = new Date('2026-09-26T10:49:12.345Z');
assert.equal(formatJourneyOutputTimestamp(date), '20260926T104912Z');
assert.throws(
() => formatJourneyOutputTimestamp(new Date('nope')),
/invalid-date/
);
const root = await mkdtemp(join(tmpdir(), 'iptvnator-journey-summary-'));
try {
const summaryPath = resolveJourneySummaryPath(root, date);
assert.equal(
summaryPath,
join(
root,
'dist',
'performance',
'journeys',
'20260926T104912Z',
'summary.json'
)
);
const summary: JourneySummary = {
generatedAt: date.toISOString(),
harness: {
arch: 'arm64',
ci: false,
electron: '43.0.0',
electronMain: 'dist/apps/electron-backend/main.js',
measuredIterations: 1,
node: 'v22',
platform: 'darwin',
rendererIndex: 'dist/apps/web/index.html',
warmupIterations: 0,
},
journeys: {
launch: summarizeJourneyIterations(
[iteration(0, { a: 1 }, { w: 1 })],
{}
),
},
schemaVersion: JOURNEY_SUMMARY_SCHEMA_VERSION,
};
await writeJourneySummary(summaryPath, summary);
const written = JSON.parse(
await readFile(summaryPath, 'utf8')
) as JourneySummary;
assert.equal(written.journeys['launch']?.counters['a'], 1);
assert.equal(written.journeys['launch']?.wallClock['w.p50'], 1);
await assert.rejects(
writeJourneySummary(summaryPath, summary),
/EEXIST/
);
} finally {
await rm(root, { force: true, recursive: true });
}
});
@@ -0,0 +1,204 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
/**
* Journey summary schema written to
* `dist/performance/journeys/<timestamp>/summary.json`.
*
* `journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are
* plain numbers so the ratchet checker in `tools/performance/` can compare
* them with the committed baselines. Everything else is evidence.
*/
export const JOURNEY_SUMMARY_SCHEMA_VERSION = 1;
export interface JourneyIterationRecord {
readonly counters: Readonly<Record<string, number>>;
readonly evidence: Readonly<Record<string, unknown>>;
readonly index: number;
readonly pid: number;
readonly wallClock: Readonly<Record<string, number>>;
readonly warmup: boolean;
}
export interface JourneyCounterStability {
readonly stable: boolean;
readonly values: readonly number[];
}
export interface JourneySummaryEntry {
readonly counterStability: Readonly<
Record<string, JourneyCounterStability>
>;
readonly counters: Readonly<Record<string, number>>;
readonly iterations: readonly JourneyIterationRecord[];
readonly unavailable: Readonly<Record<string, string>>;
readonly wallClock: Readonly<Record<string, number>>;
}
export interface JourneySummaryHarness {
readonly arch: string;
readonly ci: boolean;
readonly electron: string;
readonly electronMain: string;
readonly measuredIterations: number;
readonly node: string;
readonly platform: string;
readonly rendererIndex: string;
readonly warmupIterations: number;
}
export interface JourneySummary {
readonly generatedAt: string;
readonly harness: JourneySummaryHarness;
readonly journeys: Readonly<Record<string, JourneySummaryEntry>>;
readonly schemaVersion: number;
}
/** Linear-interpolation percentile, the method `performance-statistics.ts` uses. */
export function percentile(values: readonly number[], rank: number): number {
if (
values.length === 0 ||
!Number.isFinite(rank) ||
rank < 0 ||
rank > 100
) {
throw new Error('journey-summary-percentile-input');
}
const sorted = [...values].sort((left, right) => left - right);
if (sorted.length === 1) {
return sorted[0] ?? 0;
}
const position = ((sorted.length - 1) * rank) / 100;
const lowerIndex = Math.floor(position);
const upperIndex = Math.ceil(position);
const lower = sorted[lowerIndex] ?? 0;
const upper = sorted[upperIndex] ?? lower;
return lower + (upper - lower) * (position - lowerIndex);
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
function assertSameKeys(
expected: readonly string[],
actual: Readonly<Record<string, number>>,
kind: string,
index: number
): void {
const keys = Object.keys(actual).sort();
if (
keys.length !== expected.length ||
keys.some((key, position) => key !== expected[position])
) {
throw new Error(
`journey-summary-${kind}-set-mismatch-iteration-${index}`
);
}
for (const key of keys) {
if (!Number.isFinite(actual[key])) {
throw new Error(`journey-summary-${kind}-not-finite-${key}`);
}
}
}
/**
* Counters are exact: the summary carries the value shared by every measured
* iteration. When iterations disagree the maximum is reported (a ratchet
* must never read a value lower than what a run produced) and the
* disagreement is recorded in `counterStability` so the counter is not
* promoted to a guardrail until it is deterministic.
*/
export function summarizeJourneyIterations(
iterations: readonly JourneyIterationRecord[],
unavailable: Readonly<Record<string, string>>
): JourneySummaryEntry {
const measured = iterations.filter((iteration) => !iteration.warmup);
if (measured.length === 0) {
throw new Error('journey-summary-no-measured-iterations');
}
const pids = new Set(iterations.map((iteration) => iteration.pid));
if (pids.size !== iterations.length) {
throw new Error('journey-summary-duplicate-pid');
}
const first = measured[0] as JourneyIterationRecord;
const counterNames = Object.keys(first.counters).sort();
const wallClockNames = Object.keys(first.wallClock).sort();
for (const iteration of measured) {
assertSameKeys(
counterNames,
iteration.counters,
'counter',
iteration.index
);
assertSameKeys(
wallClockNames,
iteration.wallClock,
'wall-clock',
iteration.index
);
}
const counters: Record<string, number> = {};
const counterStability: Record<string, JourneyCounterStability> = {};
for (const name of counterNames) {
const values = measured.map(
(iteration) => iteration.counters[name] as number
);
counters[name] = Math.max(...values);
counterStability[name] = Object.freeze({
stable: values.every((value) => value === values[0]),
values: Object.freeze(values),
});
}
const wallClock: Record<string, number> = {};
for (const name of wallClockNames) {
const values = measured.map(
(iteration) => iteration.wallClock[name] as number
);
wallClock[`${name}.p50`] = roundTenth(percentile(values, 50));
wallClock[`${name}.p90`] = roundTenth(percentile(values, 90));
}
return Object.freeze({
counterStability: Object.freeze(counterStability),
counters: Object.freeze(counters),
iterations: Object.freeze([...iterations]),
unavailable: Object.freeze({ ...unavailable }),
wallClock: Object.freeze(wallClock),
});
}
/** `YYYYMMDDTHHMMSSZ`, the timestamp form the other benchmarks use. */
export function formatJourneyOutputTimestamp(date: Date): string {
if (Number.isNaN(date.getTime())) {
throw new Error('journey-summary-invalid-date');
}
return date
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z');
}
export function resolveJourneySummaryPath(
repositoryRoot: string,
date: Date = new Date()
): string {
return join(
repositoryRoot,
'dist',
'performance',
'journeys',
formatJourneyOutputTimestamp(date),
'summary.json'
);
}
export async function writeJourneySummary(
summaryPath: string,
summary: JourneySummary
): Promise<void> {
await mkdir(dirname(summaryPath), { recursive: true });
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx',
});
}
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyRendererProbeState } from './journey-renderer-probe';
import {
LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS,
toLaunchIterationRecord,
type LaunchJourneyMeasurement,
} from './launch-journey-record';
function measurement(
overrides: Partial<LaunchJourneyMeasurement> = {}
): LaunchJourneyMeasurement {
const renderer: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'unavailable-ng-global-not-published',
layoutShift: true,
longTask: true,
observedTarget: 'document',
},
counters: {
domMutations: 480,
layoutShiftScore: 0.123456789,
longTasks: 2,
},
final: true,
firstCardPaintEpochMs: 2_650,
installed: {
bridgePresent: true,
documentElementPresent: false,
epochMs: 1_200,
readyState: 'loading',
scriptCount: 0,
},
invalidReasons: [],
journey: 'launch',
longTaskDurationsMs: [71.26, 120.04],
navigation: {
domContentLoadedEpochMs: 1_300,
loadEventEndEpochMs: 1_400.26,
},
schemaVersion: 1,
sentinel: { epochMs: 2_601, status: 'sent' },
terminal: {
cardTag: 'div',
cardTestId: 'dashboard-recent-sources-rail-card',
epochMs: 2_600.04,
pathname: '/dist/apps/web/workspace/dashboard',
},
};
const ipc: JourneyMainIpcCaptureState = {
callsAfterSentinel: 3,
callsBeforeSentinel: 14,
callsByMethod: { dbGetAppPlaylists: 1, getSettings: 13 },
installedEpochMs: 1_100,
malformedEvents: 0,
processStartEpochMs: 900,
senderIds: [1],
sentinel: { occurrences: 1, receivedEpochMs: 2_602 },
};
return {
electronVersion: '43.3.0',
ipc,
pid: 4242,
renderer,
spawnEpochMs: 1_000,
...overrides,
};
}
test('maps the probe and IPC capture to exact counters and spawn-relative wall-clock', () => {
const record = toLaunchIterationRecord(2, false, measurement());
assert.equal(record.index, 2);
assert.equal(record.warmup, false);
assert.equal(record.pid, 4242);
assert.deepEqual(record.counters, {
'renderer.domMutationsToFirstCard': 480,
'renderer.ipcCallsToFirstCard': 14,
'renderer.layoutShiftScore': 0.1235,
'renderer.longTasks': 2,
});
assert.deepEqual(record.wallClock, {
spawnToDidFinishLoadMs: 400.3,
spawnToFirstCardMs: 1_600,
});
assert.deepEqual(record.evidence['ipcCallsByMethod'], {
dbGetAppPlaylists: 1,
getSettings: 13,
});
assert.deepEqual(record.evidence['longTaskDurationsMs'], [71.3, 120]);
assert.equal(record.evidence['ipcCallsAfterFirstCard'], 3);
assert.deepEqual(record.evidence['epochs'], {
firstCard: 2_600.04,
firstCardPaint: 2_650,
loadEventEnd: 1_400.26,
mainIpcCaptureInstalled: 1_100,
mainProcessStart: 900,
rendererProbeInstalled: 1_200,
spawn: 1_000,
});
});
test('rejects measurements whose clocks or probes are inconsistent', () => {
const base = measurement();
assert.throws(
() =>
toLaunchIterationRecord(0, false, {
...base,
renderer: { ...base.renderer, navigation: null },
}),
/incomplete-probe/
);
assert.throws(
() =>
toLaunchIterationRecord(0, false, { ...base, spawnEpochMs: 2_700 }),
/clock-order/
);
assert.throws(
() =>
toLaunchIterationRecord(0, false, {
...base,
renderer: {
...base.renderer,
capabilities: {
...base.renderer.capabilities,
changeDetectionTicks: 'hook-present-not-counted',
},
},
}),
/cd-hook-hook-present-not-counted/
);
});
test('names the counters the harness cannot measure yet', () => {
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS).sort(), [
'main.sqlStatementsBeforeReadyToShow',
'renderer.cdTicksToFirstCard',
]);
});
@@ -0,0 +1,117 @@
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyRendererProbeState } from './journey-renderer-probe';
import type { JourneyIterationRecord } from './journey-summary';
/**
* Maps one measured launch (renderer probe + main IPC capture) to the
* journey summary's iteration record for J1 "Launch to usable".
*/
export const LAUNCH_JOURNEY_ID = 'launch';
export const LAUNCH_JOURNEY_COUNTER = {
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
LONG_TASKS: 'renderer.longTasks',
} as const;
export const LAUNCH_JOURNEY_WALL_CLOCK = {
SPAWN_TO_DID_FINISH_LOAD: 'spawnToDidFinishLoadMs',
SPAWN_TO_FIRST_CARD: 'spawnToFirstCardMs',
} as const;
/**
* Counters the plan lists for J1 that this harness cannot measure without
* production changes. They are reported instead of faked.
*/
export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
Record<string, string>
> = Object.freeze({
'main.sqlStatementsBeforeReadyToShow':
'SQL statements are only visible as worker stdout trace lines, which are forwarded asynchronously; plan item A2 adds a countable channel.',
'renderer.cdTicksToFirstCard':
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
});
export interface LaunchJourneyMeasurement {
readonly electronVersion: string;
readonly ipc: JourneyMainIpcCaptureState;
readonly pid: number;
readonly renderer: JourneyRendererProbeState;
readonly spawnEpochMs: number;
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
export function toLaunchIterationRecord(
index: number,
warmup: boolean,
measurement: LaunchJourneyMeasurement
): JourneyIterationRecord {
const { ipc, renderer, spawnEpochMs } = measurement;
if (renderer.terminal === null || renderer.navigation === null) {
throw new Error('launch-journey-record-incomplete-probe');
}
const spawnToFirstCardMs = renderer.terminal.epochMs - spawnEpochMs;
const spawnToDidFinishLoadMs =
renderer.navigation.loadEventEndEpochMs - spawnEpochMs;
if (
spawnToDidFinishLoadMs <= 0 ||
spawnToFirstCardMs <= spawnToDidFinishLoadMs
) {
throw new Error('launch-journey-record-clock-order');
}
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
) {
throw new Error(
`launch-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}`
);
}
return Object.freeze({
counters: Object.freeze({
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]:
Math.round(renderer.counters.layoutShiftScore * 10_000) /
10_000,
[LAUNCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
}),
evidence: Object.freeze({
capabilities: renderer.capabilities,
electronVersion: measurement.electronVersion,
epochs: Object.freeze({
firstCard: renderer.terminal.epochMs,
firstCardPaint: renderer.firstCardPaintEpochMs,
loadEventEnd: renderer.navigation.loadEventEndEpochMs,
mainIpcCaptureInstalled: ipc.installedEpochMs,
mainProcessStart: ipc.processStartEpochMs,
rendererProbeInstalled: renderer.installed.epochMs,
spawn: spawnEpochMs,
}),
firstCard: Object.freeze({
cardTag: renderer.terminal.cardTag,
cardTestId: renderer.terminal.cardTestId,
pathname: renderer.terminal.pathname,
}),
ipcCallsAfterFirstCard: ipc.callsAfterSentinel,
ipcCallsByMethod: ipc.callsByMethod,
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
observedTarget: renderer.capabilities.observedTarget,
}),
index,
pid: measurement.pid,
wallClock: Object.freeze({
[LAUNCH_JOURNEY_WALL_CLOCK.SPAWN_TO_DID_FINISH_LOAD]: roundTenth(
spawnToDidFinishLoadMs
),
[LAUNCH_JOURNEY_WALL_CLOCK.SPAWN_TO_FIRST_CARD]:
roundTenth(spawnToFirstCardMs),
}),
warmup,
});
}
+183
View File
@@ -0,0 +1,183 @@
# Performance journeys and the CI ratchet
IPTVnator measures performance through a small set of everyday user journeys.
Each journey has deterministic counters that are asserted exactly, and
wall-clock timings that are recorded as evidence. Counters are ratcheted in CI:
a committed baseline may only be lowered, and only with the measured output as
evidence. This document is the contract for that loop. The journey harness
lives in `apps/electron-backend-e2e/src/journeys` and
`apps/electron-backend-e2e/src/performance/journey-*.ts`; the ratchet scripts
live in `tools/performance/`.
## Journeys
| Journey | Start | End |
| ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------- |
| J1 `launch` | Electron process spawn | first playlist or portal card rendered on `/workspace`, inline splash removed |
| J2 `open-source` | click on a portal card | live category list and first channel page painted |
| J3 `playback` | click on a channel | HTML5 `playing` event |
| J4 `search` | six-character query typed into global search | results list settled |
J1 is instrumented by the launch benchmark below. J2 to J4 follow the plan in
`.plans/` and are added one thread at a time; each thread names its journey
and counter in the PR description.
## Running the journeys
```bash
pnpm run perf:journeys
```
The script runs the Nx target `electron-backend-e2e:journeys`, which builds the
`electron-performance` configuration of the Electron app and the renderer
first, starts the Xtream mock server on the dedicated loopback port
`127.0.0.1:3231` (override with `IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT`), and runs
`playwright.journeys.config.ts` with one worker. Each run writes one file:
```
dist/performance/journeys/<YYYYMMDDTHHMMSSZ>/summary.json
```
The file is never overwritten; a second run in the same second fails instead.
`IPTVNATOR_JOURNEY_MEASURED_ITERATIONS` lowers the five measured iterations
for a quick local check; the warm-up iteration always runs. Numbers from a
laptop are previews: the Linux CI runner is the canonical measurer for
baselines, as it is for `renderer.initialBytes`.
## J1 `launch`: launch to usable
The profile holds one M3U source and one Xtream portal, both served by the
Xtream mock (`/playlist.m3u` and `player_api.php` on the same origin). The
profile is seeded once per run through the app's own "Add playlist" dialogs,
then every iteration copies that seeded data directory into a fresh temporary
directory and spawns a fresh Electron process on it. One warm-up iteration is
recorded but excluded from the summary; five measured iterations follow. The
app lands on `/workspace/dashboard`, so the first card is a card of the
"Recent sources" rail; an `app-playlist-item` row on `/workspace/sources`
also ends the journey for profiles that disable the dashboard.
The journey ends at the first `MutationObserver` batch in which all of the
following hold: the location is below `/workspace`, `#initial-splash` is no
longer in the DOM, and a source card has a non-empty client rect. Counters are
frozen at that microtask checkpoint, so bridge calls and mutations issued
later in the same task are included and everything after it is not.
Two probes are injected from the test side; production code is not changed:
- `journey-renderer-probe.ts` is registered with `addInitScript` right after
`electron.launch`, before the window exists, and records that it ran while
the document was still `loading` with zero scripts. It emits one JSON blob
under `window.__iptvnatorJourneyProbe`.
- `journey-main-ipc-capture.ts` subscribes to the preload's renderer-API trace
channel (`IPTVNATOR_DEBUG_TRACE_EVENT`, enabled with
`IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate` and records that it
was installed before the renderer probe ran.
### Counters
| Counter | Source |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsToFirstCard` | `start` trace events the preload emits for every bridge invocation (listener registrations `on*`/`remove*` excluded, as in `wrapElectronApi`). The renderer probe fires one sentinel `dbGetAppPlaylist('__iptvnator-journey-sentinel__')` at the terminal moment; renderer-to-main IPC is ordered, so events before the sentinel are the exact count. |
| `renderer.domMutationsToFirstCard` | `MutationRecord`s (not callback batches) from a `MutationObserver` on the document element with `childList`, `attributes`, `characterData` and `subtree`. When the init script runs before `<html>` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. |
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to four decimals, up to the first frame painted after the terminal batch. |
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same frame. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
Counters are exact: the summary carries the value shared by every measured
iteration. When iterations disagree, the summary reports the maximum and marks
the counter `stable: false` under `counterStability`; such a counter is not
promoted to a guardrail until it is deterministic.
Two counters from the plan are listed under `unavailable` with the reason
instead of being faked:
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
scripts, which sets `ngDevMode` to false, so Angular does not publish
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
terminal moment and the record refuses a build where the hook exists but was
not counted.
- `main.sqlStatementsBeforeReadyToShow`: SQL statements are only visible as
worker-thread trace lines on stdout, which Node forwards asynchronously, so
they cannot be ordered against `ready-to-show`. Plan item A2 adds a channel
that can be counted.
### Wall-clock
| Entry | Derivation |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spawnToDidFinishLoadMs.p50/.p90` | `performance.timeOrigin + loadEventEnd` of the navigation entry (the main frame's `load`, which is what `did-finish-load` reports) minus the test-side timestamp taken just before `electron.launch`. |
| `spawnToFirstCardMs.p50/.p90` | Terminal epoch of the renderer probe minus the same spawn timestamp. |
Percentiles use linear interpolation over the five measured iterations. The
spawn timestamp includes Playwright's own launch overhead: Playwright holds
`app.whenReady()` until its CDP session is attached, so absolute values are
larger than a bare launch. They are comparable between runs of the same
harness, which is what the ratchet needs. The main process start
(`Date.now() - process.uptime()`) is recorded per iteration under
`evidence.epochs` for cross-checks.
### Summary schema
```json
{
"schemaVersion": 1,
"generatedAt": "2026-09-26T11:02:14.318Z",
"harness": {
"platform": "darwin",
"electron": "43.3.0",
"measuredIterations": 5,
"warmupIterations": 1
},
"journeys": {
"launch": {
"counters": { "renderer.ipcCallsToFirstCard": 12 },
"counterStability": {
"renderer.ipcCallsToFirstCard": {
"stable": true,
"values": [12, 12, 12, 12, 12]
}
},
"wallClock": {
"spawnToFirstCardMs.p50": 1234.5,
"spawnToFirstCardMs.p90": 1300.1
},
"unavailable": { "renderer.cdTicksToFirstCard": "reason" },
"iterations": [
{
"index": 0,
"warmup": true,
"pid": 1,
"counters": {},
"wallClock": {},
"evidence": {}
}
]
}
}
}
```
`journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are plain
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
`tools/performance/journey-baselines.json`. A J1 baseline is added once the
numbers are stable on the CI runner; until then the summary is evidence only.
## Adding a journey
1. Add `apps/electron-backend-e2e/src/journeys/<journey>.journey.ts`. Seed the
profile through the app's dialogs, spawn a fresh process per iteration
with `measureLaunchJourney` as the model, and drive the journey's start
action with Playwright.
2. Give the journey its own probe options (`cardSelector`, `routeFragment`,
terminal condition) or extend `journey-renderer-probe.ts` when the end
condition is not "an element became visible". Keep the probe
self-contained: Playwright serializes it with `toString()`.
3. Map the measurement to a `JourneyIterationRecord` in a
`<journey>-journey-record.ts` under `src/performance/`; name counters
`renderer.*` or `main.*`, and list counters you cannot measure under
`unavailable` with the reason.
4. Add the journey under `journeys.<id>` in the summary through
`summarizeJourneyIterations`; the schema needs no change.
5. Cover the probe with jsdom fixtures and the record and summary code with
`node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`).
6. Validate a counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
+1
View File
@@ -120,6 +120,7 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
| Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` |
| Electron flows | `pnpm nx run electron-backend-e2e:e2e` |
| VOD multi-source | `pnpm nx run electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` |
| Performance journeys | `pnpm run perf:journeys` (see [performance journeys](performance-journeys.md)) |
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
+1
View File
@@ -14,6 +14,7 @@ are not prerequisites for reading repository contracts.
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, benchmark probes and the ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance` | [Performance journeys](../architecture/performance-journeys.md) | None |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
+3
View File
@@ -71,6 +71,7 @@
"serve:website": "nx serve website",
"build:website": "nx build website",
"i18n:check": "node tools/i18n/check-drift.mjs",
"perf:journeys": "nx run electron-backend-e2e:journeys",
"agents:validate": "node tools/skills/validate-agent-guidance.mjs",
"skills:validate": "node tools/skills/validate-repository-skills.mjs",
"release:artwork:dry-run": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --dry-run",
@@ -193,6 +194,7 @@
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/jest": "^30.0.0",
"@types/jsdom": "21.1.7",
"@types/mocha": "9.0.0",
"@types/node": "20.19.9",
"@types/proxy-from-env": "1.0.4",
@@ -226,6 +228,7 @@
"jest-environment-node": "^30.5.1",
"jest-preset-angular": "17.0.0",
"jest-util": "^30.5.1",
"jsdom": "26.1.0",
"jsonc-eslint-parser": "^2.1.0",
"material-design-icons-iconfont": "6.7.0",
"mrmime": "2.0.1",
+6
View File
@@ -338,6 +338,9 @@ importers:
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/jsdom':
specifier: 21.1.7
version: 21.1.7
'@types/mocha':
specifier: 9.0.0
version: 9.0.0
@@ -437,6 +440,9 @@ importers:
jest-util:
specifier: ^30.5.1
version: 30.5.1
jsdom:
specifier: 26.1.0
version: 26.1.0
jsonc-eslint-parser:
specifier: ^2.1.0
version: 2.4.2