diff --git a/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts b/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts index fe91de1db..108882406 100644 --- a/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/open-source-journey-app.ts @@ -177,6 +177,9 @@ export async function measureOpenSourceJourney( ledger, probeOptions.stateKey ); + // Requests from here on but before the renderer's click stamp arrived + // after the app settled; the record rejects such an iteration. + const settledLedgerMark = ledger.mark(); await card.click({ timeout: timeoutMs }); const renderer: JourneyRendererProbeState = await waitForJourneyRendererProbe( @@ -200,11 +203,15 @@ export async function measureOpenSourceJourney( throw new Error('open-source-journey-click-not-started'); } const sinceSpawn = ledger.since(spawnLedgerMark); + const beforeClick = sinceSpawn.filter( + (entry) => entry.epochMs < clickEpochMs + ); return { http: { - beforeClick: sinceSpawn.filter( - (entry) => entry.epochMs < clickEpochMs - ), + afterSettleBeforeClick: beforeClick.filter( + (entry) => entry.sequence >= settledLedgerMark + ).length, + beforeClick, requests: sinceSpawn.filter( (entry) => entry.epochMs >= clickEpochMs ), diff --git a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts index 51caeb3ae..d32fb7227 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts @@ -136,17 +136,36 @@ test('only the launch journey opts into SQL statement counting', () => { .filter( (file) => /\.(ts|cjs)$/.test(file) && !/\.spec\.ts$/.test(file) ); - const optedIn = files - .filter((file) => - readFileSync(join(sourceRoot, file), 'utf8').includes( - 'IPTVNATOR_PERF_COUNT_SQL' + const containing = (needle: RegExp) => + files + .filter((file) => + needle.test(readFileSync(join(sourceRoot, file), 'utf8')) ) - ) - .map((file) => relative(sourceRoot, join(sourceRoot, file))); - // The journey launch builds its flags in one place, and only the - // launch journey asks for the main counters there (J2 launches with - // `mainCounters: false`; see journey-launch-environment.spec.ts). - assert.deepEqual(optedIn, [ + .map((file) => relative(sourceRoot, join(sourceRoot, file))); + // The journey launch builds its flags in one place... + assert.deepEqual(containing(/IPTVNATOR_PERF_COUNT_SQL/), [ join('performance', 'journey-launch-environment.ts'), ]); + // ...and only J1's launch asks for them there. Another journey that + // passed `mainCounters: true` to runLaunchJourney would be measured + // under the statement hook without recording its count. + assert.deepEqual(containing(/mainCounters:\s*true/), [ + join('journeys', 'launch-journey-app.ts'), + ]); + const launchApp = readFileSync( + join(sourceRoot, 'journeys', 'launch-journey-app.ts'), + 'utf8' + ); + assert.equal(launchApp.match(/mainCounters:\s*true/g)?.length, 1); + assert.match( + launchApp, + /export async function measureLaunchJourney\([\s\S]*?\{ mainCounters: true \}[\s\S]*?\n\}/ + ); + assert.match( + readFileSync( + join(sourceRoot, 'journeys', 'open-source.journey.ts'), + 'utf8' + ), + /\{ mainCounters: false \}/ + ); }); 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 0eb4260bc..8b632e13e 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 @@ -82,6 +82,7 @@ function measurement( }; return { http: { + afterSettleBeforeClick: 0, beforeClick: [ request(0, '/player_api.php?action=get_account_info', 2_000), ], @@ -249,6 +250,41 @@ test('rejects measurements that did not start at the click or did not open the s ); }); +test('rejects an iteration with activity between the settle snapshot and the click', () => { + const base = measurement(); + assert.doesNotThrow(() => toOpenSourceIterationRecord(0, false, base)); + // The probe counted DOM mutations after the snapshot (4) but before the + // click, e.g. while Playwright ran its actionability checks. + assert.throws( + () => + toOpenSourceIterationRecord(0, false, { + ...base, + renderer: { + ...base.renderer, + preStart: { domMutations: 5, lastMutationEpochMs: 9_990 }, + }, + }), + /activity-before-click-dom$/ + ); + assert.throws( + () => + toOpenSourceIterationRecord(0, false, { + ...base, + ipc: { ...base.ipc, callsBeforeStart: 1 }, + }), + /activity-before-click-ipc$/ + ); + assert.throws( + () => + toOpenSourceIterationRecord(0, false, { + ...base, + http: { ...base.http, afterSettleBeforeClick: 1 }, + ipc: { ...base.ipc, callsBeforeStart: 2 }, + }), + /activity-before-click-ipc-http$/ + ); +}); + test('summarizes under the J2 counters with the unmeasurable ones listed', () => { const entry = summarizeJourneyIterations( [0, 1, 2].map((index) => 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 b2538091e..67f358f61 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 @@ -48,6 +48,8 @@ export interface OpenSourceJourneySettle { export interface OpenSourceJourneyMeasurement { readonly http: { + /** Mock requests after the app settled but before the click stamp. */ + readonly afterSettleBeforeClick: number; /** Mock requests from the spawn (J1 and settling) until the click. */ readonly beforeClick: readonly JourneyMockRequest[]; /** Mock requests from the click until the mock was quiet again. */ @@ -97,6 +99,23 @@ export function toOpenSourceIterationRecord( ) { throw new Error('open-source-journey-record-clock-order'); } + // Activity that started after the settle snapshot but before the click + // (while Playwright ran its actionability checks) could complete after + // the click and be counted as J2. The probe and the capture keep + // counting until the click itself, so they must still match the + // snapshot; otherwise the iteration is rejected. + const lateActivity = [ + renderer.preStart.domMutations !== settle.preStartDomMutations + ? 'dom' + : null, + ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null, + http.afterSettleBeforeClick > 0 ? 'http' : null, + ].filter((kind) => kind !== null); + if (lateActivity.length > 0) { + throw new Error( + `open-source-journey-record-activity-before-click-${lateActivity.join('-')}` + ); + } if ( renderer.capabilities.changeDetectionTicks !== 'unavailable-ng-global-not-published' diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index b7ea4dc41..49bd05558 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -264,7 +264,12 @@ fails the iteration). Bridge calls in flight come from J1's IPC capture: it was installed before the document loaded, and the preload follows every traced `start` with exactly one `success` or `error`, so a call that is still pending cannot resolve after the click and have its DOM changes or follow-up -calls counted as J2. The settle wait and what happened during +calls counted as J2. The settle is a snapshot, and Playwright's +actionability checks run between it and the click. The probe and the IPC +capture keep counting pre-click activity until the click event itself, and +the ledger splits at the click stamp. So the record rejects an iteration +whose DOM mutations, bridge calls or mock requests moved after the snapshot +(`open-source-journey-record-activity-before-click-*`). The settle wait and what happened during it are kept under `evidence.settle`. The renderer probe is armed in the loaded document with `page.evaluate` (the same self-contained script as J1, with `startClick` set). It registers a capture-phase `click` listener on