From 9bfd47df04527ac8c287cb4f960c57bb689dfe95 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 28 Sep 2026 09:25:02 +0200 Subject: [PATCH] test(perf): restart the J2 quiet period when pending work completes Both waits in the open-source journey (settling before the click, closing the mock window after the terminal) now use one waitForJourneyQuiet helper that compares whole samples, in-flight counts included. The poll that first sees a request or bridge call complete restarts the quiet period, so the window is never measured from a poll at which work was still pending. A fake-clock spec covers the in-flight to zero case. Co-Authored-By: Claude Opus 5.5 --- .../src/journeys/open-source-journey-app.ts | 77 ++++++++----------- .../performance/journey-quiet-wait.spec.ts | 72 +++++++++++++++++ .../src/performance/journey-quiet-wait.ts | 54 +++++++++++++ 3 files changed, 156 insertions(+), 47 deletions(-) create mode 100644 apps/electron-backend-e2e/src/performance/journey-quiet-wait.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/journey-quiet-wait.ts 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 79e1c8058..fe91de1db 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 @@ -10,6 +10,7 @@ import { readJourneyMainIpcCapture, } from '../performance/journey-main-ipc-capture'; import type { JourneyMockRequestLedger } from '../performance/journey-mock-request-ledger'; +import { waitForJourneyQuiet } from '../performance/journey-quiet-wait'; import { armJourneyRendererProbe, createOpenSourceJourneyProbeOptions, @@ -76,7 +77,6 @@ async function waitForQuiet( ledger: JourneyMockRequestLedger, probeStateKey: string ): Promise { - const startedMs = Date.now(); const armMark = ledger.mark(); const sample = async (): Promise => { const launchCapture = await peekJourneyMainIpcCapture( @@ -99,36 +99,24 @@ async function waitForQuiet( ipcInFlight: countJourneyMainIpcInFlight(launchCapture), }; }; - let last = await sample(); - let quietSinceMs = Date.now(); - for (;;) { - await new Promise((resolve) => setTimeout(resolve, POLL_MS)); - const next = await sample(); - const now = Date.now(); - if ( - next.domMutations !== last.domMutations || - next.httpInFlight > 0 || - next.httpRequests !== last.httpRequests || - next.ipcCalls !== last.ipcCalls || - next.ipcInFlight > 0 - ) { - last = next; - quietSinceMs = now; - } else if (now - quietSinceMs >= QUIET_MS) { - return { - preStartDomMutations: next.domMutations, - preStartHttpRequests: next.httpRequests - armMark, - preStartIpcCalls: next.ipcCalls, - quietMs: QUIET_MS, - waitedMs: now - startedMs, - }; - } - if (now - startedMs > SETTLE_TIMEOUT_MS) { - throw new Error( - `open-source-journey-not-quiet: ${JSON.stringify(next)}` - ); - } - } + const { sample: quiet, waitedMs } = await waitForJourneyQuiet({ + inFlight: (activity) => activity.httpInFlight + activity.ipcInFlight, + pollMs: POLL_MS, + quietMs: QUIET_MS, + sample, + timeoutError: (activity) => + new Error( + `open-source-journey-not-quiet: ${JSON.stringify(activity)}` + ), + timeoutMs: SETTLE_TIMEOUT_MS, + }); + return { + preStartDomMutations: quiet.domMutations, + preStartHttpRequests: quiet.httpRequests - armMark, + preStartIpcCalls: quiet.ipcCalls, + quietMs: QUIET_MS, + waitedMs, + }; } /** @@ -139,22 +127,17 @@ async function waitForQuiet( async function waitForMockQuiet( ledger: JourneyMockRequestLedger ): Promise { - const startedMs = Date.now(); - let count = ledger.mark(); - let quietSinceMs = Date.now(); - for (;;) { - await new Promise((resolve) => setTimeout(resolve, POLL_MS)); - const now = Date.now(); - if (ledger.mark() !== count || ledger.inFlight() > 0) { - count = ledger.mark(); - quietSinceMs = now; - } else if (now - quietSinceMs >= QUIET_MS) { - return; - } - if (now - startedMs > SETTLE_TIMEOUT_MS) { - throw new Error('open-source-journey-mock-not-quiet'); - } - } + await waitForJourneyQuiet({ + inFlight: (activity) => activity.inFlight, + pollMs: POLL_MS, + quietMs: QUIET_MS, + sample: async () => ({ + inFlight: ledger.inFlight(), + requests: ledger.mark(), + }), + timeoutError: () => new Error('open-source-journey-mock-not-quiet'), + timeoutMs: SETTLE_TIMEOUT_MS, + }); } /** diff --git a/apps/electron-backend-e2e/src/performance/journey-quiet-wait.spec.ts b/apps/electron-backend-e2e/src/performance/journey-quiet-wait.spec.ts new file mode 100644 index 000000000..e2030374f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-quiet-wait.spec.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { waitForJourneyQuiet } from './journey-quiet-wait'; + +interface Activity { + readonly inFlight: number; + readonly requests: number; +} + +/** + * Runs the waiter on a fake clock: each poll advances time by `pollMs`, and + * `script(ms)` says what the app looks like at that time. + */ +async function run( + script: (atMs: number) => Activity, + quietMs = 1_000, + timeoutMs = 30_000 +) { + let clock = 0; + const polls: number[] = []; + const result = await waitForJourneyQuiet({ + inFlight: (activity) => activity.inFlight, + now: () => clock, + pollMs: 100, + quietMs, + sample: async () => { + polls.push(clock); + return script(clock); + }, + sleep: async (ms) => { + clock += ms; + }, + timeoutError: (activity) => + new Error(`not-quiet ${JSON.stringify(activity)}`), + timeoutMs, + }); + return { ...result, polls }; +} + +test('returns once nothing changed for the whole quiet period', async () => { + const result = await run(() => ({ inFlight: 0, requests: 3 })); + assert.equal(result.waitedMs, 1_000); + assert.deepEqual(result.sample, { inFlight: 0, requests: 3 }); +}); + +test('restarts the quiet period at every new request', async () => { + const result = await run((atMs) => ({ + inFlight: 0, + requests: atMs < 500 ? 1 : 2, + })); + // The second request is first seen at 500 ms. + assert.equal(result.waitedMs, 1_500); +}); + +test('counts the quiet period from the poll that saw pending work complete', async () => { + // One request is in flight until 750 ms; the count never changes. The + // poll at 800 ms is the first to see it done, so the app is quiet only + // from 800 ms on, not from the 700 ms poll at which it was pending. + const result = await run((atMs) => ({ + inFlight: atMs < 750 ? 1 : 0, + requests: 1, + })); + assert.equal(result.waitedMs, 1_800); +}); + +test('never returns while work stays in flight and fails at the timeout', async () => { + await assert.rejects( + run(() => ({ inFlight: 1, requests: 1 }), 1_000, 3_000), + /not-quiet \{"inFlight":1,"requests":1\}/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-quiet-wait.ts b/apps/electron-backend-e2e/src/performance/journey-quiet-wait.ts new file mode 100644 index 000000000..493cafb98 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-quiet-wait.ts @@ -0,0 +1,54 @@ +/** + * Polls activity until nothing has changed for `quietMs` and nothing is in + * flight. Used by the click-started journeys to settle the app before the + * click and to close the mock request window after the terminal. + * + * A sample is compared as a whole, in-flight counts included, so the poll + * that first sees pending work complete restarts the quiet period: the + * window is always at least `quietMs` after the last observed activity, + * never measured from a poll at which work was still pending. + */ +export interface JourneyQuietWaitOptions { + /** Requests or calls still pending in a sample; any makes it busy. */ + readonly inFlight: (sample: T) => number; + readonly now?: () => number; + readonly pollMs: number; + readonly quietMs: number; + readonly sample: () => Promise; + readonly sleep?: (ms: number) => Promise; + readonly timeoutError: (last: T) => Error; + readonly timeoutMs: number; +} + +export interface JourneyQuietWaitResult { + readonly sample: T; + readonly waitedMs: number; +} + +export async function waitForJourneyQuiet( + options: JourneyQuietWaitOptions +): Promise> { + const now = options.now ?? Date.now; + const sleep = + options.sleep ?? + ((ms: number) => + new Promise((resolve) => setTimeout(resolve, ms))); + const startedMs = now(); + let lastKey = JSON.stringify(await options.sample()); + let quietSinceMs = now(); + for (;;) { + await sleep(options.pollMs); + const next = await options.sample(); + const nextKey = JSON.stringify(next); + const sampledMs = now(); + if (nextKey !== lastKey || options.inFlight(next) > 0) { + lastKey = nextKey; + quietSinceMs = sampledMs; + } else if (sampledMs - quietSinceMs >= options.quietMs) { + return { sample: next, waitedMs: sampledMs - startedMs }; + } + if (sampledMs - startedMs > options.timeoutMs) { + throw options.timeoutError(next); + } + } +}