test(perf): count change-detection ticks in the electron-performance build (#1776)

* test(perf): count change-detection ticks in the electron-performance build

J1 renderer.cdTicksToFirstCard, J2 renderer.cdTicksToFirstPage and the
J1 idle baseline renderer.cdTicksIdle30s. Angular's ɵsetProfiler is only
reachable through the dev-mode window.ng global, so the electron-performance
configuration alone swaps environment.ts for environment.performance.ts,
which re-exports the production AppConfig and wraps ApplicationRef._tick.
Production and PWA sources and output are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): refuse a J1 idle window that opened late after the settle point

Addresses review: the idle window opens in the settle timer's callback while
the settle point is that timer's deadline, so a late callback left ticks
uncounted between the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 14:23:15 +02:00
1 parent 5a4d7a8a11
commit e4cf48fdc2
20 files changed
+778 -81

No files matched your search

@@ -159,13 +159,13 @@ test('only the launch journey opts into SQL statement counting', () => {
assert.equal(launchApp.match(/mainCounters:\s*true/g)?.length, 1);
assert.match(
launchApp,
/export async function measureLaunchJourney\([\s\S]*?\{ mainCounters: true \}[\s\S]*?\n\}/
/export async function measureLaunchJourney\([\s\S]*?\{ idleWindowMs: JOURNEY_IDLE_WINDOW_MS, mainCounters: true \}[\s\S]*?\n\}/
);
assert.match(
readFileSync(
join(sourceRoot, 'journeys', 'open-source.journey.ts'),
'utf8'
),
/\{ mainCounters: false \}/
/\{ idleWindowMs: null, mainCounters: false \}/
);
});
@@ -7,6 +7,8 @@ import {
assertJourneyRendererProbeState,
createLaunchJourneyProbeOptions,
createOpenSourceJourneyProbeOptions,
JOURNEY_CD_TICK_COUNTER_KEY,
JOURNEY_IDLE_WINDOW_MS,
JOURNEY_IPC_SENTINEL_ID,
JOURNEY_IPC_SENTINEL_METHOD,
JOURNEY_OPEN_SOURCE_END_SENTINEL_ID,
@@ -57,7 +59,7 @@ function createFixture(
return createFixtureFromDom(
dom,
{
...createLaunchJourneyProbeOptions(),
...createLaunchJourneyProbeOptions(null),
settle: FAST_SETTLE,
...optionOverrides,
},
@@ -65,6 +67,19 @@ function createFixture(
);
}
/**
* The electron-performance build's tick counter, installed in the fixture's
* window the way `environment.performance.ts` installs it before bootstrap.
*/
function installTickCounter(fixture: Fixture): { count: number } {
const counter = { count: 0 };
Object.defineProperty(fixture.window, JOURNEY_CD_TICK_COUNTER_KEY, {
configurable: true,
value: counter,
});
return counter;
}
function renderFirstCard(fixture: Fixture): void {
const { document } = fixture.window;
document.getElementById('initial-splash')?.remove();
@@ -99,7 +114,7 @@ test('installs once per document', () => {
const fixture = createFixture();
const first = fixture.rawState();
fixture.window.eval(
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(createLaunchJourneyProbeOptions())})`
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(createLaunchJourneyProbeOptions(null))})`
);
assert.equal(fixture.rawState(), first);
});
@@ -134,10 +149,12 @@ test('counts mutation records until the first card is visible after the splash i
state.navigation?.loadEventEndEpochMs,
fixture.window.performance.timeOrigin + 120
);
// No tick counter in this build: reported, never zero.
assert.equal(
state.capabilities.changeDetectionTicks,
'unavailable-ng-global-not-published'
'unavailable-counter-missing'
);
assert.equal(state.counters.changeDetectionTicks, null);
assert.ok(fixture.observers.every((observer) => observer.disconnected));
appRoot.append(document.createElement('div'));
@@ -506,8 +523,66 @@ test('rejects a probe whose performance observers were unavailable instead of re
);
});
test('counts change-detection ticks from document start until the terminal batch', async () => {
const fixture = createFixture();
const counter = installTickCounter(fixture);
counter.count += 3;
renderFirstCard(fixture);
// The tick that rendered the card ran before the observer's microtask.
counter.count += 1;
await settle();
counter.count += 5;
const state = fixture.state();
assert.equal(state.capabilities.changeDetectionTicks, 'counted');
assert.equal(state.counters.changeDetectionTicks, 4);
assert.equal(state.idle.status, 'disabled');
assert.equal(state.idle.ticks, null);
});
test('counts ticks and mutations in the idle window that opens at the settle point', async () => {
const fixture = createFixture({ idle: { durationMs: 80 } });
const counter = installTickCounter(fixture);
renderFirstCard(fixture);
counter.count += 2;
await waitFor(() => fixture.rawState().idle.startEpochMs !== null);
const opened = fixture.state();
assert.equal(opened.idle.status, 'pending');
assert.ok(opened.settle.epochMs !== null);
assert.ok((opened.idle.startEpochMs ?? 0) >= opened.settle.epochMs);
assert.throws(
() => assertJourneyRendererProbeState(opened),
/idle-pending/
);
counter.count += 7;
fixture.window.document.body.append(
fixture.window.document.createElement('div')
);
await waitFor(() => fixture.rawState().idle.status === 'done');
counter.count += 100;
const state = fixture.state();
assert.equal(state.counters.changeDetectionTicks, 2);
assert.equal(state.idle.ticks, 7);
assert.equal(state.idle.domMutations, 1);
assert.ok(
(state.idle.endEpochMs ?? 0) - (state.idle.startEpochMs ?? 0) >= 79
);
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
});
test('launch options add the idle window only when asked', () => {
assert.equal(createLaunchJourneyProbeOptions(null).idle, undefined);
assert.deepEqual(
createLaunchJourneyProbeOptions(JOURNEY_IDLE_WINDOW_MS).idle,
{ durationMs: 30_000 }
);
assert.equal(
createLaunchJourneyProbeOptions(null).cdTickCounterKey,
'__iptvnatorCdTicks'
);
});
test('launch options target the workspace source cards and the shared sentinel', () => {
const options = createLaunchJourneyProbeOptions();
const options = createLaunchJourneyProbeOptions(null);
assert.equal(options.stateKey, JOURNEY_PROBE_STATE_KEY);
assert.equal(options.sentinelMethod, 'cancelSourceProbe');
assert.equal(options.splashId, 'initial-splash');
@@ -631,6 +706,10 @@ test('the click sends the start sentinel before the app sees it and the end sent
assert.equal(state.navigation, null);
assert.equal(state.final, true);
assert.ok(state.terminal.epochMs >= state.start.epochMs);
assert.equal(
state.capabilities.changeDetectionTicks,
'unavailable-counter-missing'
);
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
// One start per armed probe.
@@ -640,6 +719,23 @@ test('the click sends the start sentinel before the app sees it and the end sent
assert.equal(fixture.bridgeCalls.length, 2);
});
test('counts ticks from the click, not from document start', async () => {
const fixture = createOpenSourceFixture();
const counter = installTickCounter(fixture);
counter.count = 40;
fixture.card.addEventListener('click', () => {
counter.count += 1;
openSource(fixture);
counter.count += 1;
});
fixture.card.click();
await settle();
counter.count += 10;
const state = fixture.state();
assert.equal(state.capabilities.changeDetectionTicks, 'counted');
assert.equal(state.counters.changeDetectionTicks, 2);
});
test('the first page needs the category list as well as the items', async () => {
const fixture = createOpenSourceFixture();
fixture.card.addEventListener('click', () =>
@@ -155,7 +155,9 @@ export async function settle(ms = 40): Promise<void> {
const state = read();
return (
state.terminal !== null &&
(!state.final || state.settle.status === 'pending')
(!state.final ||
state.settle.status === 'pending' ||
state.idle.status === 'pending')
);
}) &&
Date.now() < deadline
@@ -14,10 +14,18 @@ import type { Page } from '@playwright/test';
* layout shifts and long tasks until the journey's terminal condition and
* then emits one JSON blob under `options.stateKey`. With `options.settle`
* (J1) it keeps summing layout shifts after the first card until the page
* has settled, for shifts such as collapsing skeletons that land later.
* With `options.media` (J3 "Playback") the terminal condition is a media
* event (`playing`) on an element matching `cardSelector` instead of that
* element becoming visible.
* has settled, for shifts such as collapsing skeletons that land later, and
* with `options.idle` it then counts what the untouched page does for a
* fixed window. With `options.media` (J3 "Playback") the terminal condition
* is a media event (`playing`) on an element matching `cardSelector` instead
* of that element becoming visible.
*
* Change-detection ticks are read from the counter that only the
* `electron-performance` build of `apps/web` installs
* (`apps/web/src/environments/change-detection-tick-counter.ts`) under
* `options.cdTickCounterKey`. The probe takes differences of that running
* total at the journey's boundaries; a build without the counter fails the
* iteration in the journey record.
*
* IPC invocations are not counted here: the bridge object exposed by
* `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe
@@ -94,11 +102,32 @@ export const JOURNEY_SETTLE_CAP_MS = 3_000;
/** The workspace shell's content pane; the rail and header stay outside. */
export const JOURNEY_SETTLE_ROOT_SELECTOR = 'main.workspace-content';
/**
* Global the electron-performance build's tick counter lives under; the
* build-config spec checks it against
* `CHANGE_DETECTION_TICK_COUNTER_KEY` in apps/web.
*/
export const JOURNEY_CD_TICK_COUNTER_KEY = '__iptvnatorCdTicks';
/**
* J1's idle window: it opens at the settle point and counts what the page
* does, with no input, for `durationMs`. See performance-journeys.md.
*/
export interface JourneyRendererProbeIdleOptions {
readonly durationMs: number;
}
export const JOURNEY_IDLE_WINDOW_MS = 30_000;
export interface JourneyRendererProbeOptions {
/** Selector for the element whose visibility ends the journey. */
readonly cardSelector: string;
/** Global holding the build's change-detection tick counter. */
readonly cdTickCounterKey: string;
/** Further selectors that must each match a visible element as well. */
readonly companionSelectors?: readonly string[];
/** Absent: no idle window. Needs `settle`, which it follows. */
readonly idle?: JourneyRendererProbeIdleOptions;
readonly journey: string;
/** Absent: the journey ends when `cardSelector` becomes visible. */
readonly media?: JourneyRendererProbeMediaOptions;
@@ -116,6 +145,11 @@ export interface JourneyRendererProbeOptions {
}
export interface JourneyRendererProbeCounters {
/**
* `ApplicationRef` ticks from the journey's start until the terminal
* batch. Null when the build has no tick counter.
*/
changeDetectionTicks: number | null;
domMutations: number;
/** Shifts with `hadRecentInput === false` (the CLS definition). */
layoutShiftScore: number;
@@ -146,7 +180,8 @@ export interface JourneyRendererProbeLateShift {
export interface JourneyRendererProbeState {
readonly capabilities: {
changeDetectionTicks: string;
changeDetectionTicks:
'counted' | 'pending' | 'unavailable-counter-missing';
layoutShift: boolean;
longTask: boolean;
observedTarget: 'document' | 'documentElement';
@@ -154,6 +189,16 @@ export interface JourneyRendererProbeState {
readonly counters: JourneyRendererProbeCounters;
final: boolean;
firstCardPaintEpochMs: number | null;
/** The idle window after the settle point (J1). */
idle: {
/** Mutation records in the whole document during the window. */
domMutations: number;
endEpochMs: number | null;
startEpochMs: number | null;
status: 'disabled' | 'done' | 'pending';
/** Ticks during the window; null without a tick counter. */
ticks: number | null;
};
readonly installed: {
readonly bridgePresent: boolean;
readonly documentElementPresent: boolean;
@@ -240,6 +285,7 @@ export function journeyRendererProbeScript(
const startClick = options.startClick ?? null;
const mediaOptions = options.media ?? null;
const settleOptions = options.settle ?? null;
const idleOptions = options.idle ?? null;
const companionSelectors = options.companionSelectors ?? [];
const bridge = target['electron'] as Record<string, unknown> | undefined;
const state: JourneyRendererProbeState = {
@@ -252,6 +298,7 @@ export function journeyRendererProbeScript(
: 'document',
},
counters: {
changeDetectionTicks: null,
domMutations: 0,
layoutShiftScore: 0,
layoutShiftScoreSettled: 0,
@@ -260,6 +307,13 @@ export function journeyRendererProbeScript(
},
final: false,
firstCardPaintEpochMs: null,
idle: {
domMutations: 0,
endEpochMs: null,
startEpochMs: null,
status: idleOptions === null ? 'disabled' : 'pending',
ticks: null,
},
installed: {
bridgePresent: typeof bridge === 'object' && bridge !== null,
documentElementPresent: document.documentElement !== null,
@@ -287,6 +341,15 @@ export function journeyRendererProbeScript(
terminal: null,
};
target[options.stateKey] = state;
// The build installs its counter while main.js evaluates, before
// Angular bootstraps, so J1 starts from zero; a click start reads the
// running total at the click.
const readTicks = (): number | null => {
const counter = target[options.cdTickCounterKey] as
{ count?: unknown } | undefined;
return typeof counter?.count === 'number' ? counter.count : null;
};
let ticksAtStart: number | null = startClick === null ? 0 : null;
if (
startClick === null &&
(state.installed.scriptCount > 0 ||
@@ -439,6 +502,33 @@ export function journeyRendererProbeScript(
state.counters.layoutShiftScoreSettled = score;
state.settle.epochMs = untilEpochMs;
state.settle.status = status;
if (idleOptions !== null) startIdle(idleOptions);
};
// Opens when the settle window closes, so startup work that is still
// landing does not count as idle work. Nothing touches the page.
const startIdle = (idle: JourneyRendererProbeIdleOptions): void => {
const idleObserver = new MutationObserver((records) => {
state.idle.domMutations += records.length;
});
idleObserver.observe(document.documentElement ?? document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
const startTicks = readTicks();
state.idle.startEpochMs = epoch();
setTimeout(() => {
const endTicks = readTicks();
state.idle.domMutations += idleObserver.takeRecords().length;
idleObserver.disconnect();
state.idle.endEpochMs = epoch();
state.idle.ticks =
startTicks === null || endTicks === null
? null
: endTicks - startTicks;
state.idle.status = 'done';
}, idle.durationMs);
};
type LateShiftSource = {
currentRect?: { height: number; y: number };
@@ -626,11 +716,17 @@ export function journeyRendererProbeScript(
);
}
}
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';
// A tick that rendered the card ran before this microtask (or,
// for a media terminal, before the event's task), so it is
// included; no other tick can run in between.
const ticks = readTicks();
if (ticks === null || ticksAtStart === null) {
state.capabilities.changeDetectionTicks =
'unavailable-counter-missing';
} else {
state.capabilities.changeDetectionTicks = 'counted';
state.counters.changeDetectionTicks = ticks - ticksAtStart;
}
// 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
@@ -660,6 +756,8 @@ export function journeyRendererProbeScript(
const listenerEpochMs = epoch();
const eventEpochMs = performance.timeOrigin + event.timeStamp;
countPreStart(mutationObserver.takeRecords().length);
// Capture phase: no tick for this click has run yet.
ticksAtStart = readTicks();
const sentinelStatus = callSentinel(startClick.sentinelId);
state.start = {
epochMs:
@@ -715,10 +813,21 @@ export function journeyRendererProbeScript(
}
}
export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions {
/**
* Options for J1. `idleWindowMs` adds the idle window after the settle point;
* journeys that continue from the launch (J2) pass null so their click does
* not wait for it.
*/
export function createLaunchJourneyProbeOptions(
idleWindowMs: number | null
): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null
? {}
: { idle: { durationMs: idleWindowMs } }),
journey: 'launch',
routeFragment: '/workspace',
sentinelId: JOURNEY_IPC_SENTINEL_ID,
@@ -744,6 +853,7 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio
// cards and live channel rows; skeleton cards are not matched.
cardSelector:
'app-grid-list mat-card, .content-card, [data-test-id="channel-item"]',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
companionSelectors: ['app-workspace-context-panel .category-item'],
journey: 'open-source',
routeFragment: '/workspace/xtreams/',
@@ -766,6 +876,7 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio
export function createPlaybackJourneyProbeOptions(): JourneyRendererProbeOptions {
return {
cardSelector: JOURNEY_PLAYBACK_VIDEO_SELECTOR,
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
journey: 'playback',
media: { endEvent: 'playing', phaseEvents: ['loadedmetadata'] },
routeFragment: '/workspace/xtreams/',
@@ -810,16 +921,25 @@ export async function waitForJourneyRendererProbe(
stateKey: string,
timeoutMs: number
): Promise<JourneyRendererProbeState> {
// A settle window, where enabled, ends after `final`.
// A settle window, where enabled, ends after `final`, and an idle
// window after that.
await page.waitForFunction(
(key) => {
const state = (
globalThis as unknown as Record<
string,
{ final?: boolean; settle?: { status?: string } }
{
final?: boolean;
idle?: { status?: string };
settle?: { status?: string };
}
>
)[key];
return state?.final === true && state.settle?.status !== 'pending';
return (
state?.final === true &&
state.settle?.status !== 'pending' &&
state.idle?.status !== 'pending'
);
},
stateKey,
{ polling: 50, timeout: timeoutMs }
@@ -851,6 +971,9 @@ export function assertJourneyRendererProbeState(
if (state.settle.status === 'pending') {
throw new Error('journey-renderer-probe-settle-pending');
}
if (state.idle.status === 'pending') {
throw new Error('journey-renderer-probe-idle-pending');
}
if (state.invalidReasons.length > 0) {
throw new Error(
`journey-renderer-probe-invalid: ${state.invalidReasons.join(', ')}`
@@ -14,12 +14,13 @@ function measurement(
): LaunchJourneyMeasurement {
const renderer: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'unavailable-ng-global-not-published',
changeDetectionTicks: 'counted',
layoutShift: true,
longTask: true,
observedTarget: 'document',
},
counters: {
changeDetectionTicks: 23,
domMutations: 480,
layoutShiftScore: 0.123456789,
layoutShiftScoreSettled: 0.2304999,
@@ -28,6 +29,13 @@ function measurement(
},
final: true,
firstCardPaintEpochMs: 2_650,
idle: {
domMutations: 12,
endEpochMs: 33_200.04,
startEpochMs: 3_200,
status: 'done',
ticks: 31,
},
installed: {
bridgePresent: true,
documentElementPresent: false,
@@ -138,6 +146,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
assert.deepEqual(record.counters, {
'main.modulesRegisteredBeforeWindow': 2,
'main.sqlStatementsBeforeReadyToShow': 9,
'renderer.cdTicksIdle30s': 31,
'renderer.cdTicksToFirstCard': 23,
'renderer.domMutationsToFirstCard': 480,
'renderer.ipcCallsToFirstCard': 14,
'renderer.ipcSerialDepthToFirstCard': 2,
@@ -208,6 +218,11 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
observedTarget: 'root',
reason: 'quiet',
});
assert.deepEqual(record.evidence['idle'], {
domMutations: 12,
durationMs: 30_000,
settledToIdleStartMs: 19.9,
});
});
test('rejects measurements whose clocks or probes are inconsistent', () => {
@@ -240,11 +255,85 @@ test('rejects measurements whose clocks or probes are inconsistent', () => {
...base.renderer,
capabilities: {
...base.renderer.capabilities,
changeDetectionTicks: 'hook-present-not-counted',
changeDetectionTicks: 'unavailable-counter-missing',
},
counters: {
...base.renderer.counters,
changeDetectionTicks: null,
},
},
}),
/cd-hook-hook-present-not-counted/
/cd-ticks-unavailable-counter-missing/
);
});
test('refuses a launch without a complete, on-time idle window after the settle point', () => {
const base = measurement();
const withIdle = (
idle: Partial<LaunchJourneyMeasurement['renderer']['idle']>
): LaunchJourneyMeasurement => ({
...base,
renderer: {
...base.renderer,
idle: { ...base.renderer.idle, ...idle },
},
});
// J2's launches skip the window; such a launch is not a J1 measurement.
assert.throws(
() =>
toLaunchIterationRecord(
0,
false,
withIdle({ status: 'disabled', ticks: null })
),
/idle-disabled/
);
assert.throws(
() => toLaunchIterationRecord(0, false, withIdle({ ticks: null })),
/idle-done/
);
assert.throws(
() =>
toLaunchIterationRecord(
0,
false,
withIdle({ endEpochMs: 33_000, startEpochMs: 3_000 })
),
/idle-before-settle/
);
// The settle point is 3_180.06: a window opened 120 ms after it left
// ticks uncounted in between.
assert.throws(
() =>
toLaunchIterationRecord(
0,
false,
withIdle({ endEpochMs: 33_300.1, startEpochMs: 3_300.1 })
),
/idle-start-late/
);
assert.doesNotThrow(() =>
toLaunchIterationRecord(
0,
false,
withIdle({ endEpochMs: 33_250, startEpochMs: 3_250 })
)
);
assert.throws(
() =>
toLaunchIterationRecord(0, false, withIdle({ endEpochMs: 33_000 })),
/idle-window-short/
);
assert.throws(
() =>
toLaunchIterationRecord(0, false, withIdle({ endEpochMs: 34_500 })),
/idle-window-late/
);
assert.equal(
toLaunchIterationRecord(0, false, withIdle({ ticks: 0 })).counters[
'renderer.cdTicksIdle30s'
],
0
);
});
@@ -284,7 +373,7 @@ test('refuses a launch whose settle window did not end after the first-card cuto
const capped = toLaunchIterationRecord(
0,
false,
withSettle({ epochMs: 5_650, status: 'cap' })
withSettle({ epochMs: 3_150, status: 'cap' })
);
assert.equal(
(capped.evidence['settle'] as { reason: string }).reason,
@@ -292,10 +381,8 @@ test('refuses a launch whose settle window did not end after the first-card cuto
);
});
test('names the counters the harness cannot measure yet', () => {
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), [
'renderer.cdTicksToFirstCard',
]);
test('measures every counter the plan lists for J1', () => {
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), []);
});
test('never reports a measured counter as unavailable', () => {
@@ -5,7 +5,10 @@ import {
} from './journey-main-counters';
import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyRendererProbeState } from './journey-renderer-probe';
import {
JOURNEY_IDLE_WINDOW_MS,
type JourneyRendererProbeState,
} from './journey-renderer-probe';
import type { JourneyIterationRecord } from './journey-summary';
/**
@@ -20,6 +23,8 @@ export const LAUNCH_JOURNEY_COUNTER = {
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
SQL_STATEMENTS_BEFORE_READY_TO_SHOW:
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
CD_TICKS: 'renderer.cdTicksToFirstCard',
CD_TICKS_IDLE: 'renderer.cdTicksIdle30s',
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
IPC_SERIAL_DEPTH: 'renderer.ipcSerialDepthToFirstCard',
@@ -34,15 +39,26 @@ export const LAUNCH_JOURNEY_WALL_CLOCK = {
} as const;
/**
* Counters the plan lists for J1 that this harness cannot measure without
* production changes. They are reported instead of faked.
* Counters the plan lists for J1 that this harness cannot measure. Every one
* is measured now; the list stays so a future gap is reported, not faked.
*/
export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
Record<string, string>
> = Object.freeze({
'renderer.cdTicksToFirstCard':
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
});
> = Object.freeze({});
/**
* A timer on an idle page runs within milliseconds of its deadline; a window
* that closed later than this measured a busy page, not an idle one.
*/
export const LAUNCH_JOURNEY_IDLE_LATE_TOLERANCE_MS = 1_000;
/**
* The idle window opens in the settle timer's callback, but the settle point
* is that timer's deadline. A callback that ran later than this left ticks
* between the two outside both windows, and means the page was still busy
* at the settle point, so the iteration is refused rather than undercounted.
*/
export const LAUNCH_JOURNEY_IDLE_START_TOLERANCE_MS = 100;
export interface LaunchJourneyMeasurement {
readonly electronVersion: string;
@@ -95,14 +111,16 @@ export function toLaunchIterationRecord(
) {
throw new Error(`launch-journey-record-settle-${settle.status}`);
}
const cdTicks = renderer.counters.changeDetectionTicks;
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
renderer.capabilities.changeDetectionTicks !== 'counted' ||
cdTicks === null
) {
throw new Error(
`launch-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}`
`launch-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}`
);
}
const idle = assertLaunchIdleWindow(renderer, settle.epochMs);
return Object.freeze({
counters: Object.freeze({
[LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW]:
@@ -113,6 +131,8 @@ export function toLaunchIterationRecord(
mainCounters.counters[
LAUNCH_JOURNEY_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
],
[LAUNCH_JOURNEY_COUNTER.CD_TICKS]: cdTicks,
[LAUNCH_JOURNEY_COUNTER.CD_TICKS_IDLE]: idle.ticks,
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
@@ -153,6 +173,13 @@ export function toLaunchIterationRecord(
cardTestId: renderer.terminal.cardTestId,
pathname: renderer.terminal.pathname,
}),
idle: Object.freeze({
domMutations: idle.domMutations,
durationMs: roundTenth(idle.durationMs),
settledToIdleStartMs: roundTenth(
idle.startEpochMs - settle.epochMs
),
}),
ipcCallsAfterFirstCard: ipc.callsAfterSentinel,
// Running totals when the counters were read, after the first card.
mainCountersAtRead: mainCounters.counters,
@@ -196,3 +223,53 @@ export function toLaunchIterationRecord(
warmup,
});
}
/**
* The idle window must have opened at or after the settle point and closed
* on time; see performance-journeys.md.
*/
function assertLaunchIdleWindow(
renderer: JourneyRendererProbeState,
settledEpochMs: number
): {
readonly domMutations: number;
readonly durationMs: number;
readonly startEpochMs: number;
readonly ticks: number;
} {
const { idle } = renderer;
if (
idle.status !== 'done' ||
idle.ticks === null ||
idle.startEpochMs === null ||
idle.endEpochMs === null
) {
throw new Error(`launch-journey-record-idle-${idle.status}`);
}
if (idle.startEpochMs < settledEpochMs) {
throw new Error('launch-journey-record-idle-before-settle');
}
if (
idle.startEpochMs - settledEpochMs >
LAUNCH_JOURNEY_IDLE_START_TOLERANCE_MS
) {
throw new Error('launch-journey-record-idle-start-late');
}
const durationMs = idle.endEpochMs - idle.startEpochMs;
// Timers may fire up to a millisecond early after clamping.
if (durationMs < JOURNEY_IDLE_WINDOW_MS - 1) {
throw new Error('launch-journey-record-idle-window-short');
}
if (
durationMs >
JOURNEY_IDLE_WINDOW_MS + LAUNCH_JOURNEY_IDLE_LATE_TOLERANCE_MS
) {
throw new Error('launch-journey-record-idle-window-late');
}
return {
domMutations: idle.domMutations,
durationMs,
startEpochMs: idle.startEpochMs,
ticks: idle.ticks,
};
}
@@ -22,12 +22,13 @@ function measurement(
): OpenSourceJourneyMeasurement {
const renderer: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'unavailable-ng-global-not-published',
changeDetectionTicks: 'counted',
layoutShift: true,
longTask: true,
observedTarget: 'documentElement',
},
counters: {
changeDetectionTicks: 9,
domMutations: 1_596,
layoutShiftScore: 0.0004,
layoutShiftScoreSettled: 0,
@@ -36,6 +37,13 @@ function measurement(
},
final: true,
firstCardPaintEpochMs: 10_090,
idle: {
domMutations: 0,
endEpochMs: null,
startEpochMs: null,
status: 'disabled',
ticks: null,
},
installed: {
bridgePresent: true,
documentElementPresent: true,
@@ -127,6 +135,7 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters
assert.equal(record.pid, 4343);
assert.deepEqual(record.counters, {
'main.mockHttpRequestsToSettled': 2,
'renderer.cdTicksToFirstPage': 9,
'renderer.domMutationsToFirstPage': 1_596,
'renderer.ipcCallsToFirstPage': 17,
'renderer.layoutShiftScore': 0.221,
@@ -261,11 +270,15 @@ test('rejects measurements that did not start at the click or did not open the s
...renderer,
capabilities: {
...renderer.capabilities,
changeDetectionTicks: 'hook-present-not-counted',
changeDetectionTicks: 'unavailable-counter-missing',
},
counters: {
...renderer.counters,
changeDetectionTicks: null,
},
},
}),
/cd-hook-hook-present-not-counted/
/cd-ticks-unavailable-counter-missing/
);
});
@@ -322,8 +335,8 @@ test('summarizes under the J2 counters with the unmeasurable ones listed', () =>
);
assert.equal(entry.wallClock['clickToFirstPageMs.p50'], 78.3);
assert.equal(entry.wallClock['clickToFirstPagePaintMs.p90'], 89.8);
assert.equal(entry.counters['renderer.cdTicksToFirstPage'], 9);
assert.deepEqual(Object.keys(entry.unavailable).sort(), [
'main.sqlStatementsToFirstPage',
'renderer.cdTicksToFirstPage',
]);
});
@@ -18,6 +18,7 @@ import type { JourneyIterationRecord } from './journey-summary';
export const OPEN_SOURCE_JOURNEY_ID = 'open-source';
export const OPEN_SOURCE_JOURNEY_COUNTER = {
CD_TICKS: 'renderer.cdTicksToFirstPage',
DOM_MUTATIONS: 'renderer.domMutationsToFirstPage',
IPC_CALLS: 'renderer.ipcCallsToFirstPage',
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
@@ -37,8 +38,6 @@ export const OPEN_SOURCE_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
> = Object.freeze({
'main.sqlStatementsToFirstPage':
'The main.sqlStatements running total is read from the test process through the journey gate, so it cannot be sampled at the click or at the first-page batch, and the worker count is ordered against worker responses rather than the renderer. A click-to-settled count is a follow-up.',
'renderer.cdTicksToFirstPage':
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
});
/** How long the app was left alone before the click, and what it did. */
@@ -117,12 +116,13 @@ export function toOpenSourceIterationRecord(
`open-source-journey-record-activity-before-click-${lateActivity.join('-')}`
);
}
const cdTicks = renderer.counters.changeDetectionTicks;
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
renderer.capabilities.changeDetectionTicks !== 'counted' ||
cdTicks === null
) {
throw new Error(
`open-source-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}`
`open-source-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}`
);
}
// The ledger's clock is the test process's, the terminal's the
@@ -137,6 +137,7 @@ export function toOpenSourceIterationRecord(
.split('/')[1];
return Object.freeze({
counters: Object.freeze({
[OPEN_SOURCE_JOURNEY_COUNTER.CD_TICKS]: cdTicks,
[OPEN_SOURCE_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[OPEN_SOURCE_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { join } from 'node:path';
import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe';
interface TargetConfiguration {
configurations?: Record<string, Record<string, unknown>>;
dependsOn?: unknown;
@@ -116,13 +118,59 @@ test('the web performance build keeps production renderer behavior with profilin
assert.equal(performance['serviceWorker'], false);
assert.deepEqual(performance['optimization'], production['optimization']);
assert.equal(performance['outputHashing'], production['outputHashing']);
assert.deepEqual(
performance['fileReplacements'],
production['fileReplacements']
);
// The only difference is the environment: production values plus the
// change-detection tick counter the journeys read.
assert.deepEqual(performance['fileReplacements'], [
{
replace: 'apps/web/src/environments/environment.ts',
with: 'apps/web/src/environments/environment.performance.ts',
},
]);
assert.deepEqual(production['fileReplacements'], [
{
replace: 'apps/web/src/environments/environment.ts',
with: 'apps/web/src/environments/environment.prod.ts',
},
]);
assert.equal(performance['sourceMap'], true);
});
test('only the web performance build installs the tick counter the journeys read', () => {
const environments = join(workspaceRoot, 'apps/web/src/environments');
const performanceEnvironment = readFileSync(
join(environments, 'environment.performance.ts'),
'utf8'
);
assert.match(
performanceEnvironment,
/export \{ AppConfig \} from '\.\/environment\.prod';/
);
assert.match(
performanceEnvironment,
/installChangeDetectionTickCounter\(\);/
);
assert.match(
readFileSync(
join(environments, 'change-detection-tick-counter.ts'),
'utf8'
),
new RegExp(
`CHANGE_DETECTION_TICK_COUNTER_KEY = '${JOURNEY_CD_TICK_COUNTER_KEY}'`
)
);
// No other configuration may reference the performance environment.
for (const [name, configuration] of Object.entries(
webProject.targets['build'].configurations ?? {}
)) {
if (name === 'electron-performance') continue;
assert.doesNotMatch(
JSON.stringify(configuration['fileReplacements'] ?? []),
/environment\.performance/,
name
);
}
});
test('the resolved web build cache output is the renderer directory', () => {
const task = readResolvedWebBuildTask();
@@ -27,12 +27,13 @@ function measurement(
): PlaybackJourneyMeasurement {
const renderer: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'unavailable-ng-global-not-published',
changeDetectionTicks: 'counted',
layoutShift: true,
longTask: true,
observedTarget: 'documentElement',
},
counters: {
changeDetectionTicks: 14,
domMutations: 6_188,
layoutShiftScore: 0,
layoutShiftScoreSettled: 0,
@@ -41,6 +42,13 @@ function measurement(
},
final: true,
firstCardPaintEpochMs: 10_360,
idle: {
domMutations: 0,
endEpochMs: null,
startEpochMs: null,
status: 'disabled',
ticks: null,
},
installed: {
bridgePresent: true,
documentElementPresent: true,
@@ -156,6 +164,7 @@ test('maps the media-terminated probe, IPC window and mock ledger to exact count
assert.equal(record.warmup, false);
assert.equal(record.pid, 5151);
assert.deepEqual(record.counters, {
'renderer.cdTicksToPlaying': 14,
'renderer.domMutationsToPlaying': 6_188,
'renderer.httpRequestsToPlaying': 2,
'renderer.ipcCallsToPlaying': 4,
@@ -263,10 +272,14 @@ test('rejects measurements that did not start at a live channel or did not play
withRenderer({
capabilities: {
...base.renderer.capabilities,
changeDetectionTicks: 'hook-present-not-counted',
changeDetectionTicks: 'unavailable-counter-missing',
},
counters: {
...base.renderer.counters,
changeDetectionTicks: null,
},
}),
/cd-hook-hook-present-not-counted/,
/cd-ticks-unavailable-counter-missing/,
],
];
for (const [input, error] of cases) {
@@ -337,8 +350,8 @@ test('summarizes playback iterations with J3 counters and unavailable reasons',
});
assert.equal(entry.wallClock['clickToPlayingMs.p50'], 349.8);
assert.equal(entry.wallClock['clickToLoadedMetadataMs.p90'], 95);
assert.equal(entry.counters['renderer.cdTicksToPlaying'], 14);
assert.deepEqual(Object.keys(entry.unavailable).sort(), [
'renderer.cdTicksToPlaying',
'renderer.ipcSerialDepthToPlaying',
]);
});
@@ -19,6 +19,7 @@ import type { JourneyIterationRecord } from './journey-summary';
export const PLAYBACK_JOURNEY_ID = 'playback';
export const PLAYBACK_JOURNEY_COUNTER = {
CD_TICKS: 'renderer.cdTicksToPlaying',
DOM_MUTATIONS: 'renderer.domMutationsToPlaying',
HTTP_REQUESTS: 'renderer.httpRequestsToPlaying',
IPC_CALLS: 'renderer.ipcCallsToPlaying',
@@ -39,8 +40,6 @@ export const PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS = 1_000;
export const PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
Record<string, string>
> = Object.freeze({
'renderer.cdTicksToPlaying':
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
'renderer.ipcSerialDepthToPlaying':
'The serial-depth helper is being added for J1 in a separate thread and is not on master yet; J3 adopts it once it lands.',
});
@@ -140,16 +139,18 @@ export function toPlaybackIterationRecord(
`playback-journey-record-activity-before-click-${lateActivity.join('-')}`
);
}
const cdTicks = renderer.counters.changeDetectionTicks;
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
renderer.capabilities.changeDetectionTicks !== 'counted' ||
cdTicks === null
) {
throw new Error(
`playback-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}`
`playback-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}`
);
}
return Object.freeze({
counters: Object.freeze({
[PLAYBACK_JOURNEY_COUNTER.CD_TICKS]: cdTicks,
[PLAYBACK_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[PLAYBACK_JOURNEY_COUNTER.HTTP_REQUESTS]: http.toPlaying.length,