mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
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,
|
||||
|
||||
Reference in new issue
Block a user