diff --git a/apps/electron-backend-e2e/playwright.journeys.config.ts b/apps/electron-backend-e2e/playwright.journeys.config.ts index c5721281f..02f158d43 100644 --- a/apps/electron-backend-e2e/playwright.journeys.config.ts +++ b/apps/electron-backend-e2e/playwright.journeys.config.ts @@ -6,7 +6,9 @@ import { defineConfig } from '@playwright/test'; * 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. + * 3211 cannot be reused by accident. Locally a server left behind by an + * earlier run on that port is reused (its fixtures are deterministic); CI + * always starts its own. */ const xtreamMockPort = process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231'; @@ -28,7 +30,7 @@ export default defineConfig({ HOST: '127.0.0.1', PORT: xtreamMockPort, }, - reuseExistingServer: false, + reuseExistingServer: !process.env['CI'], url: `http://127.0.0.1:${xtreamMockPort}/health`, }, workers: 1, diff --git a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts new file mode 100644 index 000000000..9fe311cb1 --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts @@ -0,0 +1,77 @@ +import type { ElectronApplication } from '@playwright/test'; + +/** + * Test-side client for the main-process gate in + * `../performance/journey-renderer-gate.cjs`. + */ +export const JOURNEY_RENDERER_GATE_KEY = '__iptvnatorJourneyGate'; + +export interface JourneyRendererGateState { + readonly blankLoadedEpochMs: number | null; + readonly errors: readonly string[]; + readonly gatedEpochMs: number | null; + readonly gatedMethod: string | null; + readonly passThroughLoads: number; + readonly releasedEpochMs: number | null; + readonly timedOut: boolean; +} + +export async function readJourneyRendererGate( + electronApp: ElectronApplication, + gateKey: string, + action: 'read' | 'release' +): Promise { + const state = await electronApp.evaluate( + (_electron, input) => { + const gate = (globalThis as unknown as Record)[ + input.gateKey + ] as { release(): unknown; state: unknown } | undefined; + if (!gate) { + return null; + } + const result = + input.action === 'release' ? gate.release() : gate.state; + return JSON.parse(JSON.stringify(result)) as unknown; + }, + { action, gateKey } + ); + if (state === null) { + throw new Error('journey-renderer-gate-not-installed'); + } + return state as JourneyRendererGateState; +} + +/** + * The gate proves the ordering the counters rely on: the real document was + * loaded once, only after the test released it, and the renderer probe ran + * after the release (so it was registered before that document existed). + */ +export function assertJourneyRendererGate( + gate: JourneyRendererGateState, + rendererProbeInstalledEpochMs: number +): JourneyRendererGateState { + if (gate.timedOut) { + throw new Error('journey-renderer-gate-timed-out'); + } + if (gate.errors.length > 0) { + throw new Error( + `journey-renderer-gate-errors: ${gate.errors.join(', ')}` + ); + } + if ( + gate.gatedEpochMs === null || + gate.blankLoadedEpochMs === null || + gate.releasedEpochMs === null + ) { + throw new Error('journey-renderer-gate-incomplete'); + } + if (gate.passThroughLoads !== 0) { + throw new Error( + `journey-renderer-gate-extra-loads-${gate.passThroughLoads}` + ); + } + if (rendererProbeInstalledEpochMs < gate.releasedEpochMs) { + throw new Error('journey-renderer-gate-probe-before-release'); + } + return gate; +} diff --git a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts index ef601b13c..b3cdf762c 100644 --- a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts @@ -1,8 +1,8 @@ import { cp, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; -import { _electron as electron } from '@playwright/test'; +import { _electron as electron, type Page } from '@playwright/test'; import { captureElectronProcess } from '../electron-process-lifecycle'; import { @@ -29,6 +29,11 @@ import { installJourneyRendererProbe, waitForJourneyRendererProbe, } from '../performance/journey-renderer-probe'; +import { + assertJourneyRendererGate, + JOURNEY_RENDERER_GATE_KEY, + readJourneyRendererGate, +} from './journey-renderer-gate-client'; import type { LaunchJourneyMeasurement } from '../performance/launch-journey-record'; /** @@ -40,6 +45,11 @@ import type { LaunchJourneyMeasurement } from '../performance/launch-journey-rec 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}`; +/** Main-process hook loaded with `-r`; see journey-renderer-gate.cjs. */ +export const JOURNEY_RENDERER_GATE_PATH = resolve( + __dirname, + '../performance/journey-renderer-gate.cjs' +); function launchOptions( env: Record = {} @@ -98,10 +108,12 @@ export function removeLaunchJourneyProfile(directory: string): Promise { } /** - * 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. + * Spawns a fresh Electron process on a copy of the seeded profile. The gate + * hook parks the first renderer load on `about:blank`, which gives Playwright + * a page to attach the renderer probe to; the main-process IPC capture is + * installed next, and only then is the real load released. Both captures are + * therefore in place before the renderer runs any script, and the probe, + * capture and gate records still prove it. */ export async function measureLaunchJourney( templateDirectory: string, @@ -116,28 +128,51 @@ export async function measureLaunchJourney( dataDirectory, launchOptions({ IPTVNATOR_TRACE_IPC: '1' }) ); - const args = buildElectronLaunchArgs(); + const args = buildElectronLaunchArgs([ + '-r', + JOURNEY_RENDERER_GATE_PATH, + ]); const spawnEpochMs = Date.now(); const electronApp = await electron.launch({ args, env }); captureElectronProcess(electronApp); try { const probeOptions = createLaunchJourneyProbeOptions(); - await installJourneyRendererProbe( - electronApp.context(), - probeOptions - ); + // The gate parks the window on about:blank, so this resolves + // before the real document exists. + const mainWindow = await electronApp.firstWindow(); + if (mainWindow.url() !== 'about:blank') { + throw new Error( + `journey-renderer-gate-missing: first document is ${mainWindow.url()}` + ); + } + await installJourneyRendererProbe(mainWindow, 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 gate = await readJourneyRendererGate( + electronApp, + JOURNEY_RENDERER_GATE_KEY, + 'release' + ); + // The page object is still on about:blank; wait for the real + // document to commit before touching its execution context. + await mainWindow.waitForURL((url) => url.href !== 'about:blank', { + timeout: timeoutMs, + waitUntil: 'commit', + }); + await assertJourneyRendererProbeInstalled( + mainWindow, + probeOptions.stateKey + ); const renderer = await waitForJourneyRendererProbe( mainWindow, probeOptions.stateKey, timeoutMs ); + assertJourneyRendererGate(gate, renderer.installed.epochMs); const ipc = await readJourneyMainIpcCapture( electronApp, JOURNEY_MAIN_IPC_STATE_KEY, @@ -151,6 +186,7 @@ export async function measureLaunchJourney( ); return { electronVersion, + gate, ipc, pid: electronApp.process().pid ?? -1, renderer, @@ -166,3 +202,18 @@ export async function measureLaunchJourney( await removeDirectory(dataDirectory); } } + +async function assertJourneyRendererProbeInstalled( + mainWindow: Page, + stateKey: string +): Promise { + const installed = await mainWindow.evaluate( + (key) => + (globalThis as unknown as Record)[key] !== + undefined, + stateKey + ); + if (!installed) { + throw new Error('journey-renderer-probe-not-installed'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts new file mode 100644 index 000000000..77a461533 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + assertJourneyRendererGate, + JOURNEY_RENDERER_GATE_KEY, + type JourneyRendererGateState, +} from '../journeys/journey-renderer-gate-client'; + +function gate( + overrides: Partial = {} +): JourneyRendererGateState { + return { + blankLoadedEpochMs: 1_050, + errors: [], + gatedEpochMs: 1_020, + gatedMethod: 'loadFile', + passThroughLoads: 0, + releasedEpochMs: 1_150, + timedOut: false, + ...overrides, + }; +} + +test('the client and the hook agree on the global key', () => { + assert.equal(JOURNEY_RENDERER_GATE_KEY, '__iptvnatorJourneyGate'); +}); + +test('accepts a gate that held the load until the test released it', () => { + assert.equal( + assertJourneyRendererGate(gate(), 1_200).releasedEpochMs, + 1_150 + ); +}); + +test('rejects gates that cannot prove the probe preceded the document', () => { + assert.throws( + () => assertJourneyRendererGate(gate({ timedOut: true }), 1_200), + /timed-out/ + ); + assert.throws( + () => + assertJourneyRendererGate( + gate({ errors: ['blank-failed'] }), + 1_200 + ), + /errors: blank-failed/ + ); + assert.throws( + () => assertJourneyRendererGate(gate({ releasedEpochMs: null }), 1_200), + /incomplete/ + ); + assert.throws( + () => assertJourneyRendererGate(gate({ passThroughLoads: 1 }), 1_200), + /extra-loads-1/ + ); + assert.throws( + () => assertJourneyRendererGate(gate(), 1_100), + /probe-before-release/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs new file mode 100644 index 000000000..bac5ee866 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs @@ -0,0 +1,96 @@ +'use strict'; + +/** + * Main-process gate for the performance journeys, loaded into Electron with + * `-r` from the test side (the same mechanism Playwright uses for its own + * loader). It is not part of the application build. + * + * Problem: Playwright resolves `electron.launch()` while the app is already + * creating its window, and an init script registered afterwards races the + * renderer's first document. Electron reports no page to Playwright until a + * navigation commits, so the gate makes the first `loadFile`/`loadURL` + * navigate to `about:blank` first. That gives Playwright a page object the + * test can attach `addInitScript` to; the real load proceeds only after the + * test calls `globalThis.__iptvnatorJourneyGate.release()`. A safety timeout + * releases the gate on its own and records that it did, so a broken test + * cannot hang the app; the journey treats a timed-out gate as invalid. + */ +const GATE_KEY = '__iptvnatorJourneyGate'; +const DEFAULT_TIMEOUT_MS = 15000; + +function installJourneyRendererGate(BrowserWindow, target, options = {}) { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const now = options.now ?? (() => Date.now()); + const state = { + blankLoadedEpochMs: null, + errors: [], + gatedEpochMs: null, + gatedMethod: null, + passThroughLoads: 0, + releasedEpochMs: null, + timedOut: false, + }; + let releaseGate = null; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const timer = setTimeout(() => { + if (state.releasedEpochMs === null) { + state.timedOut = true; + state.releasedEpochMs = now(); + releaseGate(); + } + }, timeoutMs); + const api = { + release() { + if (state.releasedEpochMs === null) { + state.releasedEpochMs = now(); + clearTimeout(timer); + releaseGate(); + } + return state; + }, + state, + }; + Object.defineProperty(target, GATE_KEY, { + configurable: false, + enumerable: false, + value: api, + writable: false, + }); + for (const method of ['loadFile', 'loadURL']) { + const original = BrowserWindow.prototype[method]; + if (typeof original !== 'function') continue; + BrowserWindow.prototype[method] = async function gatedLoad(...args) { + if (state.gatedEpochMs !== null) { + state.passThroughLoads += 1; + return original.apply(this, args); + } + state.gatedEpochMs = now(); + state.gatedMethod = method; + try { + await this.webContents.loadURL('about:blank'); + state.blankLoadedEpochMs = now(); + } catch (error) { + state.errors.push( + error instanceof Error ? error.message : String(error) + ); + } + await gate; + return original.apply(this, args); + }; + } + return api; +} + +module.exports = { GATE_KEY, installJourneyRendererGate }; + +if ( + process.versions && + process.versions.electron && + !process.env['IPTVNATOR_JOURNEY_GATE_MANUAL'] +) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { BrowserWindow } = require('electron'); + installJourneyRendererGate(BrowserWindow, globalThis); +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts new file mode 100644 index 000000000..12cef8643 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +interface GateState { + blankLoadedEpochMs: number | null; + errors: string[]; + gatedEpochMs: number | null; + gatedMethod: string | null; + passThroughLoads: number; + releasedEpochMs: number | null; + timedOut: boolean; +} + +interface GateApi { + release(): GateState; + state: GateState; +} + +interface GateModule { + GATE_KEY: string; + installJourneyRendererGate( + browserWindow: { prototype: Record }, + target: Record, + options?: { now?: () => number; timeoutMs?: number } + ): GateApi; +} + +// The e2e project compiles to CommonJS, so the hook is loaded with require. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const gateModule = require('./journey-renderer-gate.cjs') as GateModule; + +function createFakeBrowserWindow(log: string[]) { + class FakeBrowserWindow { + webContents = { + loadURL: async (url: string) => { + log.push(`webContents.loadURL:${url}`); + }, + }; + async loadFile(file: string): Promise { + log.push(`loadFile:${file}`); + return `loaded:${file}`; + } + async loadURL(url: string): Promise { + log.push(`loadURL:${url}`); + return `loaded:${url}`; + } + } + return FakeBrowserWindow; +} + +function settle(): Promise { + return new Promise((resolve) => setTimeout(resolve, 5)); +} + +test('the module does not touch Electron when loaded outside it', () => { + assert.equal(typeof gateModule.installJourneyRendererGate, 'function'); + assert.equal(gateModule.GATE_KEY, '__iptvnatorJourneyGate'); + assert.equal( + (globalThis as Record)[gateModule.GATE_KEY], + undefined + ); +}); + +test('holds the first load behind about:blank until released, then passes later loads through', async () => { + const log: string[] = []; + const FakeBrowserWindow = createFakeBrowserWindow(log); + const target: Record = {}; + let clock = 100; + const api = gateModule.installJourneyRendererGate( + FakeBrowserWindow as unknown as { prototype: Record }, + target, + { now: () => clock++, timeoutMs: 60_000 } + ); + assert.equal(target[gateModule.GATE_KEY], api); + const window = new FakeBrowserWindow(); + const load = window.loadFile('index.html'); + await settle(); + assert.deepEqual(log, ['webContents.loadURL:about:blank']); + assert.equal(api.state.gatedMethod, 'loadFile'); + assert.equal(api.state.gatedEpochMs, 100); + assert.equal(api.state.blankLoadedEpochMs, 101); + assert.equal(api.state.releasedEpochMs, null); + + api.release(); + assert.equal(await load, 'loaded:index.html'); + assert.deepEqual(log, [ + 'webContents.loadURL:about:blank', + 'loadFile:index.html', + ]); + assert.equal(api.state.releasedEpochMs, 102); + assert.equal(api.state.timedOut, false); + + assert.equal( + await window.loadURL('http://localhost/'), + 'loaded:http://localhost/' + ); + assert.equal(api.state.passThroughLoads, 1); + assert.equal(api.release().releasedEpochMs, 102); +}); + +test('releases itself after the timeout and records it', async () => { + const log: string[] = []; + const FakeBrowserWindow = createFakeBrowserWindow(log); + const api = gateModule.installJourneyRendererGate( + FakeBrowserWindow as unknown as { prototype: Record }, + {}, + { timeoutMs: 10 } + ); + const window = new FakeBrowserWindow(); + assert.equal( + await window.loadURL('http://localhost/'), + 'loaded:http://localhost/' + ); + assert.equal(api.state.timedOut, true); + assert.equal(typeof api.state.releasedEpochMs, 'number'); +}); + +test('records a failed about:blank navigation and still loads after release', async () => { + class BrokenBrowserWindow { + webContents = { + loadURL: async () => { + throw new Error('blank-failed'); + }, + }; + async loadFile(file: string): Promise { + return `loaded:${file}`; + } + } + const api = gateModule.installJourneyRendererGate( + BrokenBrowserWindow as unknown as { + prototype: Record; + }, + {}, + { timeoutMs: 60_000 } + ); + const load = new BrokenBrowserWindow().loadFile('index.html'); + await settle(); + assert.deepEqual(api.state.errors, ['blank-failed']); + api.release(); + assert.equal(await load, 'loaded:index.html'); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index fab50749f..7dcc107d3 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -238,7 +238,7 @@ test('counts mutation records until the first card is visible after the splash i 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 () => { +test('sums layout shifts without recent input and counts long tasks over 50 ms up to the post-paint cutoff', async () => { const fixture = createFixture(); const [layoutShift, longTask] = fixture.observers as [ FakeObserver, @@ -283,12 +283,39 @@ test('sums layout shifts without recent input and counts long tasks over 50 ms u { duration: 300, entryType: 'longtask', startTime: now() + 60_000 } ); renderFirstCard(fixture); + await new Promise((resolve) => queueMicrotask(() => resolve(undefined))); + // Delivered after the terminal batch, before the post-paint cutoff. + assert.ok(fixture.rawState().terminal, 'terminal must be set'); + assert.equal(fixture.rawState().final, false); + layoutShift.emit([ + { + entryType: 'layout-shift', + hadRecentInput: false, + startTime: now(), + value: 0.125, + }, + { + entryType: 'layout-shift', + hadRecentInput: false, + startTime: now() + 60_000, + value: 7, + }, + ]); + longTask.emit([ + { duration: 64, entryType: 'longtask', startTime: now() }, + { duration: 500, entryType: 'longtask', startTime: now() + 60_000 }, + ]); 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]); + assert.ok( + (state.firstCardPaintEpochMs ?? 0) > + (state.terminal?.epochMs ?? Number.POSITIVE_INFINITY), + 'the cutoff is sampled after the terminal batch' + ); + assert.equal(state.counters.layoutShiftScore, 0.875); + assert.equal(state.counters.longTasks, 3); + assert.deepEqual(state.longTaskDurationsMs, [80, 64, 120]); layoutShift.emit([ { @@ -299,8 +326,8 @@ test('sums layout shifts without recent input and counts long tasks over 50 ms u }, ]); longTask.emit([{ duration: 99, entryType: 'longtask', startTime: now() }]); - assert.equal(fixture.state().counters.layoutShiftScore, 0.75); - assert.equal(fixture.state().counters.longTasks, 2); + assert.equal(fixture.state().counters.layoutShiftScore, 0.875); + assert.equal(fixture.state().counters.longTasks, 3); }); test('does not end while the splash is present, off the workspace route, or before a card is visible', async () => { diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 5b700e181..a790ac665 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -1,10 +1,12 @@ -import type { BrowserContext, Page } from '@playwright/test'; +import type { 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 + * The probe is injected from the test side through `addInitScript` while the + * journey gate (`journey-renderer-gate.cjs`) parks the window on + * `about:blank`, 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`. @@ -160,13 +162,22 @@ export function journeyRendererProbeScript( state.longTaskDurationsMs.push(entry.duration); } }; + // Entries delivered between the terminal batch and the post-paint + // cutoff wait here so the cutoff applies to them as well. + const pendingLayoutShifts: PerformanceEntry[] = []; + const pendingLongTasks: PerformanceEntry[] = []; const observe = ( type: string, - accept: (entries: readonly PerformanceEntry[], until: number) => void + accept: (entries: readonly PerformanceEntry[], until: number) => void, + pending: PerformanceEntry[] ): PerformanceObserver | null => { try { const observer = new PerformanceObserver((list) => { if (state.final) return; + if (state.terminal !== null) { + pending.push(...list.getEntries()); + return; + } accept(list.getEntries(), Number.POSITIVE_INFINITY); }); observer.observe({ type, buffered: true }); @@ -175,18 +186,32 @@ export function journeyRendererProbeScript( return null; } }; - const layoutShiftObserver = observe('layout-shift', acceptLayoutShift); - const longTaskObserver = observe('longtask', acceptLongTasks); + const layoutShiftObserver = observe( + 'layout-shift', + acceptLayoutShift, + pendingLayoutShifts + ); + const longTaskObserver = observe( + 'longtask', + acceptLongTasks, + pendingLongTasks + ); state.capabilities.layoutShift = layoutShiftObserver !== null; state.capabilities.longTask = longTaskObserver !== null; const finalize = (untilEpochMs: number): void => { if (layoutShiftObserver) { - acceptLayoutShift(layoutShiftObserver.takeRecords(), untilEpochMs); + acceptLayoutShift( + [...pendingLayoutShifts, ...layoutShiftObserver.takeRecords()], + untilEpochMs + ); layoutShiftObserver.disconnect(); } if (longTaskObserver) { - acceptLongTasks(longTaskObserver.takeRecords(), untilEpochMs); + acceptLongTasks( + [...pendingLongTasks, ...longTaskObserver.takeRecords()], + untilEpochMs + ); longTaskObserver.disconnect(); } state.firstCardPaintEpochMs = untilEpochMs; @@ -252,12 +277,12 @@ export function journeyRendererProbeScript( 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. + // A rAF callback runs before that frame's style, layout and paint, + // so the cutoff is sampled in a timer queued from it: by then the + // frame that paints the card has been committed, and the render + // task's own long task and layout shift fall inside the cutoff. requestAnimationFrame(() => { - const frameEpochMs = epoch(); - setTimeout(() => finalize(frameEpochMs), 0); + setTimeout(() => finalize(epoch()), 0); }); }); mutationObserver.observe(document.documentElement ?? document, { @@ -281,11 +306,15 @@ export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions { }; } +/** + * Registers the probe on a page that is still parked on `about:blank` by the + * journey gate, so it is guaranteed to run at the start of the next document. + */ export async function installJourneyRendererProbe( - context: BrowserContext, + page: Page, options: JourneyRendererProbeOptions ): Promise { - await context.addInitScript(journeyRendererProbeScript, options); + await page.addInitScript(journeyRendererProbeScript, options); } export async function waitForJourneyRendererProbe( diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index f6afd10d6..4820f1c60 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -61,6 +61,15 @@ function measurement( }; return { electronVersion: '43.3.0', + gate: { + blankLoadedEpochMs: 1_050, + errors: [], + gatedEpochMs: 1_020, + gatedMethod: 'loadFile', + passThroughLoads: 0, + releasedEpochMs: 1_150, + timedOut: false, + }, ipc, pid: 4242, renderer, @@ -77,7 +86,7 @@ test('maps the probe and IPC capture to exact counters and spawn-relative wall-c assert.deepEqual(record.counters, { 'renderer.domMutationsToFirstCard': 480, 'renderer.ipcCallsToFirstCard': 14, - 'renderer.layoutShiftScore': 0.1235, + 'renderer.layoutShiftScore': 0.123, 'renderer.longTasks': 2, }); assert.deepEqual(record.wallClock, { @@ -96,6 +105,8 @@ test('maps the probe and IPC capture to exact counters and spawn-relative wall-c loadEventEnd: 1_400.26, mainIpcCaptureInstalled: 1_100, mainProcessStart: 900, + rendererGateBlankLoaded: 1_050, + rendererGateReleased: 1_150, rendererProbeInstalled: 1_200, spawn: 1_000, }); diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index 046c5d9ab..14343fc0a 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -1,3 +1,4 @@ +import type { JourneyRendererGateState } from '../journeys/journey-renderer-gate-client'; import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; import type { JourneyRendererProbeState } from './journey-renderer-probe'; import type { JourneyIterationRecord } from './journey-summary'; @@ -35,6 +36,7 @@ export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< export interface LaunchJourneyMeasurement { readonly electronVersion: string; + readonly gate: JourneyRendererGateState; readonly ipc: JourneyMainIpcCaptureState; readonly pid: number; readonly renderer: JourneyRendererProbeState; @@ -77,8 +79,7 @@ export function toLaunchIterationRecord( 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, + Math.round(renderer.counters.layoutShiftScore * 1_000) / 1_000, [LAUNCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks, }), evidence: Object.freeze({ @@ -90,6 +91,8 @@ export function toLaunchIterationRecord( loadEventEnd: renderer.navigation.loadEventEndEpochMs, mainIpcCaptureInstalled: ipc.installedEpochMs, mainProcessStart: ipc.processStartEpochMs, + rendererGateBlankLoaded: measurement.gate.blankLoadedEpochMs, + rendererGateReleased: measurement.gate.releasedEpochMs, rendererProbeInstalled: renderer.installed.epochMs, spawn: spawnEpochMs, }), diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 14ef47f4f..05ae0e4e0 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -63,25 +63,34 @@ 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: +Three test-side pieces are injected; 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-renderer-gate.cjs` is loaded into the main process with `-r`, the + mechanism Playwright uses for its own loader. Playwright resolves + `electron.launch()` while the app is already creating its window, and + Electron reports no page until a navigation commits, so an init script + registered afterwards would race the first document. The gate makes the + first `loadFile` navigate to `about:blank` and holds the real load until + the test releases it. A 15 s safety timeout releases it on its own and the + iteration is then invalid. +- `journey-renderer-probe.ts` is registered with `addInitScript` on that + `about:blank` page, so it runs at the start of the real document. It + records that it ran while the document was still `loading` with zero + scripts and 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. + `IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate`, also before the + release. The record refuses an iteration whose gate timed out, saw a second + load, or released before the probe was in place. ### 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 `` 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. | +| 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 `` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. | +| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. | +| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. 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 @@ -109,9 +118,10 @@ instead of being faked: | `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 +spawn timestamp includes Playwright's own launch overhead and the gate's +`about:blank` detour: Playwright holds `app.whenReady()` until its CDP session +is attached, and the real document loads only after the probes are in place, +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. @@ -207,17 +217,17 @@ counter: ```json { - "journeys": { - "launch": { - "renderer.initialBytes": { - "value": 2739510, - "unit": "bytes", - "updatedAt": "2026-09-26", - "evidencePr": 1693, - "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" - } - } + "journeys": { + "launch": { + "renderer.initialBytes": { + "value": 2739510, + "unit": "bytes", + "updatedAt": "2026-09-26", + "evidencePr": 1693, + "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" + } } + } } ```