# Performance journeys and the CI ratchet IPTVnator measures performance through a small set of everyday user journeys. Each journey has deterministic counters that are asserted exactly, and wall-clock timings that are recorded as evidence. Counters are ratcheted in CI: a committed baseline may only be lowered, and only with the measured output as evidence. This document is the contract for that loop. The journey harness lives in `apps/electron-backend-e2e/src/journeys` and `apps/electron-backend-e2e/src/performance/journey-*.ts`; the ratchet scripts live in `tools/performance/`. ## Journeys | Journey | Start | End | | ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------- | | J1 `launch` | Electron process spawn | first playlist or portal card rendered on `/workspace`, inline splash removed | | J2 `open-source` | click on the Xtream portal card | category list and first page of the opened section painted | | J3 `playback` | click on a channel | HTML5 `playing` event | | J4 `search` | six-character query typed into global search | results list settled | J1 is instrumented: `renderer.initialBytes` from the built output, and the runtime counters of the launch benchmark below. J2 and J3 are instrumented by their own specs (below). J4 follows the plan in `.plans/` and is added in its own thread; each thread names its journey and counter in the PR description. ## Running the journeys ```bash pnpm run perf:journeys ``` The script runs the Nx target `electron-backend-e2e:journeys`, which builds the `electron-performance` configuration of the Electron app and the renderer first, starts the Xtream mock server on the dedicated loopback port `127.0.0.1:3231` (override with `IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT`), and runs `playwright.journeys.config.ts` with one worker. Each run writes one file: ``` dist/performance/journeys//summary.json ``` Every journey spec (`src/journeys/*.journey.ts`) adds its own `journeys.` entry to that file. The config starts a run only in the Playwright runner (not in a worker, which has `TEST_WORKER_INDEX`): it sets `IPTVNATOR_JOURNEY_RUN_STARTED_AT` and a random `IPTVNATOR_JOURNEY_RUN_ID` before the worker forks, replacing any value left in the environment. All specs of one invocation, including a restarted worker, therefore share the directory and `harness.runId`. The first spec creates the file; a later one merges into it only when `harness.runId` matches and the rest of the harness is identical, through a temporary file and a rename. A journey that is already present fails, so no measurement is ever overwritten; a second invocation in the same second fails instead of merging into the first. `IPTVNATOR_JOURNEY_MEASURED_ITERATIONS` lowers the five measured iterations for a quick local check; the warm-up iteration always runs. Numbers from a laptop are previews: the Linux CI runner is the canonical measurer for baselines, as it is for `renderer.initialBytes`. ## J1 `launch`: launch to usable The profile holds one M3U source and one Xtream portal, both served by the Xtream mock (`/playlist.m3u` and `player_api.php` on the same origin). The profile is seeded once per run through the app's own "Add playlist" dialogs, then every iteration copies that seeded data directory into a fresh temporary directory and spawns a fresh Electron process on it. One warm-up iteration is recorded but excluded from the summary; five measured iterations follow. The app lands on `/workspace/dashboard`, so the first card is a card of the "Recent sources" rail; an `app-playlist-item` row on `/workspace/sources` also ends the journey for profiles that disable the dashboard. The journey ends at the first `MutationObserver` batch in which all of the following hold: the location is below `/workspace`, `#initial-splash` is no longer in the DOM, and a source card has a non-empty client rect. Counters are frozen at that microtask checkpoint, so bridge calls and mutations issued later in the same task are included and everything after it is not. Three test-side pieces are injected. The app itself only contributes the main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`: - `journey-renderer-gate.cjs` is loaded into the main process with `-r`, the mechanism Playwright uses for its own loader. Playwright resolves `electron.launch()` while the app is already creating its window, and Electron reports no page until a navigation commits, so an init script registered afterwards would race the first document. The gate makes the first `loadFile` navigate to `about:blank` and holds the real load until the test releases it. A 15 s safety timeout releases it on its own and the iteration is then invalid. Electron emits `ready-to-show` for the first paint of a hidden window, and `about:blank` paints too, so the gate drops that event while the window shows `about:blank`; otherwise the app would show a blank window and freeze its `ready-to-show` counter before its own document exists. Electron emits the event again for the real document's first paint because the window is still hidden, which is the moment production sees. The gate also keeps the listener the app registers with `ipcMain.handle('performance:read-counters')`, so the test can call it from the main process. - `journey-renderer-probe.ts` is registered with `addInitScript` on that `about:blank` page, so it runs at the start of the real document. It records that it ran while the document was still `loading` with zero scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`, complete once the [settle window](#settle-window) has closed. - `journey-main-ipc-capture.ts` subscribes to the preload's renderer-API trace channel (`IPTVNATOR_DEBUG_TRACE_EVENT`, enabled with `IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate`, also before the release. The record refuses an iteration whose gate timed out, saw a second load, or released before the probe was in place. ### Counters | Counter | Source | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `renderer.ipcCallsToFirstCard` | `start` trace events the preload emits for every bridge invocation (listener registrations `on*`/`remove*` excluded, as in `wrapElectronApi`). The renderer probe fires one sentinel `cancelSourceProbe('__iptvnator-journey-sentinel__')` at the terminal moment; renderer-to-main IPC is ordered, so events before the sentinel are the exact count. The preload traces the call before forwarding it, and `SOURCE_HEALTH_CANCEL` only looks the id up in an in-memory map, so the sentinel never reaches the database worker. | | `renderer.domMutationsToFirstCard` | `MutationRecord`s (not callback batches) from a `MutationObserver` on the document element with `childList`, `attributes`, `characterData` and `subtree`. When the init script runs before `` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. | | `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. | | `renderer.layoutShiftScoreSettled` | The same filter from navigation start until the settle point after the first card (see [Settle window](#settle-window)), rounded to three decimals. It catches shifts that land after the cutoff, such as skeletons that collapse once their data resolves. | | `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. | #### Settle window `renderer.layoutShiftScore` stops at the first-card cutoff, one frame after the terminal batch. A shift that lands later is invisible to it: in #1738, rail skeletons of rails that resolved empty collapsed about 15 ms after the first card and pulled the rails below upwards, a shift of about 0.23 on every relaunch with sources that the counter read as 0. `renderer.layoutShiftScoreSettled` sums the same entries until the page has settled. The first-card counter is unchanged, so its baselines and history stay comparable. The settle window opens at the first-card cutoff. A second `MutationObserver` watches `main.workspace-content`, the workspace shell's content pane (the document element if it is missing, reported in `evidence.settle.observedTarget`). The window closes when nothing in that subtree has mutated for 500 ms, or 3 s after the cutoff, whichever comes first. The settle point is the deadline the firing timer was scheduled for (the last mutation plus 500 ms, or the cutoff plus 3 s), or the moment it ran if that is earlier, so a timer delayed by a busy main thread does not let later shifts in. Every entry that starts at or before the settle point counts, including entries still queued in the observer. Why this point: - A DOM change in the content pane is what causes the shifts this counter is after (data resolving, skeletons swapped for content), so quiet in that subtree is a condition the page reaches, not a guess at a delay. The rail and header stay outside the watched subtree, so their own updates neither keep the window open nor hide a shift in the content, which still counts wherever it happens. - 500 ms is many frames and well above the round trips to the local mock, so startup data that is already on its way lands inside the window. On the J1 profile the content pane goes quiet within about 110 ms of the first card, so the window closes about 520-610 ms after it. - The 3 s cap bounds each iteration when something keeps mutating (an animation, a ticking label). A capped window can end in the middle of that activity, so `evidence.settle.reason` (`quiet` or `cap`) is recorded for every iteration, together with `firstCardToSettledMs` and the mutation records seen (`domMutations`). Iterations that close for different reasons point at a settle point that is not deterministic; compare them before trusting `stable`. Entries with `hadRecentInput === true` are excluded, as for the first-card counter; J1 has no input. The probe keeps its layout-shift observer open only for this window: `final` still marks the first-card counters as frozen, `settle.status` moves from `pending` to `quiet` or `cap`, and the test waits for both. The record refuses an iteration whose window never closed or closed before the cutoff. J2's probe has no settle window (`settle.status` is `disabled`) and its counters are unchanged. `evidence.settle.lateShifts` lists the counted shifts after the cutoff (at most 20): the time after the first card, the value and, for each source the browser attributes the shift to, the node (`tag.class[data-test-id]`; a component host such as `lib-dashboard-rail` takes its first child's test id) and its vertical move. A late shift can therefore be traced to its component from the summary alone. First local measurement (macOS, 2026-09-29, `master` with #1738): all windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and `renderer.layoutShiftScoreSettled` was 0.236 in 14 of 15 measured iterations over three runs (`stable: false` in the first run with one 0, stable in the other two). Every iteration shows the same two shifts of 0.118: about 12 ms after the first card the `dashboard-recent-sources-rail`, which holds the first card, moves up by 316 px, and 12-65 ms later it moves back down. Something 316 px tall above it is removed and inserted again during startup, a flicker #1738 did not cover. The counter is working as intended; the flicker is a separate fix. On the Linux CI runner (`Performance journeys` job of #1756, run 36618062068) the same flicker is a race: the measured iterations read `[0, 0, 0.235, 0, 0]` (`stable: false`, every window `quiet` about 540 ms after the first card), and the one hit shows the same two 316 px moves of the recent-sources rail. #### Main-process counters With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets, `apps/electron-backend/src/app/services/debug-trace.ts` keeps named counters in the main process (`services/performance-counters.ts`) and `main.ts` registers the `performance:read-counters` IPC handler. Without the flag nothing is counted, no listener is attached and the handler does not exist; the preload never exposes the channel. SQL statements are counted only with `IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement execution: the launch journey sets both (the flags are built in `journey-launch-environment.ts`), while J2's launches and the M3U, refresh and Xtream benchmarks do not set the SQL flag and keep measuring unwrapped statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes, `journey-main-counters.ts` calls the handler through `electronApp.evaluate` and the gate's tap. | Counter | Source | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `main.modulesRegisteredBeforeWindow` | `main.startupPhases`, one per `traceStartupPhase` call (the phases printed as `[startup]` trace lines), frozen right after the first main window is constructed. | | `main.sqlStatementsBeforeReadyToShow` | `main.sqlStatements`, frozen at the first main window's `ready-to-show`. It counts the statements of the main-thread connection (`sql-main`, schema creation and migrations) and of the database worker, which posts its count over its message port ([DB worker](sqlite-db-worker.md)). | Main-thread statements are counted synchronously. The worker flushes its count before every other message it posts, so every worker statement whose response the main process has handled is included. The worker count is ordered against the worker's responses, not against wall-clock: statements whose count is still in flight when `ready-to-show` is dispatched are not. One call of `run`, `get`, `all`, `iterate` or `exec` that returns normally is one statement; on the launch workloads this matches the number of SQL trace lines exactly. An `exec` with several statements would count as one, so the shared connection passes one statement per call, and its historical-upgrade test fails on a batch. `main.sqlStatementsBeforeReadyToShow` is not yet deterministic. The main thread runs the shared connection's schema creation and migrations (about 90 statements on the J1 profile) in one synchronous block after the load event, and `ready-to-show` is dispatched after it. The stale-download and stale-recording recovery that follows (one statement each) races the event, so iterations differ by two and the summary marks the counter `stable: false`. The database worker runs no statement before the first paint. Each frozen counter carries its epoch. The record refuses an iteration whose window counter was frozen after the gate saw the first load, or whose `ready-to-show` counter was frozen before the gate released the real document. Running totals at read time are kept under `evidence.mainCountersAtRead`, the freeze epochs under `evidence.epochs.mainWindowCreated` and `evidence.epochs.mainReadyToShow`, and the number of dropped blank `ready-to-show` events under `evidence.rendererGateReadyToShowHeldOnBlank`. Counters are exact: the summary carries the value shared by every measured iteration. When iterations disagree, the summary reports the maximum and marks the counter `stable: false` under `counterStability`; such a counter is not promoted to a guardrail until it is deterministic. One counter from the plan is listed under `unavailable` with the reason instead of being faked: - `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes scripts, which sets `ngDevMode` to false, so Angular does not publish `window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the terminal moment and the record refuses a build where the hook exists but was not counted. ### Wall-clock | Entry | Derivation | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `spawnToDidFinishLoadMs.p50/.p90` | `performance.timeOrigin + loadEventEnd` of the navigation entry (the main frame's `load`, which is what `did-finish-load` reports) minus the test-side timestamp taken just before `electron.launch`. | | `spawnToFirstCardMs.p50/.p90` | Terminal epoch of the renderer probe minus the same spawn timestamp. | Percentiles use linear interpolation over the five measured iterations. The spawn timestamp includes Playwright's own launch overhead and the gate's `about:blank` detour: Playwright holds `app.whenReady()` until its CDP session is attached, and the real document loads only after the probes are in place, so absolute values are larger than a bare launch. They are comparable between runs of the same harness, which is what the ratchet needs. The main process start (`Date.now() - process.uptime()`) is recorded per iteration under `evidence.epochs` for cross-checks. ### Startup work before the first card `renderer.ipcCallsToFirstCard` counts what the renderer asks of the main process before the first card. - `PlaylistsService.getAllPlaylists()` shares its first SQLite read: at startup the playlist effect and the XMLTV source reconciliation both read the inventory, and the second caller joins the first read and receives a `structuredClone` of its result. Sharing ends when that read settles or a `PlaylistsService` write starts. It is limited to startup on purpose: other services write playlists too (the settings reset deletes them through `DatabaseService`), and while the startup screen is up no such action can run. `dbGetAppPlaylistMetas` before the first card: 2 → 1. - `reconcileEpgSources` stays before the first card on purpose: its completion bumps `EpgSourceSettingsService.revision()`, the fence that keeps XMLTV lookups from returning data of a removed source. Validation (#1716, Principle 3): deferring the download list, update status and dashboard recent/favorites reads until after the first render took the counter from 12 to 7 on a Mac, but moved neither `spawnToFirstCardMs` nor load→card beyond run-to-run drift on a quiet machine, and it grew `renderer.initialBytes`, so it was dropped. Those calls were never on the path the first card waits for. That path is a serial chain of round trips (the migration reads, the inventory read and `reconcileEpgSources`), so a serial-depth counter is a better guardrail candidate than a raw call count. ### Summary schema ```json { "schemaVersion": 1, "generatedAt": "2026-09-26T11:02:14.318Z", "harness": { "platform": "darwin", "electron": "43.3.0", "measuredIterations": 5, "runId": "0b6f7f1e-…", "warmupIterations": 1 }, "journeys": { "launch": { "counters": { "renderer.ipcCallsToFirstCard": 12, "renderer.layoutShiftScoreSettled": 0.236 }, "counterStability": { "renderer.ipcCallsToFirstCard": { "stable": true, "values": [12, 12, 12, 12, 12] }, "renderer.layoutShiftScoreSettled": { "stable": true, "values": [0.236, 0.236, 0.236, 0.236, 0.236] } }, "wallClock": { "spawnToFirstCardMs.p50": 1234.5, "spawnToFirstCardMs.p90": 1300.1 }, "unavailable": { "renderer.cdTicksToFirstCard": "reason" }, "iterations": [ { "index": 0, "warmup": true, "pid": 1, "counters": {}, "wallClock": {}, "evidence": { "settle": { "domMutations": 458, "firstCardToSettledMs": 536.6, "lateShifts": [ { "afterFirstCardMs": 12.4, "sources": [ { "deltaHeight": 0, "deltaY": -316, "node": "lib-dashboard-rail[data-test-id=\"dashboard-recent-sources-rail\"]" } ], "value": 0.118 } ], "observedTarget": "root", "reason": "quiet" } } } ] } } } ``` `journeys..counters.` and `journeys..wallClock.` are plain numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with `tools/performance/journey-baselines.json`. The summary writer checks only that every measured iteration reports the same counter names with finite values, so a new counter needs no schema change. A J1 runtime baseline is added once its counter is deterministic on the CI runner; the launch counters are not yet (see [Ratchet](#ratchet)), so the summary is evidence only. J3 adds the `journeys.playback` entry with the same shape and no schema version change: `counters` and `wallClock` hold only plain numbers, and its iterations carry `evidence.media` (the video element at `playing`) and `evidence.epochs.loadedMetadata` / `.playing`. The renderer probe blob gained a `media` field (`null` for J1 and J2), which the probe's `schemaVersion` 1 readers ignore. ## J2 `open-source`: open a source to a browsable list `open-source.journey.ts` reuses the J1 profile and process pattern: the profile is seeded once through the "Add playlist" dialogs, and every iteration copies it and spawns a fresh process through `runLaunchJourney`, which measures J1 as usual (gate, probe, IPC capture) and then hands the running app to `measureOpenSourceJourney` in `src/journeys/open-source-journey-app.ts`. The click therefore happens after J1's terminal condition and its counters are final, and after J1's settle window has closed, so the two journeys never overlap. One warm-up and five measured iterations, as for J1; the J1 numbers of these launches are not reported again. J2 does not read J1's main-process counters, so its launches run without `IPTVNATOR_PERF_CAPTURE` and `IPTVNATOR_PERF_COUNT_SQL` (`runLaunchJourney` with `mainCounters: false`). The click is not measured under the SQL hook that wraps every statement. **Start.** The click on the dashboard card of the Xtream portal (`dashboard-recent-sources-rail-card` with the portal's name; the probe also accepts an `app-playlist-item` row on `/workspace/sources`). Before the click the test hovers the card and waits until the app has been quiet for 1 s: no DOM mutation, no new bridge call, no new request to the mock, and neither a bridge call nor a mock request still in flight (30 s timeout, which 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. After settling, J1's capture is detached (`detachJourneyMainIpcCapture`), so its listener does not run for every bridge call of the measured click. 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 `window`, which runs before every listener of the app. On the first click inside the start selector it stamps the start at the event's timestamp (or the listener's time if that is earlier) and sends the start sentinel `cancelSourceProbe('__iptvnator-journey-open-source-start__')`, the same no-op marker as J1's. Only then do the counters start. **End.** The first `MutationObserver` batch after the start in which the path contains `/workspace/xtreams/`, an item of the first page is visible (`app-grid-list mat-card, .content-card, [data-test-id="channel-item"]`; skeleton cards do not match) and a category of the context panel is visible (`app-workspace-context-panel .category-item`). The probe then sends the end sentinel `cancelSourceProbe('__iptvnator-journey-open-source-end__')` and closes the observers at the same post-paint cutoff as J1. A portal card opens the source's default section, which is VOD (`getPlaylistLink` links to `/workspace/xtreams//vod`): the category list is the movie category list and the first page is the "All items" grid. The plan's "live category list" would need a second click and is not measured; the landed section is recorded under `evidence.firstPage.section`. **HTTP requests to the mock.** The J2 profile is seeded with the origin of a loopback proxy in the test process (`src/performance/journey-mock-request-ledger.ts`) that forwards to the mock and records every request, so requests from the main process (Xtream API, M3U) and from the renderer (artwork served by the mock) are all counted. The default fixture's posters point at `picsum.photos`, so they are neither counted nor blocked: they load after the first page is painted, and on an offline runner they fail instead. Blocking them with `page.route` would put request interception on every renderer request, including the lazy chunks the journey loads. The mock's own `/__control/state` ledger is not used: it exists only in performance-control mode, which disables `/playlist.m3u` and tracks only the 100k scenario, and a Playwright request listener would see renderer traffic only. The ledger stores the method, the path and, for `player_api.php`, the `action` parameter; query strings and stream paths carry credentials and are never stored. ### Counters | Counter | Source | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `renderer.ipcCallsToFirstPage` | Bridge `start` trace events between the start and end sentinels, counted by a second `journey-main-ipc-capture.ts` instance installed with `startSentinelId`. Calls before the start marker are tallied separately (`callsBeforeStart`); a start marker that is missing, repeated or received after the end sentinel fails the iteration. | | `renderer.domMutationsToFirstPage` | `MutationRecord`s from the click until the terminal batch. Records produced before the click (hover, settling) are taken from the observer at the start and counted under `evidence.settle` instead. | | `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`. | | `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the cutoff. The task that dispatches the click began before the event's timestamp and still counts; buffered J1 tasks that ended before the click are dropped. Evidence until it is shown to be stable on the CI runner, as for J1. | | `main.mockHttpRequestsToSettled` | Requests the proxy received from the click until, after the terminal batch, no new request had arrived for 1 s and none was in flight (a response slower than that, and what it triggers, stays inside the window). The window ends at the ledger position read by that accepted quiet sample; a request arriving after it was never seen in flight, so it goes to `evidence.httpRequestsAfterSettledByRoute` instead of the counter. The ledger is read 1 s after that sample, so that late traffic is actually observed. The window starts at the renderer's click stamp, the same boundary as every other J2 counter, not when Playwright began its actionability checks; the proxy stamps requests with the test process's wall clock, and both processes read the same host clock. Bounding by the terminal would compare the test process's clock with the renderer's, so the count up to the terminal epoch is evidence only (`evidence.httpRequestsToFirstPage`); `evidence.httpRequestsByRoute` names the requests. | Two counters are listed under `unavailable`. `renderer.cdTicksToFirstPage` is missing for the same reason as its J1 counterpart. `main.sqlStatementsToFirstPage` is missing because the running `main.sqlStatements` total that J1 freezes at `ready-to-show` can only be read from the test process through the journey gate. It therefore cannot be sampled at the click or at the first-page batch, and the worker's count is ordered against its responses, not against the renderer. Reading it after the app has settled before the click and again after the first page would give a click-to-settled count; that is left to a follow-up. ### Wall-clock | Entry | Derivation | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `clickToFirstPageMs.p50/.p90` | Terminal epoch minus start epoch: the click until the batch that made the category list and first page visible, the same boundary J1's `spawnToFirstCardMs` uses. | | `clickToFirstPagePaintMs.p50/.p90` | Post-paint cutoff minus start epoch: the click until the frame that paints the first page has been committed (the timer queued from the next `requestAnimationFrame`). This is the "painted" figure of the journey definition. | All epochs are taken in the renderer, so neither entry crosses a process clock. ## J3 `playback`: start playback to the first frame `playback.journey.ts` follows J2: the profile is seeded once through the "Add playlist" dialogs (`seedLaunchJourneyProfile` with `PLAYBACK_JOURNEY_SEED`), every iteration copies it, spawns a fresh process through `runLaunchJourney` without main-process counters, and hands the running app to `measurePlaybackJourney` in `src/journeys/playback-journey-app.ts`. One warm-up and five measured iterations. **Profile.** J2's M3U source plus an Xtream portal ("Journey live portal") on the mock's `live-fallback:live-fallback` account, behind the same request ledger proxy. Seeding also selects **Settings > Playback > Video player > HTML5 video player** and **Stream format > ts** through the settings page (`configureLiveFormat`), so live URLs end in `.ts`. Embedded MPV and external players are out of scope. **Stream.** `/live/live-fallback/live-fallback/10000.ts` returns `apps/xtream-mock-server/src/fixtures/live.mpegts` from disk: six seconds of 160x90 H.264 baseline video and AAC audio in MPEG-TS, about 300 KB. The HTML5 player plays `.ts` through mpegts.js, which transmuxes to fragmented MP4 for Media Source Extensions; H.264 and AAC are among the codecs Electron's Chromium decodes on every platform, including the Linux runner, and the Electron E2E for the live-format fallback already plays this fixture there. Two other choices were rejected: the `marketing` and `marketing2` accounts serve live URLs from local bytes, but those bytes are zero-filled (a fixture for download screenshots, not media), so no player ever fires `playing`; every other account redirects streams to a public HLS test stream. The record fails an iteration whose click-to-`playing` window has no `.ts` request for a live stream, so a player that played something else is never measured. **No request leaves the machine.** The generated live catalog's channel and category logos point at `picsum.photos`. Before navigating, the journey registers `session.defaultSession.webRequest.onBeforeRequest` for `*://picsum.photos/*` from the test side and cancels those requests (the app registers no `onBeforeRequest` listener of its own, so none is replaced). A logo therefore never loads, or fails, at a moment that depends on the runner's network; the number cancelled is kept as `evidence.externalArtworkCancelled`. Every other request goes to the mock through the ledger. **Start.** After J1 has ended, the test clicks the portal's dashboard card, the **Live TV** link and the first category (not measured), then installs the IPC capture with a start sentinel, arms the probe, hovers the first `app-live-stream-layout [data-test-id="channel-item"]` and waits for the same 1 s quiet as J2 (`src/performance/journey-click-settle.ts`, shared with J2). J1's capture is detached and the channel is clicked. The probe's capture-phase `click` listener stamps the start and sends `cancelSourceProbe('__iptvnator-journey-playback-start__')`. The record rejects activity between the settle snapshot and the click exactly as J2 does. **End.** The probe runs with `media: { endEvent: 'playing', phaseEvents: ['loadedmetadata'] }`. Media events do not bubble, but a capture-phase listener on `window` sees them before any listener of the app. The first `playing` event after the start on an element matching `app-web-player-view video` ends the journey: pending mutation records are taken synchronously, the end sentinel `cancelSourceProbe('__iptvnator-journey-playback-end__')` is sent, and the element's state is recorded (`evidence.media`: `readyState`, `paused`, `currentTime`, intrinsic size, and `currentSrcScheme`, which is `blob` for Media Source playback). The first `loadedmetadata` on such an element after the start is recorded as a phase. A visible video element does not end the journey, and media events before the click or on other elements are ignored. ### Counters | Counter | Source | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `renderer.ipcCallsToPlaying` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. | | `renderer.httpRequestsToPlaying` | Requests the ledger proxy received from the click stamp until the `playing` stamp, from either process (the stream request comes from the renderer, Xtream API calls from the main process). Both stamps are `performance.timeOrigin + performance.now()` of processes on the same host clock, as in J2. Unlike J2's counter the window ends at the terminal, not at a quiet mock: a live stream has no quiet end. Later requests are kept as `evidence.httpRequestsAfterPlayingByRoute`, the ones in the window as `evidence.httpRequestsByRoute`. | | `renderer.domMutationsToPlaying` | `MutationRecord`s from the click until the `playing` event, including records still queued when it fires. | | `renderer.layoutShiftScore` | All `layout-shift` entries from the click until the `playing` event, including `hadRecentInput` ones (as J2), rounded to three decimals. Entries delivered up to the post-paint cutoff are read, but only those that started by the event count. | | `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the `playing` event, so the task that dispatched the event counts. Evidence until shown to be stable on the runner. | `renderer.httpRequestsToPlaying` compares the ledger's arrival stamps (test process) with the renderer's click and `playing` stamps. Both are `performance.timeOrigin + performance.now()` on the same host clock, but the two processes' time origins can differ by a fraction of a millisecond, so every iteration records `evidence.httpBoundaryMarginsMs`: the distance of the nearest request on either side of the click and of `playing`. A margin of a few milliseconds means a clock difference could move that request across the boundary. Locally the first request after the click arrives 3-6 ms after its stamp (the click causes it, so it cannot precede the click) and the nearest request to `playing` is more than 170 ms away; both are well above a sub-millisecond origin difference. `evidence.httpRequestsAfterPlayingByRoute` covers a fixed window of 1 s after `playing` (the test waits that long before reading the ledger), not a quiet mock as in J2: a live stream has no quiet end. Two counters are listed under `unavailable`. `renderer.cdTicksToPlaying` is missing for the same reason as in J1 and J2. `renderer.ipcSerialDepthToPlaying` is missing because the serial-depth helper (see [Startup work before the first card](#startup-work-before-the-first-card)) was not on `master` when J3 landed; J3 adopts it once the J1 thread adds it. `main.sqlStatementsToPlaying` is not measured for the same reason as J2's SQL counter. ### Wall-clock | Entry | Derivation | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `clickToPlayingMs.p50/.p90` | `playing` epoch minus start epoch. | | `clickToLoadedMetadataMs.p50/.p90` | First `loadedmetadata` epoch minus start epoch: player setup, the stream request and the first transmuxed init segment. | The difference of the two is stream start: buffering until the element can play. All epochs are taken in the renderer. ### First measurement Local, macOS, 2026-09-30 (two `perf:journeys` runs, five measured iterations each): every counter identical in all ten, `renderer.ipcCallsToPlaying` 4 (`getEpgMapping`, `xtreamRequest`, `updateRemoteControlStatus`, `setUserAgent`), `renderer.httpRequestsToPlaying` 2 (the `.ts` stream and `get_simple_data_table`), `renderer.domMutationsToPlaying` 6,188, `renderer.layoutShiftScore` 0.001, `renderer.longTasks` 0; P50 click→`loadedmetadata` 92-94 ms and click→`playing` 239-257 ms. The warm-up iteration of the first run took the cold path (888 ms to `playing`, one more `updateRemoteControlStatus` call); warm-ups are excluded. About 6,000 of the mutations come from the EPG timeline rendering about 240 programme blocks from the `get_simple_data_table` response before the first frame. Whether that response and its render land before `playing` is a race on a slower machine, so check the runner's `counterStability` before trusting the mutation and request counts. No J3 baseline exists yet; J3 counters join the ratchet once three runner runs agree. ## `renderer.initialBytes` The bytes a browser fetches before Angular can bootstrap, read from the built `dist/apps/web/index.html`: - `index.html` itself, - every same-origin `