mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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>
This commit is contained in:
1 parent
525ca7bc44
commit
454c1fe10a
16 files changed
+711
-65
No files matched your search
@@ -39,6 +39,7 @@ import {
|
||||
import {
|
||||
createLaunchJourneyProbeOptions,
|
||||
installJourneyRendererProbe,
|
||||
JOURNEY_IDLE_WINDOW_MS,
|
||||
waitForJourneyRendererProbe,
|
||||
} from '../performance/journey-renderer-probe';
|
||||
import {
|
||||
@@ -119,6 +120,15 @@ export function removeLaunchJourneyProfile(directory: string): Promise<void> {
|
||||
return removeDirectory(directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* How a journey launch is instrumented: the process flags, plus J1's idle
|
||||
* window after the settle point (null skips it, so a journey that continues
|
||||
* from the launch does not wait 30 s before its own start).
|
||||
*/
|
||||
export interface LaunchJourneyOptions extends JourneyLaunchInstrumentation {
|
||||
readonly idleWindowMs: number | null;
|
||||
}
|
||||
|
||||
/** The running app after J1 ended, for journeys that continue from there. */
|
||||
export interface LaunchJourneySession {
|
||||
readonly electronApp: ElectronApplication;
|
||||
@@ -133,7 +143,7 @@ export async function measureLaunchJourney(
|
||||
const { launch } = await runLaunchJourney(
|
||||
templateDirectory,
|
||||
timeoutMs,
|
||||
{ mainCounters: true },
|
||||
{ idleWindowMs: JOURNEY_IDLE_WINDOW_MS, mainCounters: true },
|
||||
async () => undefined
|
||||
);
|
||||
return launch;
|
||||
@@ -146,14 +156,15 @@ export async function measureLaunchJourney(
|
||||
* 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. `continueJourney` runs in the
|
||||
* same process after J1's counters are final, before the app is closed.
|
||||
* same process after J1's counters are final (and after its idle window,
|
||||
* when one is requested), before the app is closed.
|
||||
* Without `instrumentation.mainCounters` the main-process counters and SQL
|
||||
* counting stay off and `launch.mainCounters` is null.
|
||||
*/
|
||||
export async function runLaunchJourney<T>(
|
||||
templateDirectory: string,
|
||||
timeoutMs: number,
|
||||
instrumentation: JourneyLaunchInstrumentation,
|
||||
instrumentation: LaunchJourneyOptions,
|
||||
continueJourney: (session: LaunchJourneySession) => Promise<T>
|
||||
): Promise<{
|
||||
readonly continuation: T;
|
||||
@@ -176,7 +187,9 @@ export async function runLaunchJourney<T>(
|
||||
const electronApp = await electron.launch({ args, env });
|
||||
captureElectronProcess(electronApp);
|
||||
try {
|
||||
const probeOptions = createLaunchJourneyProbeOptions();
|
||||
const probeOptions = createLaunchJourneyProbeOptions(
|
||||
instrumentation.idleWindowMs
|
||||
);
|
||||
// The gate parks the window on about:blank, so this resolves
|
||||
// before the real document exists.
|
||||
const mainWindow = await electronApp.firstWindow();
|
||||
|
||||
@@ -52,7 +52,7 @@ test('J2 open a source', async () => {
|
||||
JOURNEY_ITERATION_TIMEOUT_MS,
|
||||
// J2 does not read J1's main-process counters, so their
|
||||
// SQL instrumentation stays off during the click.
|
||||
{ mainCounters: false },
|
||||
{ idleWindowMs: null, mainCounters: false },
|
||||
(session) =>
|
||||
measureOpenSourceJourney(
|
||||
session,
|
||||
|
||||
@@ -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,
|
||||
@@ -130,7 +132,7 @@ function createFixture(
|
||||
return createFixtureFromDom(
|
||||
dom,
|
||||
{
|
||||
...createLaunchJourneyProbeOptions(),
|
||||
...createLaunchJourneyProbeOptions(null),
|
||||
settle: FAST_SETTLE,
|
||||
...optionOverrides,
|
||||
},
|
||||
@@ -185,6 +187,19 @@ function createFixtureFromDom(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Probes created by this file, so `settle` can wait for their cutoff. */
|
||||
const liveStates: (() => JourneyRendererProbeState)[] = [];
|
||||
|
||||
@@ -202,7 +217,9 @@ 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
|
||||
@@ -245,7 +262,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);
|
||||
});
|
||||
@@ -280,10 +297,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'));
|
||||
@@ -652,8 +671,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');
|
||||
@@ -777,6 +854,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.
|
||||
@@ -786,6 +867,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', () =>
|
||||
|
||||
@@ -14,7 +14,16 @@ 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.
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
@@ -69,11 +78,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;
|
||||
/** Pathname fragment the terminal route must contain. */
|
||||
readonly routeFragment: string;
|
||||
@@ -89,6 +119,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;
|
||||
@@ -119,7 +154,8 @@ export interface JourneyRendererProbeLateShift {
|
||||
|
||||
export interface JourneyRendererProbeState {
|
||||
readonly capabilities: {
|
||||
changeDetectionTicks: string;
|
||||
changeDetectionTicks:
|
||||
'counted' | 'pending' | 'unavailable-counter-missing';
|
||||
layoutShift: boolean;
|
||||
longTask: boolean;
|
||||
observedTarget: 'document' | 'documentElement';
|
||||
@@ -127,6 +163,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;
|
||||
@@ -198,6 +244,7 @@ export function journeyRendererProbeScript(
|
||||
const epoch = (): number => performance.timeOrigin + performance.now();
|
||||
const startClick = options.startClick ?? 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 = {
|
||||
@@ -210,6 +257,7 @@ export function journeyRendererProbeScript(
|
||||
: 'document',
|
||||
},
|
||||
counters: {
|
||||
changeDetectionTicks: null,
|
||||
domMutations: 0,
|
||||
layoutShiftScore: 0,
|
||||
layoutShiftScoreSettled: 0,
|
||||
@@ -218,6 +266,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,
|
||||
@@ -244,6 +299,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 ||
|
||||
@@ -393,6 +457,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 };
|
||||
@@ -565,11 +656,16 @@ 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';
|
||||
// The tick that rendered the card ran before this microtask, 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
|
||||
@@ -599,6 +695,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:
|
||||
@@ -617,10 +715,21 @@ export function journeyRendererProbeScript(
|
||||
window.addEventListener('click', onClick, true);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -646,6 +755,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/',
|
||||
@@ -690,16 +800,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 }
|
||||
@@ -731,6 +850,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: 36_000.04,
|
||||
startEpochMs: 6_000,
|
||||
status: 'done',
|
||||
ticks: 31,
|
||||
},
|
||||
installed: {
|
||||
bridgePresent: true,
|
||||
documentElementPresent: false,
|
||||
@@ -129,6 +137,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.layoutShiftScore': 0.123,
|
||||
@@ -185,6 +195,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: 2_819.9,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects measurements whose clocks or probes are inconsistent', () => {
|
||||
@@ -217,11 +232,67 @@ 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/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
toLaunchIterationRecord(0, false, withIdle({ endEpochMs: 35_000 })),
|
||||
/idle-window-short/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
toLaunchIterationRecord(0, false, withIdle({ endEpochMs: 37_500 })),
|
||||
/idle-window-late/
|
||||
);
|
||||
assert.equal(
|
||||
toLaunchIterationRecord(0, false, withIdle({ ticks: 0 })).counters[
|
||||
'renderer.cdTicksIdle30s'
|
||||
],
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
@@ -269,10 +340,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', () => {
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
type JourneyMainCountersState,
|
||||
} from './journey-main-counters';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -19,6 +22,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',
|
||||
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
|
||||
@@ -32,15 +37,18 @@ 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;
|
||||
|
||||
export interface LaunchJourneyMeasurement {
|
||||
readonly electronVersion: string;
|
||||
@@ -92,14 +100,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]:
|
||||
@@ -110,6 +120,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,
|
||||
@@ -149,6 +161,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,
|
||||
@@ -185,3 +204,47 @@ 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');
|
||||
}
|
||||
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,
|
||||
@@ -124,6 +132,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,
|
||||
@@ -258,11 +267,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/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -319,8 +332,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',
|
||||
]);
|
||||
});
|
||||
@@ -14,6 +14,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',
|
||||
@@ -33,8 +34,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. */
|
||||
@@ -121,12 +120,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
|
||||
@@ -141,6 +141,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();
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "apps/web/src/environments/environment.ts",
|
||||
"with": "apps/web/src/environments/environment.prod.ts"
|
||||
"with": "apps/web/src/environments/environment.performance.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ApplicationRef, Component, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
CHANGE_DETECTION_TICK_COUNTER_KEY,
|
||||
installChangeDetectionTickCounter,
|
||||
} from './change-detection-tick-counter';
|
||||
|
||||
describe('installChangeDetectionTickCounter', () => {
|
||||
it('counts every _tick call and forwards to the original', () => {
|
||||
const calls: unknown[] = [];
|
||||
const prototype = {
|
||||
_tick(this: unknown) {
|
||||
calls.push(this);
|
||||
},
|
||||
};
|
||||
const target: Record<string, unknown> = {};
|
||||
|
||||
const counter = installChangeDetectionTickCounter(target, prototype);
|
||||
const instance = {};
|
||||
prototype._tick.call(instance);
|
||||
prototype._tick.call(instance);
|
||||
|
||||
expect(counter.count).toBe(2);
|
||||
expect(calls).toEqual([instance, instance]);
|
||||
expect(target[CHANGE_DETECTION_TICK_COUNTER_KEY]).toBe(counter);
|
||||
});
|
||||
|
||||
it('installs once per target', () => {
|
||||
const prototype = { _tick: jest.fn() };
|
||||
const target: Record<string, unknown> = {};
|
||||
|
||||
const first = installChangeDetectionTickCounter(target, prototype);
|
||||
const second = installChangeDetectionTickCounter(target, prototype);
|
||||
prototype._tick();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(first.count).toBe(1);
|
||||
});
|
||||
|
||||
it('fails when the internal tick method is missing', () => {
|
||||
expect(() => installChangeDetectionTickCounter({}, {})).toThrow(
|
||||
'change-detection-tick-counter-hook-missing'
|
||||
);
|
||||
});
|
||||
|
||||
it('counts the ticks of the installed Angular version', () => {
|
||||
@Component({ template: '{{ value() }}' })
|
||||
class CounterHostComponent {
|
||||
readonly value = signal(0);
|
||||
}
|
||||
const prototype = ApplicationRef.prototype as unknown as {
|
||||
_tick: () => void;
|
||||
};
|
||||
const original = prototype._tick;
|
||||
try {
|
||||
const counter = installChangeDetectionTickCounter({});
|
||||
const fixture = TestBed.createComponent(CounterHostComponent);
|
||||
const appRef = TestBed.inject(ApplicationRef);
|
||||
appRef.attachView(fixture.componentRef.hostView);
|
||||
const before = counter.count;
|
||||
|
||||
appRef.tick();
|
||||
fixture.componentInstance.value.set(1);
|
||||
appRef.tick();
|
||||
|
||||
expect(counter.count - before).toBe(2);
|
||||
expect(fixture.nativeElement.textContent).toBe('1');
|
||||
} finally {
|
||||
prototype._tick = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Change-detection tick counter for the performance journeys. Only the
|
||||
* `electron-performance` build of `apps/web` contains this module: it is
|
||||
* imported by `environment.performance.ts`, which `fileReplacements` swaps
|
||||
* in for `environment.ts` in that configuration alone. The production and
|
||||
* PWA builds never reference it, so their output is unchanged.
|
||||
*
|
||||
* Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode
|
||||
* `window.ng` global, which the optimized build does not publish. Every
|
||||
* tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless
|
||||
* scheduler or an explicit `ApplicationRef.tick()`, goes through the
|
||||
* internal `ApplicationRef._tick`, which is also where Angular emits the
|
||||
* profiler's `ChangeDetectionStart`. Counting its calls therefore matches
|
||||
* the profiler's count and stays comparable across the zoneless migration.
|
||||
* Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks';
|
||||
|
||||
export interface ChangeDetectionTickCounter {
|
||||
/** `ApplicationRef._tick` calls since the counter was installed. */
|
||||
readonly count: number;
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
type TickPrototype = { _tick?: (this: ApplicationRef) => void };
|
||||
|
||||
export function installChangeDetectionTickCounter(
|
||||
target: Record<string, unknown> = globalThis as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
prototype: TickPrototype = ApplicationRef.prototype as unknown as TickPrototype
|
||||
): ChangeDetectionTickCounter {
|
||||
const existing = target[CHANGE_DETECTION_TICK_COUNTER_KEY];
|
||||
if (existing !== undefined) {
|
||||
return existing as ChangeDetectionTickCounter;
|
||||
}
|
||||
const original = prototype._tick;
|
||||
// A renamed internal must fail the performance build loudly, never
|
||||
// report zero ticks.
|
||||
if (typeof original !== 'function') {
|
||||
throw new Error('change-detection-tick-counter-hook-missing');
|
||||
}
|
||||
const counter = { count: 0, schemaVersion: 1 as const };
|
||||
prototype._tick = function countedTick(this: ApplicationRef): void {
|
||||
counter.count += 1;
|
||||
original.call(this);
|
||||
};
|
||||
Object.defineProperty(target, CHANGE_DETECTION_TICK_COUNTER_KEY, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
value: counter,
|
||||
writable: false,
|
||||
});
|
||||
return counter;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// The electron-performance build's environment: production values plus the
|
||||
// change-detection tick counter the performance journeys read. See
|
||||
// change-detection-tick-counter.ts; no other configuration imports this file.
|
||||
import { installChangeDetectionTickCounter } from './change-detection-tick-counter';
|
||||
|
||||
installChangeDetectionTickCounter();
|
||||
|
||||
export { AppConfig } from './environment.prod';
|
||||
@@ -253,6 +253,10 @@ this in the same thread as the first worst offender.
|
||||
network, layout and DOM-mutation counts carry over; the Angular
|
||||
change-detection and template-update counts do not (production skips
|
||||
`checkNoChanges`), and per-firing milliseconds are upper bounds.
|
||||
The tick count in the optimized build is now measured by J1's idle window:
|
||||
`renderer.cdTicksIdle30s` read 3 ticks per 30 s on the journey's dashboard
|
||||
(one M3U source, one Xtream portal), the baseline for plan item C6. See
|
||||
[performance journeys](performance-journeys.md#idle-window).
|
||||
- Windows and Linux. Throttling and occlusion behavior differ per platform.
|
||||
- Idle during playback, and on routes other than the dashboard. The
|
||||
conditional table above is from code reading only.
|
||||
|
||||
@@ -112,6 +112,8 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
|
||||
| `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.layoutShiftScoreSettled` | The same filter from navigation start until the settle point after the first card (see [Settle window](#settle-window)), rounded to three decimals. It catches shifts that land after the cutoff, such as skeletons that collapse once their data resolves. |
|
||||
| `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. |
|
||||
| `renderer.cdTicksToFirstCard` | `ApplicationRef` ticks from document start until the terminal batch, read from the `electron-performance` build's tick counter (see [Change-detection ticks](#change-detection-ticks)). The tick that rendered the card runs before the observer's microtask, so it is included. |
|
||||
| `renderer.cdTicksIdle30s` | Ticks during the 30 s [idle window](#idle-window) that opens at the settle point, with nothing touching the page. The baseline for plan item C6 (zoneless change detection). |
|
||||
|
||||
#### Settle window
|
||||
|
||||
@@ -185,6 +187,85 @@ On the Linux CI runner (`Performance journeys` job of #1756, run
|
||||
after the first card), and the one hit shows the same two 316 px moves of
|
||||
the recent-sources rail.
|
||||
|
||||
#### Idle window
|
||||
|
||||
After the settle point J1 leaves the dashboard alone for
|
||||
`JOURNEY_IDLE_WINDOW_MS` (30 s) and counts what it does anyway:
|
||||
`renderer.cdTicksIdle30s` is the number of change-detection ticks in that
|
||||
window, and `evidence.idle.domMutations` the mutation records in the whole
|
||||
document. The [idle work audit](idle-work-audit-2026-09.md) found Eager
|
||||
components re-rendering on every such tick in a dev build; this counter
|
||||
measures the ticks in the optimized build, so plan item C6 can show what
|
||||
zoneless change detection removes.
|
||||
|
||||
The window opens when the settle window closes, so startup data still
|
||||
landing is not idle work, and it is timed by a renderer `setTimeout`. The
|
||||
record refuses an iteration whose window opened before the settle point or
|
||||
lasted less than 30 s, or more than 1 s longer
|
||||
(`launch-journey-record-idle-window-late`): a timer that late means the
|
||||
page was busy, not idle. `evidence.idle` keeps the measured `durationMs` and
|
||||
`settledToIdleStartMs`. The main-process counters and the IPC capture are
|
||||
read after the window, which does not move them: they are frozen earlier.
|
||||
J2's launches skip the window (`runLaunchJourney` with `idleWindowMs: null`),
|
||||
so its click does not wait 30 s; a record without a finished window is
|
||||
refused as a J1 measurement.
|
||||
|
||||
#### Change-detection ticks
|
||||
|
||||
`window.ng` and Angular's profiler hook (`ɵsetProfiler`) exist only in dev
|
||||
mode, and the `electron-performance` build is optimized like production. So
|
||||
that build alone installs its own counter: its `fileReplacements` entry
|
||||
swaps `apps/web/src/environments/environment.ts` for
|
||||
`environment.performance.ts`, which re-exports the production `AppConfig`
|
||||
and calls `installChangeDetectionTickCounter()` from
|
||||
`change-detection-tick-counter.ts` while `main.js` is evaluated, before
|
||||
Angular bootstraps. The counter wraps the internal `ApplicationRef._tick`,
|
||||
the method every tick runs through: the zone scheduler's `onMicrotaskEmpty`,
|
||||
the zoneless scheduler, `afterNextRender` idle buckets and the public
|
||||
`ApplicationRef.tick()` all call it, and it is where Angular emits the
|
||||
profiler's `ChangeDetectionStart`. The count therefore equals the profiler's
|
||||
tick count and stays comparable across the zoneless migration. The running
|
||||
total is `window.__iptvnatorCdTicks.count`; the probe subtracts it at the
|
||||
journey's boundaries (zero at document start for J1, the value in the
|
||||
capture-phase click listener for J2). If a future Angular renames `_tick`,
|
||||
the performance build throws at startup instead of reporting zero.
|
||||
|
||||
This is a fileReplacements swap rather than an environment flag checked in
|
||||
`app.config.ts` on purpose: a flag, even one the optimizer folds, would put
|
||||
an import and a branch into the production sources, while the swap leaves
|
||||
every file the production and PWA builds compile unchanged. Their output is
|
||||
byte-identical with and without the counter (every emitted file hashes the
|
||||
same apart from the `ngsw.json` build timestamp), so
|
||||
`renderer.initialBytes` cannot move. A build-config test fails if another
|
||||
configuration references `environment.performance.ts`. The other benchmarks
|
||||
built from `electron-performance` (M3U import, Xtream, cancellation) carry
|
||||
the counter too; it adds one increment per tick.
|
||||
|
||||
A build without the counter reports
|
||||
`capabilities.changeDetectionTicks: "unavailable-counter-missing"` and the
|
||||
record refuses the iteration, so a zero is never a missing hook.
|
||||
|
||||
First local measurement (macOS, 2026-09-30, three `perf:journeys` runs,
|
||||
18 iterations per journey including warm-ups):
|
||||
|
||||
| Counter | Run 1 | Run 2 | Run 3 |
|
||||
| ----------------------------- | ----- | ----- | ----- |
|
||||
| `renderer.cdTicksToFirstCard` | 20 | 20 | 21 |
|
||||
| `renderer.cdTicksIdle30s` | 3 | 3 | 3 |
|
||||
| `renderer.cdTicksToFirstPage` | 22 | 22 | 22 |
|
||||
|
||||
Every run marked all three `stable: true`. `renderer.cdTicksIdle30s` and
|
||||
`renderer.cdTicksToFirstPage` were identical in all 18 iterations, and every
|
||||
idle window saw 90 mutation records. `renderer.cdTicksToFirstCard` read 20
|
||||
in 13 iterations and 21 in the five measured iterations of the third run
|
||||
(its warm-up read 20), with every other J1 counter unchanged
|
||||
(`renderer.ipcCallsToFirstCard` 14, `renderer.domMutationsToFirstCard` 554).
|
||||
With zone.js a tick follows every macrotask that ran in the Angular zone, so
|
||||
two startup callbacks that land in one task on one launch and in two tasks
|
||||
on another differ by one tick without any different work. Treat a one-tick
|
||||
difference in J1 as that race, and confirm on the CI runner that the counter
|
||||
is deterministic before it becomes a baseline.
|
||||
|
||||
#### Main-process counters
|
||||
|
||||
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
|
||||
@@ -239,14 +320,8 @@ iteration. When iterations disagree, the summary reports the maximum and marks
|
||||
the counter `stable: false` under `counterStability`; such a counter is not
|
||||
promoted to a guardrail until it is deterministic.
|
||||
|
||||
One counter from the plan is listed under `unavailable` with the reason
|
||||
instead of being faked:
|
||||
|
||||
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
|
||||
scripts, which sets `ngDevMode` to false, so Angular does not publish
|
||||
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
|
||||
terminal moment and the record refuses a build where the hook exists but was
|
||||
not counted.
|
||||
No J1 counter from the plan is listed under `unavailable` any more; the
|
||||
list stays in the record so a future gap is reported instead of faked.
|
||||
|
||||
### Wall-clock
|
||||
|
||||
@@ -306,6 +381,8 @@ serial-depth counter is a better guardrail candidate than a raw call count.
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"counters": {
|
||||
"renderer.cdTicksIdle30s": 3,
|
||||
"renderer.cdTicksToFirstCard": 20,
|
||||
"renderer.ipcCallsToFirstCard": 12,
|
||||
"renderer.layoutShiftScoreSettled": 0.236
|
||||
},
|
||||
@@ -323,7 +400,7 @@ serial-depth counter is a better guardrail candidate than a raw call count.
|
||||
"spawnToFirstCardMs.p50": 1234.5,
|
||||
"spawnToFirstCardMs.p90": 1300.1
|
||||
},
|
||||
"unavailable": { "renderer.cdTicksToFirstCard": "reason" },
|
||||
"unavailable": {},
|
||||
"iterations": [
|
||||
{
|
||||
"index": 0,
|
||||
@@ -446,12 +523,12 @@ strings and stream paths carry credentials and are never stored.
|
||||
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `renderer.ipcCallsToFirstPage` | Bridge `start` trace events between the start and end sentinels, counted by a second `journey-main-ipc-capture.ts` instance installed with `startSentinelId`. Calls before the start marker are tallied separately (`callsBeforeStart`); a start marker that is missing, repeated or received after the end sentinel fails the iteration. |
|
||||
| `renderer.domMutationsToFirstPage` | `MutationRecord`s from the click until the terminal batch. Records produced before the click (hover, settling) are taken from the observer at the start and counted under `evidence.settle` instead. |
|
||||
| `renderer.cdTicksToFirstPage` | `ApplicationRef` ticks from the click until the terminal batch: the counter's running total read in the capture-phase click listener, before the app handles the click, subtracted from its value at the terminal batch (see [Change-detection ticks](#change-detection-ticks)). |
|
||||
| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`. |
|
||||
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the cutoff. The task that dispatches the click began before the event's timestamp and still counts; buffered J1 tasks that ended before the click are dropped. Evidence until it is shown to be stable on the CI runner, as for J1. |
|
||||
| `main.mockHttpRequestsToSettled` | Requests the proxy received from the click until, after the terminal batch, no new request had arrived for 1 s and none was in flight (a response slower than that, and what it triggers, stays inside the window). The window ends at the ledger position read by that accepted quiet sample; a request arriving after it was never seen in flight, so it goes to `evidence.httpRequestsAfterSettledByRoute` instead of the counter. The ledger is read 1 s after that sample, so that late traffic is actually observed. The window starts at the renderer's click stamp, the same boundary as every other J2 counter, not when Playwright began its actionability checks; the proxy stamps requests with the test process's wall clock, and both processes read the same host clock. Bounding by the terminal would compare the test process's clock with the renderer's, so the count up to the terminal epoch is evidence only (`evidence.httpRequestsToFirstPage`); `evidence.httpRequestsByRoute` names the requests. |
|
||||
|
||||
Two counters are listed under `unavailable`. `renderer.cdTicksToFirstPage`
|
||||
is missing for the same reason as its J1 counterpart.
|
||||
One counter is listed under `unavailable`.
|
||||
`main.sqlStatementsToFirstPage` is missing because the running
|
||||
`main.sqlStatements` total that J1 freezes at `ready-to-show` can only be
|
||||
read from the test process through the journey gate. It therefore cannot be
|
||||
|
||||
Reference in new issue
Block a user