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,