From 655f54b3625c3ab493b106785e8a05d04fa7e238 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 6 Oct 2026 11:53:21 +0200 Subject: [PATCH] test(perf): record which nodes move in a journey's layout shift J2's renderer.layoutShiftScore went from 0.222 to 0.233 with #1814, and its evidence only held the recent-input / without-recent-input split, so the moved element could not be named from a summary. The probe now keeps the first 20 counted shifts (value, recent input, time since the journey start and the moved nodes, as J1's late shifts do), and J2 writes them to evidence.layoutShift.shifts. Co-Authored-By: Claude Opus 5.5 --- .../journey-renderer-probe.spec.ts | 22 +++++++++++++++ .../src/performance/journey-renderer-probe.ts | 28 +++++++++++++++++++ .../performance/launch-journey-record.spec.ts | 1 + .../open-source-journey-record.spec.ts | 28 +++++++++++++++++++ .../performance/open-source-journey-record.ts | 9 ++++++ .../playback-journey-record.spec.ts | 1 + 6 files changed, 89 insertions(+) diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts index c42b35f59..89b38ee08 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -798,6 +798,13 @@ test('drops performance entries from before the click and keeps recent-input shi { entryType: 'layout-shift', hadRecentInput: true, + sources: [ + { + currentRect: { height: 40, y: 152 }, + node: fixture.card, + previousRect: { height: 40, y: 100 }, + }, + ], startTime: now(), value: 0.25, }, @@ -823,6 +830,20 @@ 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); + // Each counted shift keeps its nodes; the pre-click ones are not listed. + assert.deepEqual( + state.shifts.map((shift) => [ + shift.hadRecentInput, + shift.value, + shift.sources.map((source) => source.deltaY), + ]), + [ + [true, 0.25, [52]], + [false, 0.125, []], + ] + ); + assert.ok(state.shifts.every((shift) => shift.sinceStartMs >= 0)); + assert.match(state.shifts[0]?.sources[0]?.node ?? '', /^[a-z-]+/); // J2 has no settle window: the observers close at the cutoff. assert.equal(state.settle.status, 'disabled'); assert.equal(state.counters.layoutShiftScoreSettled, 0); @@ -869,6 +890,7 @@ test('rejects a click start whose sentinel could not be sent', async () => { const started = { ...state, sentinel: { epochMs: 1, status: 'sent' as const }, + shifts: [], }; assert.throws( () => assertJourneyRendererProbeState(started), diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts index 71a321a23..9d8be24bd 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -166,6 +166,16 @@ export interface JourneyRendererProbeCounters { recentInputLayoutShiftScore: number; } +/** A shift counted in `layoutShiftScore` or `recentInputLayoutShiftScore`. */ +export interface JourneyRendererProbeShift { + readonly hadRecentInput: boolean; + /** Entry start minus the journey start (navigation start for J1). */ + readonly sinceStartMs: number; + /** `tag.class[data-test-id]` and the vertical move of each source. */ + readonly sources: JourneyRendererProbeLateShift['sources']; + readonly value: number; +} + export interface JourneyRendererProbeLateShift { /** Entry start minus the first-card terminal epoch. */ readonly afterFirstCardMs: number; @@ -237,6 +247,11 @@ export interface JourneyRendererProbeState { readonly epochMs: number | null; readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent'; }; + /** + * The first 20 shifts counted until the cutoff, with the nodes that + * moved, so a layout-shift score can be traced to its components. + */ + shifts: JourneyRendererProbeShift[]; /** `final` freezes the first-card counters; the settle window ends later. */ settle: { /** Mutation records under the settle root after the cutoff. */ @@ -329,6 +344,7 @@ export function journeyRendererProbeScript( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: null, status: 'not-sent' }, + shifts: [], settle: { domMutations: 0, epochMs: null, @@ -389,6 +405,7 @@ export function journeyRendererProbeScript( for (const entry of entries) { const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; + sources?: readonly LateShiftSource[]; value?: number; }; if ( @@ -397,6 +414,17 @@ export function journeyRendererProbeScript( ) { continue; } + if (state.shifts.length < 20) { + state.shifts.push({ + hadRecentInput: shift.hadRecentInput === true, + sinceStartMs: + performance.timeOrigin + + entry.startTime - + (state.start?.epochMs ?? performance.timeOrigin), + sources: (shift.sources ?? []).map(describeSource), + value: shift.value, + }); + } if (shift.hadRecentInput === true) { state.counters.recentInputLayoutShiftScore += shift.value; continue; diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index f2084d099..b9a6e8efe 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -54,6 +54,7 @@ function measurement( preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, sentinel: { epochMs: 2_601, status: 'sent' }, + shifts: [], settle: { domMutations: 37, epochMs: 3_180.06, diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts index 95cb2fb4d..ccbec6a5b 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.spec.ts @@ -59,6 +59,20 @@ function measurement( preStart: { domMutations: 4, lastMutationEpochMs: 9_100 }, schemaVersion: 1, sentinel: { epochMs: 10_080.5, status: 'sent' }, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.26, + sources: [ + { + deltaHeight: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.22106, + }, + ], settle: { domMutations: 0, epochMs: null, @@ -147,6 +161,20 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters }); assert.deepEqual(record.evidence['layoutShift'], { recentInput: 0.221, + shifts: [ + { + hadRecentInput: true, + sinceStartMs: 41.3, + sources: [ + { + deltaHeight: 0, + deltaY: 52, + node: 'div.content[data-test-id="category-list"]', + }, + ], + value: 0.2211, + }, + ], withoutRecentInput: 0, }); assert.deepEqual(record.evidence['httpRequestsByRoute'], { diff --git a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts index 4d68bdcaa..8594bde23 100644 --- a/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/open-source-journey-record.ts @@ -184,6 +184,15 @@ export function toOpenSourceIterationRecord( recentInput: roundThousandth( renderer.counters.recentInputLayoutShiftScore ), + // The first 20 counted shifts and the nodes that moved. + shifts: renderer.shifts.map((shift) => + Object.freeze({ + hadRecentInput: shift.hadRecentInput, + sinceStartMs: roundTenth(shift.sinceStartMs), + sources: shift.sources, + value: Math.round(shift.value * 10_000) / 10_000, + }) + ), withoutRecentInput: roundThousandth( renderer.counters.layoutShiftScore ), diff --git a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts index ee228c1c8..2aaf434b4 100644 --- a/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts @@ -74,6 +74,7 @@ function measurement( preStart: { domMutations: 53, lastMutationEpochMs: 8_900 }, schemaVersion: 1, sentinel: { epochMs: 10_350, status: 'sent' }, + shifts: [], settle: { domMutations: 0, epochMs: null,