mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
test(perf): add a settled layout-shift counter to J1 (#1756)
* test(perf): add a settled layout-shift counter to the J1 launch journey renderer.layoutShiftScore stops at the first-card cutoff, so the dashboard shift fixed in #1738 (about 0.23, roughly 15 ms after the first card) read as 0. renderer.layoutShiftScoreSettled sums the same non-input layout-shift entries until the workspace content has been quiet for 500 ms, capped at 3 s after the cutoff. The first-card counter is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(perf): attribute late layout shifts and record the first measurement evidence.settle.lateShifts lists the counted shifts after the first-card cutoff with the nodes the browser attributes them to. On master the settled score is 0.236: the recent-sources rail moves up 316 px about 12 ms after the first card and back down shortly after, a flicker #1738 did not cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(perf): end the settle window at its scheduled deadline A cap or quiet timer delayed by a busy main thread used the moment it ran as the settle point, so shifts after the 3 s cap could enter the settled counter. The settle point is now the deadline the timer was scheduled for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(perf): record the runner's settled layout-shift measurement 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:
6 files changed
+693
-24
No files matched your search
@@ -13,6 +13,9 @@ import {
|
||||
JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY,
|
||||
JOURNEY_OPEN_SOURCE_START_SENTINEL_ID,
|
||||
JOURNEY_PROBE_STATE_KEY,
|
||||
JOURNEY_SETTLE_CAP_MS,
|
||||
JOURNEY_SETTLE_QUIET_MS,
|
||||
JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
journeyRendererProbeScript,
|
||||
type JourneyRendererProbeOptions,
|
||||
type JourneyRendererProbeState,
|
||||
@@ -22,6 +25,11 @@ interface FakeEntry {
|
||||
duration?: number;
|
||||
entryType: string;
|
||||
hadRecentInput?: boolean;
|
||||
sources?: {
|
||||
currentRect: { height: number; y: number };
|
||||
node: unknown;
|
||||
previousRect: { height: number; y: number };
|
||||
}[];
|
||||
startTime: number;
|
||||
value?: number;
|
||||
}
|
||||
@@ -45,7 +53,14 @@ interface Fixture {
|
||||
|
||||
const PAGE = `<!doctype html><html><head></head><body class="mat-app-background">
|
||||
<div id="initial-splash" role="status"><span>IPTVnator</span></div>
|
||||
<app-root></app-root></body></html>`;
|
||||
<app-root><main class="workspace-content"></main></app-root></body></html>`;
|
||||
|
||||
/** Short settle timers so the launch fixtures do not wait 500 ms each. */
|
||||
const FAST_SETTLE = {
|
||||
capMs: 1_000,
|
||||
quietMs: 30,
|
||||
rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
} as const;
|
||||
|
||||
function installFakePerformance(
|
||||
window: JSDOM['window'],
|
||||
@@ -114,7 +129,11 @@ function createFixture(
|
||||
});
|
||||
return createFixtureFromDom(
|
||||
dom,
|
||||
{ ...createLaunchJourneyProbeOptions(), ...optionOverrides },
|
||||
{
|
||||
...createLaunchJourneyProbeOptions(),
|
||||
settle: FAST_SETTLE,
|
||||
...optionOverrides,
|
||||
},
|
||||
bridge
|
||||
);
|
||||
}
|
||||
@@ -171,16 +190,20 @@ const liveStates: (() => JourneyRendererProbeState)[] = [];
|
||||
|
||||
/**
|
||||
* Waits `ms`, then until every probe that reached its terminal batch has also
|
||||
* passed the post-paint cutoff (a rAF plus a timer). A fixed delay alone
|
||||
* flakes when the harness runs all spec files in parallel.
|
||||
* passed the post-paint cutoff (a rAF plus a timer) and closed its settle
|
||||
* window. A fixed delay alone flakes when the harness runs all spec files in
|
||||
* parallel.
|
||||
*/
|
||||
async function settle(ms = 40): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + 2_000;
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (
|
||||
liveStates.some((read) => {
|
||||
const state = read();
|
||||
return state.terminal !== null && !state.final;
|
||||
return (
|
||||
state.terminal !== null &&
|
||||
(!state.final || state.settle.status === 'pending')
|
||||
);
|
||||
}) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
@@ -193,7 +216,7 @@ function renderFirstCard(fixture: Fixture): void {
|
||||
document.getElementById('initial-splash')?.remove();
|
||||
const rail = document.createElement('section');
|
||||
rail.setAttribute('data-test-id', 'dashboard-recent-sources-rail');
|
||||
document.querySelector('app-root')?.append(rail);
|
||||
document.querySelector('main.workspace-content')?.append(rail);
|
||||
const card = document.createElement('div');
|
||||
card.setAttribute('data-test-id', 'dashboard-recent-sources-rail-card');
|
||||
rail.append(card);
|
||||
@@ -361,6 +384,206 @@ test('sums layout shifts without recent input and counts long tasks over 50 ms u
|
||||
assert.equal(fixture.state().counters.longTasks, 3);
|
||||
});
|
||||
|
||||
function layoutShift(
|
||||
startTime: number,
|
||||
value: number,
|
||||
hadRecentInput = false
|
||||
): FakeEntry {
|
||||
return { entryType: 'layout-shift', hadRecentInput, startTime, value };
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 3_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() > deadline) throw new Error('waitFor timed out');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps summing shifts without recent input after the first-card cutoff until the workspace content is quiet', async () => {
|
||||
const fixture = createFixture({
|
||||
settle: { ...FAST_SETTLE, capMs: 5_000, quietMs: 80 },
|
||||
});
|
||||
const [observer] = fixture.observers as [FakeObserver];
|
||||
const now = () => fixture.window.performance.now();
|
||||
const content = fixture.window.document.querySelector(
|
||||
'main.workspace-content'
|
||||
) as HTMLElement;
|
||||
observer.emit([layoutShift(now(), 0.25)]);
|
||||
renderFirstCard(fixture);
|
||||
await waitFor(() => fixture.rawState().final);
|
||||
const cutoff = fixture.state();
|
||||
assert.equal(cutoff.counters.layoutShiftScore, 0.25);
|
||||
assert.equal(cutoff.settle.status, 'pending');
|
||||
assert.equal(cutoff.settle.observedTarget, 'root');
|
||||
assert.equal(cutoff.counters.layoutShiftScoreSettled, 0);
|
||||
assert.equal(observer.disconnected, false);
|
||||
|
||||
// A skeleton rail that resolved empty collapses about 15 ms after the
|
||||
// first card and pulls the rails below upwards (#1738).
|
||||
const rail = fixture.window.document.createElement('lib-dashboard-rail');
|
||||
const railSection = fixture.window.document.createElement('section');
|
||||
railSection.className = 'rail';
|
||||
railSection.setAttribute('data-test-id', 'dashboard-favorites-rail');
|
||||
rail.append(railSection);
|
||||
const collapse = {
|
||||
...layoutShift(now(), 0.5),
|
||||
sources: [
|
||||
{
|
||||
currentRect: { height: 220, y: 300 },
|
||||
node: rail,
|
||||
previousRect: { height: 220, y: 540 },
|
||||
},
|
||||
],
|
||||
};
|
||||
observer.emit([collapse, layoutShift(now(), 0.3, true)]);
|
||||
for (let step = 0; step < 3; step += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
content.append(fixture.window.document.createElement('div'));
|
||||
}
|
||||
// Queued, not yet delivered, when the settle point is sampled.
|
||||
observer.queue.push(
|
||||
layoutShift(now(), 0.125),
|
||||
layoutShift(now() + 60_000, 9)
|
||||
);
|
||||
// Outside the settle root: does not keep the window open.
|
||||
fixture.window.document.body.setAttribute('data-late', '1');
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.equal(state.settle.status, 'quiet');
|
||||
assert.equal(state.settle.domMutations, 3);
|
||||
assert.ok(state.settle.epochMs !== null && state.settle.epochMs > 0);
|
||||
assert.ok(
|
||||
state.settle.epochMs - (state.settle.lastMutationEpochMs ?? 0) >= 75,
|
||||
'the quiet period restarts at the last mutation'
|
||||
);
|
||||
assert.equal(state.counters.layoutShiftScoreSettled, 0.875);
|
||||
// Only shifts after the cutoff are attributed; input-flagged ones are not.
|
||||
assert.deepEqual(
|
||||
state.settle.lateShifts.map((shift) => [shift.value, shift.sources]),
|
||||
[
|
||||
[
|
||||
0.5,
|
||||
[
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaY: -240,
|
||||
node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]',
|
||||
},
|
||||
],
|
||||
],
|
||||
[0.125, []],
|
||||
]
|
||||
);
|
||||
assert.ok(
|
||||
state.settle.lateShifts.every((shift) => shift.afterFirstCardMs > 0)
|
||||
);
|
||||
// The first-card counters were frozen at the cutoff.
|
||||
assert.equal(state.counters.layoutShiftScore, 0.25);
|
||||
assert.equal(state.counters.recentInputLayoutShiftScore, 0);
|
||||
assert.equal(observer.disconnected, true);
|
||||
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
|
||||
|
||||
observer.emit([layoutShift(now(), 1)]);
|
||||
content.append(fixture.window.document.createElement('div'));
|
||||
await settle();
|
||||
assert.equal(fixture.state().counters.layoutShiftScoreSettled, 0.875);
|
||||
assert.equal(fixture.state().settle.domMutations, 3);
|
||||
});
|
||||
|
||||
test('the cap ends the settle window while the workspace content keeps mutating', async () => {
|
||||
const fixture = createFixture({
|
||||
settle: { ...FAST_SETTLE, capMs: 250, quietMs: 150 },
|
||||
});
|
||||
const [observer] = fixture.observers as [FakeObserver];
|
||||
const now = () => fixture.window.performance.now();
|
||||
const { document } = fixture.window;
|
||||
renderFirstCard(fixture);
|
||||
await waitFor(() => fixture.rawState().final);
|
||||
const content = document.querySelector('main.workspace-content');
|
||||
const ticker = setInterval(() => {
|
||||
content?.setAttribute('data-tick', String(now()));
|
||||
}, 10);
|
||||
try {
|
||||
observer.emit([layoutShift(now(), 0.0625)]);
|
||||
await waitFor(() => fixture.rawState().settle.status !== 'pending');
|
||||
observer.emit([layoutShift(now(), 0.5)]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
} finally {
|
||||
clearInterval(ticker);
|
||||
}
|
||||
const state = fixture.state();
|
||||
assert.equal(state.settle.status, 'cap');
|
||||
assert.ok(state.settle.domMutations > 0);
|
||||
assert.ok(
|
||||
(state.settle.epochMs ?? 0) - (state.firstCardPaintEpochMs ?? 0) >= 245,
|
||||
'the cap is measured from the first-card cutoff'
|
||||
);
|
||||
assert.equal(state.counters.layoutShiftScoreSettled, 0.0625);
|
||||
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
|
||||
});
|
||||
|
||||
test('a late cap timer ends the window at its scheduled deadline', async () => {
|
||||
const fixture = createFixture({
|
||||
settle: { ...FAST_SETTLE, capMs: 100, quietMs: 60_000 },
|
||||
});
|
||||
const [observer] = fixture.observers as [FakeObserver];
|
||||
const now = () => fixture.window.performance.now();
|
||||
renderFirstCard(fixture);
|
||||
await waitFor(() => fixture.rawState().final);
|
||||
const cutoffMs =
|
||||
(fixture.rawState().firstCardPaintEpochMs ?? 0) -
|
||||
fixture.window.performance.timeOrigin;
|
||||
observer.emit([layoutShift(now(), 0.25)]);
|
||||
// Hold the main thread past the cap so its timer runs late, and shift
|
||||
// the layout after the deadline while it is held.
|
||||
while (now() < cutoffMs + 250) {
|
||||
// busy
|
||||
}
|
||||
observer.emit([layoutShift(now(), 0.5)]);
|
||||
observer.queue.push(layoutShift(now(), 1));
|
||||
await waitFor(() => fixture.rawState().settle.status !== 'pending');
|
||||
const state = fixture.state();
|
||||
assert.equal(state.settle.status, 'cap');
|
||||
const settledAfterCutoff =
|
||||
(state.settle.epochMs ?? 0) - (state.firstCardPaintEpochMs ?? 0);
|
||||
assert.ok(
|
||||
settledAfterCutoff >= 100 && settledAfterCutoff < 150,
|
||||
`settle point at the deadline, not when the timer ran (${settledAfterCutoff} ms)`
|
||||
);
|
||||
assert.equal(state.counters.layoutShiftScoreSettled, 0.25);
|
||||
});
|
||||
|
||||
test('watches the document element when the settle root is missing', async () => {
|
||||
const fixture = createFixture({
|
||||
settle: { ...FAST_SETTLE, rootSelector: 'app-missing-root' },
|
||||
});
|
||||
renderFirstCard(fixture);
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.equal(state.settle.observedTarget, 'documentElement');
|
||||
assert.equal(state.settle.status, 'quiet');
|
||||
});
|
||||
|
||||
test('refuses a probe whose settle window is still open', async () => {
|
||||
const fixture = createFixture({
|
||||
settle: { ...FAST_SETTLE, capMs: 60_000, quietMs: 60_000 },
|
||||
});
|
||||
renderFirstCard(fixture);
|
||||
await waitFor(() => fixture.rawState().final);
|
||||
assert.throws(
|
||||
() => assertJourneyRendererProbeState(fixture.state()),
|
||||
/settle-pending/
|
||||
);
|
||||
// Mark the window closed so later tests' `settle()` does not wait on it,
|
||||
// and stop its timers so they do not keep the runner alive.
|
||||
fixture.rawState().settle.status = 'cap';
|
||||
fixture.window.close();
|
||||
});
|
||||
|
||||
test('does not end while the splash is present, off the workspace route, or before a card is visible', async () => {
|
||||
const withSplash = createFixture();
|
||||
const rail = withSplash.window.document.createElement('div');
|
||||
@@ -437,6 +660,13 @@ test('launch options target the workspace source cards and the shared sentinel',
|
||||
assert.equal(options.routeFragment, '/workspace');
|
||||
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
|
||||
assert.match(options.cardSelector, /app-playlist-item/);
|
||||
assert.deepEqual(options.settle, {
|
||||
capMs: JOURNEY_SETTLE_CAP_MS,
|
||||
quietMs: JOURNEY_SETTLE_QUIET_MS,
|
||||
rootSelector: 'main.workspace-content',
|
||||
});
|
||||
assert.equal(JOURNEY_SETTLE_QUIET_MS, 500);
|
||||
assert.equal(JOURNEY_SETTLE_CAP_MS, 3_000);
|
||||
});
|
||||
|
||||
// J2 "Open a source": the probe is armed in a loaded document and starts at
|
||||
@@ -643,6 +873,10 @@ test('drops performance entries from before the click and keeps recent-input shi
|
||||
assert.equal(state.final, true);
|
||||
assert.equal(state.counters.layoutShiftScore, 0.125);
|
||||
assert.equal(state.counters.recentInputLayoutShiftScore, 0.25);
|
||||
// J2 has no settle window: the observers close at the cutoff.
|
||||
assert.equal(state.settle.status, 'disabled');
|
||||
assert.equal(state.counters.layoutShiftScoreSettled, 0);
|
||||
assert.ok(fixture.observers.every((observer) => observer.disconnected));
|
||||
assert.equal(state.counters.longTasks, 1);
|
||||
assert.deepEqual(state.longTaskDurationsMs, [90]);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ import type { Page } from '@playwright/test';
|
||||
* `window` (which runs before any listener of the app) and starts counting at
|
||||
* the first click inside `startClick.selector`. It counts DOM mutations,
|
||||
* layout shifts and long tasks until the journey's terminal condition and
|
||||
* then emits one JSON blob under `options.stateKey`.
|
||||
* 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.
|
||||
*
|
||||
* IPC invocations are not counted here: the bridge object exposed by
|
||||
* `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe
|
||||
@@ -50,6 +52,23 @@ export interface JourneyRendererProbeStartClick {
|
||||
readonly sentinelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* J1's settle window after the first card: it ends once nothing under
|
||||
* `rootSelector` has mutated for `quietMs`, or `capMs` after the first-card
|
||||
* cutoff, whichever comes first. See docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export interface JourneyRendererProbeSettleOptions {
|
||||
readonly capMs: number;
|
||||
readonly quietMs: number;
|
||||
/** Falls back to the document element when nothing matches. */
|
||||
readonly rootSelector: string;
|
||||
}
|
||||
|
||||
export const JOURNEY_SETTLE_QUIET_MS = 500;
|
||||
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';
|
||||
|
||||
export interface JourneyRendererProbeOptions {
|
||||
/** Selector for the element whose visibility ends the journey. */
|
||||
readonly cardSelector: string;
|
||||
@@ -59,6 +78,8 @@ export interface JourneyRendererProbeOptions {
|
||||
/** Pathname fragment the terminal route must contain. */
|
||||
readonly routeFragment: string;
|
||||
readonly sentinelId: string;
|
||||
/** Absent: no settle window, the probe ends at the first-card cutoff. */
|
||||
readonly settle?: JourneyRendererProbeSettleOptions;
|
||||
readonly sentinelMethod: string;
|
||||
/** Element id of the inline splash that must be gone at the end. */
|
||||
readonly splashId: string;
|
||||
@@ -71,6 +92,11 @@ export interface JourneyRendererProbeCounters {
|
||||
domMutations: number;
|
||||
/** Shifts with `hadRecentInput === false` (the CLS definition). */
|
||||
layoutShiftScore: number;
|
||||
/**
|
||||
* The same filter from the journey's start until the settle point. Zero
|
||||
* while `settle.status` is `pending` or `disabled`.
|
||||
*/
|
||||
layoutShiftScoreSettled: number;
|
||||
longTasks: number;
|
||||
/**
|
||||
* Shifts with `hadRecentInput === true`. Zero for J1, which has no
|
||||
@@ -79,6 +105,18 @@ export interface JourneyRendererProbeCounters {
|
||||
recentInputLayoutShiftScore: number;
|
||||
}
|
||||
|
||||
export interface JourneyRendererProbeLateShift {
|
||||
/** Entry start minus the first-card terminal epoch. */
|
||||
readonly afterFirstCardMs: number;
|
||||
/** `tag.class[data-test-id]` and the vertical move of each source. */
|
||||
readonly sources: readonly {
|
||||
readonly deltaHeight: number;
|
||||
readonly deltaY: number;
|
||||
readonly node: string;
|
||||
}[];
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
export interface JourneyRendererProbeState {
|
||||
readonly capabilities: {
|
||||
changeDetectionTicks: string;
|
||||
@@ -113,6 +151,20 @@ export interface JourneyRendererProbeState {
|
||||
readonly epochMs: number | null;
|
||||
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
|
||||
};
|
||||
/** `final` freezes the first-card counters; the settle window ends later. */
|
||||
settle: {
|
||||
/** Mutation records under the settle root after the cutoff. */
|
||||
domMutations: number;
|
||||
epochMs: number | null;
|
||||
lastMutationEpochMs: number | null;
|
||||
/**
|
||||
* Counted shifts after the first-card cutoff, at most 20, with the
|
||||
* nodes that moved, so a late shift can be traced to its component.
|
||||
*/
|
||||
lateShifts: JourneyRendererProbeLateShift[];
|
||||
observedTarget: 'documentElement' | 'root' | null;
|
||||
status: 'cap' | 'disabled' | 'pending' | 'quiet';
|
||||
};
|
||||
start: {
|
||||
/** `min(event.timeStamp, listener time)` as epoch milliseconds. */
|
||||
readonly epochMs: number;
|
||||
@@ -145,6 +197,7 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
const epoch = (): number => performance.timeOrigin + performance.now();
|
||||
const startClick = options.startClick ?? null;
|
||||
const settleOptions = options.settle ?? null;
|
||||
const companionSelectors = options.companionSelectors ?? [];
|
||||
const bridge = target['electron'] as Record<string, unknown> | undefined;
|
||||
const state: JourneyRendererProbeState = {
|
||||
@@ -159,6 +212,7 @@ export function journeyRendererProbeScript(
|
||||
counters: {
|
||||
domMutations: 0,
|
||||
layoutShiftScore: 0,
|
||||
layoutShiftScoreSettled: 0,
|
||||
longTasks: 0,
|
||||
recentInputLayoutShiftScore: 0,
|
||||
},
|
||||
@@ -178,6 +232,14 @@ export function journeyRendererProbeScript(
|
||||
preStart: { domMutations: 0, lastMutationEpochMs: null },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: null, status: 'not-sent' },
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
lastMutationEpochMs: null,
|
||||
lateShifts: [],
|
||||
observedTarget: null,
|
||||
status: settleOptions === null ? 'disabled' : 'pending',
|
||||
},
|
||||
start: null,
|
||||
terminal: null,
|
||||
};
|
||||
@@ -249,13 +311,24 @@ export function journeyRendererProbeScript(
|
||||
// cutoff wait here so the cutoff applies to them as well.
|
||||
const pendingLayoutShifts: PerformanceEntry[] = [];
|
||||
const pendingLongTasks: PerformanceEntry[] = [];
|
||||
// Every layout shift delivered until the settle point, kept apart from
|
||||
// the first-card path so that counter stays exactly as it was; the
|
||||
// settle bound is applied once it is known.
|
||||
const settleLayoutShifts: PerformanceEntry[] = [];
|
||||
const collectSettleShifts = (entries: readonly PerformanceEntry[]) => {
|
||||
if (state.settle.status === 'pending') {
|
||||
settleLayoutShifts.push(...entries);
|
||||
}
|
||||
};
|
||||
const observe = (
|
||||
type: string,
|
||||
accept: (entries: readonly PerformanceEntry[], until: number) => void,
|
||||
pending: PerformanceEntry[]
|
||||
pending: PerformanceEntry[],
|
||||
collect: ((entries: readonly PerformanceEntry[]) => void) | null
|
||||
): PerformanceObserver | null => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
collect?.(list.getEntries());
|
||||
if (state.final) return;
|
||||
if (state.terminal !== null) {
|
||||
pending.push(...list.getEntries());
|
||||
@@ -272,23 +345,133 @@ export function journeyRendererProbeScript(
|
||||
const layoutShiftObserver = observe(
|
||||
'layout-shift',
|
||||
acceptLayoutShift,
|
||||
pendingLayoutShifts
|
||||
pendingLayoutShifts,
|
||||
collectSettleShifts
|
||||
);
|
||||
const longTaskObserver = observe(
|
||||
'longtask',
|
||||
acceptLongTasks,
|
||||
pendingLongTasks
|
||||
pendingLongTasks,
|
||||
null
|
||||
);
|
||||
state.capabilities.layoutShift = layoutShiftObserver !== null;
|
||||
state.capabilities.longTask = longTaskObserver !== null;
|
||||
|
||||
const endSettle = (status: 'cap' | 'quiet', untilEpochMs: number) => {
|
||||
if (layoutShiftObserver) {
|
||||
collectSettleShifts(layoutShiftObserver.takeRecords());
|
||||
layoutShiftObserver.disconnect();
|
||||
}
|
||||
let score = 0;
|
||||
for (const entry of settleLayoutShifts) {
|
||||
const shift = entry as PerformanceEntry & {
|
||||
hadRecentInput?: boolean;
|
||||
sources?: readonly LateShiftSource[];
|
||||
value?: number;
|
||||
};
|
||||
if (
|
||||
typeof shift.value !== 'number' ||
|
||||
shift.hadRecentInput === true ||
|
||||
!inWindow(entry, untilEpochMs)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
score += shift.value;
|
||||
const entryEpochMs = performance.timeOrigin + entry.startTime;
|
||||
if (
|
||||
entryEpochMs > (state.firstCardPaintEpochMs ?? untilEpochMs) &&
|
||||
state.settle.lateShifts.length < 20
|
||||
) {
|
||||
state.settle.lateShifts.push({
|
||||
afterFirstCardMs:
|
||||
entryEpochMs - (state.terminal?.epochMs ?? 0),
|
||||
sources: (shift.sources ?? []).map(describeSource),
|
||||
value: shift.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
state.counters.layoutShiftScoreSettled = score;
|
||||
state.settle.epochMs = untilEpochMs;
|
||||
state.settle.status = status;
|
||||
};
|
||||
type LateShiftSource = {
|
||||
currentRect?: { height: number; y: number };
|
||||
node?: Node | null;
|
||||
previousRect?: { height: number; y: number };
|
||||
};
|
||||
const describeSource = (source: LateShiftSource) => {
|
||||
const node = source.node;
|
||||
let label = node ? node.nodeName.toLowerCase() : 'unknown';
|
||||
if (node instanceof Element) {
|
||||
const className = node.classList.item(0);
|
||||
// Component hosts such as `lib-dashboard-rail` carry the test
|
||||
// id on their first child.
|
||||
const testId =
|
||||
node.getAttribute('data-test-id') ??
|
||||
node.firstElementChild?.getAttribute('data-test-id');
|
||||
label += className ? `.${className}` : '';
|
||||
label += testId ? `[data-test-id="${testId}"]` : '';
|
||||
}
|
||||
const before = source.previousRect;
|
||||
const after = source.currentRect;
|
||||
return {
|
||||
deltaHeight: before && after ? after.height - before.height : 0,
|
||||
deltaY: before && after ? after.y - before.y : 0,
|
||||
node: label,
|
||||
};
|
||||
};
|
||||
// Starts at the first-card cutoff. Every mutation record under the root
|
||||
// restarts the quiet timer; the cap timer never moves. A timer can run
|
||||
// late on a busy main thread, so the settle point is the deadline it was
|
||||
// scheduled for, never the moment it ran: a late timer must not let
|
||||
// shifts after the deadline into the counter.
|
||||
const startSettle = (settle: JourneyRendererProbeSettleOptions) => {
|
||||
const root = document.querySelector(settle.rootSelector);
|
||||
state.settle.observedTarget =
|
||||
root === null ? 'documentElement' : 'root';
|
||||
let quietTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let quietDeadlineEpochMs = Number.POSITIVE_INFINITY;
|
||||
const capDeadlineEpochMs = epoch() + settle.capMs;
|
||||
const capTimer = setTimeout(() => end('cap'), settle.capMs);
|
||||
const settleObserver = new MutationObserver((records) => {
|
||||
if (state.settle.status !== 'pending') return;
|
||||
state.settle.domMutations += records.length;
|
||||
state.settle.lastMutationEpochMs = epoch();
|
||||
armQuiet();
|
||||
});
|
||||
const end = (status: 'cap' | 'quiet'): void => {
|
||||
if (state.settle.status !== 'pending') return;
|
||||
clearTimeout(quietTimer);
|
||||
clearTimeout(capTimer);
|
||||
state.settle.domMutations += settleObserver.takeRecords().length;
|
||||
settleObserver.disconnect();
|
||||
const deadline =
|
||||
status === 'cap' ? capDeadlineEpochMs : quietDeadlineEpochMs;
|
||||
endSettle(status, Math.min(epoch(), deadline));
|
||||
};
|
||||
const armQuiet = (): void => {
|
||||
clearTimeout(quietTimer);
|
||||
quietDeadlineEpochMs = epoch() + settle.quietMs;
|
||||
quietTimer = setTimeout(() => end('quiet'), settle.quietMs);
|
||||
};
|
||||
settleObserver.observe(root ?? document.documentElement ?? document, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
armQuiet();
|
||||
};
|
||||
|
||||
const finalize = (untilEpochMs: number): void => {
|
||||
if (layoutShiftObserver) {
|
||||
const records = layoutShiftObserver.takeRecords();
|
||||
collectSettleShifts(records);
|
||||
acceptLayoutShift(
|
||||
[...pendingLayoutShifts, ...layoutShiftObserver.takeRecords()],
|
||||
[...pendingLayoutShifts, ...records],
|
||||
untilEpochMs
|
||||
);
|
||||
layoutShiftObserver.disconnect();
|
||||
if (settleOptions === null) layoutShiftObserver.disconnect();
|
||||
}
|
||||
if (longTaskObserver) {
|
||||
acceptLongTasks(
|
||||
@@ -299,6 +482,7 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
state.firstCardPaintEpochMs = untilEpochMs;
|
||||
state.final = true;
|
||||
if (settleOptions !== null) startSettle(settleOptions);
|
||||
};
|
||||
const callSentinel = (id: string): 'bridge-missing' | 'failed' | 'sent' => {
|
||||
const method = bridge?.[options.sentinelMethod];
|
||||
@@ -441,6 +625,11 @@ export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions {
|
||||
routeFragment: '/workspace',
|
||||
sentinelId: JOURNEY_IPC_SENTINEL_ID,
|
||||
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
|
||||
settle: {
|
||||
capMs: JOURNEY_SETTLE_CAP_MS,
|
||||
quietMs: JOURNEY_SETTLE_QUIET_MS,
|
||||
rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
},
|
||||
splashId: 'initial-splash',
|
||||
stateKey: JOURNEY_PROBE_STATE_KEY,
|
||||
};
|
||||
@@ -501,10 +690,17 @@ export async function waitForJourneyRendererProbe(
|
||||
stateKey: string,
|
||||
timeoutMs: number
|
||||
): Promise<JourneyRendererProbeState> {
|
||||
// A settle window, where enabled, ends after `final`.
|
||||
await page.waitForFunction(
|
||||
(key) =>
|
||||
(globalThis as unknown as Record<string, { final?: boolean }>)[key]
|
||||
?.final === true,
|
||||
(key) => {
|
||||
const state = (
|
||||
globalThis as unknown as Record<
|
||||
string,
|
||||
{ final?: boolean; settle?: { status?: string } }
|
||||
>
|
||||
)[key];
|
||||
return state?.final === true && state.settle?.status !== 'pending';
|
||||
},
|
||||
stateKey,
|
||||
{ polling: 50, timeout: timeoutMs }
|
||||
);
|
||||
@@ -532,6 +728,9 @@ export function assertJourneyRendererProbeState(
|
||||
) {
|
||||
throw new Error('journey-renderer-probe-incomplete');
|
||||
}
|
||||
if (state.settle.status === 'pending') {
|
||||
throw new Error('journey-renderer-probe-settle-pending');
|
||||
}
|
||||
if (state.invalidReasons.length > 0) {
|
||||
throw new Error(
|
||||
`journey-renderer-probe-invalid: ${state.invalidReasons.join(', ')}`
|
||||
|
||||
@@ -22,6 +22,7 @@ function measurement(
|
||||
counters: {
|
||||
domMutations: 480,
|
||||
layoutShiftScore: 0.123456789,
|
||||
layoutShiftScoreSettled: 0.2304999,
|
||||
longTasks: 2,
|
||||
recentInputLayoutShiftScore: 0,
|
||||
},
|
||||
@@ -44,6 +45,26 @@ function measurement(
|
||||
preStart: { domMutations: 0, lastMutationEpochMs: null },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 2_601, status: 'sent' },
|
||||
settle: {
|
||||
domMutations: 37,
|
||||
epochMs: 3_180.06,
|
||||
lastMutationEpochMs: 2_680,
|
||||
lateShifts: [
|
||||
{
|
||||
afterFirstCardMs: 14.96,
|
||||
sources: [
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaY: -240,
|
||||
node: 'section.dashboard-rail',
|
||||
},
|
||||
],
|
||||
value: 0.23049,
|
||||
},
|
||||
],
|
||||
observedTarget: 'root',
|
||||
status: 'quiet',
|
||||
},
|
||||
start: null,
|
||||
terminal: {
|
||||
cardCount: 2,
|
||||
@@ -111,6 +132,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
|
||||
'renderer.domMutationsToFirstCard': 480,
|
||||
'renderer.ipcCallsToFirstCard': 14,
|
||||
'renderer.layoutShiftScore': 0.123,
|
||||
'renderer.layoutShiftScoreSettled': 0.23,
|
||||
'renderer.longTasks': 2,
|
||||
});
|
||||
assert.deepEqual(record.wallClock, {
|
||||
@@ -141,8 +163,28 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
|
||||
rendererGateBlankLoaded: 1_050,
|
||||
rendererGateReleased: 1_150,
|
||||
rendererProbeInstalled: 1_200,
|
||||
settled: 3_180.06,
|
||||
spawn: 1_000,
|
||||
});
|
||||
assert.deepEqual(record.evidence['settle'], {
|
||||
domMutations: 37,
|
||||
firstCardToSettledMs: 580,
|
||||
lateShifts: [
|
||||
{
|
||||
afterFirstCardMs: 15,
|
||||
sources: [
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaY: -240,
|
||||
node: 'section.dashboard-rail',
|
||||
},
|
||||
],
|
||||
value: 0.2305,
|
||||
},
|
||||
],
|
||||
observedTarget: 'root',
|
||||
reason: 'quiet',
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects measurements whose clocks or probes are inconsistent', () => {
|
||||
@@ -183,6 +225,50 @@ test('rejects measurements whose clocks or probes are inconsistent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a launch whose settle window did not end after the first-card cutoff', () => {
|
||||
const base = measurement();
|
||||
const withSettle = (
|
||||
settle: Partial<LaunchJourneyMeasurement['renderer']['settle']>
|
||||
): LaunchJourneyMeasurement => ({
|
||||
...base,
|
||||
renderer: {
|
||||
...base.renderer,
|
||||
settle: { ...base.renderer.settle, ...settle },
|
||||
},
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
toLaunchIterationRecord(
|
||||
0,
|
||||
false,
|
||||
withSettle({ epochMs: null, status: 'pending' })
|
||||
),
|
||||
/settle-pending/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
toLaunchIterationRecord(
|
||||
0,
|
||||
false,
|
||||
withSettle({ epochMs: null, status: 'disabled' })
|
||||
),
|
||||
/settle-disabled/
|
||||
);
|
||||
assert.throws(
|
||||
() => toLaunchIterationRecord(0, false, withSettle({ epochMs: 2_640 })),
|
||||
/settle-quiet/
|
||||
);
|
||||
const capped = toLaunchIterationRecord(
|
||||
0,
|
||||
false,
|
||||
withSettle({ epochMs: 5_650, status: 'cap' })
|
||||
);
|
||||
assert.equal(
|
||||
(capped.evidence['settle'] as { reason: string }).reason,
|
||||
'cap'
|
||||
);
|
||||
});
|
||||
|
||||
test('names the counters the harness cannot measure yet', () => {
|
||||
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), [
|
||||
'renderer.cdTicksToFirstCard',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const LAUNCH_JOURNEY_COUNTER = {
|
||||
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
|
||||
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
|
||||
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
|
||||
LAYOUT_SHIFT_SCORE_SETTLED: 'renderer.layoutShiftScoreSettled',
|
||||
LONG_TASKS: 'renderer.longTasks',
|
||||
} as const;
|
||||
|
||||
@@ -56,6 +57,11 @@ function roundTenth(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Layout-shift scores keep three decimals; see performance-journeys.md. */
|
||||
function roundThousandth(value: number): number {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
export function toLaunchIterationRecord(
|
||||
index: number,
|
||||
warmup: boolean,
|
||||
@@ -77,6 +83,15 @@ export function toLaunchIterationRecord(
|
||||
) {
|
||||
throw new Error('launch-journey-record-clock-order');
|
||||
}
|
||||
const { settle } = renderer;
|
||||
if (
|
||||
(settle.status !== 'quiet' && settle.status !== 'cap') ||
|
||||
settle.epochMs === null ||
|
||||
renderer.firstCardPaintEpochMs === null ||
|
||||
settle.epochMs < renderer.firstCardPaintEpochMs
|
||||
) {
|
||||
throw new Error(`launch-journey-record-settle-${settle.status}`);
|
||||
}
|
||||
if (
|
||||
renderer.capabilities.changeDetectionTicks !==
|
||||
'unavailable-ng-global-not-published'
|
||||
@@ -98,8 +113,11 @@ export function toLaunchIterationRecord(
|
||||
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
|
||||
renderer.counters.domMutations,
|
||||
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
|
||||
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]:
|
||||
Math.round(renderer.counters.layoutShiftScore * 1_000) / 1_000,
|
||||
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth(
|
||||
renderer.counters.layoutShiftScore
|
||||
),
|
||||
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE_SETTLED]:
|
||||
roundThousandth(renderer.counters.layoutShiftScoreSettled),
|
||||
[LAUNCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
@@ -123,6 +141,7 @@ export function toLaunchIterationRecord(
|
||||
rendererGateBlankLoaded: measurement.gate.blankLoadedEpochMs,
|
||||
rendererGateReleased: measurement.gate.releasedEpochMs,
|
||||
rendererProbeInstalled: renderer.installed.epochMs,
|
||||
settled: settle.epochMs,
|
||||
spawn: spawnEpochMs,
|
||||
}),
|
||||
firstCard: Object.freeze({
|
||||
@@ -138,6 +157,21 @@ export function toLaunchIterationRecord(
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
|
||||
observedTarget: renderer.capabilities.observedTarget,
|
||||
settle: Object.freeze({
|
||||
domMutations: settle.domMutations,
|
||||
firstCardToSettledMs: roundTenth(
|
||||
settle.epochMs - renderer.terminal.epochMs
|
||||
),
|
||||
lateShifts: settle.lateShifts.map((shift) =>
|
||||
Object.freeze({
|
||||
afterFirstCardMs: roundTenth(shift.afterFirstCardMs),
|
||||
sources: shift.sources,
|
||||
value: Math.round(shift.value * 10_000) / 10_000,
|
||||
})
|
||||
),
|
||||
observedTarget: settle.observedTarget,
|
||||
reason: settle.status,
|
||||
}),
|
||||
}),
|
||||
index,
|
||||
pid: measurement.pid,
|
||||
|
||||
@@ -30,6 +30,7 @@ function measurement(
|
||||
counters: {
|
||||
domMutations: 1_596,
|
||||
layoutShiftScore: 0.0004,
|
||||
layoutShiftScoreSettled: 0,
|
||||
longTasks: 1,
|
||||
recentInputLayoutShiftScore: 0.22061,
|
||||
},
|
||||
@@ -49,6 +50,14 @@ function measurement(
|
||||
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 10_080.5, status: 'sent' },
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
lastMutationEpochMs: null,
|
||||
lateShifts: [],
|
||||
observedTarget: null,
|
||||
status: 'disabled',
|
||||
},
|
||||
start: {
|
||||
epochMs: 10_000.2,
|
||||
listenerEpochMs: 10_001,
|
||||
|
||||
@@ -95,7 +95,8 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
|
||||
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
|
||||
`about:blank` page, so it runs at the start of the real document. It
|
||||
records that it ran while the document was still `loading` with zero
|
||||
scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`.
|
||||
scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`,
|
||||
complete once the [settle window](#settle-window) has closed.
|
||||
- `journey-main-ipc-capture.ts` subscribes to the preload's renderer-API trace
|
||||
channel (`IPTVNATOR_DEBUG_TRACE_EVENT`, enabled with
|
||||
`IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate`, also before the
|
||||
@@ -109,8 +110,81 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
|
||||
| `renderer.ipcCallsToFirstCard` | `start` trace events the preload emits for every bridge invocation (listener registrations `on*`/`remove*` excluded, as in `wrapElectronApi`). The renderer probe fires one sentinel `cancelSourceProbe('__iptvnator-journey-sentinel__')` at the terminal moment; renderer-to-main IPC is ordered, so events before the sentinel are the exact count. The preload traces the call before forwarding it, and `SOURCE_HEALTH_CANCEL` only looks the id up in an in-memory map, so the sentinel never reaches the database worker. |
|
||||
| `renderer.domMutationsToFirstCard` | `MutationRecord`s (not callback batches) from a `MutationObserver` on the document element with `childList`, `attributes`, `characterData` and `subtree`. When the init script runs before `<html>` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. |
|
||||
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
|
||||
| `renderer.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. |
|
||||
|
||||
#### Settle window
|
||||
|
||||
`renderer.layoutShiftScore` stops at the first-card cutoff, one frame after
|
||||
the terminal batch. A shift that lands later is invisible to it: in #1738,
|
||||
rail skeletons of rails that resolved empty collapsed about 15 ms after the
|
||||
first card and pulled the rails below upwards, a shift of about 0.23 on every
|
||||
relaunch with sources that the counter read as 0.
|
||||
`renderer.layoutShiftScoreSettled` sums the same entries until the page has
|
||||
settled. The first-card counter is unchanged, so its baselines and history
|
||||
stay comparable.
|
||||
|
||||
The settle window opens at the first-card cutoff. A second
|
||||
`MutationObserver` watches `main.workspace-content`, the workspace shell's
|
||||
content pane (the document element if it is missing, reported in
|
||||
`evidence.settle.observedTarget`). The window closes when nothing in that
|
||||
subtree has mutated for 500 ms, or 3 s after the cutoff, whichever comes
|
||||
first. The settle point is the deadline the firing timer was scheduled for
|
||||
(the last mutation plus 500 ms, or the cutoff plus 3 s), or the moment it
|
||||
ran if that is earlier, so a timer delayed by a busy main thread does not
|
||||
let later shifts in. Every entry that starts at or before the settle point
|
||||
counts, including entries still queued in the observer. Why this point:
|
||||
|
||||
- A DOM change in the content pane is what causes the shifts this counter
|
||||
is after (data resolving, skeletons swapped for content), so quiet in that
|
||||
subtree is a condition the page reaches, not a guess at a delay. The rail
|
||||
and header stay outside the watched subtree, so their own updates neither
|
||||
keep the window open nor hide a shift in the content, which still counts
|
||||
wherever it happens.
|
||||
- 500 ms is many frames and well above the round trips to the local mock,
|
||||
so startup data that is already on its way lands inside the window. On the
|
||||
J1 profile the content pane goes quiet within about 110 ms of the first
|
||||
card, so the window closes about 520-610 ms after it.
|
||||
- The 3 s cap bounds each iteration when something keeps mutating (an
|
||||
animation, a ticking label). A capped window can end in the middle of that
|
||||
activity, so `evidence.settle.reason` (`quiet` or `cap`) is recorded for
|
||||
every iteration, together with `firstCardToSettledMs` and the mutation
|
||||
records seen (`domMutations`). Iterations that close for different reasons
|
||||
point at a settle point that is not deterministic; compare them before
|
||||
trusting `stable`.
|
||||
|
||||
Entries with `hadRecentInput === true` are excluded, as for the first-card
|
||||
counter; J1 has no input. The probe keeps its layout-shift observer open
|
||||
only for this window: `final` still marks the first-card counters as
|
||||
frozen, `settle.status` moves from `pending` to `quiet` or `cap`, and the
|
||||
test waits for both. The record refuses an iteration whose window never
|
||||
closed or closed before the cutoff. J2's probe has no settle window
|
||||
(`settle.status` is `disabled`) and its counters are unchanged.
|
||||
|
||||
`evidence.settle.lateShifts` lists the counted shifts after the cutoff (at
|
||||
most 20): the time after the first card, the value and, for each source the
|
||||
browser attributes the shift to, the node (`tag.class[data-test-id]`; a
|
||||
component host such as `lib-dashboard-rail` takes its first child's test id)
|
||||
and its vertical move. A late shift can therefore be traced to its component
|
||||
from the summary alone.
|
||||
|
||||
First local measurement (macOS, 2026-09-29, `master` with #1738): all
|
||||
windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and
|
||||
`renderer.layoutShiftScoreSettled` was 0.236 in 14 of 15 measured
|
||||
iterations over three runs (`stable: false` in the first run with one 0,
|
||||
stable in the other two). Every iteration shows the same two shifts of 0.118:
|
||||
about 12 ms after the first card the `dashboard-recent-sources-rail`, which
|
||||
holds the first card, moves up by 316 px, and 12-65 ms later it moves back
|
||||
down. Something 316 px tall above it is removed and inserted again during
|
||||
startup, a flicker #1738 did not cover. The counter is working as intended;
|
||||
the flicker is a separate fix.
|
||||
|
||||
On the Linux CI runner (`Performance journeys` job of #1756, run
|
||||
36618062068) the same flicker is a race: the measured iterations read
|
||||
`[0, 0, 0.235, 0, 0]` (`stable: false`, every window `quiet` about 540 ms
|
||||
after the first card), and the one hit shows the same two 316 px moves of
|
||||
the recent-sources rail.
|
||||
|
||||
#### Main-process counters
|
||||
|
||||
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
|
||||
@@ -231,11 +305,18 @@ serial-depth counter is a better guardrail candidate than a raw call count.
|
||||
},
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"counters": { "renderer.ipcCallsToFirstCard": 12 },
|
||||
"counters": {
|
||||
"renderer.ipcCallsToFirstCard": 12,
|
||||
"renderer.layoutShiftScoreSettled": 0.236
|
||||
},
|
||||
"counterStability": {
|
||||
"renderer.ipcCallsToFirstCard": {
|
||||
"stable": true,
|
||||
"values": [12, 12, 12, 12, 12]
|
||||
},
|
||||
"renderer.layoutShiftScoreSettled": {
|
||||
"stable": true,
|
||||
"values": [0.236, 0.236, 0.236, 0.236, 0.236]
|
||||
}
|
||||
},
|
||||
"wallClock": {
|
||||
@@ -250,7 +331,27 @@ serial-depth counter is a better guardrail candidate than a raw call count.
|
||||
"pid": 1,
|
||||
"counters": {},
|
||||
"wallClock": {},
|
||||
"evidence": {}
|
||||
"evidence": {
|
||||
"settle": {
|
||||
"domMutations": 458,
|
||||
"firstCardToSettledMs": 536.6,
|
||||
"lateShifts": [
|
||||
{
|
||||
"afterFirstCardMs": 12.4,
|
||||
"sources": [
|
||||
{
|
||||
"deltaHeight": 0,
|
||||
"deltaY": -316,
|
||||
"node": "lib-dashboard-rail[data-test-id=\"dashboard-recent-sources-rail\"]"
|
||||
}
|
||||
],
|
||||
"value": 0.118
|
||||
}
|
||||
],
|
||||
"observedTarget": "root",
|
||||
"reason": "quiet"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -260,7 +361,9 @@ serial-depth counter is a better guardrail candidate than a raw call count.
|
||||
|
||||
`journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are plain
|
||||
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
|
||||
`tools/performance/journey-baselines.json`. A J1 runtime baseline is added
|
||||
`tools/performance/journey-baselines.json`. The summary writer checks only
|
||||
that every measured iteration reports the same counter names with finite
|
||||
values, so a new counter needs no schema change. A J1 runtime baseline is added
|
||||
once its counter is deterministic on the CI runner; the launch counters are
|
||||
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
|
||||
|
||||
@@ -272,8 +375,8 @@ iteration copies it and spawns a fresh process through `runLaunchJourney`,
|
||||
which measures J1 as usual (gate, probe, IPC capture) and then hands the
|
||||
running app to `measureOpenSourceJourney` in
|
||||
`src/journeys/open-source-journey-app.ts`. The click therefore happens after
|
||||
J1's terminal condition and its counters are final, and the two journeys never
|
||||
overlap. One warm-up and five measured iterations, as for J1; the J1 numbers
|
||||
J1's terminal condition and its counters are final, and after J1's settle
|
||||
window has closed, so the two journeys never overlap. One warm-up and five measured iterations, as for J1; the J1 numbers
|
||||
of these launches are not reported again. J2 does not read J1's
|
||||
main-process counters, so its launches run without `IPTVNATOR_PERF_CAPTURE`
|
||||
and `IPTVNATOR_PERF_COUNT_SQL` (`runLaunchJourney` with
|
||||
@@ -553,6 +656,10 @@ in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
|
||||
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
|
||||
differ from a Mac (12 and 571 there, the fast path without the Linux-only
|
||||
`getWindowState` call), so take J1 baseline values from the runner only.
|
||||
`renderer.layoutShiftScoreSettled` has no baseline either: the runner reads
|
||||
it as `stable: false` because the dashboard flicker it reports is a race
|
||||
there (see [Settle window](#settle-window)). Add the runner's number once
|
||||
that flicker is fixed and the counter is deterministic.
|
||||
|
||||
## Charset parse benchmark
|
||||
|
||||
|
||||
Reference in new issue
Block a user