mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
test(performance): gate the renderer load so the probes never race startup
Review follow-up for #1698. - journey-renderer-gate.cjs: a main-process hook loaded with `-r` (the mechanism Playwright uses for its own loader) makes the first loadFile/loadURL navigate to about:blank and holds the real load until the test releases it. Playwright reports no page before a navigation commits, so this is what lets the renderer probe be registered on the page before the real document exists; the IPC capture is installed before the release too. A safety timeout releases the gate on its own and marks the iteration invalid. Unit-tested with a fake BrowserWindow. - launch-journey-app.ts: registers the probe on the parked page, releases the gate, waits for the real document to commit, fails fast when the probe is missing, and refuses an iteration whose gate timed out, saw a second load, or released before the probe was in place. - journey-renderer-probe.ts: entries delivered live after the terminal batch are buffered and filtered by the same cutoff as queued ones, and the cutoff is sampled in a timer queued from the first rAF, i.e. after the card's frame is painted, so the render task's long task and layout shift are consistently included. - launch-journey-record.ts: layoutShiftScore rounded to three decimals; a 0.0001 shift flipped in and out of the cutoff between iterations. - playwright.journeys.config.ts: reuse a mock server left on the journeys port locally (its fixtures are deterministic); CI still starts its own. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
60d276e573
commit
5343e32c8f
11 files changed
+572
-64
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<JourneyRendererGateState> {
|
||||
const state = await electronApp.evaluate(
|
||||
(_electron, input) => {
|
||||
const gate = (globalThis as unknown as Record<string, unknown>)[
|
||||
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;
|
||||
}
|
||||
@@ -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<string, string> = {}
|
||||
@@ -98,10 +108,12 @@ export function removeLaunchJourneyProfile(directory: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
const installed = await mainWindow.evaluate(
|
||||
(key) =>
|
||||
(globalThis as unknown as Record<string, unknown>)[key] !==
|
||||
undefined,
|
||||
stateKey
|
||||
);
|
||||
if (!installed) {
|
||||
throw new Error('journey-renderer-probe-not-installed');
|
||||
}
|
||||
}
|
||||
@@ -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> = {}
|
||||
): 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/
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string, unknown> },
|
||||
target: Record<string, unknown>,
|
||||
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<string> {
|
||||
log.push(`loadFile:${file}`);
|
||||
return `loaded:${file}`;
|
||||
}
|
||||
async loadURL(url: string): Promise<string> {
|
||||
log.push(`loadURL:${url}`);
|
||||
return `loaded:${url}`;
|
||||
}
|
||||
}
|
||||
return FakeBrowserWindow;
|
||||
}
|
||||
|
||||
function settle(): Promise<void> {
|
||||
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<string, unknown>)[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<string, unknown> = {};
|
||||
let clock = 100;
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
FakeBrowserWindow as unknown as { prototype: Record<string, unknown> },
|
||||
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<string, unknown> },
|
||||
{},
|
||||
{ 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<string> {
|
||||
return `loaded:${file}`;
|
||||
}
|
||||
}
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
BrokenBrowserWindow as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ 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');
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
await context.addInitScript(journeyRendererProbeScript, options);
|
||||
await page.addInitScript(journeyRendererProbeScript, options);
|
||||
}
|
||||
|
||||
export async function waitForJourneyRendererProbe(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 `<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. |
|
||||
| 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 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in new issue
Block a user