Files
iptvnator/apps/electron-backend-e2e/src/performance/journey-click-settle.ts
T
6e229f85b6 test(perf): add the J3 playback journey (#1774)
* test(perf): add the J3 playback journey

J3 clicks a live channel of an Xtream portal and ends at the built-in
HTML5 player's first `playing` event, with `loadedmetadata` as a
secondary phase. It follows J2: every iteration is a fresh J1 launch on
a copy of a profile seeded through the app's dialogs, and the click
happens after the app has settled in the portal's first live category.

The portal is the mock's `live-fallback` account, whose `.ts` live URLs
serve the local H.264/AAC MPEG-TS fixture that mpegts.js plays through
MSE on every platform. The marketing accounts' local live bytes are
zero-filled and never reach `playing`. Seeding selects the HTML5 player
and the `ts` stream format; the catalog's picsum.photos logos are
cancelled from the test side so no request leaves the machine.

Counters: renderer.ipcCallsToPlaying, renderer.httpRequestsToPlaying,
renderer.domMutationsToPlaying, renderer.layoutShiftScore and
renderer.longTasks; wall-clock click->loadedmetadata and click->playing.
renderer.ipcSerialDepthToPlaying is listed as unavailable until the
serial-depth helper lands. No baseline yet.

The probe gains a media-event terminal; J2's pre-click settle moves to
journey-click-settle.ts so both journeys share it unchanged, and the
probe spec's jsdom fixtures move to a shared test helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): show J3's HTTP boundary margins and watch 1 s after playing

Review follow-up. renderer.httpRequestsToPlaying compares the ledger's
arrival stamps with the renderer's click and playing stamps, which come
from different processes on the same host clock. Each iteration now
records the distance of the nearest request on either side of both
boundaries, so a count a clock difference could flip is visible.

Requests after playing were a single snapshot taken right after the
probe; the test now watches the ledger for a fixed 1 s after playing.
A live stream never leaves the mock quiet, so J2's quiet wait does not
apply.

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>
2026-10-01 08:37:02 +02:00

180 lines
6.6 KiB
TypeScript

import type { ElectronApplication, Page } from '@playwright/test';
import {
countJourneyMainIpcInFlight,
JOURNEY_MAIN_IPC_STATE_KEY,
peekJourneyMainIpcCaptures,
} from './journey-main-ipc-capture';
import type { JourneyMockRequestLedger } from './journey-mock-request-ledger';
import { waitForJourneyQuiet } from './journey-quiet-wait';
/**
* Settling for the click-started journeys (J2 "Open a source", J3
* "Playback"). Before the click the app must be quiet, so leftovers of the
* startup or of the navigation to the start screen are not attributed to
* the click. Contract: docs/architecture/performance-journeys.md.
*/
export const JOURNEY_CLICK_QUIET_MS = 1_000;
const POLL_MS = 100;
const SETTLE_TIMEOUT_MS = 30_000;
/** How long the app was left alone before the click, and what it did. */
export interface JourneyClickSettle {
readonly preStartDomMutations: number;
readonly preStartHttpRequests: number;
readonly preStartIpcCalls: number;
readonly quietMs: number;
readonly waitedMs: number;
}
export interface JourneyClickSettleOptions {
/** Prefix of the timeout errors, e.g. `open-source-journey`. */
readonly errorPrefix: string;
/** State key of the journey's own IPC capture (with a start sentinel). */
readonly ipcCaptureStateKey: string;
readonly probeStateKey: string;
}
interface ActivitySample {
readonly domMutations: number;
readonly httpInFlight: number;
readonly httpRequests: number;
readonly ipcCalls: number;
readonly ipcInFlight: number;
}
async function readPreStartMutations(
page: Page,
stateKey: string
): Promise<number> {
return page.evaluate((key) => {
const state = (globalThis as unknown as Record<string, unknown>)[
key
] as { preStart?: { domMutations?: number } } | undefined;
const count = state?.preStart?.domMutations;
if (typeof count !== 'number') {
throw new Error('journey-renderer-probe-not-armed');
}
return count;
}, stateKey);
}
/**
* Waits until DOM, bridge and mock traffic have all been unchanged for
* `JOURNEY_CLICK_QUIET_MS` with no mock request and no bridge call in
* flight: a slow response or a pending bridge call can still change the DOM
* or trigger follow-up work after the click. Pending bridge calls come from
* J1's capture, which was installed before the document loaded and so has
* seen every call start. An app that never settles fails the iteration
* instead of producing a count that includes its background work.
*/
export async function waitForJourneyClickQuiet(
electronApp: ElectronApplication,
page: Page,
ledger: JourneyMockRequestLedger,
options: JourneyClickSettleOptions
): Promise<{
/** Ledger position read by the accepted quiet sample itself. */
readonly ledgerMark: number;
readonly settle: JourneyClickSettle;
}> {
const armMark = ledger.mark();
const sample = async (): Promise<ActivitySample> => {
// Both captures in one snapshot: a call counted by the journey's
// capture is then also pending in J1's, never counted with a stale
// in-flight 0.
const [launchCapture, journeyCapture] =
await peekJourneyMainIpcCaptures(electronApp, [
JOURNEY_MAIN_IPC_STATE_KEY,
options.ipcCaptureStateKey,
]);
if (launchCapture.unmatchedCompletions > 0) {
throw new Error(
`${options.errorPrefix}-bridge-completions-unmatched`
);
}
return {
domMutations: await readPreStartMutations(
page,
options.probeStateKey
),
httpInFlight: ledger.inFlight(),
httpRequests: ledger.mark(),
ipcCalls: journeyCapture.callsBeforeStart,
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
};
};
const { sample: quiet, waitedMs } = await waitForJourneyQuiet({
inFlight: (activity) => activity.httpInFlight + activity.ipcInFlight,
pollMs: POLL_MS,
quietMs: JOURNEY_CLICK_QUIET_MS,
sample,
timeoutError: (activity) =>
new Error(
`${options.errorPrefix}-not-quiet: ${JSON.stringify(activity)}`
),
timeoutMs: SETTLE_TIMEOUT_MS,
});
return {
ledgerMark: quiet.httpRequests,
settle: {
preStartDomMutations: quiet.domMutations,
preStartHttpRequests: quiet.httpRequests - armMark,
preStartIpcCalls: quiet.ipcCalls,
quietMs: JOURNEY_CLICK_QUIET_MS,
waitedMs,
},
};
}
/**
* Waits until the mock has seen no new request for `JOURNEY_CLICK_QUIET_MS`
* and none is in flight, so responses slower than the quiet interval and
* the requests they trigger stay inside the measured window.
*/
export async function waitForJourneyMockQuiet(
ledger: JourneyMockRequestLedger,
errorPrefix: string
): Promise<number> {
const { sample: quiet } = await waitForJourneyQuiet({
inFlight: (activity) => activity.inFlight,
pollMs: POLL_MS,
quietMs: JOURNEY_CLICK_QUIET_MS,
sample: async () => ({
inFlight: ledger.inFlight(),
requests: ledger.mark(),
}),
timeoutError: () => new Error(`${errorPrefix}-mock-not-quiet`),
timeoutMs: SETTLE_TIMEOUT_MS,
});
// The window ends at the ledger position this accepted sample read. A
// request that arrives after it was never seen in flight, so its
// response and follow-ups are not waited for; counting it would make
// the counter depend on when the ledger is read.
return quiet.requests;
}
/**
* Rejects an iteration whose activity moved after the settle snapshot but
* before the click (while Playwright ran its actionability checks): it
* could complete after the click and be counted as the journey's. The probe
* and the capture keep counting until the click itself, so they must still
* match the snapshot. Returns the kinds that moved.
*/
export function journeyActivityBeforeClick(
settle: JourneyClickSettle,
observed: {
readonly httpAfterSettleBeforeClick: number;
readonly ipcCallsBeforeStart: number;
readonly preStartDomMutations: number;
}
): string[] {
return [
observed.preStartDomMutations !== settle.preStartDomMutations
? 'dom'
: null,
observed.ipcCallsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
observed.httpAfterSettleBeforeClick > 0 ? 'http' : null,
].filter((kind): kind is string => kind !== null);
}