From 6e229f85b699c5223ff77c3cdebb6369274c2166 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:37:02 +0200 Subject: [PATCH 1/7] 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 * 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 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +- README.md | 5 +- .../src/journeys/launch-journey-app.ts | 17 +- .../src/journeys/open-source-journey-app.ts | 154 +------- .../src/journeys/playback-journey-app.ts | 224 ++++++++++++ .../src/journeys/playback.journey.ts | 91 +++++ .../src/performance/journey-click-settle.ts | 179 +++++++++ .../journey-renderer-probe-media.spec.ts | 233 ++++++++++++ .../journey-renderer-probe.spec.ts | 160 +------- .../journey-renderer-probe.test-helpers.ts | 165 +++++++++ .../src/performance/journey-renderer-probe.ts | 128 ++++++- .../performance/launch-journey-record.spec.ts | 1 + .../open-source-journey-record.spec.ts | 1 + .../performance/open-source-journey-record.ts | 24 +- .../playback-journey-record.spec.ts | 344 ++++++++++++++++++ .../performance/playback-journey-record.ts | 231 ++++++++++++ docs/architecture/performance-journeys.md | 149 +++++++- 17 files changed, 1792 insertions(+), 318 deletions(-) create mode 100644 apps/electron-backend-e2e/src/journeys/playback-journey-app.ts create mode 100644 apps/electron-backend-e2e/src/journeys/playback.journey.ts create mode 100644 apps/electron-backend-e2e/src/performance/journey-click-settle.ts create mode 100644 apps/electron-backend-e2e/src/performance/journey-renderer-probe-media.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts create mode 100644 apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/playback-journey-record.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b05476d9c..60e1a14a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -296,8 +296,8 @@ jobs: if: needs.performance-journeys-scope.outputs.run == 'true' runs-on: ubuntu-latest # The electron-performance build is the bulk of the time; each journey - # (launch, open-source) is six fresh Electron processes plus one - # seeding run. + # (launch, open-source, playback) is six fresh Electron processes plus + # one seeding run. timeout-minutes: 30 # Warn-only for the first two weeks of plan item B3: a failure is # visible on the run but does not fail the workflow. diff --git a/README.md b/README.md index 25bcbb9d8..ae7f09d20 100644 --- a/README.md +++ b/README.md @@ -388,8 +388,9 @@ $ pnpm run perf:initial-bytes The contract behind that number is in [docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). -To benchmark the "launch to usable" journey (fresh Electron process on a -seeded profile, exact renderer counters plus wall-clock), run: +To benchmark the "launch to usable", "open a source" and "start playback" +journeys (fresh Electron processes on a seeded profile against the local +Xtream mock, exact renderer counters plus wall-clock), run: ``` $ pnpm run perf:journeys diff --git a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts index b69633e70..10f1bcb12 100644 --- a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts @@ -85,12 +85,21 @@ function removeDirectory(directory: string): Promise { }); } +/** What a journey changes in the seeded profile; J1 and J2 use neither. */ +export interface LaunchJourneySeedOptions { + /** Portal credentials; default: the mock's default account. */ + readonly portal?: Parameters[1]; + /** Runs after both sources are imported, e.g. to change settings. */ + readonly configure?: (page: Page) => Promise; +} + /** * Seeds one M3U source and one Xtream portal through the app's own dialogs * and returns the data directory to copy for every measured launch. */ export async function seedLaunchJourneyProfile( - mockOrigin: string + mockOrigin: string, + options: LaunchJourneySeedOptions = {} ): Promise { const templateDirectory = await mkdtemp( join(tmpdir(), 'iptvnator-journey-launch-seed-') @@ -103,8 +112,12 @@ export async function seedLaunchJourneyProfile( `${mockOrigin}/playlist.m3u` ); await waitForM3uCatalog(app.mainWindow); - await addXtreamPortal(app.mainWindow, { serverUrl: mockOrigin }); + await addXtreamPortal(app.mainWindow, { + ...options.portal, + serverUrl: mockOrigin, + }); await waitForXtreamCatalog(app.mainWindow); + await options.configure?.(app.mainWindow); } finally { await closeElectronAppAndConfirmExit(app); } 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 15a7295ed..45c9cc074 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 @@ -1,161 +1,44 @@ -import type { ElectronApplication, Page } from '@playwright/test'; - import { defaultXtreamPortalName } from '../electron-test-fixtures'; import { - countJourneyMainIpcInFlight, + JOURNEY_CLICK_QUIET_MS, + waitForJourneyClickQuiet, + waitForJourneyMockQuiet, +} from '../performance/journey-click-settle'; +import { detachJourneyMainIpcCapture, installJourneyMainIpcCapture, JOURNEY_MAIN_IPC_STATE_KEY, JOURNEY_RENDERER_API_TRACE_CHANNEL, - peekJourneyMainIpcCaptures, 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, waitForJourneyRendererProbe, type JourneyRendererProbeState, } from '../performance/journey-renderer-probe'; -import type { - OpenSourceJourneyMeasurement, - OpenSourceJourneySettle, -} from '../performance/open-source-journey-record'; +import type { OpenSourceJourneyMeasurement } from '../performance/open-source-journey-record'; import type { LaunchJourneySession } from './launch-journey-app'; /** * J2 "Open a source": runs inside a process that J1 has just launched, after * J1's counters are final. The app is first allowed to settle (no DOM - * mutation, bridge call or mock request for `QUIET_MS`), so leftovers of the - * startup are not attributed to the click. Then the Xtream portal card on + * mutation, bridge call or mock request for `JOURNEY_CLICK_QUIET_MS`), so + * leftovers of the startup are not attributed to the click. Then the Xtream portal card on * the dashboard is clicked and the probe, the IPC capture and the mock * request ledger measure until the category list and the first page of * items are painted. */ export const OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY = '__iptvnatorJourneyOpenSourceMainIpcCapture'; -const QUIET_MS = 1_000; -const POLL_MS = 100; -const SETTLE_TIMEOUT_MS = 30_000; +const ERROR_PREFIX = 'open-source-journey'; /** * After the mock settled, the ledger is watched this much longer before it * is read, so requests that arrive after the accepted quiet sample show up * in `httpRequestsAfterSettledByRoute` instead of vanishing unseen. */ -const LATE_REQUEST_OBSERVATION_MS = QUIET_MS; - -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 { - return page.evaluate((key) => { - const state = (globalThis as unknown as Record)[ - 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 - * `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. - */ -async function waitForQuiet( - electronApp: ElectronApplication, - page: Page, - ledger: JourneyMockRequestLedger, - probeStateKey: string -): Promise<{ - /** Ledger position read by the accepted quiet sample itself. */ - readonly ledgerMark: number; - readonly settle: OpenSourceJourneySettle; -}> { - const armMark = ledger.mark(); - const sample = async (): Promise => { - // Both captures in one snapshot: a call counted by J2's capture is - // then also pending in J1's, never counted with a stale in-flight 0. - const [launchCapture, openSourceCapture] = - await peekJourneyMainIpcCaptures(electronApp, [ - JOURNEY_MAIN_IPC_STATE_KEY, - OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY, - ]); - if (launchCapture.unmatchedCompletions > 0) { - throw new Error('open-source-journey-bridge-completions-unmatched'); - } - return { - domMutations: await readPreStartMutations(page, probeStateKey), - httpInFlight: ledger.inFlight(), - httpRequests: ledger.mark(), - ipcCalls: openSourceCapture.callsBeforeStart, - ipcInFlight: countJourneyMainIpcInFlight(launchCapture), - }; - }; - 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 { - ledgerMark: quiet.httpRequests, - settle: { - preStartDomMutations: quiet.domMutations, - preStartHttpRequests: quiet.httpRequests - armMark, - preStartIpcCalls: quiet.ipcCalls, - quietMs: QUIET_MS, - waitedMs, - }, - }; -} - -/** - * Waits until the mock has seen no new request for `QUIET_MS` and none is - * in flight, so responses slower than the quiet interval and the requests - * they trigger stay inside the measured window. - */ -async function waitForMockQuiet( - ledger: JourneyMockRequestLedger -): Promise { - const { sample: 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, - }); - // 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; -} +const LATE_REQUEST_OBSERVATION_MS = JOURNEY_CLICK_QUIET_MS; /** * `spawnLedgerMark` is the ledger position taken before the process was @@ -194,12 +77,12 @@ export async function measureOpenSourceJourney( // reading the IPC capture. Requests from that position on but before // the renderer's click stamp arrived after the app settled, and the // record rejects such an iteration. - const { ledgerMark: settledLedgerMark, settle } = await waitForQuiet( - electronApp, - mainWindow, - ledger, - probeOptions.stateKey - ); + const { ledgerMark: settledLedgerMark, settle } = + await waitForJourneyClickQuiet(electronApp, mainWindow, ledger, { + errorPrefix: ERROR_PREFIX, + ipcCaptureStateKey: OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY, + probeStateKey: probeOptions.stateKey, + }); // J1's capture was only needed to see pending launch calls while // settling; detached, it no longer runs for every J2 bridge call. await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY); @@ -215,7 +98,10 @@ export async function measureOpenSourceJourney( OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY, 10_000 ); - const settledAfterLedgerMark = await waitForMockQuiet(ledger); + const settledAfterLedgerMark = await waitForJourneyMockQuiet( + ledger, + ERROR_PREFIX + ); await new Promise((resolve) => setTimeout(resolve, LATE_REQUEST_OBSERVATION_MS) ); diff --git a/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts b/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts new file mode 100644 index 000000000..1068f9f83 --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/playback-journey-app.ts @@ -0,0 +1,224 @@ +import type { ElectronApplication, Page } from '@playwright/test'; + +import { configureLiveFormat } from '../xtream-live-format.fixture'; +import { + JOURNEY_CLICK_QUIET_MS, + waitForJourneyClickQuiet, +} from '../performance/journey-click-settle'; +import { + detachJourneyMainIpcCapture, + installJourneyMainIpcCapture, + JOURNEY_MAIN_IPC_STATE_KEY, + JOURNEY_RENDERER_API_TRACE_CHANNEL, + readJourneyMainIpcCapture, +} from '../performance/journey-main-ipc-capture'; +import type { JourneyMockRequestLedger } from '../performance/journey-mock-request-ledger'; +import { + armJourneyRendererProbe, + createPlaybackJourneyProbeOptions, + JOURNEY_OPEN_SOURCE_START_SELECTOR, + waitForJourneyRendererProbe, +} from '../performance/journey-renderer-probe'; +import { + PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS, + type PlaybackJourneyMeasurement, +} from '../performance/playback-journey-record'; +import type { + LaunchJourneySeedOptions, + LaunchJourneySession, +} from './launch-journey-app'; + +/** + * J3 "Playback": runs inside a process that J1 has just launched. The test + * opens the Xtream portal's live section and its first category (not + * measured), lets the app settle like J2, then clicks the first live channel + * and measures until the HTML5 player's video element fires `playing`. + * + * The portal is the mock's `live-fallback` account, whose `.ts` live URLs + * serve a local six-second H.264 baseline + AAC MPEG-TS fixture + * (`apps/xtream-mock-server/src/fixtures/live.mpegts`). The HTML5 player + * plays it through mpegts.js and Media Source Extensions, which Electron's + * Chromium supports on every platform. The marketing accounts' live URLs + * serve zero-filled bytes that no player can decode, and the other accounts + * redirect to a public HLS stream. Contract: + * docs/architecture/performance-journeys.md. + */ +export const PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY = + '__iptvnatorJourneyPlaybackMainIpcCapture'; +export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal'; +const ERROR_PREFIX = 'playback-journey'; +const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork'; +/** + * The generated live catalog's channel and category logos point at + * picsum.photos. They are cancelled in the main process, so no request of + * the journey leaves the machine and a logo never loads, or fails, at a + * different moment on a runner with a different network. + */ +const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*']; + +/** Seeds J2's profile with the local-media portal and the HTML5 player. */ +export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = { + // The built-in HTML5 player with the `ts` stream format: live URLs end + // in `.ts`, which the mock serves from the local fixture. + configure: (page) => configureLiveFormat(page, 'html5', 'ts'), + portal: { + name: PLAYBACK_JOURNEY_PORTAL_NAME, + password: 'live-fallback', + username: 'live-fallback', + }, +}; + +async function blockExternalArtwork( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + ({ session }, input) => { + const target = globalThis as unknown as Record; + if (target[input.key] !== undefined) { + throw new Error('playback-journey-artwork-block-installed'); + } + const state = { cancelled: 0 }; + target[input.key] = state; + // The app registers no onBeforeRequest listener of its own + // (only onBeforeSendHeaders), so this replaces nothing. + session.defaultSession.webRequest.onBeforeRequest( + { urls: input.urls }, + (_details, callback) => { + state.cancelled += 1; + callback({ cancel: true }); + } + ); + }, + { key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS } + ); +} + +async function readCancelledExternalArtwork( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate( + (_electron, key) => + ( + (globalThis as unknown as Record)[key] as { + cancelled: number; + } + ).cancelled, + EXTERNAL_ARTWORK_STATE_KEY + ); +} + +/** Dashboard card → live section → first category, as a user would. */ +async function openLiveCategory(page: Page, timeoutMs: number): Promise { + await page + .locator(JOURNEY_OPEN_SOURCE_START_SELECTOR) + .filter({ hasText: PLAYBACK_JOURNEY_PORTAL_NAME }) + .first() + .click({ timeout: timeoutMs }); + await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, { + timeout: timeoutMs, + }); + await page + .getByRole('link', { name: 'Live TV', exact: true }) + .click({ timeout: timeoutMs }); + await page + .locator('app-workspace-context-panel .category-item') + .first() + .click({ timeout: timeoutMs }); +} + +/** + * `spawnLedgerMark` is the ledger position taken before the process was + * spawned, so the launch's and the navigation's mock traffic is kept as + * evidence. + */ +export async function measurePlaybackJourney( + session: LaunchJourneySession, + ledger: JourneyMockRequestLedger, + spawnLedgerMark: number, + timeoutMs: number +): Promise { + const { electronApp, mainWindow } = session; + const probeOptions = createPlaybackJourneyProbeOptions(); + const startClick = probeOptions.startClick; + if (!startClick) { + throw new Error('playback-journey-probe-without-start'); + } + await blockExternalArtwork(electronApp); + await openLiveCategory(mainWindow, timeoutMs); + const channel = mainWindow.locator(startClick.selector).first(); + await channel.waitFor({ state: 'visible', timeout: timeoutMs }); + await installJourneyMainIpcCapture(electronApp, { + channel: JOURNEY_RENDERER_API_TRACE_CHANNEL, + sentinelId: probeOptions.sentinelId, + sentinelMethod: probeOptions.sentinelMethod, + startSentinelId: startClick.sentinelId, + stateKey: PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY, + }); + await armJourneyRendererProbe(mainWindow, probeOptions); + // Hover first so hover effects happen before the app settles. + await channel.hover({ timeout: timeoutMs }); + const { ledgerMark: settledLedgerMark, settle } = + await waitForJourneyClickQuiet(electronApp, mainWindow, ledger, { + errorPrefix: ERROR_PREFIX, + ipcCaptureStateKey: PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY, + probeStateKey: probeOptions.stateKey, + }); + await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY); + await channel.click({ timeout: timeoutMs }); + const renderer = await waitForJourneyRendererProbe( + mainWindow, + probeOptions.stateKey, + timeoutMs + ); + const ipc = await readJourneyMainIpcCapture( + electronApp, + PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY, + 10_000 + ); + const clickEpochMs = renderer.start?.epochMs; + const playingEpochMs = renderer.terminal?.epochMs; + if (clickEpochMs === undefined || playingEpochMs === undefined) { + throw new Error('playback-journey-probe-incomplete'); + } + // A live stream never leaves the mock quiet, so instead of J2's quiet + // wait the ledger is watched for a fixed window after `playing`. + const afterPlayingUntilEpochMs = + playingEpochMs + PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS; + const remainingMs = + afterPlayingUntilEpochMs - (performance.timeOrigin + performance.now()); + if (remainingMs > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingMs)); + } + // The ledger stamps arrivals with this process's clock, the probe with + // the renderer's; both read the same host clock (as in J2). The record + // keeps the distance of the nearest request to each boundary, so a + // count that a small clock difference could flip is visible. + const sinceSpawn = ledger.since(spawnLedgerMark); + const beforeClick = sinceSpawn.filter( + (entry) => entry.epochMs < clickEpochMs + ); + return { + externalArtworkCancelled: + await readCancelledExternalArtwork(electronApp), + http: { + afterPlaying: sinceSpawn.filter( + (entry) => + entry.epochMs >= playingEpochMs && + entry.epochMs < afterPlayingUntilEpochMs + ), + afterSettleBeforeClick: beforeClick.filter( + (entry) => entry.sequence >= settledLedgerMark + ).length, + beforeClick, + toPlaying: sinceSpawn.filter( + (entry) => + entry.epochMs >= clickEpochMs && + entry.epochMs < playingEpochMs + ), + }, + ipc, + pid: session.launch.pid, + renderer, + settle, + }; +} diff --git a/apps/electron-backend-e2e/src/journeys/playback.journey.ts b/apps/electron-backend-e2e/src/journeys/playback.journey.ts new file mode 100644 index 000000000..a4c8c42e5 --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/playback.journey.ts @@ -0,0 +1,91 @@ +import { test } from '@playwright/test'; + +import { startJourneyMockRequestLedger } from '../performance/journey-mock-request-ledger'; +import type { JourneyIterationRecord } from '../performance/journey-summary'; +import { + PLAYBACK_JOURNEY_ID, + PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS, + toPlaybackIterationRecord, +} from '../performance/playback-journey-record'; +import { + JOURNEY_ITERATION_TIMEOUT_MS, + JOURNEY_MEASURED_ITERATIONS, + JOURNEY_WARMUP_ITERATIONS, + logJourneyIteration, + writeJourneyRunEntry, +} from './journey-run'; +import { + LAUNCH_JOURNEY_MOCK_ORIGIN, + removeLaunchJourneyProfile, + runLaunchJourney, + seedLaunchJourneyProfile, +} from './launch-journey-app'; +import { + measurePlaybackJourney, + PLAYBACK_JOURNEY_SEED, +} from './playback-journey-app'; + +/** + * J3 "Playback": click on a live channel of an Xtream portal until the + * built-in HTML5 player's video element fires `playing`. Every iteration is + * a fresh J1 launch on a copy of the seeded profile (J2's profile with the + * portal on the mock's local-media `live-fallback` account and the HTML5 + * player selected); the click happens after the app has settled in the + * portal's first live category. + * Contract: docs/architecture/performance-journeys.md. + */ +test.describe.configure({ mode: 'serial' }); + +test('J3 start playback', async () => { + const ledger = await startJourneyMockRequestLedger( + LAUNCH_JOURNEY_MOCK_ORIGIN + ); + const iterations: JourneyIterationRecord[] = []; + let electronVersion = 'unknown'; + try { + const templateDirectory = await seedLaunchJourneyProfile( + ledger.origin, + PLAYBACK_JOURNEY_SEED + ); + try { + const total = + JOURNEY_WARMUP_ITERATIONS + JOURNEY_MEASURED_ITERATIONS; + for (let index = 0; index < total; index += 1) { + const warmup = index < JOURNEY_WARMUP_ITERATIONS; + const spawnLedgerMark = ledger.mark(); + const { continuation, launch } = await runLaunchJourney( + templateDirectory, + JOURNEY_ITERATION_TIMEOUT_MS, + // Like J2: no main-process counters or SQL hook. + { mainCounters: false }, + (session) => + measurePlaybackJourney( + session, + ledger, + spawnLedgerMark, + JOURNEY_ITERATION_TIMEOUT_MS + ) + ); + electronVersion = launch.electronVersion; + const record = toPlaybackIterationRecord( + index, + warmup, + continuation + ); + iterations.push(record); + logJourneyIteration(PLAYBACK_JOURNEY_ID, record); + } + } finally { + await removeLaunchJourneyProfile(templateDirectory); + } + } finally { + await ledger.close(); + } + + await writeJourneyRunEntry( + PLAYBACK_JOURNEY_ID, + iterations, + PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS, + electronVersion + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-click-settle.ts b/apps/electron-backend-e2e/src/performance/journey-click-settle.ts new file mode 100644 index 000000000..2b1f6fa69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-click-settle.ts @@ -0,0 +1,179 @@ +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 { + return page.evaluate((key) => { + const state = (globalThis as unknown as Record)[ + 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 => { + // 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 { + 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); +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe-media.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe-media.spec.ts new file mode 100644 index 000000000..857126a03 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe-media.spec.ts @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { JSDOM } from 'jsdom'; + +import { + assertJourneyRendererProbeState, + createPlaybackJourneyProbeOptions, + JOURNEY_IPC_SENTINEL_ID, + JOURNEY_IPC_SENTINEL_METHOD, + JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY, + JOURNEY_PLAYBACK_END_SENTINEL_ID, + JOURNEY_PLAYBACK_PROBE_STATE_KEY, + JOURNEY_PLAYBACK_START_SENTINEL_ID, + JOURNEY_PROBE_STATE_KEY, + type JourneyRendererProbeOptions, +} from './journey-renderer-probe'; +import { + createFixtureFromDom, + settle, + type FakeObserver, + type Fixture, +} from './journey-renderer-probe.test-helpers'; + +const LIVE_URL = 'http://localhost/workspace/xtreams/playlist-1/live'; + +interface PlaybackFixture extends Fixture { + readonly channel: HTMLElement; + readonly player: HTMLElement; +} + +function createPlaybackFixture( + overrides: Partial = {} +): PlaybackFixture { + const dom = new JSDOM( + ` + +
Channel 1
+ +
`, + { pretendToBeVisual: true, runScripts: 'outside-only', url: LIVE_URL } + ); + const fixture = createFixtureFromDom( + dom, + { ...createPlaybackJourneyProbeOptions(), ...overrides }, + true + ); + const { document } = fixture.window; + return { + ...fixture, + channel: document.querySelector( + '[data-test-id="channel-item"]' + ) as HTMLElement, + player: document.querySelector('app-web-player-view') as HTMLElement, + }; +} + +/** What the player does on the click: mount a video element. */ +function mountVideo(fixture: PlaybackFixture): HTMLVideoElement { + const video = fixture.window.document.createElement('video'); + fixture.player.append(video); + return video; +} + +function fire(fixture: Fixture, target: EventTarget, type: string): void { + // Media events do not bubble; the probe must see them in capture. + target.dispatchEvent(new fixture.window.Event(type, { bubbles: false })); +} + +test('playback options start at a live channel row and end on the player video', () => { + const options = createPlaybackJourneyProbeOptions(); + assert.equal(options.journey, 'playback'); + assert.equal(options.stateKey, JOURNEY_PLAYBACK_PROBE_STATE_KEY); + assert.notEqual(options.stateKey, JOURNEY_PROBE_STATE_KEY); + assert.notEqual(options.stateKey, JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY); + assert.equal(options.sentinelId, JOURNEY_PLAYBACK_END_SENTINEL_ID); + assert.notEqual(options.sentinelId, JOURNEY_IPC_SENTINEL_ID); + assert.equal(options.sentinelMethod, JOURNEY_IPC_SENTINEL_METHOD); + assert.equal( + options.startClick?.sentinelId, + JOURNEY_PLAYBACK_START_SENTINEL_ID + ); + assert.match(options.startClick?.selector ?? '', /channel-item/); + assert.equal(options.cardSelector, 'app-web-player-view video'); + assert.deepEqual(options.media, { + endEvent: 'playing', + phaseEvents: ['loadedmetadata'], + }); +}); + +test('ends at the playing event, not when the video becomes visible, and records loadedmetadata on the way', async () => { + const fixture = createPlaybackFixture(); + let video: HTMLVideoElement | null = null; + fixture.channel.addEventListener('click', () => { + video = mountVideo(fixture); + }); + (fixture.channel.querySelector('.name') as HTMLElement).click(); + await settle(); + assert.ok(video, 'the app mounted a video'); + const mounted = video as HTMLVideoElement; + assert.equal( + fixture.state().terminal, + null, + 'a visible video is not enough' + ); + assert.deepEqual(fixture.bridgeCalls, [JOURNEY_PLAYBACK_START_SENTINEL_ID]); + + fire(fixture, mounted, 'loadedmetadata'); + await settle(); + const afterMetadata = fixture.state(); + assert.equal(afterMetadata.terminal, null); + assert.equal( + typeof afterMetadata.media?.phases['loadedmetadata'], + 'number' + ); + + // Mutations queued in the same task as the event still count: the + // probe takes them synchronously at the event. + mounted.setAttribute('data-state', 'playing'); + fire(fixture, mounted, 'playing'); + mounted.setAttribute('data-state', 'after'); + await settle(); + const state = fixture.state(); + assert.deepEqual(fixture.bridgeCalls, [ + JOURNEY_PLAYBACK_START_SENTINEL_ID, + JOURNEY_PLAYBACK_END_SENTINEL_ID, + ]); + assert.ok(state.start && state.terminal && state.media?.element); + assert.equal(state.start.targetTestId, 'channel-item'); + assert.equal(state.terminal.cardTag, 'video'); + assert.equal(state.terminal.cardCount, 1); + assert.deepEqual(state.terminal.companionCounts, []); + assert.equal(state.terminal.pathname, '/workspace/xtreams/playlist-1/live'); + // video mounted (1) + data-state=playing (1); the later change is not. + assert.equal(state.counters.domMutations, 2); + const metadataEpochMs = state.media.phases['loadedmetadata'] as number; + assert.ok(state.start.epochMs <= metadataEpochMs); + assert.ok(metadataEpochMs <= state.terminal.epochMs); + assert.equal(state.media.phases['playing'], state.terminal.epochMs); + assert.equal(state.media.element.paused, true); + assert.equal(state.final, true); + assert.equal(state.settle.status, 'disabled'); + assert.doesNotThrow(() => assertJourneyRendererProbeState(state)); +}); + +test('ignores media events before the click and on other media elements', async () => { + const fixture = createPlaybackFixture(); + const early = mountVideo(fixture); + fire(fixture, early, 'loadedmetadata'); + fire(fixture, early, 'playing'); + await settle(); + assert.equal(fixture.state().start, null); + assert.equal(fixture.state().terminal, null); + + fixture.channel.click(); + const preview = fixture.window.document.createElement('video'); + fixture.window.document.body.append(preview); + fire(fixture, preview, 'playing'); + await settle(); + const state = fixture.state(); + assert.ok(state.start); + assert.equal(state.terminal, null); + assert.deepEqual(state.media?.phases, {}); + assert.deepEqual(fixture.bridgeCalls, [JOURNEY_PLAYBACK_START_SENTINEL_ID]); +}); + +test('counts shifts and long tasks up to the playing event, not to the post-paint cutoff', async () => { + const fixture = createPlaybackFixture(); + const [layoutShift, longTask] = fixture.observers as [ + FakeObserver, + FakeObserver, + ]; + const now = () => fixture.window.performance.now(); + fixture.channel.click(); + const video = mountVideo(fixture); + layoutShift.emit([ + { + entryType: 'layout-shift', + hadRecentInput: true, + startTime: now(), + value: 0.004, + }, + ]); + longTask.emit([{ duration: 80, entryType: 'longtask', startTime: now() }]); + // Leave room between the click and the event for the entries below. + await new Promise((resolve) => setTimeout(resolve, 20)); + fire(fixture, video, 'playing'); + const playingMs = + (fixture.rawState().terminal?.epochMs ?? 0) - + fixture.window.performance.timeOrigin; + // Delivered after the event: one started before it, one after. + layoutShift.emit([ + { + entryType: 'layout-shift', + hadRecentInput: false, + startTime: playingMs - 1, + value: 0.002, + }, + { + entryType: 'layout-shift', + hadRecentInput: false, + startTime: playingMs + 5, + value: 0.5, + }, + ]); + longTask.emit([ + // The task that dispatched `playing` began before it and counts. + { duration: 60, entryType: 'longtask', startTime: playingMs - 10 }, + { duration: 300, entryType: 'longtask', startTime: playingMs + 5 }, + ]); + await settle(); + const state = fixture.state(); + assert.equal(state.final, true); + assert.equal(state.counters.recentInputLayoutShiftScore, 0.004); + assert.equal(state.counters.layoutShiftScore, 0.002); + assert.equal(state.counters.longTasks, 2); + assert.deepEqual(state.longTaskDurationsMs, [80, 60]); + assert.ok((state.firstCardPaintEpochMs ?? 0) >= state.terminal!.epochMs); +}); + +test('a media terminal without a click start is invalid', () => { + const fixture = createPlaybackFixture({ startClick: undefined }); + assert.ok( + fixture + .state() + .invalidReasons.includes('media-terminal-needs-start-click') + ); +}); + +test('other journeys carry no media block', () => { + const fixture = createPlaybackFixture({ media: undefined }); + assert.equal(fixture.state().media, null); +}); 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 9dfa35301..58e64fb77 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 @@ -18,38 +18,14 @@ import { JOURNEY_SETTLE_ROOT_SELECTOR, journeyRendererProbeScript, type JourneyRendererProbeOptions, - type JourneyRendererProbeState, } from './journey-renderer-probe'; - -interface FakeEntry { - duration?: number; - entryType: string; - hadRecentInput?: boolean; - sources?: { - currentRect: { height: number; y: number }; - node: unknown; - previousRect: { height: number; y: number }; - }[]; - startTime: number; - value?: number; -} - -interface FakeObserver { - disconnected: boolean; - emit(entries: FakeEntry[]): void; - queue: FakeEntry[]; - type: string | null; -} - -interface Fixture { - readonly bridgeCalls: unknown[]; - readonly observers: FakeObserver[]; - /** The live state object inside the jsdom realm. */ - readonly rawState: () => JourneyRendererProbeState; - /** A JSON clone, so assertions compare values across realms. */ - readonly state: () => JourneyRendererProbeState; - readonly window: JSDOM['window']; -} +import { + createFixtureFromDom, + settle, + type FakeEntry, + type FakeObserver, + type Fixture, +} from './journey-renderer-probe.test-helpers'; const PAGE = `
IPTVnator
@@ -62,55 +38,6 @@ const FAST_SETTLE = { rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR, } as const; -function installFakePerformance( - window: JSDOM['window'], - observers: FakeObserver[] -): void { - class FakePerformanceObserver implements FakeObserver { - disconnected = false; - queue: FakeEntry[] = []; - type: string | null = null; - constructor( - private readonly callback: (list: { - getEntries(): FakeEntry[]; - }) => void - ) { - observers.push(this); - } - observe(options: { type: string }): void { - this.type = options.type; - } - takeRecords(): FakeEntry[] { - const queued = this.queue; - this.queue = []; - return queued; - } - disconnect(): void { - this.disconnected = true; - } - emit(entries: FakeEntry[]): void { - this.callback({ getEntries: () => entries }); - } - } - Object.defineProperty(window, 'PerformanceObserver', { - configurable: true, - value: FakePerformanceObserver, - }); - Object.defineProperty(window.performance, 'getEntriesByType', { - configurable: true, - value: (type: string) => - type === 'navigation' - ? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }] - : [], - }); - // jsdom never lays out, so visibility is "connected to the document". - window.HTMLElement.prototype.getClientRects = function getClientRects( - this: HTMLElement - ) { - return (this.isConnected ? [{}] : []) as unknown as DOMRectList; - }; -} - function createFixture( overrides: Partial & { bridge?: boolean; @@ -138,79 +65,6 @@ function createFixture( ); } -function createFixtureFromDom( - dom: JSDOM, - options: JourneyRendererProbeOptions, - bridge: boolean -): Fixture { - const { window } = dom; - const observers: FakeObserver[] = []; - const bridgeCalls: unknown[] = []; - installFakePerformance(window, observers); - // tsx (esbuild keepNames) rewrites named inner functions as - // `__name(fn, 'name')` when it transpiles the probe for this test runner. - // Playwright's Babel transform, which serializes the probe for the real - // browser, does not, so the shim is a test-runner concern only. - Object.defineProperty(window, '__name', { - configurable: true, - value: (target: unknown) => target, - }); - if (bridge) { - Object.defineProperty(window, 'electron', { - configurable: true, - value: Object.freeze({ - [JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => { - bridgeCalls.push(id); - return Promise.resolve(null); - }, - onSomething: () => undefined, - }), - }); - } - window.eval( - `(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})` - ); - const rawState = (): JourneyRendererProbeState => - (window as unknown as Record)[ - options.stateKey - ] as JourneyRendererProbeState; - liveStates.push(rawState); - return { - bridgeCalls, - observers, - rawState, - state: () => - JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState, - window, - }; -} - -/** Probes created by this file, so `settle` can wait for their cutoff. */ -const liveStates: (() => JourneyRendererProbeState)[] = []; - -/** - * Waits `ms`, then until every probe that reached its terminal batch has also - * passed the post-paint cutoff (a rAF plus a timer) and closed its settle - * window. A fixed delay alone flakes when the harness runs all spec files in - * parallel. - */ -async function settle(ms = 40): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); - const deadline = Date.now() + 3_000; - while ( - liveStates.some((read) => { - const state = read(); - return ( - state.terminal !== null && - (!state.final || state.settle.status === 'pending') - ); - }) && - Date.now() < deadline - ) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - function renderFirstCard(fixture: Fixture): void { const { document } = fixture.window; document.getElementById('initial-splash')?.remove(); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts new file mode 100644 index 000000000..d1cb41e7d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts @@ -0,0 +1,165 @@ +import type { JSDOM } from 'jsdom'; + +import { + JOURNEY_IPC_SENTINEL_METHOD, + journeyRendererProbeScript, + type JourneyRendererProbeOptions, + type JourneyRendererProbeState, +} from './journey-renderer-probe'; + +/** + * jsdom fixtures shared by the renderer probe specs: fake performance + * observers, a frozen bridge that records sentinel calls, and a wait for + * every probe's post-paint cutoff. + */ +export interface FakeEntry { + duration?: number; + entryType: string; + hadRecentInput?: boolean; + sources?: { + currentRect: { height: number; y: number }; + node: unknown; + previousRect: { height: number; y: number }; + }[]; + startTime: number; + value?: number; +} + +export interface FakeObserver { + disconnected: boolean; + emit(entries: FakeEntry[]): void; + queue: FakeEntry[]; + type: string | null; +} + +export interface Fixture { + readonly bridgeCalls: unknown[]; + readonly observers: FakeObserver[]; + /** The live state object inside the jsdom realm. */ + readonly rawState: () => JourneyRendererProbeState; + /** A JSON clone, so assertions compare values across realms. */ + readonly state: () => JourneyRendererProbeState; + readonly window: JSDOM['window']; +} + +export function installFakePerformance( + window: JSDOM['window'], + observers: FakeObserver[] +): void { + class FakePerformanceObserver implements FakeObserver { + disconnected = false; + queue: FakeEntry[] = []; + type: string | null = null; + constructor( + private readonly callback: (list: { + getEntries(): FakeEntry[]; + }) => void + ) { + observers.push(this); + } + observe(options: { type: string }): void { + this.type = options.type; + } + takeRecords(): FakeEntry[] { + const queued = this.queue; + this.queue = []; + return queued; + } + disconnect(): void { + this.disconnected = true; + } + emit(entries: FakeEntry[]): void { + this.callback({ getEntries: () => entries }); + } + } + Object.defineProperty(window, 'PerformanceObserver', { + configurable: true, + value: FakePerformanceObserver, + }); + Object.defineProperty(window.performance, 'getEntriesByType', { + configurable: true, + value: (type: string) => + type === 'navigation' + ? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }] + : [], + }); + // jsdom never lays out, so visibility is "connected to the document". + window.HTMLElement.prototype.getClientRects = function getClientRects( + this: HTMLElement + ) { + return (this.isConnected ? [{}] : []) as unknown as DOMRectList; + }; +} + +export function createFixtureFromDom( + dom: JSDOM, + options: JourneyRendererProbeOptions, + bridge: boolean +): Fixture { + const { window } = dom; + const observers: FakeObserver[] = []; + const bridgeCalls: unknown[] = []; + installFakePerformance(window, observers); + // tsx (esbuild keepNames) rewrites named inner functions as + // `__name(fn, 'name')` when it transpiles the probe for this test runner. + // Playwright's Babel transform, which serializes the probe for the real + // browser, does not, so the shim is a test-runner concern only. + Object.defineProperty(window, '__name', { + configurable: true, + value: (target: unknown) => target, + }); + if (bridge) { + Object.defineProperty(window, 'electron', { + configurable: true, + value: Object.freeze({ + [JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => { + bridgeCalls.push(id); + return Promise.resolve(null); + }, + onSomething: () => undefined, + }), + }); + } + window.eval( + `(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})` + ); + const rawState = (): JourneyRendererProbeState => + (window as unknown as Record)[ + options.stateKey + ] as JourneyRendererProbeState; + liveStates.push(rawState); + return { + bridgeCalls, + observers, + rawState, + state: () => + JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState, + window, + }; +} + +/** Probes created by this file, so `settle` can wait for their cutoff. */ +const liveStates: (() => JourneyRendererProbeState)[] = []; + +/** + * Waits `ms`, then until every probe that reached its terminal batch has also + * passed the post-paint cutoff (a rAF plus a timer) and closed its settle + * window. A fixed delay alone flakes when the harness runs all spec files in + * parallel. + */ +export async function settle(ms = 40): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + const deadline = Date.now() + 3_000; + while ( + liveStates.some((read) => { + const state = read(); + return ( + state.terminal !== null && + (!state.final || state.settle.status === 'pending') + ); + }) && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} 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 0326119b0..86af2e664 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -15,6 +15,9 @@ import type { Page } from '@playwright/test'; * then emits one JSON blob under `options.stateKey`. With `options.settle` * (J1) it keeps summing layout shifts after the first card until the page * has settled, for shifts such as collapsing skeletons that land later. + * With `options.media` (J3 "Playback") the terminal condition is a media + * event (`playing`) on an element matching `cardSelector` instead of that + * element becoming visible. * * IPC invocations are not counted here: the bridge object exposed by * `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe @@ -33,6 +36,17 @@ export const JOURNEY_OPEN_SOURCE_START_SENTINEL_ID = '__iptvnator-journey-open-source-start__'; export const JOURNEY_OPEN_SOURCE_END_SENTINEL_ID = '__iptvnator-journey-open-source-end__'; +export const JOURNEY_PLAYBACK_PROBE_STATE_KEY = + '__iptvnatorJourneyPlaybackProbe'; +export const JOURNEY_PLAYBACK_START_SENTINEL_ID = + '__iptvnator-journey-playback-start__'; +export const JOURNEY_PLAYBACK_END_SENTINEL_ID = + '__iptvnator-journey-playback-end__'; +/** A live channel row in the Xtream live layout. */ +export const JOURNEY_PLAYBACK_START_SELECTOR = + 'app-live-stream-layout [data-test-id="channel-item"]'; +/** The HTML5 player's video element inside the web player view. */ +export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video'; /** The Xtream portal card on the dashboard or its row on /workspace/sources. */ export const JOURNEY_OPEN_SOURCE_START_SELECTOR = '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item'; @@ -64,6 +78,17 @@ export interface JourneyRendererProbeSettleOptions { readonly rootSelector: string; } +/** + * A journey that ends on a media event rather than on visibility (J3). The + * first `endEvent` after the start on an element matching `cardSelector` + * is the terminal moment; the first of each `phaseEvents` after the start + * is recorded under `media.phases`. + */ +export interface JourneyRendererProbeMediaOptions { + readonly endEvent: string; + readonly phaseEvents: readonly string[]; +} + export const JOURNEY_SETTLE_QUIET_MS = 500; export const JOURNEY_SETTLE_CAP_MS = 3_000; /** The workspace shell's content pane; the rail and header stay outside. */ @@ -75,6 +100,8 @@ export interface JourneyRendererProbeOptions { /** Further selectors that must each match a visible element as well. */ readonly companionSelectors?: readonly string[]; readonly journey: string; + /** Absent: the journey ends when `cardSelector` becomes visible. */ + readonly media?: JourneyRendererProbeMediaOptions; /** Pathname fragment the terminal route must contain. */ readonly routeFragment: string; readonly sentinelId: string; @@ -137,6 +164,20 @@ export interface JourneyRendererProbeState { readonly invalidReasons: string[]; readonly journey: string; readonly longTaskDurationsMs: number[]; + /** Media-terminated journeys only; null otherwise. */ + readonly media: { + /** At the terminal event, the element it fired on. */ + element: { + readonly currentSrcScheme: string; + readonly currentTime: number; + readonly paused: boolean; + readonly readyState: number; + readonly videoHeight: number; + readonly videoWidth: number; + } | null; + /** Event type → epoch of its first occurrence after the start. */ + readonly phases: Record; + } | null; navigation: { readonly domContentLoadedEpochMs: number; readonly loadEventEndEpochMs: number; @@ -197,6 +238,7 @@ export function journeyRendererProbeScript( } const epoch = (): number => performance.timeOrigin + performance.now(); const startClick = options.startClick ?? null; + const mediaOptions = options.media ?? null; const settleOptions = options.settle ?? null; const companionSelectors = options.companionSelectors ?? []; const bridge = target['electron'] as Record | undefined; @@ -228,6 +270,7 @@ export function journeyRendererProbeScript( invalidReasons: [], journey: options.journey, longTaskDurationsMs: [], + media: mediaOptions === null ? null : { element: null, phases: {} }, navigation: null, preStart: { domMutations: 0, lastMutationEpochMs: null }, schemaVersion: 1, @@ -251,6 +294,9 @@ export function journeyRendererProbeScript( ) { state.invalidReasons.push('probe-installed-after-document-start'); } + if (mediaOptions !== null && startClick === null) { + state.invalidReasons.push('media-terminal-needs-start-click'); + } // Performance entries before the journey's start belong to an earlier // journey (buffered entries included) and are dropped. let fromEpochMs = @@ -463,20 +509,27 @@ export function journeyRendererProbeScript( armQuiet(); }; + // A media journey's counters stop at the terminal event itself (the + // task that dispatched it still overlaps and counts); the others count + // until the post-paint cutoff. const finalize = (untilEpochMs: number): void => { + const countUntilEpochMs = + mediaOptions !== null && state.terminal !== null + ? state.terminal.epochMs + : untilEpochMs; if (layoutShiftObserver) { const records = layoutShiftObserver.takeRecords(); collectSettleShifts(records); acceptLayoutShift( [...pendingLayoutShifts, ...records], - untilEpochMs + countUntilEpochMs ); if (settleOptions === null) layoutShiftObserver.disconnect(); } if (longTaskObserver) { acceptLongTasks( [...pendingLongTasks, ...longTaskObserver.takeRecords()], - untilEpochMs + countUntilEpochMs ); longTaskObserver.disconnect(); } @@ -535,6 +588,7 @@ export function journeyRendererProbeScript( } state.counters.domMutations += records.length; if ( + mediaOptions !== null || !location.pathname.includes(options.routeFragment) || document.getElementById(options.splashId) !== null ) { @@ -547,12 +601,19 @@ export function journeyRendererProbeScript( if (!isVisible(document.querySelector(selector))) return; companionCounts.push(document.querySelectorAll(selector).length); } + end(card, companionCounts, epoch()); + }); + const end = ( + card: Element, + companionCounts: number[], + epochMs: number + ): void => { state.terminal = { cardCount: document.querySelectorAll(options.cardSelector).length, cardTag: card.tagName.toLowerCase(), cardTestId: card.getAttribute('data-test-id'), companionCounts, - epochMs: epoch(), + epochMs, pathname: location.pathname, }; mutationObserver.disconnect(); @@ -577,7 +638,7 @@ export function journeyRendererProbeScript( requestAnimationFrame(() => { setTimeout(() => finalize(epoch()), 0); }); - }); + }; mutationObserver.observe(document.documentElement ?? document, { attributes: true, characterData: true, @@ -615,6 +676,43 @@ export function journeyRendererProbeScript( window.removeEventListener('click', onClick, true); }; window.addEventListener('click', onClick, true); + if (mediaOptions === null) return; + + // Media events do not bubble, but a capture listener on window still + // sees them before any listener of the app. Mutations up to the event + // are taken synchronously, so the count ends exactly at the event. + const onMediaEvent = (event: Event): void => { + const element = event.target; + if ( + state.start === null || + state.terminal !== null || + state.media === null || + !(element instanceof HTMLMediaElement) || + !element.matches(options.cardSelector) + ) { + return; + } + const at = epoch(); + state.media.phases[event.type] ??= at; + if (event.type !== mediaOptions.endEvent) return; + state.counters.domMutations += mutationObserver.takeRecords().length; + const video = element as HTMLMediaElement & { + videoHeight?: number; + videoWidth?: number; + }; + state.media.element = { + currentSrcScheme: element.currentSrc.split(':')[0] ?? '', + currentTime: element.currentTime, + paused: element.paused, + readyState: element.readyState, + videoHeight: video.videoHeight ?? 0, + videoWidth: video.videoWidth ?? 0, + }; + end(element, [], at); + }; + for (const type of [mediaOptions.endEvent, ...mediaOptions.phaseEvents]) { + window.addEventListener(type, onMediaEvent, true); + } } export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions { @@ -660,6 +758,28 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio }; } +/** + * Options for J3 "Playback": the click on a live channel row starts the + * journey; it ends at the first `playing` event of the HTML5 player's video + * element, with `loadedmetadata` recorded as an intermediate phase. + */ +export function createPlaybackJourneyProbeOptions(): JourneyRendererProbeOptions { + return { + cardSelector: JOURNEY_PLAYBACK_VIDEO_SELECTOR, + journey: 'playback', + media: { endEvent: 'playing', phaseEvents: ['loadedmetadata'] }, + routeFragment: '/workspace/xtreams/', + sentinelId: JOURNEY_PLAYBACK_END_SENTINEL_ID, + sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD, + splashId: 'initial-splash', + startClick: { + selector: JOURNEY_PLAYBACK_START_SELECTOR, + sentinelId: JOURNEY_PLAYBACK_START_SENTINEL_ID, + }, + stateKey: JOURNEY_PLAYBACK_PROBE_STATE_KEY, + }; +} + /** * Registers the probe on a page that is still parked on `about:blank` by the * journey gate, so it is guaranteed to run at the start of the next document. 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 e88dc2a9b..e2b81858a 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 @@ -38,6 +38,7 @@ function measurement( invalidReasons: [], journey: 'launch', longTaskDurationsMs: [71.26, 120.04], + media: null, navigation: { domContentLoadedEpochMs: 1_300, loadEventEndEpochMs: 1_400.26, 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 d4067df5c..779eb4178 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 @@ -46,6 +46,7 @@ function measurement( invalidReasons: [], journey: 'open-source', longTaskDurationsMs: [61.26], + media: null, navigation: null, preStart: { domMutations: 4, lastMutationEpochMs: 9_100 }, schemaVersion: 1, 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 5ff3bd307..3d4241f16 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 @@ -1,3 +1,7 @@ +import { + journeyActivityBeforeClick, + type JourneyClickSettle, +} from './journey-click-settle'; import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; import { countJourneyMockRoutes, @@ -38,13 +42,7 @@ export const OPEN_SOURCE_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< }); /** How long the app was left alone before the click, and what it did. */ -export interface OpenSourceJourneySettle { - readonly preStartDomMutations: number; - readonly preStartHttpRequests: number; - readonly preStartIpcCalls: number; - readonly quietMs: number; - readonly waitedMs: number; -} +export type OpenSourceJourneySettle = JourneyClickSettle; export interface OpenSourceJourneyMeasurement { readonly http: { @@ -109,13 +107,11 @@ export function toOpenSourceIterationRecord( // 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); + const lateActivity = journeyActivityBeforeClick(settle, { + httpAfterSettleBeforeClick: http.afterSettleBeforeClick, + ipcCallsBeforeStart: ipc.callsBeforeStart, + preStartDomMutations: renderer.preStart.domMutations, + }); if (lateActivity.length > 0) { throw new Error( `open-source-journey-record-activity-before-click-${lateActivity.join('-')}` 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 new file mode 100644 index 000000000..21bd8df3c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.spec.ts @@ -0,0 +1,344 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; +import type { JourneyMockRequest } from './journey-mock-request-ledger'; +import type { JourneyRendererProbeState } from './journey-renderer-probe'; +import { summarizeJourneyIterations } from './journey-summary'; +import { + PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS, + toPlaybackIterationRecord, + type PlaybackJourneyMeasurement, +} from './playback-journey-record'; + +const LIVE_PATH = '/dist/apps/web/workspace/xtreams/playlist-1/live'; +const STREAM_ROUTE = '/live/:username/:password/10000.ts'; + +function request(sequence: number, route: string, epochMs: number) { + return { epochMs, method: 'GET', route, sequence } as JourneyMockRequest; +} + +type Start = NonNullable; +type Terminal = NonNullable; +type Media = NonNullable; + +function measurement( + overrides: Partial = {} +): PlaybackJourneyMeasurement { + const renderer: JourneyRendererProbeState = { + capabilities: { + changeDetectionTicks: 'unavailable-ng-global-not-published', + layoutShift: true, + longTask: true, + observedTarget: 'documentElement', + }, + counters: { + domMutations: 6_188, + layoutShiftScore: 0, + layoutShiftScoreSettled: 0, + longTasks: 0, + recentInputLayoutShiftScore: 0.00061, + }, + final: true, + firstCardPaintEpochMs: 10_360, + installed: { + bridgePresent: true, + documentElementPresent: true, + epochMs: 9_000, + readyState: 'complete', + scriptCount: 12, + }, + invalidReasons: [], + journey: 'playback', + longTaskDurationsMs: [], + media: { + element: { + currentSrcScheme: 'blob', + currentTime: 0.02133, + paused: false, + readyState: 4, + videoHeight: 90, + videoWidth: 160, + }, + phases: { loadedmetadata: 10_095.04, playing: 10_349.84 }, + }, + navigation: null, + preStart: { domMutations: 53, lastMutationEpochMs: 8_900 }, + schemaVersion: 1, + sentinel: { epochMs: 10_350, status: 'sent' }, + settle: { + domMutations: 0, + epochMs: null, + lastMutationEpochMs: null, + lateShifts: [], + observedTarget: null, + status: 'disabled', + }, + start: { + epochMs: 10_000, + listenerEpochMs: 10_000.5, + pathname: LIVE_PATH, + sentinelStatus: 'sent', + targetTag: 'div', + targetTestId: 'channel-item', + }, + terminal: { + cardCount: 1, + cardTag: 'video', + cardTestId: null, + companionCounts: [], + epochMs: 10_349.84, + pathname: LIVE_PATH, + }, + }; + const ipc: JourneyMainIpcCaptureState = { + callsAfterSentinel: 3, + callsBeforeStart: 6, + callsBeforeSentinel: 4, + callsByMethod: { + getEpgMapping: 1, + setUserAgent: 1, + updateRemoteControlStatus: 1, + xtreamRequest: 1, + }, + inFlightByMethod: {}, + installedEpochMs: 9_500, + malformedEvents: 0, + processStartEpochMs: 1_000, + senderIds: [1], + sentinel: { occurrences: 1, receivedEpochMs: 10_351 }, + start: { occurrences: 1, receivedEpochMs: 10_001 }, + unmatchedCompletions: 0, + }; + return { + externalArtworkCancelled: 8, + http: { + afterPlaying: [ + request(9, '/player_api.php?action=get_short_epg', 10_400), + ], + afterSettleBeforeClick: 0, + beforeClick: [ + request(0, '/player_api.php?action=get_live_streams', 8_000), + ], + toPlaying: [ + request(7, STREAM_ROUTE, 10_020), + request( + 8, + '/player_api.php?action=get_simple_data_table', + 10_030 + ), + ], + }, + ipc, + pid: 5151, + renderer, + settle: { + preStartDomMutations: 53, + preStartHttpRequests: 4, + preStartIpcCalls: 6, + quietMs: 1_000, + waitedMs: 1_809, + }, + ...overrides, + }; +} + +function withRenderer( + patch: Partial +): PlaybackJourneyMeasurement { + const base = measurement(); + return { ...base, renderer: { ...base.renderer, ...patch } }; +} + +test('maps the media-terminated probe, IPC window and mock ledger to exact counters', () => { + const record = toPlaybackIterationRecord(2, false, measurement()); + assert.equal(record.index, 2); + assert.equal(record.warmup, false); + assert.equal(record.pid, 5151); + assert.deepEqual(record.counters, { + 'renderer.domMutationsToPlaying': 6_188, + 'renderer.httpRequestsToPlaying': 2, + 'renderer.ipcCallsToPlaying': 4, + 'renderer.layoutShiftScore': 0.001, + 'renderer.longTasks': 0, + }); + assert.deepEqual(record.wallClock, { + clickToLoadedMetadataMs: 95, + clickToPlayingMs: 349.8, + }); + assert.deepEqual(record.evidence['httpRequestsByRoute'], { + '/live/:username/:password/10000.ts': 1, + '/player_api.php?action=get_simple_data_table': 1, + }); + assert.deepEqual(record.evidence['httpRequestsAfterPlayingByRoute'], { + '/player_api.php?action=get_short_epg': 1, + }); + assert.deepEqual(record.evidence['httpRequestsBeforeClickByRoute'], { + '/player_api.php?action=get_live_streams': 1, + }); + assert.deepEqual(record.evidence['media'], { + currentSrcScheme: 'blob', + currentTime: 0.021, + paused: false, + readyState: 4, + videoElements: 1, + videoHeight: 90, + videoWidth: 160, + }); + assert.deepEqual(record.evidence['epochs'], { + click: 10_000, + clickListener: 10_000.5, + loadedMetadata: 10_095.04, + mainIpcSentinel: 10_351, + mainIpcStart: 10_001, + playing: 10_349.84, + }); + // Click 10,000: last request before at 8,000, first after at 10,020. + // Playing 10,349.84: last before at 10,030, first after at 10,400. + assert.deepEqual(record.evidence['httpBoundaryMarginsMs'], { + click: 20, + playing: 50.2, + }); + assert.equal(record.evidence['externalArtworkCancelled'], 8); + assert.equal(record.evidence['ipcCallsAfterPlaying'], 3); +}); + +test('rejects measurements that did not start at a live channel or did not play a video', () => { + const base = measurement(); + const start = base.renderer.start as Start; + const terminal = base.renderer.terminal as Terminal; + const media = base.renderer.media as Media; + const cases: [PlaybackJourneyMeasurement, RegExp][] = [ + [withRenderer({ start: null }), /incomplete-probe/], + [withRenderer({ terminal: null }), /incomplete-probe/], + [withRenderer({ media: null }), /incomplete-probe/], + [{ ...base, ipc: { ...base.ipc, start: null } }, /ipc-without-start/], + [ + withRenderer({ + start: { ...start, pathname: '/workspace/dashboard' }, + }), + /start-route/, + ], + [ + withRenderer({ + start: { + ...start, + pathname: '/workspace/xtreams/playlist-1/vod', + }, + }), + /start-route/, + ], + [ + withRenderer({ terminal: { ...terminal, cardTag: 'div' } }), + /not-a-video/, + ], + [withRenderer({ media: { ...media, element: null } }), /not-a-video/], + [withRenderer({ media: { ...media, phases: {} } }), /clock-order/], + [ + withRenderer({ + media: { + ...media, + phases: { loadedmetadata: terminal.epochMs + 1 }, + }, + }), + /clock-order/, + ], + [ + withRenderer({ + media: { + ...media, + phases: { loadedmetadata: start.epochMs - 1 }, + }, + }), + /clock-order/, + ], + [ + withRenderer({ + terminal: { ...terminal, epochMs: start.epochMs }, + media: { ...media, phases: { loadedmetadata: start.epochMs } }, + }), + /clock-order/, + ], + [ + withRenderer({ + capabilities: { + ...base.renderer.capabilities, + changeDetectionTicks: 'hook-present-not-counted', + }, + }), + /cd-hook-hook-present-not-counted/, + ], + ]; + for (const [input, error] of cases) { + assert.throws(() => toPlaybackIterationRecord(0, false, input), error); + } +}); + +test('requires the stream from the local fixture inside the window', () => { + const base = measurement(); + const withoutStream = { + ...base, + http: { + ...base.http, + toPlaying: base.http.toPlaying.filter( + (entry) => entry.route !== STREAM_ROUTE + ), + }, + }; + assert.throws( + () => toPlaybackIterationRecord(0, false, withoutStream), + /no-local-stream/ + ); + const hls = { + ...base, + http: { + ...base.http, + toPlaying: [request(7, '/live/:username/:password/10000.m3u8', 1)], + }, + }; + assert.throws( + () => toPlaybackIterationRecord(0, false, hls), + /no-local-stream/ + ); +}); + +test('rejects activity that moved between the settle snapshot and the click', () => { + const base = measurement(); + assert.throws( + () => + toPlaybackIterationRecord(0, false, { + ...base, + http: { ...base.http, afterSettleBeforeClick: 1 }, + ipc: { ...base.ipc, callsBeforeStart: 7 }, + renderer: { + ...base.renderer, + preStart: { domMutations: 54, lastMutationEpochMs: 1 }, + }, + }), + /activity-before-click-dom-ipc-http/ + ); +}); + +test('summarizes playback iterations with J3 counters and unavailable reasons', () => { + const iterations = [0, 1, 2].map((index) => + toPlaybackIterationRecord(index, index === 0, { + ...measurement(), + pid: 100 + index, + }) + ); + const entry = summarizeJourneyIterations( + iterations, + PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS + ); + assert.equal(entry.counters['renderer.ipcCallsToPlaying'], 4); + assert.deepEqual(entry.counterStability['renderer.ipcCallsToPlaying'], { + stable: true, + values: [4, 4], + }); + assert.equal(entry.wallClock['clickToPlayingMs.p50'], 349.8); + assert.equal(entry.wallClock['clickToLoadedMetadataMs.p90'], 95); + assert.deepEqual(Object.keys(entry.unavailable).sort(), [ + 'renderer.cdTicksToPlaying', + 'renderer.ipcSerialDepthToPlaying', + ]); +}); diff --git a/apps/electron-backend-e2e/src/performance/playback-journey-record.ts b/apps/electron-backend-e2e/src/performance/playback-journey-record.ts new file mode 100644 index 000000000..ce0f59a44 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.ts @@ -0,0 +1,231 @@ +import { + journeyActivityBeforeClick, + type JourneyClickSettle, +} from './journey-click-settle'; +import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; +import { + countJourneyMockRoutes, + type JourneyMockRequest, +} from './journey-mock-request-ledger'; +import type { JourneyRendererProbeState } from './journey-renderer-probe'; +import type { JourneyIterationRecord } from './journey-summary'; + +/** + * Maps one measured "start playback" (renderer probe armed at the click and + * ended by the video element's `playing` event, main IPC capture between the + * start and end sentinels, mock request ledger) to the journey summary's + * iteration record for J3. + */ +export const PLAYBACK_JOURNEY_ID = 'playback'; + +export const PLAYBACK_JOURNEY_COUNTER = { + DOM_MUTATIONS: 'renderer.domMutationsToPlaying', + HTTP_REQUESTS: 'renderer.httpRequestsToPlaying', + IPC_CALLS: 'renderer.ipcCallsToPlaying', + LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore', + LONG_TASKS: 'renderer.longTasks', +} as const; + +export const PLAYBACK_JOURNEY_WALL_CLOCK = { + /** Click until the video element's first `loadedmetadata`. */ + CLICK_TO_LOADED_METADATA: 'clickToLoadedMetadataMs', + /** Click until its first `playing`. */ + CLICK_TO_PLAYING: 'clickToPlayingMs', +} as const; + +/** How long requests after `playing` are observed, for evidence only. */ +export const PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS = 1_000; + +export const PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< + Record +> = Object.freeze({ + 'renderer.cdTicksToPlaying': + 'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.', + 'renderer.ipcSerialDepthToPlaying': + 'The serial-depth helper is being added for J1 in a separate thread and is not on master yet; J3 adopts it once it lands.', +}); + +export interface PlaybackJourneyMeasurement { + /** Logo requests to picsum.photos cancelled in the main process. */ + readonly externalArtworkCancelled: number; + readonly http: { + /** Requests in the first second after `playing`. */ + readonly afterPlaying: readonly JourneyMockRequest[]; + /** Mock requests after the app settled but before the click stamp. */ + readonly afterSettleBeforeClick: number; + /** Mock requests from the spawn (J1, navigation, settling). */ + readonly beforeClick: readonly JourneyMockRequest[]; + /** Mock requests from the click stamp until `playing`. */ + readonly toPlaying: readonly JourneyMockRequest[]; + }; + readonly ipc: JourneyMainIpcCaptureState; + readonly pid: number; + readonly renderer: JourneyRendererProbeState; + readonly settle: JourneyClickSettle; +} + +const ROUTE_FRAGMENT = '/workspace/xtreams/'; +const LIVE_STREAM_ROUTE = /^\/live\/:username\/:password\/[^/]+\.ts$/; + +/** + * Distance of the nearest ledger request to a boundary on either side + * (null when there is none). The ledger and the renderer stamp with + * different processes' clocks; a small margin flags a count that a clock + * difference could move across the boundary. + */ +function boundaryMarginMs( + before: readonly JourneyMockRequest[], + after: readonly JourneyMockRequest[], + boundaryEpochMs: number +): number | null { + const distances = [ + ...before.map((entry) => boundaryEpochMs - entry.epochMs), + ...after.map((entry) => entry.epochMs - boundaryEpochMs), + ]; + return distances.length === 0 ? null : roundTenth(Math.min(...distances)); +} + +function roundTenth(value: number): number { + return Math.round(value * 10) / 10; +} + +function roundThousandth(value: number): number { + return Math.round(value * 1_000) / 1_000; +} + +export function toPlaybackIterationRecord( + index: number, + warmup: boolean, + measurement: PlaybackJourneyMeasurement +): JourneyIterationRecord { + const { http, ipc, renderer, settle } = measurement; + const { media, start, terminal } = renderer; + if (start === null || terminal === null || media === null) { + throw new Error('playback-journey-record-incomplete-probe'); + } + if (ipc.start === null) { + throw new Error('playback-journey-record-ipc-without-start'); + } + if ( + !start.pathname.includes(ROUTE_FRAGMENT) || + !start.pathname.includes('/live') + ) { + throw new Error('playback-journey-record-start-route'); + } + if (terminal.cardTag !== 'video' || media.element === null) { + throw new Error('playback-journey-record-not-a-video'); + } + const loadedMetadataEpochMs = media.phases['loadedmetadata']; + const clickToPlayingMs = terminal.epochMs - start.epochMs; + if ( + loadedMetadataEpochMs === undefined || + loadedMetadataEpochMs < start.epochMs || + loadedMetadataEpochMs > terminal.epochMs || + clickToPlayingMs <= 0 + ) { + throw new Error('playback-journey-record-clock-order'); + } + // The stream itself must have come from the mock's local fixture; a + // player that played something else did not measure this journey. + if (!http.toPlaying.some((entry) => LIVE_STREAM_ROUTE.test(entry.route))) { + throw new Error('playback-journey-record-no-local-stream'); + } + const lateActivity = journeyActivityBeforeClick(settle, { + httpAfterSettleBeforeClick: http.afterSettleBeforeClick, + ipcCallsBeforeStart: ipc.callsBeforeStart, + preStartDomMutations: renderer.preStart.domMutations, + }); + if (lateActivity.length > 0) { + throw new Error( + `playback-journey-record-activity-before-click-${lateActivity.join('-')}` + ); + } + if ( + renderer.capabilities.changeDetectionTicks !== + 'unavailable-ng-global-not-published' + ) { + throw new Error( + `playback-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}` + ); + } + return Object.freeze({ + counters: Object.freeze({ + [PLAYBACK_JOURNEY_COUNTER.DOM_MUTATIONS]: + renderer.counters.domMutations, + [PLAYBACK_JOURNEY_COUNTER.HTTP_REQUESTS]: http.toPlaying.length, + [PLAYBACK_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel, + // The click's 500 ms input window covers the start of the + // journey, so shifts flagged hadRecentInput are included, as + // in J2. + [PLAYBACK_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth( + renderer.counters.layoutShiftScore + + renderer.counters.recentInputLayoutShiftScore + ), + [PLAYBACK_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks, + }), + evidence: Object.freeze({ + capabilities: renderer.capabilities, + epochs: Object.freeze({ + click: start.epochMs, + clickListener: start.listenerEpochMs, + loadedMetadata: loadedMetadataEpochMs, + mainIpcSentinel: ipc.sentinel.receivedEpochMs, + mainIpcStart: ipc.start.receivedEpochMs, + playing: terminal.epochMs, + }), + externalArtworkCancelled: measurement.externalArtworkCancelled, + httpRequestsAfterPlayingByRoute: countJourneyMockRoutes( + http.afterPlaying + ), + httpRequestsBeforeClickByRoute: countJourneyMockRoutes( + http.beforeClick + ), + httpRequestsByRoute: countJourneyMockRoutes(http.toPlaying), + httpBoundaryMarginsMs: Object.freeze({ + click: boundaryMarginMs( + http.beforeClick, + http.toPlaying, + start.epochMs + ), + playing: boundaryMarginMs( + http.toPlaying, + http.afterPlaying, + terminal.epochMs + ), + }), + ipcCallsAfterPlaying: ipc.callsAfterSentinel, + ipcCallsByMethod: ipc.callsByMethod, + layoutShift: Object.freeze({ + recentInput: roundThousandth( + renderer.counters.recentInputLayoutShiftScore + ), + withoutRecentInput: roundThousandth( + renderer.counters.layoutShiftScore + ), + }), + longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth), + media: Object.freeze({ + ...media.element, + currentTime: roundThousandth(media.element.currentTime), + videoElements: terminal.cardCount, + }), + settle, + start: Object.freeze({ + pathname: start.pathname, + targetTag: start.targetTag, + targetTestId: start.targetTestId, + }), + terminalPathname: terminal.pathname, + }), + index, + pid: measurement.pid, + wallClock: Object.freeze({ + [PLAYBACK_JOURNEY_WALL_CLOCK.CLICK_TO_LOADED_METADATA]: roundTenth( + loadedMetadataEpochMs - start.epochMs + ), + [PLAYBACK_JOURNEY_WALL_CLOCK.CLICK_TO_PLAYING]: + roundTenth(clickToPlayingMs), + }), + warmup, + }); +} diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 66fc6c15d..26c7cb0ab 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -19,9 +19,9 @@ live in `tools/performance/`. | 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 is instrumented by its own -spec (below). J3 and J4 follow the plan in `.plans/` and are added one thread -at a time; each thread names its journey and counter in the PR description. +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 @@ -367,6 +367,13 @@ 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 @@ -469,6 +476,132 @@ give a click-to-settled count; that is left to a follow-up. 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 @@ -764,10 +897,12 @@ reports slow imports of non-Latin playlists. continues from J1 with `runLaunchJourney` and lets the app settle first, as `open-source-journey-app.ts` does. 2. Give the journey its own probe options (`cardSelector`, - `companionSelectors`, `routeFragment`, `startClick` for a click start) or - extend `journey-renderer-probe.ts` when the end condition is not "elements - became visible". Use a state key and sentinel ids of its own. Keep the - probe self-contained: Playwright serializes it with `toString()`. + `companionSelectors`, `routeFragment`, `startClick` for a click start, + `media` for a media-event end such as J3's `playing`) or extend + `journey-renderer-probe.ts` when the end condition is neither. Use a state + key and sentinel ids of its own. Keep the probe self-contained: Playwright + serializes it with `toString()`. A click-started journey settles with + `waitForJourneyClickQuiet` from `journey-click-settle.ts`. 3. Map the measurement to a `JourneyIterationRecord` in a `-journey-record.ts` under `src/performance/`; name counters `renderer.*` or `main.*`, and list counters you cannot measure under From 8b6fcf35602101b2e37e29a4bff8f7844ee2318c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:19:51 +0200 Subject: [PATCH 2/7] fix(e2e): let web-e2e:e2e run outside CI (#1779) --- .github/workflows/ci.yml | 6 + apps/web-e2e/project.json | 4 +- docs/architecture/pwa-self-hosted.md | 6 +- docs/architecture/validation-map.md | 7 + docs/architecture/xtream-mock-server.md | 21 +++ nx.json | 3 +- package.json | 3 + tools/nx/check-e2e-task-graphs.mjs | 172 ++++++++++++++++++++++++ tools/nx/check-e2e-task-graphs.test.mjs | 99 ++++++++++++++ 9 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 tools/nx/check-e2e-task-graphs.mjs create mode 100644 tools/nx/check-e2e-task-graphs.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60e1a14a8..f5944354e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,6 +375,12 @@ jobs: - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate + # Nx rejects a non-parallel task with continuous dependencies, and + # the Playwright plugin infers serve dependencies only when CI is + # unset, so this checks both the local and the CI inference. + - name: Validate Playwright task graphs + run: pnpm run e2e:task-graphs:validate + - name: Typecheck web and Electron entry points run: pnpm run typecheck:ci diff --git a/apps/web-e2e/project.json b/apps/web-e2e/project.json index 367cf8fb8..fa2b449b3 100644 --- a/apps/web-e2e/project.json +++ b/apps/web-e2e/project.json @@ -11,7 +11,9 @@ ], "// targets": "to see all targets run: nx show project web-e2e --web", "targets": { - "e2e": {}, + "e2e": { + "dependsOn": [] + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index c09f338a4..14da8401c 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -87,8 +87,10 @@ launches the backend through Nx or its `env` drifts from the `serve` target. `self-hosted.e2e.ts` URLs, like `MOCK_PORT` does for the mocks. Use it when another worktree holds 3333. The `web:serve` entry can stay on Nx: `@angular/build:dev-server` runs inside the Nx process, so the group kill -stops it. The mock servers follow the same -rule; see [Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration). +stops it. Playwright launches that command itself even outside CI: the +web-e2e targets carry no Nx `serve` dependency, because Nx refuses a +non-parallel task with a continuous dependency. The mock servers follow the +same rule; see [Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration). The PWA continues to use `PwaService`; only the backend base URL is resolved at runtime. Electron routes remain owned by the Electron backend and preload diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 46da72dd8..4d4ed2bf2 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -197,6 +197,13 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the Use atomized E2E targets when available, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`. +After changing a Playwright config's `webServer` list, `nx.json` target +defaults or an E2E project's `dependsOn`, run +`pnpm run e2e:task-graphs:validate`. It builds every Playwright target's task +graph with and without `CI` and fails on the graphs Nx refuses to run; CI runs +it in the `unit-and-typecheck` job. See +[Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration). + Inside `expect.poll`, read a changing list in one DOM snapshot (`allTextContents()` or `evaluateAll()`, as in `apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts`), not by diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index a82e1618f..a5b068544 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -586,6 +586,27 @@ continuous `serve` task. The same spec guards the `e2e*` entries of `nx.json` `targetDefaults` and every target in the `apps/*-e2e` `project.json` files. The web-e2e `web-backend` entry uses the same launch form; see [PWA web backend](pwa-self-hosted.md#web-backend). +Playwright, not Nx, starts every E2E server, including the web dev server. +`@nx/playwright/plugin` infers a continuous `serve` dependency from a +`pnpm nx run :serve` webServer only while `reuseExistingServer` is +true, which the configs set only when `CI` is unset. It also sets +`parallelism: false` on a target when a webServer has an `env` or a plain +`node` command, because no Nx task covers that server. Nx refuses to run a +non-parallel task that depends on a continuous task, so the inferred +`web-e2e:e2e` failed locally with "do not support parallelism but depend on +continuous tasks" and passed only in CI. Therefore: + +- `apps/web-e2e/project.json` sets `e2e.dependsOn` to `[]`, and the filtered + `e2e-ci--src/*.e2e.ts` target default in `nx.json` does the same for the + per-file web targets. The per-file Electron targets depend only on + `electron-backend:build-e2e`. +- The plugin runs with `waitForWebServer: false`, because no target consumes + its `e2e--wait-for-webserver` readiness task. Playwright's own URL probe + covers readiness. +- `pnpm run e2e:task-graphs:validate` (`tools/nx/check-e2e-task-graphs.mjs`) + builds each Playwright target's task graph with Nx's own validation, once + with `CI` unset and once with it set. + ### Request Interception The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these: diff --git a/nx.json b/nx.json index 2f36ffe99..85d4df44a 100644 --- a/nx.json +++ b/nx.json @@ -88,7 +88,8 @@ { "plugin": "@nx/playwright/plugin", "options": { - "targetName": "e2e" + "targetName": "e2e", + "waitForWebServer": false } }, { diff --git a/package.json b/package.json index 7e944d655..251cba78c 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", + "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", + "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", + "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs tools/coverage/unit-coverage-scope.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", diff --git a/tools/nx/check-e2e-task-graphs.mjs b/tools/nx/check-e2e-task-graphs.mjs new file mode 100644 index 000000000..f88de4284 --- /dev/null +++ b/tools/nx/check-e2e-task-graphs.mjs @@ -0,0 +1,172 @@ +import { execFileSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Nx refuses to run a task with `parallelism: false` that depends on a + * continuous task. `@nx/playwright/plugin` sets `parallelism: false` whenever + * a config has a webServer no inferred Nx task covers (our mocks and the web + * backend launch as plain `node` processes), and it infers continuous serve + * dependencies only from servers with `reuseExistingServer`, which the + * configs enable only when `CI` is unset. A target graph can therefore pass + * in CI and still fail locally, so every Playwright target is validated with + * Nx's own task-graph checks under both environments. + */ +const RESULT_PREFIX = 'e2e-task-graphs-result:'; + +export const MODES = { + local: { CI: undefined }, + ci: { CI: 'true' }, +}; + +/** + * The Playwright projects whose targets must be inferred. The check only sees + * targets the plugin tags as Playwright ones, so a target that drops out of + * the inference would otherwise pass unchecked. + */ +export const E2E_PROJECTS = { + 'web-e2e': 'apps/web-e2e', + 'electron-backend-e2e': 'apps/electron-backend-e2e', +}; + +/** `e2e` plus one atomized target per spec, as the plugin names them. */ +export function expectedPlaywrightTargets(specFilesByProject) { + return Object.entries(specFilesByProject).flatMap(([project, specs]) => [ + `${project}:e2e`, + ...specs.map((spec) => `${project}:e2e-ci--${spec}`), + ]); +} + +export function findMissingTargets(projectGraph, expectedTargets) { + const inferred = new Set( + playwrightTargets(projectGraph).map( + ({ project, target }) => `${project}:${target}` + ) + ); + return expectedTargets.filter((task) => !inferred.has(task)); +} + +function readSpecFiles(workspaceRoot) { + return Object.fromEntries( + Object.entries(E2E_PROJECTS).map(([project, root]) => [ + project, + readdirSync(path.join(workspaceRoot, root, 'src'), { + recursive: true, + }) + .filter((file) => file.endsWith('.e2e.ts')) + .map((file) => `src/${file.split(path.sep).join('/')}`) + .sort(), + ]) + ); +} + +export function playwrightTargets(projectGraph) { + return Object.values(projectGraph.nodes) + .flatMap((node) => + Object.entries(node.data.targets ?? {}) + .filter(([, target]) => + target.metadata?.technologies?.includes('playwright') + ) + .map(([target]) => ({ project: node.name, target })) + ) + .sort((a, b) => + `${a.project}:${a.target}`.localeCompare(`${b.project}:${b.target}`) + ); +} + +export async function findInvalidTaskGraphs(projectGraph) { + const { createTaskGraph } = + await import('nx/src/tasks-runner/create-task-graph.js'); + const { assertTaskGraphDoesNotContainInvalidTargets } = + await import('nx/src/tasks-runner/task-graph-utils.js'); + + return playwrightTargets(projectGraph).flatMap(({ project, target }) => { + try { + assertTaskGraphDoesNotContainInvalidTargets( + createTaskGraph( + projectGraph, + {}, + [project], + [target], + undefined, + {} + ) + ); + return []; + } catch (error) { + return [{ task: `${project}:${target}`, message: error.message }]; + } + }); +} + +function childEnv(mode) { + const env = { ...process.env, NX_DAEMON: 'false' }; + for (const [name, value] of Object.entries(MODES[mode])) { + if (value === undefined) delete env[name]; + else env[name] = value; + } + return env; +} + +async function checkCurrentEnvironment() { + const { createProjectGraphAsync, workspaceRoot } = + await import('@nx/devkit'); + const projectGraph = await createProjectGraphAsync({ exitOnError: true }); + const failures = await findInvalidTaskGraphs(projectGraph); + const checked = playwrightTargets(projectGraph).length; + const missing = findMissingTargets( + projectGraph, + expectedPlaywrightTargets(readSpecFiles(workspaceRoot)) + ); + console.log( + `${RESULT_PREFIX}${JSON.stringify({ checked, failures, missing })}` + ); +} + +function main() { + let failed = false; + for (const mode of Object.keys(MODES)) { + const output = execFileSync( + process.execPath, + [fileURLToPath(import.meta.url), '--current-environment'], + { + env: childEnv(mode), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + } + ); + const result = output + .split('\n') + .find((line) => line.startsWith(RESULT_PREFIX)); + const { checked, failures, missing } = JSON.parse( + result.slice(RESULT_PREFIX.length) + ); + for (const task of missing) { + failed = true; + console.error( + `[${mode}] ${task} was not inferred as a Playwright target` + ); + } + for (const { task, message } of failures) { + failed = true; + console.error( + `[${mode}] ${task}\n ${message.replace(/\n/g, '\n ')}` + ); + } + if (failures.length === 0 && missing.length === 0) { + console.log( + `[${mode}] ${checked} Playwright task graphs are valid` + ); + } + } + process.exitCode = failed ? 1 : 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--current-environment')) { + await checkCurrentEnvironment(); + } else { + main(); + } +} diff --git a/tools/nx/check-e2e-task-graphs.test.mjs b/tools/nx/check-e2e-task-graphs.test.mjs new file mode 100644 index 000000000..d31743552 --- /dev/null +++ b/tools/nx/check-e2e-task-graphs.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + expectedPlaywrightTargets, + findInvalidTaskGraphs, + findMissingTargets, + MODES, + playwrightTargets, +} from './check-e2e-task-graphs.mjs'; + +const playwright = { technologies: ['playwright'] }; + +/** + * Mirrors web-e2e as the Playwright plugin infers it without CI: an uncovered + * mock webServer makes `e2e` non-parallel while `pnpm nx run web:serve` adds a + * continuous dependency on the dev server. + */ +function graphWithE2eTarget(e2eTarget) { + const node = (name, targets) => ({ + name, + type: 'app', + data: { root: `apps/${name}`, targets }, + }); + return { + nodes: { + web: node('web', { + serve: { + executor: '@angular/build:dev-server', + continuous: true, + }, + }), + 'web-e2e': node('web-e2e', { + e2e: { + executor: 'nx:run-commands', + options: { command: 'playwright test' }, + metadata: playwright, + ...e2eTarget, + }, + lint: { + executor: '@nx/eslint:lint', + parallelism: false, + dependsOn: [{ projects: ['web'], target: 'serve' }], + }, + }), + }, + dependencies: { web: [], 'web-e2e': [] }, + }; +} + +test('checks only Playwright-inferred targets', () => { + const graph = graphWithE2eTarget({ parallelism: false }); + + assert.deepEqual(playwrightTargets(graph), [ + { project: 'web-e2e', target: 'e2e' }, + ]); +}); + +test('reports a non-parallel e2e target that depends on a continuous serve', async () => { + const graph = graphWithE2eTarget({ + parallelism: false, + dependsOn: [{ projects: ['web'], target: 'serve' }], + }); + + const failures = await findInvalidTaskGraphs(graph); + + assert.equal(failures.length, 1); + assert.equal(failures[0].task, 'web-e2e:e2e'); + assert.match(failures[0].message, /web-e2e:e2e -> web:serve/); +}); + +test('accepts a non-parallel e2e target whose servers Playwright starts', async () => { + const graph = graphWithE2eTarget({ parallelism: false, dependsOn: [] }); + + assert.deepEqual(await findInvalidTaskGraphs(graph), []); +}); + +test('validates both the local and the CI plugin inference', () => { + assert.deepEqual(MODES, { local: { CI: undefined }, ci: { CI: 'true' } }); +}); + +test('expects the e2e target and one atomized target per spec', () => { + assert.deepEqual( + expectedPlaywrightTargets({ 'web-e2e': ['src/basic.e2e.ts'] }), + ['web-e2e:e2e', 'web-e2e:e2e-ci--src/basic.e2e.ts'] + ); +}); + +test('reports expected targets the plugin no longer infers', () => { + const graph = graphWithE2eTarget({ parallelism: false, dependsOn: [] }); + + assert.deepEqual( + findMissingTargets(graph, [ + 'web-e2e:e2e', + 'web-e2e:e2e-ci--src/basic.e2e.ts', + ]), + ['web-e2e:e2e-ci--src/basic.e2e.ts'] + ); +}); From d1e79bdc3e34d1533d10ac306d6327f015026fd5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:21:27 +0200 Subject: [PATCH 3/7] fix(parental-lock): per-flow PIN dialog labels and visible mismatch error (#1777) --- .changes/settings-parental-lock-pin-dialog.md | 6 + .../src/parental-lock-pin-dialog.e2e.ts | 267 ++++++++++++ .../parental-lock-prompt.service.spec.ts | 12 +- .../services/parental-lock-prompt.service.ts | 1 + apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/parental-lock.md | 21 +- .../parental-lock-prompt-requests.ts | 72 ++++ .../parental-lock-prompt.token.ts | 5 + .../parental-lock.service.spec.ts | 40 ++ .../parental-lock/parental-lock.service.ts | 38 +- .../parental-lock-pin-dialog.component.html | 40 +- .../parental-lock-pin-dialog.component.scss | 29 +- ...parental-lock-pin-dialog.component.spec.ts | 389 ++++++++++++++++-- .../parental-lock-pin-dialog.component.ts | 143 ++++++- 32 files changed, 998 insertions(+), 103 deletions(-) create mode 100644 .changes/settings-parental-lock-pin-dialog.md create mode 100644 apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts create mode 100644 libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts diff --git a/.changes/settings-parental-lock-pin-dialog.md b/.changes/settings-parental-lock-pin-dialog.md new file mode 100644 index 000000000..4f8c05d8b --- /dev/null +++ b/.changes/settings-parental-lock-pin-dialog.md @@ -0,0 +1,6 @@ +--- +type: fix +area: settings +--- + +The parental PIN dialog now names what it will do: Save PIN, Confirm, Unlock or Turn off, next to a Cancel button. When the repeated PIN differs, the dialog says so under the field as you type, and pressing Enter shows the error instead of doing nothing. The error shake is skipped when the system asks for reduced motion. diff --git a/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts b/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts new file mode 100644 index 000000000..5980d6292 --- /dev/null +++ b/apps/electron-backend-e2e/src/parental-lock-pin-dialog.e2e.ts @@ -0,0 +1,267 @@ +import { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + test, +} from './electron-test-fixtures'; + +/** + * The parental-lock PIN dialog (Settings → Parental lock): + * + * 1. Setting a PIN, a repeat that differs is shown on the repeat field as + * soon as it is as long as the PIN, marked `aria-invalid` and placed + * in the field's live region. Enter is refused visibly — the field + * shakes, except under reduced motion — instead of being swallowed by + * a disabled submit button; fixing the repeat saves. + * 2. Every flow names its own submit verb: Save PIN, Confirm (current PIN + * before a change), Unlock, Turn off; the dismiss button is Cancel. + */ + +const PIN = '2468'; +const THEMES = [ + { dark: false, color: 'rgb(179, 38, 30)' }, + { dark: true, color: 'rgb(255, 180, 171)' }, +] as const; + +function pinDialog(page: Page) { + // The newest one: a flow can open the next prompt as one closes. + const dialog = page + .locator('mat-dialog-container', { + has: page.locator('app-parental-lock-pin-dialog'), + }) + .last(); + return { + dialog, + pin: dialog.getByTestId('parental-lock-pin'), + confirm: dialog.getByTestId('parental-lock-pin-confirm'), + submit: dialog.getByTestId('parental-lock-pin-submit'), + cancel: dialog.getByRole('button', { name: 'Cancel', exact: true }), + mismatch: dialog.getByTestId('parental-lock-pin-mismatch'), + }; +} + +async function openParentalSettings(page: Page): Promise { + await openSettings(page); + await openSettingsSection(page, 'parental'); + return page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); +} + +/** + * Presses Enter in `input` and reports the field's animation at the moment + * the shake class lands (a MutationObserver sees it synchronously, well + * inside the 400ms the class stays on). + */ +async function pressEnterAndCatchShake(input: Locator): Promise { + const field = input.locator('xpath=ancestor::mat-form-field'); + await expect(field).not.toHaveClass(/pin-dialog__field--shake/); + await field.evaluate((element) => { + const target = element as HTMLElement & { shakeAnimation?: string }; + delete target.shakeAnimation; + const observer = new MutationObserver(() => { + if (target.classList.contains('pin-dialog__field--shake')) { + target.shakeAnimation = getComputedStyle(target).animationName; + observer.disconnect(); + } + }); + observer.observe(target, { attributeFilter: ['class'] }); + }); + await input.press('Enter'); + await expect + .poll(() => + field.evaluate( + (element) => + (element as HTMLElement & { shakeAnimation?: string }) + .shakeAnimation ?? null + ) + ) + .not.toBeNull(); + return field.evaluate( + (element) => + (element as HTMLElement & { shakeAnimation?: string }) + .shakeAnimation ?? null + ); +} + +test.describe('Electron parental-lock PIN dialog', () => { + test('@settings @parental @electron shows, announces and refuses a mismatched repeat, then saves the fixed PIN', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + const toggle = await openParentalSettings(page); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + await toggle.click(); + + const { dialog, pin, confirm, submit, cancel, mismatch } = + pinDialog(page); + await expect(pin).toBeFocused(); + await expect(submit).toHaveText('Save PIN'); + await expect(cancel).toBeVisible(); + await expect(pin).toHaveAttribute('autocomplete', 'new-password'); + await expect(confirm).toHaveAttribute( + 'autocomplete', + 'new-password' + ); + + // Enter after the PIN moves on to the empty repeat; Save + // with the repeat still empty is refused. + await pin.fill(PIN); + await pin.press('Enter'); + await expect(confirm).toBeFocused(); + await expect(mismatch).toBeHidden(); + await submit.click(); + await expect(mismatch).toBeVisible(); + await expect(dialog).toBeVisible(); + + await confirm.fill(PIN.slice(0, 3)); + await expect(mismatch).toBeHidden(); + await expect(confirm).toHaveAttribute('aria-invalid', 'false'); + + // As long as the PIN and different: shown before any submit. + await confirm.fill('2469'); + await expect(mismatch).toHaveText('The two PINs do not match.'); + await expect(confirm).toHaveAttribute('aria-invalid', 'true'); + const errorId = await mismatch.getAttribute('id'); + expect(await confirm.getAttribute('aria-describedby')).toContain( + errorId + ); + await expect( + mismatch.locator('xpath=ancestor::*[@aria-live="polite"]') + ).toHaveCount(1); + + // The parental-lock red in both themes, on the text and outline. + const outline = confirm + .locator('xpath=ancestor::mat-form-field') + .locator('.mdc-notched-outline__leading'); + for (const theme of THEMES) { + await page.evaluate( + (dark) => + document.body.classList.toggle('dark-theme', dark), + theme.dark + ); + await expect(mismatch).toHaveCSS('color', theme.color); + await expect(outline).toHaveCSS( + 'border-top-color', + theme.color + ); + } + + // Enter is refused with a shake, or without one under reduced + // motion; the dialog stays and nothing is saved. + await page.emulateMedia({ reducedMotion: 'reduce' }); + expect(await pressEnterAndCatchShake(confirm)).toBe('none'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + expect(await pressEnterAndCatchShake(confirm)).toContain( + 'pin-dialog-shake' + ); + // Refused again while it still shakes, the field shakes from + // the start: its class never comes off, so the animation is + // rewound. Held at 300ms, the second refusal must reset it. + const replayedAt = await dialog + .locator('form') + .evaluate(async (form: HTMLFormElement) => { + const nextFrame = () => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(resolve) + ) + ); + const field = form + .querySelector( + '[data-test-id="parental-lock-pin-confirm"]' + ) + ?.closest('mat-form-field') as HTMLElement; + form.requestSubmit(); + await nextFrame(); + const [shake] = field.getAnimations(); + shake.pause(); + shake.currentTime = 300; + form.requestSubmit(); + await nextFrame(); + return shake.currentTime; + }); + expect(replayedAt).toBe(0); + await expect(dialog).toBeVisible(); + await expect(mismatch).toBeVisible(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + // Refused from the PIN field, focus moves to the repeat. + await pin.press('Enter'); + await expect(confirm).toBeFocused(); + + await confirm.fill(PIN); + await expect(mismatch).toBeHidden(); + await expect(confirm).toHaveAttribute('aria-invalid', 'false'); + await confirm.press('Enter'); + await expect(dialog).toBeHidden(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + } finally { + await closeElectronApp(app); + } + }); + + test('@settings @parental @electron names each PIN flow with its own submit verb', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + const toggle = await openParentalSettings(page); + const { dialog, pin, confirm, submit, cancel } = pinDialog(page); + + await toggle.click(); + await expect(submit).toHaveText('Save PIN'); + await pin.fill(PIN); + await confirm.fill(PIN); + await submit.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + // Change PIN: the current PIN is confirmed, the new one saved. + await page.getByTestId('parental-lock-change-pin').click(); + await expect(submit).toHaveText('Confirm'); + await expect(confirm).toHaveCount(0); + await pin.fill(PIN); + await pin.press('Enter'); + await expect(confirm).toBeVisible(); + await expect(submit).toHaveText('Save PIN'); + await cancel.click(); + await expect(dialog).toBeHidden(); + + await page.getByTestId('parental-lock-lock-now').click(); + await page.getByTestId('parental-lock-unlock').click(); + await expect(submit).toHaveText('Unlock'); + await expect(cancel).toBeVisible(); + // A wrong PIN keeps the field focused for the next try, although + // it is disabled while the PIN is checked. + await pin.fill('1357'); + await pin.press('Enter'); + await expect( + dialog.getByTestId('parental-lock-pin-error') + ).toHaveText('Wrong PIN. Try again.'); + await expect(pin).toHaveAttribute('aria-invalid', 'true'); + await expect(pin).toBeFocused(); + await pin.fill(PIN); + await pin.press('Enter'); + await expect(dialog).toBeHidden(); + await expect( + page.getByTestId('parental-lock-lock-now') + ).toBeVisible(); + + await toggle.click(); + await expect(submit).toHaveText('Turn off'); + await pin.fill(PIN); + await pin.press('Enter'); + await expect(dialog).toBeHidden(); + await expect(toggle).toHaveAttribute('aria-checked', 'false'); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web/src/app/services/parental-lock-prompt.service.spec.ts b/apps/web/src/app/services/parental-lock-prompt.service.spec.ts index c7f857ce8..fe8c14f1a 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.spec.ts @@ -24,10 +24,18 @@ describe('AppParentalLockPromptService', () => { ParentalLockPinDialogComponent: { open }, })) as never; - await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234'); + await expect( + service.requestPin({ + mode: 'set', + submitKey: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + }) + ).resolves.toBe('1234'); expect(open).toHaveBeenCalledWith( dialog, - expect.objectContaining({ mode: 'set' }) + expect.objectContaining({ + mode: 'set', + submitKey: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + }) ); }); diff --git a/apps/web/src/app/services/parental-lock-prompt.service.ts b/apps/web/src/app/services/parental-lock-prompt.service.ts index 4c4bec098..9d40f9c55 100644 --- a/apps/web/src/app/services/parental-lock-prompt.service.ts +++ b/apps/web/src/app/services/parental-lock-prompt.service.ts @@ -44,6 +44,7 @@ export class AppParentalLockPromptService implements ParentalLockPrompt { throttle: request.throttle, titleKey: request.titleKey, descriptionKey: request.descriptionKey, + submitKey: request.submitKey, } ); const pin = await firstValueFrom(dialogRef.afterClosed()); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index e4dff9e22..f09483744 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "لا توجد طريقة لاستعادة رمز PIN المنسي — لا يزيله إلا إعادة تعيين بيانات التطبيق.", "SAVE": "حفظ رمز PIN", "UNLOCK": "إلغاء القفل", + "CONFIRM": "تأكيد", + "TURN_OFF": "تعطيل", "CONFIRM_TITLE": "تأكيد رمز PIN للرقابة الأبوية", "CONFIRM_DESCRIPTION": "أدخل رمز PIN الحالي لتغيير هذا الإعداد." }, diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 56f24be99..7e672bb78 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "ماكايناش طريقة باش ترجع رمز PIN لي نسيتي — غير إعادة الضبط ديال بيانات التطبيق هي لي كتحيدو.", "SAVE": "حفظ رمز PIN", "UNLOCK": "حل القفل", + "CONFIRM": "أكّد", + "TURN_OFF": "طفّي", "CONFIRM_TITLE": "أكّد رمز PIN ديال الرقابة الأبوية", "CONFIRM_DESCRIPTION": "دخّل رمز PIN الحالي باش تبدّل هاد الإعداد." }, diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 1a5583ef1..bd06e3b88 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Забыты PIN аднавіць немагчыма — яго выдаляе толькі скід дадзеных праграмы.", "SAVE": "Захаваць PIN", "UNLOCK": "Разблакіраваць", + "CONFIRM": "Пацвердзіць", + "TURN_OFF": "Выключыць", "CONFIRM_TITLE": "Пацвердзіце бацькоўскі PIN", "CONFIRM_DESCRIPTION": "Увядзіце бягучы PIN, каб змяніць гэту наладу." }, diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 9c7170c7e..12ca756f3 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.", "SAVE": "PIN speichern", "UNLOCK": "Entsperren", + "CONFIRM": "Bestätigen", + "TURN_OFF": "Deaktivieren", "CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen", "CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern." }, diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 5a7d89dc5..d4065b7b7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Δεν υπάρχει τρόπος ανάκτησης ενός ξεχασμένου PIN — μόνο η επαναφορά των δεδομένων της εφαρμογής το αφαιρεί.", "SAVE": "Αποθήκευση PIN", "UNLOCK": "Ξεκλείδωμα", + "CONFIRM": "Επιβεβαίωση", + "TURN_OFF": "Απενεργοποίηση", "CONFIRM_TITLE": "Επιβεβαίωση γονικού PIN", "CONFIRM_DESCRIPTION": "Εισαγάγετε το τρέχον PIN για να αλλάξετε αυτή τη ρύθμιση." }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6544f35ee..401da3613 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.", "SAVE": "Save PIN", "UNLOCK": "Unlock", + "CONFIRM": "Confirm", + "TURN_OFF": "Turn off", "CONFIRM_TITLE": "Confirm parental PIN", "CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting." }, diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4c340b517..0e460f3ae 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "No hay forma de recuperar un PIN olvidado: solo restablecer los datos de la aplicación lo elimina.", "SAVE": "Guardar PIN", "UNLOCK": "Desbloquear", + "CONFIRM": "Confirmar", + "TURN_OFF": "Desactivar", "CONFIRM_TITLE": "Confirmar el PIN parental", "CONFIRM_DESCRIPTION": "Introduce el PIN actual para cambiar este ajuste." }, diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 812cb01dd..54eb60961 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Il est impossible de récupérer un code PIN oublié — seule la réinitialisation des données de l'application le supprime.", "SAVE": "Enregistrer le code PIN", "UNLOCK": "Déverrouiller", + "CONFIRM": "Confirmer", + "TURN_OFF": "Désactiver", "CONFIRM_TITLE": "Confirmer le code PIN parental", "CONFIRM_DESCRIPTION": "Saisissez le code PIN actuel pour modifier ce réglage." }, diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 6027cebbf..aee8487dd 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Az elfelejtett PIN-kód nem állítható vissza — csak az alkalmazásadatok alaphelyzetbe állítása távolítja el.", "SAVE": "PIN-kód mentése", "UNLOCK": "Feloldás", + "CONFIRM": "Megerősítés", + "TURN_OFF": "Kikapcsolás", "CONFIRM_TITLE": "Szülői PIN-kód megerősítése", "CONFIRM_DESCRIPTION": "A beállítás módosításához adja meg a jelenlegi PIN-kódot." }, diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a978c7560..886d12389 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Non è possibile recuperare un PIN dimenticato: si rimuove solo reimpostando i dati dell'app.", "SAVE": "Salva PIN", "UNLOCK": "Sblocca", + "CONFIRM": "Conferma", + "TURN_OFF": "Disattiva", "CONFIRM_TITLE": "Conferma il PIN parentale", "CONFIRM_DESCRIPTION": "Inserisci il PIN attuale per modificare questa impostazione." }, diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e0ea48c88..fc86eae05 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "忘れた PIN を復元する方法はありません。PIN を削除するには、アプリのデータをリセットするしかありません。", "SAVE": "PIN を保存", "UNLOCK": "ロック解除", + "CONFIRM": "確認", + "TURN_OFF": "無効化", "CONFIRM_TITLE": "ペアレンタル PIN を確認", "CONFIRM_DESCRIPTION": "この設定を変更するには、現在の PIN を入力してください。" }, diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 5c2adf2b7..9b4f72484 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "잊어버린 PIN은 복구할 수 없습니다. 앱 데이터를 초기화해야만 PIN이 삭제됩니다.", "SAVE": "PIN 저장", "UNLOCK": "잠금 해제", + "CONFIRM": "확인", + "TURN_OFF": "끄기", "CONFIRM_TITLE": "보호자 PIN 확인", "CONFIRM_DESCRIPTION": "이 설정을 변경하려면 현재 PIN을 입력하세요." }, diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index aebe8cc35..81146e2d8 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Een vergeten pincode kan niet worden hersteld — alleen het resetten van de app-gegevens verwijdert hem.", "SAVE": "Pincode opslaan", "UNLOCK": "Ontgrendelen", + "CONFIRM": "Bevestigen", + "TURN_OFF": "Uitschakelen", "CONFIRM_TITLE": "Ouderpincode bevestigen", "CONFIRM_DESCRIPTION": "Voer de huidige pincode in om deze instelling te wijzigen." }, diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index dfa56de3d..27e2db41c 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Zapomnianego kodu PIN nie da się odzyskać — usuwa go tylko zresetowanie danych aplikacji.", "SAVE": "Zapisz kod PIN", "UNLOCK": "Odblokuj", + "CONFIRM": "Potwierdź", + "TURN_OFF": "Wyłącz", "CONFIRM_TITLE": "Potwierdź rodzicielski kod PIN", "CONFIRM_DESCRIPTION": "Wpisz obecny kod PIN, aby zmienić to ustawienie." }, diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 9c3fac933..afb27e027 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Não é possível recuperar um PIN esquecido — somente redefinir os dados do app o remove.", "SAVE": "Salvar PIN", "UNLOCK": "Desbloquear", + "CONFIRM": "Confirmar", + "TURN_OFF": "Desativar", "CONFIRM_TITLE": "Confirmar PIN parental", "CONFIRM_DESCRIPTION": "Digite o PIN atual para alterar esta configuração." }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1e6d37dc4..c8e73edd5 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.", "SAVE": "Сохранить PIN", "UNLOCK": "Разблокировать", + "CONFIRM": "Подтвердить", + "TURN_OFF": "Выключить", "CONFIRM_TITLE": "Подтвердите родительский PIN", "CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку." }, diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 863c33469..a4c3c71b9 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "Unutulan bir PIN'i kurtarmanın yolu yoktur — yalnızca uygulama verilerini sıfırlamak onu kaldırır.", "SAVE": "PIN'i kaydet", "UNLOCK": "Kilidi aç", + "CONFIRM": "Onayla", + "TURN_OFF": "Kapat", "CONFIRM_TITLE": "Ebeveyn PIN'ini onaylayın", "CONFIRM_DESCRIPTION": "Bu ayarı değiştirmek için mevcut PIN'i girin." }, diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index f860f0818..5b09ac96c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "忘记的 PIN 码无法找回——只有重置应用数据才能将其移除。", "SAVE": "保存 PIN 码", "UNLOCK": "解锁", + "CONFIRM": "确认", + "TURN_OFF": "关闭", "CONFIRM_TITLE": "确认家长 PIN 码", "CONFIRM_DESCRIPTION": "请输入当前 PIN 码以更改此设置。" }, diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ef606aab3..8e9cfad4f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1936,6 +1936,8 @@ "NO_RECOVERY": "忘記的 PIN 碼無法復原——只有重設應用程式資料才能將其移除。", "SAVE": "儲存 PIN 碼", "UNLOCK": "解鎖", + "CONFIRM": "確認", + "TURN_OFF": "關閉", "CONFIRM_TITLE": "確認家長 PIN 碼", "CONFIRM_DESCRIPTION": "請輸入目前的 PIN 碼以變更此設定。" }, diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d9b871fb0..8ffda3a1d 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -416,8 +416,27 @@ so a cancelled or refused PIN leaves it showing the real state. so a child cannot switch the idle relock off for the next unlock. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. +- The PIN dialog's submit button names the flow: the service passes a + `submitKey` with every prompt — Unlock (`requestUnlock`), Save PIN (a new + PIN in `setupPin` and `changePin`), Confirm (the current PIN before a + change), Turn off (`disable`). The dismiss button is the shared Cancel. + Errors sit on their field as `mat-error` (an `errorStateMatcher` drives + it, so the input gets `aria-invalid` and the message lands in the field's + live region): a wrong PIN, the cooldown or a too-short PIN on the PIN + field; a mismatch on the repeat field, shown while typing once the repeat + is as long as the PIN. Submit is disabled only while busy or cooling + down, never for incomplete input, because Enter does nothing on a + disabled default button; `submit()` refuses instead, shakes the field + (no shake under `prefers-reduced-motion: reduce`) and focuses it — except + that Enter in the PIN field with the repeat still empty only moves focus + to the repeat (a `keydown.enter` handler: focus cannot tell Enter from a + click on Save, since WebKit does not focus a clicked button). Set + mode marks both inputs `autocomplete="new-password"`. The Electron + `parental-lock-pin-dialog.e2e.ts` covers the labels and the + mismatch → fix → save flow. - Styling uses app tokens only (the theme declares no `--mat-sys-*`); the - PIN error and the Stalker adult chip use a local red per theme. Keyboard + PIN errors, the fields' error outline and the Stalker adult chip use a + local red per theme. Keyboard focus on a lock row is a 2px inset `--app-selection-color` ring, and the lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts` diff --git a/libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts b/libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts new file mode 100644 index 000000000..e5cbd88e5 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-prompt-requests.ts @@ -0,0 +1,72 @@ +import { + ParentalLockPinThrottle, + verifyParentalLockPin, +} from '@iptvnator/shared/interfaces'; +import { ParentalLockPromptRequest } from './parental-lock-prompt.token'; + +type PromptLabels = Pick< + ParentalLockPromptRequest, + 'titleKey' | 'descriptionKey' | 'submitKey' +>; + +/** + * The submit verb of each PIN flow, so the button says what it will do + * instead of a generic "Unlock". + */ +export const PARENTAL_LOCK_SUBMIT_KEYS = { + unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK', + save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM', + turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF', +} as const; + +/** Setting up or replacing the PIN: typed twice, saved on submit. */ +export const NEW_PIN_REQUEST: ParentalLockPromptRequest = { + mode: 'set', + submitKey: PARENTAL_LOCK_SUBMIT_KEYS.save, +}; + +/** Unlocks the session with the current PIN. */ +export function unlockPinRequest( + hash: string, + throttle: ParentalLockPinThrottle, + labels: Pick = {} +): ParentalLockPromptRequest { + return currentPinRequest(hash, throttle, { + submitKey: PARENTAL_LOCK_SUBMIT_KEYS.unlock, + ...labels, + }); +} + +/** + * Confirms the current PIN before a protected settings change; `submitKey` + * names that change (Confirm before a new PIN, Turn off). + */ +export function confirmPinRequest( + hash: string, + throttle: ParentalLockPinThrottle, + submitKey: string +): ParentalLockPromptRequest { + return currentPinRequest(hash, throttle, { + titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE', + descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION', + submitKey, + }); +} + +/** + * Asks for the current PIN, checked against `hash`. Every such prompt shares + * the service's `throttle`, so the cooldown survives a dismissed dialog. + */ +function currentPinRequest( + hash: string, + throttle: ParentalLockPinThrottle, + labels: PromptLabels +): ParentalLockPromptRequest { + return { + mode: 'unlock', + verify: (candidate) => verifyParentalLockPin(candidate, hash), + throttle, + ...labels, + }; +} diff --git a/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts b/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts index 2a3bdf3e8..451beb558 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-prompt.token.ts @@ -20,6 +20,11 @@ export interface ParentalLockPromptRequest { titleKey?: string; /** Optional translation key overriding the mode's default description. */ descriptionKey?: string; + /** + * Translation key of the flow's verb on the submit button ("Unlock", + * "Save PIN", "Turn off", …); the mode's default when absent. + */ + submitKey?: string; } /** diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 83f5bbc9f..8ccbddc61 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -559,6 +559,46 @@ describe('ParentalLockService', () => { expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock'); }); + it('labels each prompt with the verb of its flow', async () => { + prompt.requestPin.mockImplementation( + async (request: ParentalLockPromptRequest) => + request.mode === 'set' + ? '1234' + : (await request.verify?.('1234')) + ? '1234' + : null + ); + const service = await createService(); + const submitKeys = () => + prompt.requestPin.mock.calls.map( + ([request]: [ParentalLockPromptRequest]) => + `${request.mode}:${request.submitKey}` + ); + + await expect(service.setupPin()).resolves.toBe(true); + expect(submitKeys()).toEqual(['set:PARENTAL_LOCK.PIN_DIALOG.SAVE']); + + prompt.requestPin.mockClear(); + await expect(service.changePin()).resolves.toBe(true); + expect(submitKeys()).toEqual([ + 'unlock:PARENTAL_LOCK.PIN_DIALOG.CONFIRM', + 'set:PARENTAL_LOCK.PIN_DIALOG.SAVE', + ]); + + prompt.requestPin.mockClear(); + service.lock(); + await expect(service.requestUnlock()).resolves.toBe(true); + expect(submitKeys()).toEqual([ + 'unlock:PARENTAL_LOCK.PIN_DIALOG.UNLOCK', + ]); + + prompt.requestPin.mockClear(); + await expect(service.disable()).resolves.toBe(true); + expect(submitKeys()).toEqual([ + 'unlock:PARENTAL_LOCK.PIN_DIALOG.TURN_OFF', + ]); + }); + it('persists locks per portal, stamps the Xtream column and bumps the version', async () => { const service = await createService(); const versionBefore = service.version(); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index c291b6d8b..d98875df3 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -13,7 +13,6 @@ import { ParentalLockPlaylistLocks, ParentalLockStalkerCategoryType, ParentalLockXtreamCategoryType, - verifyParentalLockPin, } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; import { SettingsStore } from '../settings-store.service'; @@ -35,6 +34,12 @@ import { persistParentalLockEnabled, persistParentalLockRelockMinutes, } from './parental-lock-settings-writer'; +import { + confirmPinRequest, + NEW_PIN_REQUEST, + PARENTAL_LOCK_SUBMIT_KEYS, + unlockPinRequest, +} from './parental-lock-prompt-requests'; import { ParentalLockStorageService } from './parental-lock-storage'; /** @@ -242,12 +247,9 @@ export class ParentalLockService { if (!this.prompt || !hash) { return false; } - const pin = await this.prompt.requestPin({ - mode: 'unlock', - verify: (candidate) => verifyParentalLockPin(candidate, hash), - throttle: this.pinThrottle, - ...options, - }); + const pin = await this.prompt.requestPin( + unlockPinRequest(hash, this.pinThrottle, options) + ); if (pin === null) { return false; } @@ -276,7 +278,7 @@ export class ParentalLockService { // depends on. const needsPersist = this.settingsStore.parentalLockEnabled?.() !== true; - const pin = await this.prompt.requestPin({ mode: 'set' }); + const pin = await this.prompt.requestPin(NEW_PIN_REQUEST); if (pin === null) { return false; } @@ -306,10 +308,10 @@ export class ParentalLockService { if (!this.prompt || !this.hasPin()) { return false; } - if (!(await this.verifyCurrentPin())) { + if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.confirm))) { return false; } - const pin = await this.prompt.requestPin({ mode: 'set' }); + const pin = await this.prompt.requestPin(NEW_PIN_REQUEST); if (pin === null) { return false; } @@ -321,7 +323,7 @@ export class ParentalLockService { if (!this.enabled()) { return true; } - if (!(await this.verifyCurrentPin())) { + if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.turnOff))) { return false; } if (!(await this.persistEnabled(false))) { @@ -335,22 +337,18 @@ export class ParentalLockService { * Always asks for the PIN, unlocked session or not: changing the PIN or * switching the feature off must not be possible just because a parent * left the app unlocked. Unlike `requestUnlock()` this never short-cuts - * on `active`. + * on `active`. `submitKey` names the step the PIN confirms. */ - private async verifyCurrentPin(): Promise { + private async verifyCurrentPin(submitKey: string): Promise { await this.initialize(); await this.ensurePin(); const hash = this.pinHash(); if (!this.prompt || !hash) { return false; } - const pin = await this.prompt.requestPin({ - mode: 'unlock', - verify: (candidate) => verifyParentalLockPin(candidate, hash), - throttle: this.pinThrottle, - titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE', - descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION', - }); + const pin = await this.prompt.requestPin( + confirmPinRequest(hash, this.pinThrottle, submitKey) + ); return pin !== null; } diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html index 918addce9..0a7a63987 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.html @@ -11,7 +11,7 @@ appearance="outline" subscriptSizing="dynamic" class="pin-dialog__field" - [class.pin-dialog__field--shake]="shake()" + [class.pin-dialog__field--shake]="shake() === 'pin'" > {{ 'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate }} @@ -33,6 +35,11 @@ | translate: { min: minLength, max: maxLength } }} + @if (pinErrorKey(); as errorKey) { + + {{ errorKey | translate: { min: minLength, max: maxLength } }} + + } @if (isSetMode()) { @@ -40,30 +47,30 @@ appearance="outline" subscriptSizing="dynamic" class="pin-dialog__field" + [class.pin-dialog__field--shake]="shake() === 'confirmation'" > {{ 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate }} + @if (mismatch()) { + + {{ 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH' | translate }} + + } - } - @if (error(); as errorKey) { - - } - - @if (isSetMode()) {

{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}

@@ -72,21 +79,16 @@ diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss index 9dcec7338..94a9e8232 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.scss @@ -1,7 +1,28 @@ // The theme declares no Material system colours, so the error colour is a // local token with a value per theme (>= 6:1 on the dialog surface in both). +// The fields' error state (message, outline, label, caret) takes it too. :host { --pin-dialog-error-color: #b3261e; + --mat-form-field-error-text-color: var(--pin-dialog-error-color); + --mat-form-field-outlined-error-outline-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-hover-outline-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-focus-outline-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-label-text-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-hover-label-text-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-focus-label-text-color: var( + --pin-dialog-error-color + ); + --mat-form-field-outlined-error-caret-color: var(--pin-dialog-error-color); } :host-context(.dark-theme) { @@ -37,12 +58,10 @@ .pin-dialog__field--shake { animation: pin-dialog-shake 0.4s ease; -} -.pin-dialog__error { - margin: 0; - color: var(--pin-dialog-error-color); - font-size: 0.8125rem; + @media (prefers-reduced-motion: reduce) { + animation: none; + } } .pin-dialog__note { diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts index 437a2c271..342acb8fb 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; @@ -6,48 +6,361 @@ import { createParentalLockPinThrottle, PARENTAL_LOCK_PIN_MAX_ATTEMPTS, } from '@iptvnator/shared/interfaces'; -import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component'; +import { + ParentalLockPinDialogComponent, + ParentalLockPinDialogData, +} from './parental-lock-pin-dialog.component'; -describe('ParentalLockPinDialogComponent cooldown', () => { - const throttle = createParentalLockPinThrottle(); - const verify = jest.fn(async () => false); +// Without a loader the translate pipe renders the key itself. +const KEYS = { + save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE', + unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK', + confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM', + turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF', + mismatch: 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH', + hint: 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT', + wrongPin: 'PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN', +}; - function openDialog(): ParentalLockPinDialogComponent { - TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [ - ParentalLockPinDialogComponent, - NoopAnimationsModule, - TranslateModule.forRoot(), - ], - providers: [ - { - provide: MAT_DIALOG_DATA, - useValue: { mode: 'unlock', verify, throttle }, - }, - { provide: MatDialogRef, useValue: { close: jest.fn() } }, - ], +interface DialogHarness { + fixture: ComponentFixture; + component: ParentalLockPinDialogComponent; + close: jest.Mock; + query(testId: string): T | null; + type(testId: string, value: string): Promise; + /** Submits the form the way Enter in a field does. */ + pressEnter(): Promise; +} + +async function openDialog( + data: ParentalLockPinDialogData +): Promise { + TestBed.resetTestingModule(); + const close = jest.fn(); + TestBed.configureTestingModule({ + imports: [ + ParentalLockPinDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: { close } }, + ], + }); + const fixture = TestBed.createComponent(ParentalLockPinDialogComponent); + await fixture.whenStable(); + const root = fixture.nativeElement as HTMLElement; + const query = (testId: string) => + root.querySelector(`[data-test-id="${testId}"]`); + return { + fixture, + component: fixture.componentInstance, + close, + query, + async type(testId, value) { + const input = query(testId); + if (!input) throw new Error(`No input ${testId}`); + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + }, + async pressEnter() { + root.querySelector('form')?.dispatchEvent( + new Event('submit', { cancelable: true }) + ); + await fixture.whenStable(); + }, + }; +} + +function text(element: Element | null): string { + return element?.textContent?.trim() ?? ''; +} + +describe('ParentalLockPinDialogComponent', () => { + describe('submit labels', () => { + it.each([ + ['unlock', undefined, KEYS.unlock], + ['set', undefined, KEYS.save], + ['unlock', KEYS.confirm, KEYS.confirm], + ['unlock', KEYS.turnOff, KEYS.turnOff], + ['set', KEYS.save, KEYS.save], + ] as const)( + '%s mode with submitKey %s reads %s', + async (mode, submitKey, expected) => { + const dialog = await openDialog({ mode, submitKey }); + + expect(text(dialog.query('parental-lock-pin-submit'))).toBe( + expected + ); + const dismiss = dialog.fixture.nativeElement.querySelector( + 'mat-dialog-actions button[type="button"]' + ); + expect(text(dismiss)).toBe('CANCEL'); + } + ); + }); + + describe('set mode', () => { + it('asks password managers for a new password in both fields', async () => { + const dialog = await openDialog({ mode: 'set' }); + + expect( + dialog.query('parental-lock-pin')?.getAttribute('autocomplete') + ).toBe('new-password'); + expect( + dialog + .query('parental-lock-pin-confirm') + ?.getAttribute('autocomplete') + ).toBe('new-password'); }); - const fixture = TestBed.createComponent(ParentalLockPinDialogComponent); - fixture.detectChanges(); - return fixture.componentInstance; - } - it('keeps the cooldown when the prompt is dismissed and opened again', async () => { - const first = openDialog(); - for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { - first.onPinInput('0000'); - await first.submit(); - } - expect(first.inCooldown()).toBe(true); - expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS); + it('shows the mismatch once the repeat is as long as the PIN, then saves the fixed PIN', async () => { + const dialog = await openDialog({ mode: 'set' }); + const confirm = () => + dialog.query('parental-lock-pin-confirm'); - const reopened = openDialog(); - expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); - reopened.onPinInput('0000'); - await reopened.submit(); + await dialog.type('parental-lock-pin', '2468'); + await dialog.type('parental-lock-pin-confirm', '246'); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + expect(confirm()?.getAttribute('aria-invalid')).toBe('false'); - expect(reopened.inCooldown()).toBe(true); - expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS); + await dialog.type('parental-lock-pin-confirm', '2469'); + const error = dialog.query('parental-lock-pin-mismatch'); + expect(text(error)).toBe(KEYS.mismatch); + expect(confirm()?.getAttribute('aria-invalid')).toBe('true'); + // Announced: the error is the input's description. + expect(confirm()?.getAttribute('aria-describedby')).toContain( + error?.id + ); + + // Enter is refused visibly rather than swallowed. + expect( + dialog.query('parental-lock-pin-submit') + ?.disabled + ).toBe(false); + await dialog.pressEnter(); + expect(dialog.close).not.toHaveBeenCalled(); + expect(dialog.component.shake()).toBe('confirmation'); + expect(document.activeElement).toBe(confirm()); + + await dialog.type('parental-lock-pin-confirm', '2468'); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + expect(confirm()?.getAttribute('aria-invalid')).toBe('false'); + await dialog.pressEnter(); + expect(dialog.close).toHaveBeenCalledWith('2468'); + }); + + it('shows the mismatch for a short repeat when Enter is pressed', async () => { + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + await dialog.type('parental-lock-pin-confirm', '24'); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.close).not.toHaveBeenCalled(); + + // Editing the repeat hands the error back to the length rule. + await dialog.type('parental-lock-pin-confirm', '246'); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + }); + + it('moves on to an empty repeat on Enter in the PIN field', async () => { + const dialog = await openDialog({ mode: 'set' }); + const enter = new KeyboardEvent('keydown', { + key: 'Enter', + cancelable: true, + }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin')?.dispatchEvent(enter); + await dialog.fixture.whenStable(); + + // Handled before the form sees it: no implicit submission. + expect(enter.defaultPrevented).toBe(true); + expect(document.activeElement).toBe( + dialog.query('parental-lock-pin-confirm') + ); + expect(dialog.query('parental-lock-pin-mismatch')).toBeNull(); + expect(dialog.component.shake()).toBeNull(); + }); + + it('refuses an empty repeat on Save while the PIN field keeps focus', async () => { + // WebKit does not focus a clicked button, so focus says nothing + // about how the form was submitted. + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin')?.focus(); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.component.shake()).toBe('confirmation'); + expect(dialog.close).not.toHaveBeenCalled(); + }); + + it('refuses an empty repeat when Enter is pressed in it', async () => { + const dialog = await openDialog({ mode: 'set' }); + + await dialog.type('parental-lock-pin', '2468'); + dialog.query('parental-lock-pin-confirm')?.focus(); + await dialog.pressEnter(); + + expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe( + KEYS.mismatch + ); + expect(dialog.component.shake()).toBe('confirmation'); + expect(dialog.close).not.toHaveBeenCalled(); + }); + + it('lets a second refusal shake for its full time', async () => { + const { component } = await openDialog({ mode: 'set' }); + jest.useFakeTimers(); + try { + component.onPinInput('24'); + await component.submit(); + expect(component.shake()).toBe('pin'); + + jest.advanceTimersByTime(300); + component.onPinInput('2468'); + component.onConfirmationInput('2469'); + await component.submit(); + expect(component.shake()).toBe('confirmation'); + + // The first refusal's timer must not end the second shake. + jest.advanceTimersByTime(150); + expect(component.shake()).toBe('confirmation'); + jest.advanceTimersByTime(250); + expect(component.shake()).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + + it('replays the shake when the same field is refused again', async () => { + const dialog = await openDialog({ mode: 'set' }); + const field = dialog + .query('parental-lock-pin') + ?.closest('mat-form-field'); + // jsdom has no Web Animations: stand in for the running shake. + const shake: Partial = { + animationName: '_ngcontent-x_pin-dialog-shake', + currentTime: 250, + }; + Object.defineProperty(field, 'getAnimations', { + value: () => [shake], + }); + + await dialog.type('parental-lock-pin', '24'); + await dialog.pressEnter(); + expect(shake.currentTime).toBe(250); + + await dialog.pressEnter(); + expect(dialog.component.shake()).toBe('pin'); + expect(shake.currentTime).toBe(0); + }); + + it('refuses a too-short PIN on Enter with an error on the PIN field', async () => { + const dialog = await openDialog({ mode: 'set' }); + const pin = () => + dialog.query('parental-lock-pin'); + + await dialog.type('parental-lock-pin', '24'); + await dialog.type('parental-lock-pin-confirm', '24'); + await dialog.pressEnter(); + + expect(dialog.close).not.toHaveBeenCalled(); + expect(text(dialog.query('parental-lock-pin-error'))).toBe( + KEYS.hint + ); + expect(pin()?.getAttribute('aria-invalid')).toBe('true'); + expect(document.activeElement).toBe(pin()); + + await dialog.type('parental-lock-pin', '246'); + expect(dialog.query('parental-lock-pin-error')).toBeNull(); + }); + }); + + describe('unlock mode', () => { + it('has no repeat field and leaves autocomplete off', async () => { + const dialog = await openDialog({ mode: 'unlock' }); + + expect(dialog.query('parental-lock-pin-confirm')).toBeNull(); + expect( + dialog.query('parental-lock-pin')?.getAttribute('autocomplete') + ).toBe('off'); + }); + + it('marks the PIN field invalid after a wrong PIN and unlocks with the right one', async () => { + const verify = jest.fn(async (pin: string) => pin === '2468'); + const dialog = await openDialog({ mode: 'unlock', verify }); + const pin = () => + dialog.query('parental-lock-pin'); + + await dialog.type('parental-lock-pin', '1357'); + await dialog.pressEnter(); + await dialog.fixture.whenStable(); + + expect(text(dialog.query('parental-lock-pin-error'))).toBe( + KEYS.wrongPin + ); + expect(pin()?.getAttribute('aria-invalid')).toBe('true'); + expect(dialog.close).not.toHaveBeenCalled(); + + await dialog.type('parental-lock-pin', '2468'); + expect(dialog.query('parental-lock-pin-error')).toBeNull(); + await dialog.pressEnter(); + expect(dialog.close).toHaveBeenCalledWith('2468'); + }); + + it('does not verify an incomplete PIN', async () => { + const verify = jest.fn(async () => true); + const dialog = await openDialog({ mode: 'unlock', verify }); + + await dialog.type('parental-lock-pin', '12'); + await dialog.pressEnter(); + + expect(verify).not.toHaveBeenCalled(); + expect(text(dialog.query('parental-lock-pin-error'))).toBe( + KEYS.hint + ); + }); + }); + + describe('cooldown', () => { + const throttle = createParentalLockPinThrottle(); + const verify = jest.fn(async () => false); + + it('keeps the cooldown when the prompt is dismissed and opened again', async () => { + const first = ( + await openDialog({ mode: 'unlock', verify, throttle }) + ).component; + for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) { + first.onPinInput('0000'); + await first.submit(); + } + expect(first.inCooldown()).toBe(true); + expect(verify).toHaveBeenCalledTimes( + PARENTAL_LOCK_PIN_MAX_ATTEMPTS + ); + + const reopened = ( + await openDialog({ mode: 'unlock', verify, throttle }) + ).component; + expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); + reopened.onPinInput('0000'); + await reopened.submit(); + + expect(reopened.inCooldown()).toBe(true); + expect(verify).toHaveBeenCalledTimes( + PARENTAL_LOCK_PIN_MAX_ATTEMPTS + ); + }); }); }); diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index e9eafbfd2..42fb878d9 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -9,6 +9,7 @@ import { } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, @@ -29,6 +30,8 @@ import { export type ParentalLockPinDialogMode = 'unlock' | 'set'; +type PinDialogField = 'pin' | 'confirmation'; + export interface ParentalLockPinDialogData { mode: ParentalLockPinDialogMode; /** Unlock only: whether the typed PIN is the right one. */ @@ -41,12 +44,18 @@ export interface ParentalLockPinDialogData { throttle?: ParentalLockPinThrottle; titleKey?: string; descriptionKey?: string; + /** The flow's verb on the submit button; defaults to the mode's. */ + submitKey?: string; } /** * PIN prompt for the parental lock. Pure UI: the caller supplies `verify` * for the unlock mode and receives the accepted PIN (or `undefined` when * dismissed) through the dialog result. `set` mode asks for the PIN twice. + * + * Submit stays enabled while the input is incomplete: implicit submission + * (Enter) does nothing on a disabled default button, so an invalid entry + * is refused by `submit()` with an error on the field instead. */ @Component({ selector: 'app-parental-lock-pin-dialog', @@ -70,18 +79,24 @@ export class ParentalLockPinDialogComponent { ); private readonly pinInput = viewChild>('pinInput'); + private readonly confirmationInput = + viewChild>('confirmationInput'); readonly minLength = PARENTAL_LOCK_PIN_MIN_LENGTH; readonly maxLength = PARENTAL_LOCK_PIN_MAX_LENGTH; readonly pin = signal(''); readonly confirmation = signal(''); readonly busy = signal(false); + /** Wrong PIN or cooldown, shown on the PIN field. */ readonly error = signal(null); - readonly shake = signal(false); + readonly shake = signal(null); readonly cooldownUntil = signal(0); + /** A submit was refused for this field; cleared when it is edited. */ + private readonly refused = signal(null); private readonly throttle = this.data.throttle ?? createParentalLockPinThrottle(); private cooldownTimer: number | null = null; + private shakeTimer: number | null = null; readonly isSetMode = computed(() => this.data.mode === 'set'); readonly titleKey = computed( @@ -98,14 +113,43 @@ export class ParentalLockPinDialogComponent { ? 'PARENTAL_LOCK.PIN_DIALOG.SET_DESCRIPTION' : 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK_DESCRIPTION') ); - readonly inCooldown = computed(() => this.cooldownUntil() > Date.now()); - readonly canSubmit = computed( + readonly submitKey = computed( () => - !this.busy() && - !this.inCooldown() && - isValidParentalLockPin(this.pin()) && - (!this.isSetMode() || this.confirmation() === this.pin()) + this.data.submitKey ?? + (this.isSetMode() + ? 'PARENTAL_LOCK.PIN_DIALOG.SAVE' + : 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK') ); + readonly inCooldown = computed(() => this.cooldownUntil() > Date.now()); + /** The message under the PIN field, if any. */ + readonly pinErrorKey = computed( + () => + this.error() ?? + (this.refused() === 'pin' && !isValidParentalLockPin(this.pin()) + ? 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT' + : null) + ); + /** + * Set mode: the repeat differs from the PIN. Shown while typing once the + * repeat is as long as the PIN, and at any length after a refused submit. + */ + readonly mismatch = computed(() => { + const confirmation = this.confirmation(); + const pin = this.pin(); + if (!this.isSetMode() || confirmation === pin) { + return false; + } + return ( + this.refused() === 'confirmation' || + (confirmation.length > 0 && confirmation.length >= pin.length) + ); + }); + readonly pinErrorState: ErrorStateMatcher = { + isErrorState: () => this.pinErrorKey() !== null, + }; + readonly confirmationErrorState: ErrorStateMatcher = { + isErrorState: () => this.mismatch(), + }; constructor() { // Reopened during a cooldown: the pause carries on where it was. @@ -132,24 +176,48 @@ export class ParentalLockPinDialogComponent { onPinInput(value: string): void { this.pin.set(value.replace(/\D/g, '').slice(0, this.maxLength)); this.error.set(null); + this.clearRefusal('pin'); } onConfirmationInput(value: string): void { this.confirmation.set( value.replace(/\D/g, '').slice(0, this.maxLength) ); - this.error.set(null); + this.clearRefusal('confirmation'); + } + + /** + * Set mode: Enter after a complete first PIN moves on to an empty repeat + * instead of submitting. Handled on the key, not in `submit()`: focus + * cannot tell Enter from a click on Save, as WebKit does not focus a + * clicked button. + */ + onPinEnter(event: Event): void { + if ( + this.isSetMode() && + !this.confirmation() && + isValidParentalLockPin(this.pin()) + ) { + event.preventDefault(); + this.confirmationInput()?.nativeElement.focus(); + } } async submit(): Promise { - if (!this.canSubmit()) { - if (this.isSetMode() && this.confirmation() !== this.pin()) { - this.fail('PARENTAL_LOCK.PIN_DIALOG.MISMATCH'); - } + if (this.busy() || this.inCooldown()) { return; } const pin = this.pin(); + if (!isValidParentalLockPin(pin)) { + this.error.set(null); + this.refuse('pin'); + return; + } if (this.isSetMode()) { + if (this.confirmation() !== pin) { + this.refuse('confirmation'); + return; + } this.dialogRef.close(pin); return; } @@ -165,10 +233,11 @@ export class ParentalLockPinDialogComponent { this.pin.set(''); if (this.throttle.recordFailure()) { this.startCooldown(this.throttle.cooldownUntil()); - this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); + this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN'); } else { - this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN'); + this.error.set('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN'); } + this.shakeField('pin'); } finally { this.busy.set(false); queueMicrotask(() => this.pinInput()?.nativeElement.focus()); @@ -179,10 +248,48 @@ export class ParentalLockPinDialogComponent { this.dialogRef.close(undefined); } - private fail(messageKey: string): void { - this.error.set(messageKey); - this.shake.set(true); - window.setTimeout(() => this.shake.set(false), 400); + /** Shows the field's error, shakes it and moves focus to it. */ + private refuse(field: PinDialogField): void { + this.refused.set(field); + this.shakeField(field); + this.fieldInput(field)?.focus(); + } + + private fieldInput(field: PinDialogField): HTMLInputElement | undefined { + return (field === 'pin' ? this.pinInput() : this.confirmationInput()) + ?.nativeElement; + } + + private clearRefusal(field: PinDialogField): void { + if (this.refused() === field) { + this.refused.set(null); + } + } + + private shakeField(field: PinDialogField): void { + // A refusal inside the previous one's 400ms shakes for its own + // full time; the earlier timer would otherwise end it early. + if (this.shakeTimer !== null) { + window.clearTimeout(this.shakeTimer); + } + if (this.shake() === field) { + // Same field again: its class stays on, so the running shake + // would only finish. Rewind it (none under reduced motion). + this.fieldInput(field) + ?.closest('mat-form-field') + ?.getAnimations?.() + .filter((animation) => + (animation as CSSAnimation).animationName?.includes( + 'pin-dialog-shake' + ) + ) + .forEach((animation) => (animation.currentTime = 0)); + } + this.shake.set(field); + this.shakeTimer = window.setTimeout(() => { + this.shakeTimer = null; + this.shake.set(null); + }, 400); } private startCooldown(until: number): void { From 5a4d7a8a111b4afce0e7ece99cde89d6796bff01 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:20:39 +0200 Subject: [PATCH 4/7] test(perf): measure the serial IPC depth before the J1 first card (#1773) * test(perf): measure the serial IPC depth before the J1 first card Adds renderer.ipcSerialDepthToFirstCard to the launch journey: the length of the longest chain of bridge calls in which each call started after the previous one completed, among calls that completed before the first card. The main IPC capture now records the ordered start/completion timeline; the depth, its lower bound, the chain and the timeline are per-iteration evidence, and the CI job summary prints the chain. Co-Authored-By: Claude Opus 5.5 * test(perf): keep the IPC timeline consistent around the J2 start marker A call that started before the start marker no longer records its completion in the timeline, and completions of a method with calls in flight both inside and outside the timeline are attributed outside and counted, instead of skipping the first marker-method completion. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../actions/performance-journeys/action.yml | 4 +- .../journey-ipc-serial-depth.spec.ts | 150 ++++++++++++++++++ .../performance/journey-ipc-serial-depth.ts | 109 +++++++++++++ .../journey-main-ipc-capture.spec.ts | 86 ++++++++++ .../performance/journey-main-ipc-capture.ts | 62 ++++++++ .../performance/launch-journey-record.spec.ts | 22 +++ .../src/performance/launch-journey-record.ts | 11 ++ .../open-source-journey-record.spec.ts | 2 + docs/architecture/performance-journeys.md | 77 +++++++++ 9 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.ts diff --git a/.github/actions/performance-journeys/action.yml b/.github/actions/performance-journeys/action.yml index 009675585..5b9ead263 100644 --- a/.github/actions/performance-journeys/action.yml +++ b/.github/actions/performance-journeys/action.yml @@ -70,5 +70,7 @@ runs: ($j.counterStability[.key] // {}) as $s | "| `\(.key)` | \(.value) | \(($s.values // []) | map(tostring) | join(", "))\(if $s.stable == false then " (unstable)" else "" end) |"), (($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"), - "") + "", + ([($j.iterations // [])[] | select(.warmup | not) | .evidence.ipcSerialDepth // empty][0] // empty | + "Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", "")) ' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.spec.ts b/apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.spec.ts new file mode 100644 index 000000000..08ebd92ac --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.spec.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + computeJourneyIpcSerialDepth, + type JourneyIpcTimelineEvent, +} from './journey-ipc-serial-depth'; + +/** `+name` starts a call of `name`, `-name` completes one. */ +function timeline(...events: string[]): JourneyIpcTimelineEvent[] { + return events.map((event) => ({ + method: event.slice(1), + phase: event.startsWith('+') ? 'start' : 'end', + })); +} + +test('an empty timeline has depth 0', () => { + assert.deepEqual(computeJourneyIpcSerialDepth([]), { + chain: [], + depth: 0, + depthLowerBound: 0, + inFlightAtEnd: 0, + }); +}); + +test('parallel calls count as one level', () => { + const result = computeJourneyIpcSerialDepth( + timeline('+a', '+b', '+c', '-b', '-a', '-c') + ); + assert.equal(result.depth, 1); + assert.equal(result.depthLowerBound, 1); + assert.deepEqual(result.chain, ['c']); +}); + +test('each call that starts after a completion adds a level', () => { + const result = computeJourneyIpcSerialDepth( + timeline('+a', '-a', '+b', '-b', '+c', '-c') + ); + assert.equal(result.depth, 3); + assert.deepEqual(result.chain, ['a', 'b', 'c']); +}); + +test('a call started before an earlier call completed does not chain on it', () => { + // b starts while a is in flight, so b is level 1 even though it ends later. + const result = computeJourneyIpcSerialDepth( + timeline('+a', '+b', '-a', '+c', '-b', '-c') + ); + assert.equal(result.depth, 2); + assert.deepEqual(result.chain, ['a', 'c']); +}); + +test('the longest chain wins over a later but shallower one', () => { + const result = computeJourneyIpcSerialDepth( + timeline( + '+a', + '+x', + '-a', + '+b', + '-b', + '+c', + '-c', + // x resolves last but only ever was level 1. + '-x' + ) + ); + assert.equal(result.depth, 3); + assert.deepEqual(result.chain, ['a', 'b', 'c']); +}); + +test('calls still in flight at the end are excluded', () => { + const result = computeJourneyIpcSerialDepth( + timeline('+a', '-a', '+b', '-b', '+c', '+d') + ); + assert.equal(result.depth, 2); + assert.equal(result.inFlightAtEnd, 2); + assert.deepEqual(result.chain, ['a', 'b']); +}); + +test('the chain names the latest completion at the deepest level', () => { + const result = computeJourneyIpcSerialDepth( + timeline('+a', '+b', '-a', '-b', '+c', '-c') + ); + assert.deepEqual(result.chain, ['b', 'c']); +}); + +test('the J1 startup shape measures the recovery chain', () => { + // Shape of the 2026-09-30 macOS trace on master. + const result = computeJourneyIpcSerialDepth( + timeline( + '+announcePlaylistOpenListener', + '+dbGetAppState', + '+dbGetAppState', + '+dbGetAppState', + '+getAppUpdateStatus', + '-announcePlaylistOpenListener', + '-getAppUpdateStatus', + '-dbGetAppState', + '-dbGetAppState', + '-dbGetAppState', + '+dbRecoverLegacyPlaylists', + '-dbRecoverLegacyPlaylists', + '+dbGetAppState', + '-dbGetAppState', + '+dbGetAppPlaylistMetas', + '-dbGetAppPlaylistMetas', + '+reconcileEpgSources', + '-reconcileEpgSources', + '+setParentalLockState', + '-setParentalLockState', + '+downloadsGetList', + '+dbGetRecentlyViewed' + ) + ); + assert.equal(result.depth, 6); + assert.equal(result.depthLowerBound, 6); + assert.equal(result.inFlightAtEnd, 2); + assert.deepEqual(result.chain, [ + 'dbGetAppState', + 'dbRecoverLegacyPlaylists', + 'dbGetAppState', + 'dbGetAppPlaylistMetas', + 'reconcileEpgSources', + 'setParentalLockState', + ]); +}); + +test('concurrent calls of one method at different depths give bounds', () => { + // Two `a` calls are in flight at depths 1 and 2; which one completes + // first is unknown, and only the deeper one would put `c` at depth 3. + const events = timeline('+a', '+b', '-b', '+a', '-a', '+c', '-c', '-a'); + const result = computeJourneyIpcSerialDepth(events); + assert.equal(result.depth, 3); + assert.equal(result.depthLowerBound, 2); + assert.deepEqual(result.chain, ['b', 'a', 'c']); +}); + +test('synchronous calls chain like any other bridge call', () => { + // The preload emits a sync call's completion right after its start. + const result = computeJourneyIpcSerialDepth( + timeline('+a', '-a', '+sync', '-sync', '+b', '-b') + ); + assert.equal(result.depth, 3); +}); + +test('a completion without a start fails the measurement', () => { + assert.throws( + () => computeJourneyIpcSerialDepth(timeline('+a', '-b')), + /journey-ipc-serial-depth-unmatched-end:b/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.ts b/apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.ts new file mode 100644 index 000000000..dc788f548 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-ipc-serial-depth.ts @@ -0,0 +1,109 @@ +/** + * Serial depth of the bridge calls on the way to a journey's end + * (`renderer.ipcSerialDepthToFirstCard`, see performance-journeys.md). + * + * Input is the capture's timeline: one `start` per bridge invocation and one + * `end` per completion, in the order the renderer sent them. The preload + * sends a call's completion before the caller's continuation runs, and + * renderer-to-main IPC is ordered, so a call that the renderer issued + * because another one resolved always appears after that call's `end`. + * + * The depth of a call is 1 + the largest depth of the calls that ended + * before it started. The serial depth is the largest depth among calls that + * ended within the timeline: the length of the longest chain in which each + * call started after the previous one completed. Calls still in flight at the + * end of the timeline are excluded; the journey's end did not wait for them. + * + * Trace events carry no call id. When several calls of one method are in + * flight, a completion is attributed to the deepest of them (`depth`) and, + * in a second pass, to the shallowest (`depthLowerBound`). The two agree + * unless concurrent calls of one method sit at different depths. + * + * `chain` follows one longest chain back from its last call; at each step + * the predecessor is the latest completion at the largest depth before the + * call started. It shows which methods form the chain, not causality: the + * timeline cannot tell which completion a start actually waited for. + */ +export interface JourneyIpcTimelineEvent { + readonly method: string; + readonly phase: 'end' | 'start'; +} + +export interface JourneyIpcSerialDepth { + /** Methods of one longest chain, first call first. */ + readonly chain: readonly string[]; + readonly depth: number; + readonly depthLowerBound: number; + /** Calls started within the timeline that had not completed at its end. */ + readonly inFlightAtEnd: number; +} + +interface TimelineCall { + readonly depth: number; + readonly method: string; + readonly parent: TimelineCall | null; +} + +type Attribution = 'deepest' | 'shallowest'; + +function walk( + timeline: readonly JourneyIpcTimelineEvent[], + attribution: Attribution +): { deepest: TimelineCall | null; inFlight: number } { + const inFlight = new Map(); + let deepest: TimelineCall | null = null; + let inFlightCount = 0; + for (const event of timeline) { + const pending = inFlight.get(event.method) ?? []; + if (event.phase === 'start') { + pending.push({ + depth: (deepest?.depth ?? 0) + 1, + method: event.method, + parent: deepest, + }); + inFlight.set(event.method, pending); + inFlightCount += 1; + continue; + } + if (pending.length === 0) { + throw new Error( + `journey-ipc-serial-depth-unmatched-end:${event.method}` + ); + } + let chosen = 0; + for (let index = 1; index < pending.length; index += 1) { + const better = + attribution === 'deepest' + ? pending[index].depth > pending[chosen].depth + : pending[index].depth < pending[chosen].depth; + if (better) { + chosen = index; + } + } + const [call] = pending.splice(chosen, 1); + inFlightCount -= 1; + // Ties go to the latest completion: the call a later start most + // plausibly waited on, which is what `chain` reports. + if (deepest === null || call.depth >= deepest.depth) { + deepest = call; + } + } + return { deepest, inFlight: inFlightCount }; +} + +export function computeJourneyIpcSerialDepth( + timeline: readonly JourneyIpcTimelineEvent[] +): JourneyIpcSerialDepth { + const upper = walk(timeline, 'deepest'); + const lower = walk(timeline, 'shallowest'); + const chain: string[] = []; + for (let call = upper.deepest; call !== null; call = call.parent) { + chain.unshift(call.method); + } + return Object.freeze({ + chain: Object.freeze(chain), + depth: upper.deepest?.depth ?? 0, + depthLowerBound: lower.deepest?.depth ?? 0, + inFlightAtEnd: upper.inFlight, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts index 00633261b..b369a62c3 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts @@ -6,6 +6,7 @@ import test from 'node:test'; import type { ElectronApplication } from '@playwright/test'; +import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth'; import { assertJourneyMainIpcCapture, countJourneyMainIpcInFlight, @@ -32,6 +33,7 @@ function validCapture( overrides: Partial = {} ): JourneyMainIpcCaptureState { return { + ambiguousTimelineCompletions: 0, callsAfterSentinel: 2, callsBeforeStart: 0, callsBeforeSentinel: 7, @@ -42,6 +44,7 @@ function validCapture( processStartEpochMs: 0, senderIds: [1], sentinel: { occurrences: 1, receivedEpochMs: 2 }, + timeline: [], unmatchedCompletions: 0, start: null, ...overrides, @@ -322,6 +325,89 @@ test('rejects a start marker that is missing, repeated or after the sentinel', a ); }); +test('records starts and completions in order between the markers', async () => { + await withCapture( + { startSentinelId: JOURNEY_OPEN_SOURCE_START_SENTINEL_ID }, + async (fake, read) => { + fake.send(1, 'getSettings', []); + fake.send(1, 'getSettings', [], 'success'); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [ + JOURNEY_OPEN_SOURCE_START_SENTINEL_ID, + ]); + fake.send(1, 'dbGetAppPlaylist', ['playlist-1']); + // The start marker's own completion is not part of the journey. + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [], 'success'); + fake.send(1, 'dbGetAppPlaylist', [], 'success'); + fake.send(1, 'xtreamRequest', [{ action: 'get_account_info' }]); + fake.send(1, 'xtreamRequest', [], 'error'); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [ + JOURNEY_OPEN_SOURCE_END_SENTINEL_ID, + ]); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [], 'success'); + fake.send(1, 'getSettings', []); + const state = assertJourneyMainIpcCapture(await read()); + assert.deepEqual(state.timeline, [ + { method: 'dbGetAppPlaylist', phase: 'start' }, + { method: 'dbGetAppPlaylist', phase: 'end' }, + { method: 'xtreamRequest', phase: 'start' }, + { method: 'xtreamRequest', phase: 'end' }, + ]); + } + ); +}); + +test('leaves out the completion of a call started before the start marker', async () => { + await withCapture( + { startSentinelId: JOURNEY_OPEN_SOURCE_START_SENTINEL_ID }, + async (fake, read) => { + fake.send(1, 'dbGetAppState', []); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [ + JOURNEY_OPEN_SOURCE_START_SENTINEL_ID, + ]); + fake.send(1, 'dbGetAppState', [], 'success'); + fake.send(1, 'xtreamRequest', []); + fake.send(1, 'xtreamRequest', [], 'success'); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [ + JOURNEY_OPEN_SOURCE_END_SENTINEL_ID, + ]); + const state = assertJourneyMainIpcCapture(await read()); + assert.deepEqual(state.timeline, [ + { method: 'xtreamRequest', phase: 'start' }, + { method: 'xtreamRequest', phase: 'end' }, + ]); + assert.doesNotThrow(() => + computeJourneyIpcSerialDepth(state.timeline) + ); + } + ); +}); + +test('attributes an ambiguous marker-method completion outside the timeline', async () => { + await withCapture( + { startSentinelId: JOURNEY_OPEN_SOURCE_START_SENTINEL_ID }, + async (fake, read) => { + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [ + JOURNEY_OPEN_SOURCE_START_SENTINEL_ID, + ]); + // An app call of the marker method overlaps the start marker. + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, ['source-1']); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [], 'success'); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [], 'success'); + fake.send(1, JOURNEY_IPC_SENTINEL_METHOD, [ + JOURNEY_OPEN_SOURCE_END_SENTINEL_ID, + ]); + const state = assertJourneyMainIpcCapture(await read()); + assert.equal(state.ambiguousTimelineCompletions, 1); + // The first completion could be either call; only the second one + // certainly belongs to the app call. + assert.deepEqual(state.timeline, [ + { method: JOURNEY_IPC_SENTINEL_METHOD, phase: 'start' }, + { method: JOURNEY_IPC_SENTINEL_METHOD, phase: 'end' }, + ]); + } + ); +}); + test('tracks bridge calls in flight from start to success or error', async () => { await withCapture({}, async (fake, read) => { fake.send(1, 'getSettings', []); diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts index a8c13ad6d..572ff766d 100644 --- a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts @@ -1,5 +1,7 @@ import type { ElectronApplication } from '@playwright/test'; +import type { JourneyIpcTimelineEvent } from './journey-ipc-serial-depth'; + /** * Main-process side of the journey IPC counter. * @@ -37,6 +39,12 @@ export interface JourneyMainIpcSentinelState { } export interface JourneyMainIpcCaptureState { + /** + * Completions of a method with calls in flight both inside and outside + * the timeline; attributed outside. Non-zero means `timeline` may show + * a call as in flight that already completed. + */ + readonly ambiguousTimelineCompletions: number; readonly callsAfterSentinel: number; /** Calls before the start marker; always 0 without one. */ readonly callsBeforeStart: number; @@ -59,6 +67,12 @@ export interface JourneyMainIpcCaptureState { readonly unmatchedCompletions: number; /** Null when the capture has no start marker. */ readonly start: JourneyMainIpcSentinelState | null; + /** + * Bridge starts and completions in arrival order, from the start marker + * (or install) until the sentinel, sentinels excluded. Input of + * `computeJourneyIpcSerialDepth`. + */ + readonly timeline: JourneyIpcTimelineEvent[]; } export async function installJourneyMainIpcCapture( @@ -72,6 +86,7 @@ export async function installJourneyMainIpcCapture( } const startSentinelId = input.startSentinelId ?? null; const state = { + ambiguousTimelineCompletions: 0, callsAfterSentinel: 0, callsBeforeStart: 0, callsBeforeSentinel: 0, @@ -81,6 +96,7 @@ export async function installJourneyMainIpcCapture( processStartEpochMs: Date.now() - process.uptime() * 1000, inFlightByMethod: {} as Record, senderIds: [] as number[], + timeline: [] as { method: string; phase: 'end' | 'start' }[], sentinel: { occurrences: 0, receivedEpochMs: null as number | null, @@ -102,6 +118,18 @@ export async function installJourneyMainIpcCapture( } }; target[input.stateKey] = state; + // Calls in flight per method, split by whether their start is in + // the timeline. Completions carry no call id, so only these counts + // decide whether a completion belongs to the timeline. + const timelineInFlight: Record = {}; + const outsideInFlight: Record = {}; + const bump = ( + counts: Record, + method: string, + delta: number + ): void => { + counts[method] = (counts[method] ?? 0) + delta; + }; const listener = ( event: { sender: { id: number } }, payload: unknown @@ -115,7 +143,28 @@ export async function installJourneyMainIpcCapture( return; } const phase = record['phase']; + const counting = + state.sentinel.receivedEpochMs === null && + (state.start === null || state.start.receivedEpochMs !== null); if (phase === 'success' || phase === 'error') { + const method = record['method']; + const inTimeline = timelineInFlight[method] ?? 0; + const outside = outsideInFlight[method] ?? 0; + if (inTimeline > 0 && outside > 0) { + // Either call may have completed. Attribute it outside, + // so the timeline call stays in flight (excluded from + // the depth) rather than ending too early. + bump(outsideInFlight, method, -1); + state.ambiguousTimelineCompletions += 1; + } else if (inTimeline > 0) { + bump(timelineInFlight, method, -1); + if (counting) { + state.timeline.push({ method, phase: 'end' }); + } + } else if (outside > 0) { + // Started before the start marker, or a marker itself. + bump(outsideInFlight, method, -1); + } const pending = state.inFlightByMethod[record['method']] ?? 0; if (pending === 0) { state.unmatchedCompletions += 1; @@ -138,6 +187,12 @@ export async function installJourneyMainIpcCapture( } const method = record['method']; const isMarker = method === input.sentinelMethod; + if (!counting || isMarker) { + // Markers and calls outside the counting window stay out of + // the timeline; an app call of the marker method moves in + // below. + bump(outsideInFlight, method, 1); + } if ( isMarker && state.start !== null && @@ -166,6 +221,13 @@ export async function installJourneyMainIpcCapture( return; } state.callsBeforeSentinel += 1; + if (isMarker) { + // An app call of the marker method: counted, and moved from + // outside to the timeline. + bump(outsideInFlight, method, -1); + } + bump(timelineInFlight, method, 1); + state.timeline.push({ method, phase: 'start' }); state.callsByMethod[method] = (state.callsByMethod[method] ?? 0) + 1; }; 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 e2b81858a..1b3ad50fd 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 @@ -77,6 +77,7 @@ function measurement( }, }; const ipc: JourneyMainIpcCaptureState = { + ambiguousTimelineCompletions: 0, callsAfterSentinel: 3, callsBeforeStart: 0, callsBeforeSentinel: 14, @@ -88,6 +89,13 @@ function measurement( senderIds: [1], sentinel: { occurrences: 1, receivedEpochMs: 2_602 }, start: null, + timeline: [ + { method: 'getSettings', phase: 'start' }, + { method: 'getSettings', phase: 'end' }, + { method: 'dbGetAppPlaylists', phase: 'start' }, + { method: 'getSettings', phase: 'start' }, + { method: 'dbGetAppPlaylists', phase: 'end' }, + ], unmatchedCompletions: 0, }; return { @@ -132,6 +140,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- 'main.sqlStatementsBeforeReadyToShow': 9, 'renderer.domMutationsToFirstCard': 480, 'renderer.ipcCallsToFirstCard': 14, + 'renderer.ipcSerialDepthToFirstCard': 2, 'renderer.layoutShiftScore': 0.123, 'renderer.layoutShiftScoreSettled': 0.23, 'renderer.longTasks': 2, @@ -144,6 +153,19 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- dbGetAppPlaylists: 1, getSettings: 13, }); + assert.deepEqual(record.evidence['ipcSerialDepth'], { + chain: ['getSettings', 'dbGetAppPlaylists'], + depth: 2, + depthLowerBound: 2, + inFlightAtEnd: 1, + }); + assert.deepEqual(record.evidence['ipcTimeline'], [ + '+getSettings', + '-getSettings', + '+dbGetAppPlaylists', + '+getSettings', + '-dbGetAppPlaylists', + ]); assert.deepEqual(record.evidence['longTaskDurationsMs'], [71.3, 120]); assert.equal(record.evidence['ipcCallsAfterFirstCard'], 3); assert.deepEqual(record.evidence['mainCountersAtRead'], { diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index 139f045e3..f0e02f86d 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -3,6 +3,7 @@ import { JOURNEY_MAIN_COUNTER, type JourneyMainCountersState, } from './journey-main-counters'; +import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth'; import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; import type { JourneyRendererProbeState } from './journey-renderer-probe'; import type { JourneyIterationRecord } from './journey-summary'; @@ -21,6 +22,7 @@ export const LAUNCH_JOURNEY_COUNTER = { JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW, DOM_MUTATIONS: 'renderer.domMutationsToFirstCard', IPC_CALLS: 'renderer.ipcCallsToFirstCard', + IPC_SERIAL_DEPTH: 'renderer.ipcSerialDepthToFirstCard', LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore', LAYOUT_SHIFT_SCORE_SETTLED: 'renderer.layoutShiftScoreSettled', LONG_TASKS: 'renderer.longTasks', @@ -83,6 +85,7 @@ export function toLaunchIterationRecord( ) { throw new Error('launch-journey-record-clock-order'); } + const serialDepth = computeJourneyIpcSerialDepth(ipc.timeline); const { settle } = renderer; if ( (settle.status !== 'quiet' && settle.status !== 'cap') || @@ -113,6 +116,7 @@ export function toLaunchIterationRecord( [LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]: renderer.counters.domMutations, [LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel, + [LAUNCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: serialDepth.depth, [LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth( renderer.counters.layoutShiftScore ), @@ -155,6 +159,13 @@ export function toLaunchIterationRecord( rendererGateReadyToShowHeldOnBlank: measurement.gate.readyToShowHeldOnBlank, ipcCallsByMethod: ipc.callsByMethod, + ipcSerialDepth: serialDepth, + ipcTimelineAmbiguousCompletions: ipc.ambiguousTimelineCompletions, + // `+method` for a start, `-method` for a completion. + ipcTimeline: ipc.timeline.map( + ({ method, phase }) => + `${phase === 'start' ? '+' : '-'}${method}` + ), longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth), observedTarget: renderer.capabilities.observedTarget, settle: Object.freeze({ 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 779eb4178..93f7fdb4e 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 @@ -77,6 +77,7 @@ function measurement( }, }; const ipc: JourneyMainIpcCaptureState = { + ambiguousTimelineCompletions: 0, callsAfterSentinel: 2, callsBeforeStart: 0, callsBeforeSentinel: 17, @@ -88,6 +89,7 @@ function measurement( senderIds: [1], sentinel: { occurrences: 1, receivedEpochMs: 10_081 }, start: { occurrences: 1, receivedEpochMs: 10_002 }, + timeline: [], unmatchedCompletions: 0, }; return { diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 26c7cb0ab..8f65e3b8b 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -108,6 +108,7 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`: | 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.ipcSerialDepthToFirstCard` | Length of the longest chain of bridge calls before the sentinel in which each call started after the previous one completed. Derived from the same trace channel; see [Serial IPC depth](#serial-ipc-depth). | | `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. | @@ -248,6 +249,55 @@ instead of being faked: terminal moment and the record refuses a build where the hook exists but was not counted. +#### Serial IPC depth + +`renderer.ipcCallsToFirstCard` counts calls, but calls issued in parallel +cost one round trip, and #1716 showed that lowering the count did not move +wall-clock. `renderer.ipcSerialDepthToFirstCard` counts the round trips the +renderer made one after another instead. + +The capture records every `start` and every completion (`success` or +`error`) the preload traces, in arrival order, from install until the +sentinel (`timeline` in the capture state). The preload traces a completion +inside the wrapper's `then`, before the caller's own continuation runs, and +renderer-to-main IPC is ordered, so a call the renderer issued because +another call resolved always arrives after that call's completion. +`computeJourneyIpcSerialDepth` (`src/performance/journey-ipc-serial-depth.ts`) +then defines: + +- the depth of a call is 1 plus the largest depth of the calls that + completed before it started (1 when none had); +- the counter is the largest depth of a call that completed before the + sentinel. A call still in flight at the first card is excluded: the card + did not wait for it. Every bridge call counts, including a synchronous one, + as `renderer.ipcCallsToFirstCard` does. + +Trace events carry no call id, so when several calls of one method are in +flight the capture cannot tell which one completed. The counter attributes +each completion to the deepest in-flight call of that method (an upper +bound); `evidence.ipcSerialDepth.depthLowerBound` attributes it to the +shallowest. The two differ only when concurrent calls of one method sit at +different depths. A completion with no matching start fails the iteration. + +With a start marker (J2) the timeline starts mid-run, so the capture keeps +calls that started outside it (before the marker, and the markers +themselves) apart: their completions are left out. When a method has calls +in flight both inside and outside the timeline, a completion is attributed +outside, which leaves the timeline call in flight (excluded from the depth) +rather than ending it too early; `evidence.ipcTimelineAmbiguousCompletions` +counts these. + +Per iteration, `evidence.ipcSerialDepth.chain` names the methods of one +longest chain, first call first (at each step the predecessor is the latest +completion at the largest depth), `inFlightAtEnd` counts the calls excluded +as in flight, and `evidence.ipcTimeline` is the whole ordered timeline +(`+method` start, `-method` completion). The CI job summary prints the chain +of the first measured iteration. The chain is ordering, not proven +causality: a call placed in it may have been triggered by a timer or signal +rather than by its predecessor. [Startup work before the first +card](#startup-work-before-the-first-card) records what the chain is on +`master`. + ### Wall-clock | Entry | Derivation | @@ -290,6 +340,33 @@ 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. +`renderer.ipcSerialDepthToFirstCard` is that counter. First measurement +(macOS, 2026-09-30, `master` at 525ca7bc4, six launches): 6 in every +iteration, upper and lower bound equal, with the same chain each time: + +``` +dbGetAppState → dbRecoverLegacyPlaylists → dbGetAppState + → dbGetAppPlaylistMetas → reconcileEpgSources → setParentalLockState +``` + +The first level is three parallel `dbGetAppState` reads (with +`announcePlaylistOpenListener` and `getAppUpdateStatus`); the four calls +started after `setParentalLockState` resolved (`downloadsGetList`, +`dbGetRecentlyViewed`, `dbGetAllGlobalFavorites`, `xtreamRequest`) are +still in flight at the first card and excluded. + +The chain is ordering, and its last link shows the limit of that: nothing +on the card's path awaits `setParentalLockState`. The parental lock +service fires it (without awaiting) once `SettingsStore.loadSettings()` +has resolved, which happens only after `reconcileEpgSources`, and it +completes before the card in every measured launch. The links the card +waits for are the first five: the route resolver +(`settingsReadyResolver`) and the startup overlay (`allPlaylistsLoaded`, +set by the `loadPlaylists$` effect) both wait for `loadSettings()`, which +waits for the playlist migrations, the inventory read and +`reconcileEpgSources`. No baseline yet: the counter is promoted only after a +PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3). + ### Summary schema ```json From e4cf48fdc260f890f887660df8a559f30d155582 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:23:15 +0200 Subject: [PATCH 5/7] test(perf): count change-detection ticks in the electron-performance build (#1776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(perf): count change-detection ticks in the electron-performance build J1 renderer.cdTicksToFirstCard, J2 renderer.cdTicksToFirstPage and the J1 idle baseline renderer.cdTicksIdle30s. Angular's ɵsetProfiler is only reachable through the dev-mode window.ng global, so the electron-performance configuration alone swaps environment.ts for environment.performance.ts, which re-exports the production AppConfig and wraps ApplicationRef._tick. Production and PWA sources and output are unchanged. Co-Authored-By: Claude Opus 5.5 * test(perf): refuse a J1 idle window that opened late after the settle point Addresses review: the idle window opens in the settle timer's callback while the settle point is that timer's deadline, so a late callback left ticks uncounted between the two. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../src/journeys/launch-journey-app.ts | 21 ++- .../src/journeys/open-source.journey.ts | 2 +- .../src/journeys/playback.journey.ts | 2 +- .../performance/journey-main-counters.spec.ts | 4 +- .../journey-renderer-probe.spec.ts | 104 +++++++++++- .../journey-renderer-probe.test-helpers.ts | 4 +- .../src/performance/journey-renderer-probe.ts | 151 ++++++++++++++++-- .../performance/launch-journey-record.spec.ts | 103 +++++++++++- .../src/performance/launch-journey-record.ts | 97 +++++++++-- .../open-source-journey-record.spec.ts | 21 ++- .../performance/open-source-journey-record.ts | 11 +- .../performance-build-config.spec.ts | 56 ++++++- .../playback-journey-record.spec.ts | 21 ++- .../performance/playback-journey-record.ts | 11 +- apps/web/project.json | 2 +- .../change-detection-tick-counter.spec.ts | 72 +++++++++ .../change-detection-tick-counter.ts | 58 +++++++ .../environments/environment.performance.ts | 8 + docs/architecture/idle-work-audit-2026-09.md | 4 + docs/architecture/performance-journeys.md | 107 +++++++++++-- 20 files changed, 778 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/environments/change-detection-tick-counter.spec.ts create mode 100644 apps/web/src/environments/change-detection-tick-counter.ts create mode 100644 apps/web/src/environments/environment.performance.ts diff --git a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts index 10f1bcb12..6b58597fb 100644 --- a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts @@ -39,6 +39,7 @@ import { import { createLaunchJourneyProbeOptions, installJourneyRendererProbe, + JOURNEY_IDLE_WINDOW_MS, waitForJourneyRendererProbe, } from '../performance/journey-renderer-probe'; import { @@ -132,6 +133,15 @@ export function removeLaunchJourneyProfile(directory: string): Promise { return removeDirectory(directory); } +/** + * How a journey launch is instrumented: the process flags, plus J1's idle + * window after the settle point (null skips it, so a journey that continues + * from the launch does not wait 30 s before its own start). + */ +export interface LaunchJourneyOptions extends JourneyLaunchInstrumentation { + readonly idleWindowMs: number | null; +} + /** The running app after J1 ended, for journeys that continue from there. */ export interface LaunchJourneySession { readonly electronApp: ElectronApplication; @@ -146,7 +156,7 @@ export async function measureLaunchJourney( const { launch } = await runLaunchJourney( templateDirectory, timeoutMs, - { mainCounters: true }, + { idleWindowMs: JOURNEY_IDLE_WINDOW_MS, mainCounters: true }, async () => undefined ); return launch; @@ -159,14 +169,15 @@ export async function measureLaunchJourney( * installed next, and only then is the real load released. Both captures are * therefore in place before the renderer runs any script, and the probe, * capture and gate records still prove it. `continueJourney` runs in the - * same process after J1's counters are final, before the app is closed. + * same process after J1's counters are final (and after its idle window, + * when one is requested), before the app is closed. * Without `instrumentation.mainCounters` the main-process counters and SQL * counting stay off and `launch.mainCounters` is null. */ export async function runLaunchJourney( templateDirectory: string, timeoutMs: number, - instrumentation: JourneyLaunchInstrumentation, + instrumentation: LaunchJourneyOptions, continueJourney: (session: LaunchJourneySession) => Promise ): Promise<{ readonly continuation: T; @@ -189,7 +200,9 @@ export async function runLaunchJourney( const electronApp = await electron.launch({ args, env }); captureElectronProcess(electronApp); try { - const probeOptions = createLaunchJourneyProbeOptions(); + const probeOptions = createLaunchJourneyProbeOptions( + instrumentation.idleWindowMs + ); // The gate parks the window on about:blank, so this resolves // before the real document exists. const mainWindow = await electronApp.firstWindow(); diff --git a/apps/electron-backend-e2e/src/journeys/open-source.journey.ts b/apps/electron-backend-e2e/src/journeys/open-source.journey.ts index 499e1e016..4e49e9a09 100644 --- a/apps/electron-backend-e2e/src/journeys/open-source.journey.ts +++ b/apps/electron-backend-e2e/src/journeys/open-source.journey.ts @@ -52,7 +52,7 @@ test('J2 open a source', async () => { JOURNEY_ITERATION_TIMEOUT_MS, // J2 does not read J1's main-process counters, so their // SQL instrumentation stays off during the click. - { mainCounters: false }, + { idleWindowMs: null, mainCounters: false }, (session) => measureOpenSourceJourney( session, diff --git a/apps/electron-backend-e2e/src/journeys/playback.journey.ts b/apps/electron-backend-e2e/src/journeys/playback.journey.ts index a4c8c42e5..5759dd80d 100644 --- a/apps/electron-backend-e2e/src/journeys/playback.journey.ts +++ b/apps/electron-backend-e2e/src/journeys/playback.journey.ts @@ -57,7 +57,7 @@ test('J3 start playback', async () => { templateDirectory, JOURNEY_ITERATION_TIMEOUT_MS, // Like J2: no main-process counters or SQL hook. - { mainCounters: false }, + { idleWindowMs: null, mainCounters: false }, (session) => measurePlaybackJourney( session, 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 d32fb7227..7b02bc5f0 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 @@ -159,13 +159,13 @@ test('only the launch journey opts into SQL statement counting', () => { assert.equal(launchApp.match(/mainCounters:\s*true/g)?.length, 1); assert.match( launchApp, - /export async function measureLaunchJourney\([\s\S]*?\{ mainCounters: true \}[\s\S]*?\n\}/ + /export async function measureLaunchJourney\([\s\S]*?\{ idleWindowMs: JOURNEY_IDLE_WINDOW_MS, mainCounters: true \}[\s\S]*?\n\}/ ); assert.match( readFileSync( join(sourceRoot, 'journeys', 'open-source.journey.ts'), 'utf8' ), - /\{ mainCounters: false \}/ + /\{ idleWindowMs: null, mainCounters: false \}/ ); }); 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 58e64fb77..c42b35f59 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 @@ -7,6 +7,8 @@ import { assertJourneyRendererProbeState, createLaunchJourneyProbeOptions, createOpenSourceJourneyProbeOptions, + JOURNEY_CD_TICK_COUNTER_KEY, + JOURNEY_IDLE_WINDOW_MS, JOURNEY_IPC_SENTINEL_ID, JOURNEY_IPC_SENTINEL_METHOD, JOURNEY_OPEN_SOURCE_END_SENTINEL_ID, @@ -57,7 +59,7 @@ function createFixture( return createFixtureFromDom( dom, { - ...createLaunchJourneyProbeOptions(), + ...createLaunchJourneyProbeOptions(null), settle: FAST_SETTLE, ...optionOverrides, }, @@ -65,6 +67,19 @@ function createFixture( ); } +/** + * The electron-performance build's tick counter, installed in the fixture's + * window the way `environment.performance.ts` installs it before bootstrap. + */ +function installTickCounter(fixture: Fixture): { count: number } { + const counter = { count: 0 }; + Object.defineProperty(fixture.window, JOURNEY_CD_TICK_COUNTER_KEY, { + configurable: true, + value: counter, + }); + return counter; +} + function renderFirstCard(fixture: Fixture): void { const { document } = fixture.window; document.getElementById('initial-splash')?.remove(); @@ -99,7 +114,7 @@ test('installs once per document', () => { const fixture = createFixture(); const first = fixture.rawState(); fixture.window.eval( - `(${journeyRendererProbeScript.toString()})(${JSON.stringify(createLaunchJourneyProbeOptions())})` + `(${journeyRendererProbeScript.toString()})(${JSON.stringify(createLaunchJourneyProbeOptions(null))})` ); assert.equal(fixture.rawState(), first); }); @@ -134,10 +149,12 @@ test('counts mutation records until the first card is visible after the splash i state.navigation?.loadEventEndEpochMs, fixture.window.performance.timeOrigin + 120 ); + // No tick counter in this build: reported, never zero. assert.equal( state.capabilities.changeDetectionTicks, - 'unavailable-ng-global-not-published' + 'unavailable-counter-missing' ); + assert.equal(state.counters.changeDetectionTicks, null); assert.ok(fixture.observers.every((observer) => observer.disconnected)); appRoot.append(document.createElement('div')); @@ -506,8 +523,66 @@ test('rejects a probe whose performance observers were unavailable instead of re ); }); +test('counts change-detection ticks from document start until the terminal batch', async () => { + const fixture = createFixture(); + const counter = installTickCounter(fixture); + counter.count += 3; + renderFirstCard(fixture); + // The tick that rendered the card ran before the observer's microtask. + counter.count += 1; + await settle(); + counter.count += 5; + const state = fixture.state(); + assert.equal(state.capabilities.changeDetectionTicks, 'counted'); + assert.equal(state.counters.changeDetectionTicks, 4); + assert.equal(state.idle.status, 'disabled'); + assert.equal(state.idle.ticks, null); +}); + +test('counts ticks and mutations in the idle window that opens at the settle point', async () => { + const fixture = createFixture({ idle: { durationMs: 80 } }); + const counter = installTickCounter(fixture); + renderFirstCard(fixture); + counter.count += 2; + await waitFor(() => fixture.rawState().idle.startEpochMs !== null); + const opened = fixture.state(); + assert.equal(opened.idle.status, 'pending'); + assert.ok(opened.settle.epochMs !== null); + assert.ok((opened.idle.startEpochMs ?? 0) >= opened.settle.epochMs); + assert.throws( + () => assertJourneyRendererProbeState(opened), + /idle-pending/ + ); + counter.count += 7; + fixture.window.document.body.append( + fixture.window.document.createElement('div') + ); + await waitFor(() => fixture.rawState().idle.status === 'done'); + counter.count += 100; + const state = fixture.state(); + assert.equal(state.counters.changeDetectionTicks, 2); + assert.equal(state.idle.ticks, 7); + assert.equal(state.idle.domMutations, 1); + assert.ok( + (state.idle.endEpochMs ?? 0) - (state.idle.startEpochMs ?? 0) >= 79 + ); + assert.doesNotThrow(() => assertJourneyRendererProbeState(state)); +}); + +test('launch options add the idle window only when asked', () => { + assert.equal(createLaunchJourneyProbeOptions(null).idle, undefined); + assert.deepEqual( + createLaunchJourneyProbeOptions(JOURNEY_IDLE_WINDOW_MS).idle, + { durationMs: 30_000 } + ); + assert.equal( + createLaunchJourneyProbeOptions(null).cdTickCounterKey, + '__iptvnatorCdTicks' + ); +}); + test('launch options target the workspace source cards and the shared sentinel', () => { - const options = createLaunchJourneyProbeOptions(); + const options = createLaunchJourneyProbeOptions(null); assert.equal(options.stateKey, JOURNEY_PROBE_STATE_KEY); assert.equal(options.sentinelMethod, 'cancelSourceProbe'); assert.equal(options.splashId, 'initial-splash'); @@ -631,6 +706,10 @@ test('the click sends the start sentinel before the app sees it and the end sent assert.equal(state.navigation, null); assert.equal(state.final, true); assert.ok(state.terminal.epochMs >= state.start.epochMs); + assert.equal( + state.capabilities.changeDetectionTicks, + 'unavailable-counter-missing' + ); assert.doesNotThrow(() => assertJourneyRendererProbeState(state)); // One start per armed probe. @@ -640,6 +719,23 @@ test('the click sends the start sentinel before the app sees it and the end sent assert.equal(fixture.bridgeCalls.length, 2); }); +test('counts ticks from the click, not from document start', async () => { + const fixture = createOpenSourceFixture(); + const counter = installTickCounter(fixture); + counter.count = 40; + fixture.card.addEventListener('click', () => { + counter.count += 1; + openSource(fixture); + counter.count += 1; + }); + fixture.card.click(); + await settle(); + counter.count += 10; + const state = fixture.state(); + assert.equal(state.capabilities.changeDetectionTicks, 'counted'); + assert.equal(state.counters.changeDetectionTicks, 2); +}); + test('the first page needs the category list as well as the items', async () => { const fixture = createOpenSourceFixture(); fixture.card.addEventListener('click', () => diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts index d1cb41e7d..799aeeb48 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.test-helpers.ts @@ -155,7 +155,9 @@ export async function settle(ms = 40): Promise { const state = read(); return ( state.terminal !== null && - (!state.final || state.settle.status === 'pending') + (!state.final || + state.settle.status === 'pending' || + state.idle.status === 'pending') ); }) && Date.now() < deadline 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 86af2e664..71a321a23 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -14,10 +14,18 @@ import type { Page } from '@playwright/test'; * layout shifts and long tasks until the journey's terminal condition and * then emits one JSON blob under `options.stateKey`. With `options.settle` * (J1) it keeps summing layout shifts after the first card until the page - * has settled, for shifts such as collapsing skeletons that land later. - * With `options.media` (J3 "Playback") the terminal condition is a media - * event (`playing`) on an element matching `cardSelector` instead of that - * element becoming visible. + * has settled, for shifts such as collapsing skeletons that land later, and + * with `options.idle` it then counts what the untouched page does for a + * fixed window. With `options.media` (J3 "Playback") the terminal condition + * is a media event (`playing`) on an element matching `cardSelector` instead + * of that element becoming visible. + * + * Change-detection ticks are read from the counter that only the + * `electron-performance` build of `apps/web` installs + * (`apps/web/src/environments/change-detection-tick-counter.ts`) under + * `options.cdTickCounterKey`. The probe takes differences of that running + * total at the journey's boundaries; a build without the counter fails the + * iteration in the journey record. * * IPC invocations are not counted here: the bridge object exposed by * `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe @@ -94,11 +102,32 @@ export const JOURNEY_SETTLE_CAP_MS = 3_000; /** The workspace shell's content pane; the rail and header stay outside. */ export const JOURNEY_SETTLE_ROOT_SELECTOR = 'main.workspace-content'; +/** + * Global the electron-performance build's tick counter lives under; the + * build-config spec checks it against + * `CHANGE_DETECTION_TICK_COUNTER_KEY` in apps/web. + */ +export const JOURNEY_CD_TICK_COUNTER_KEY = '__iptvnatorCdTicks'; + +/** + * J1's idle window: it opens at the settle point and counts what the page + * does, with no input, for `durationMs`. See performance-journeys.md. + */ +export interface JourneyRendererProbeIdleOptions { + readonly durationMs: number; +} + +export const JOURNEY_IDLE_WINDOW_MS = 30_000; + export interface JourneyRendererProbeOptions { /** Selector for the element whose visibility ends the journey. */ readonly cardSelector: string; + /** Global holding the build's change-detection tick counter. */ + readonly cdTickCounterKey: string; /** Further selectors that must each match a visible element as well. */ readonly companionSelectors?: readonly string[]; + /** Absent: no idle window. Needs `settle`, which it follows. */ + readonly idle?: JourneyRendererProbeIdleOptions; readonly journey: string; /** Absent: the journey ends when `cardSelector` becomes visible. */ readonly media?: JourneyRendererProbeMediaOptions; @@ -116,6 +145,11 @@ export interface JourneyRendererProbeOptions { } export interface JourneyRendererProbeCounters { + /** + * `ApplicationRef` ticks from the journey's start until the terminal + * batch. Null when the build has no tick counter. + */ + changeDetectionTicks: number | null; domMutations: number; /** Shifts with `hadRecentInput === false` (the CLS definition). */ layoutShiftScore: number; @@ -146,7 +180,8 @@ export interface JourneyRendererProbeLateShift { export interface JourneyRendererProbeState { readonly capabilities: { - changeDetectionTicks: string; + changeDetectionTicks: + 'counted' | 'pending' | 'unavailable-counter-missing'; layoutShift: boolean; longTask: boolean; observedTarget: 'document' | 'documentElement'; @@ -154,6 +189,16 @@ export interface JourneyRendererProbeState { readonly counters: JourneyRendererProbeCounters; final: boolean; firstCardPaintEpochMs: number | null; + /** The idle window after the settle point (J1). */ + idle: { + /** Mutation records in the whole document during the window. */ + domMutations: number; + endEpochMs: number | null; + startEpochMs: number | null; + status: 'disabled' | 'done' | 'pending'; + /** Ticks during the window; null without a tick counter. */ + ticks: number | null; + }; readonly installed: { readonly bridgePresent: boolean; readonly documentElementPresent: boolean; @@ -240,6 +285,7 @@ export function journeyRendererProbeScript( const startClick = options.startClick ?? null; const mediaOptions = options.media ?? null; const settleOptions = options.settle ?? null; + const idleOptions = options.idle ?? null; const companionSelectors = options.companionSelectors ?? []; const bridge = target['electron'] as Record | undefined; const state: JourneyRendererProbeState = { @@ -252,6 +298,7 @@ export function journeyRendererProbeScript( : 'document', }, counters: { + changeDetectionTicks: null, domMutations: 0, layoutShiftScore: 0, layoutShiftScoreSettled: 0, @@ -260,6 +307,13 @@ export function journeyRendererProbeScript( }, final: false, firstCardPaintEpochMs: null, + idle: { + domMutations: 0, + endEpochMs: null, + startEpochMs: null, + status: idleOptions === null ? 'disabled' : 'pending', + ticks: null, + }, installed: { bridgePresent: typeof bridge === 'object' && bridge !== null, documentElementPresent: document.documentElement !== null, @@ -287,6 +341,15 @@ export function journeyRendererProbeScript( terminal: null, }; target[options.stateKey] = state; + // The build installs its counter while main.js evaluates, before + // Angular bootstraps, so J1 starts from zero; a click start reads the + // running total at the click. + const readTicks = (): number | null => { + const counter = target[options.cdTickCounterKey] as + { count?: unknown } | undefined; + return typeof counter?.count === 'number' ? counter.count : null; + }; + let ticksAtStart: number | null = startClick === null ? 0 : null; if ( startClick === null && (state.installed.scriptCount > 0 || @@ -439,6 +502,33 @@ export function journeyRendererProbeScript( state.counters.layoutShiftScoreSettled = score; state.settle.epochMs = untilEpochMs; state.settle.status = status; + if (idleOptions !== null) startIdle(idleOptions); + }; + // Opens when the settle window closes, so startup work that is still + // landing does not count as idle work. Nothing touches the page. + const startIdle = (idle: JourneyRendererProbeIdleOptions): void => { + const idleObserver = new MutationObserver((records) => { + state.idle.domMutations += records.length; + }); + idleObserver.observe(document.documentElement ?? document, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + const startTicks = readTicks(); + state.idle.startEpochMs = epoch(); + setTimeout(() => { + const endTicks = readTicks(); + state.idle.domMutations += idleObserver.takeRecords().length; + idleObserver.disconnect(); + state.idle.endEpochMs = epoch(); + state.idle.ticks = + startTicks === null || endTicks === null + ? null + : endTicks - startTicks; + state.idle.status = 'done'; + }, idle.durationMs); }; type LateShiftSource = { currentRect?: { height: number; y: number }; @@ -626,11 +716,17 @@ export function journeyRendererProbeScript( ); } } - const ng = target['ng'] as Record | undefined; - state.capabilities.changeDetectionTicks = - typeof ng?.['ɵsetProfiler'] === 'function' - ? 'hook-present-not-counted' - : 'unavailable-ng-global-not-published'; + // A tick that rendered the card ran before this microtask (or, + // for a media terminal, before the event's task), so it is + // included; no other tick can run in between. + const ticks = readTicks(); + if (ticks === null || ticksAtStart === null) { + state.capabilities.changeDetectionTicks = + 'unavailable-counter-missing'; + } else { + state.capabilities.changeDetectionTicks = 'counted'; + state.counters.changeDetectionTicks = ticks - ticksAtStart; + } // A rAF callback runs before that frame's style, layout and paint, // so the cutoff is sampled in a timer queued from it: by then the // frame that paints the card has been committed, and the render @@ -660,6 +756,8 @@ export function journeyRendererProbeScript( const listenerEpochMs = epoch(); const eventEpochMs = performance.timeOrigin + event.timeStamp; countPreStart(mutationObserver.takeRecords().length); + // Capture phase: no tick for this click has run yet. + ticksAtStart = readTicks(); const sentinelStatus = callSentinel(startClick.sentinelId); state.start = { epochMs: @@ -715,10 +813,21 @@ export function journeyRendererProbeScript( } } -export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions { +/** + * Options for J1. `idleWindowMs` adds the idle window after the settle point; + * journeys that continue from the launch (J2) pass null so their click does + * not wait for it. + */ +export function createLaunchJourneyProbeOptions( + idleWindowMs: number | null +): JourneyRendererProbeOptions { return { cardSelector: '[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item', + cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, + ...(idleWindowMs === null + ? {} + : { idle: { durationMs: idleWindowMs } }), journey: 'launch', routeFragment: '/workspace', sentinelId: JOURNEY_IPC_SENTINEL_ID, @@ -744,6 +853,7 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio // cards and live channel rows; skeleton cards are not matched. cardSelector: 'app-grid-list mat-card, .content-card, [data-test-id="channel-item"]', + cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, companionSelectors: ['app-workspace-context-panel .category-item'], journey: 'open-source', routeFragment: '/workspace/xtreams/', @@ -766,6 +876,7 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio export function createPlaybackJourneyProbeOptions(): JourneyRendererProbeOptions { return { cardSelector: JOURNEY_PLAYBACK_VIDEO_SELECTOR, + cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY, journey: 'playback', media: { endEvent: 'playing', phaseEvents: ['loadedmetadata'] }, routeFragment: '/workspace/xtreams/', @@ -810,16 +921,25 @@ export async function waitForJourneyRendererProbe( stateKey: string, timeoutMs: number ): Promise { - // A settle window, where enabled, ends after `final`. + // A settle window, where enabled, ends after `final`, and an idle + // window after that. await page.waitForFunction( (key) => { const state = ( globalThis as unknown as Record< string, - { final?: boolean; settle?: { status?: string } } + { + final?: boolean; + idle?: { status?: string }; + settle?: { status?: string }; + } > )[key]; - return state?.final === true && state.settle?.status !== 'pending'; + return ( + state?.final === true && + state.settle?.status !== 'pending' && + state.idle?.status !== 'pending' + ); }, stateKey, { polling: 50, timeout: timeoutMs } @@ -851,6 +971,9 @@ export function assertJourneyRendererProbeState( if (state.settle.status === 'pending') { throw new Error('journey-renderer-probe-settle-pending'); } + if (state.idle.status === 'pending') { + throw new Error('journey-renderer-probe-idle-pending'); + } if (state.invalidReasons.length > 0) { throw new Error( `journey-renderer-probe-invalid: ${state.invalidReasons.join(', ')}` 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 1b3ad50fd..02bfe3d21 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 @@ -14,12 +14,13 @@ function measurement( ): LaunchJourneyMeasurement { const renderer: JourneyRendererProbeState = { capabilities: { - changeDetectionTicks: 'unavailable-ng-global-not-published', + changeDetectionTicks: 'counted', layoutShift: true, longTask: true, observedTarget: 'document', }, counters: { + changeDetectionTicks: 23, domMutations: 480, layoutShiftScore: 0.123456789, layoutShiftScoreSettled: 0.2304999, @@ -28,6 +29,13 @@ function measurement( }, final: true, firstCardPaintEpochMs: 2_650, + idle: { + domMutations: 12, + endEpochMs: 33_200.04, + startEpochMs: 3_200, + status: 'done', + ticks: 31, + }, installed: { bridgePresent: true, documentElementPresent: false, @@ -138,6 +146,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- assert.deepEqual(record.counters, { 'main.modulesRegisteredBeforeWindow': 2, 'main.sqlStatementsBeforeReadyToShow': 9, + 'renderer.cdTicksIdle30s': 31, + 'renderer.cdTicksToFirstCard': 23, 'renderer.domMutationsToFirstCard': 480, 'renderer.ipcCallsToFirstCard': 14, 'renderer.ipcSerialDepthToFirstCard': 2, @@ -208,6 +218,11 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn- observedTarget: 'root', reason: 'quiet', }); + assert.deepEqual(record.evidence['idle'], { + domMutations: 12, + durationMs: 30_000, + settledToIdleStartMs: 19.9, + }); }); test('rejects measurements whose clocks or probes are inconsistent', () => { @@ -240,11 +255,85 @@ test('rejects measurements whose clocks or probes are inconsistent', () => { ...base.renderer, capabilities: { ...base.renderer.capabilities, - changeDetectionTicks: 'hook-present-not-counted', + changeDetectionTicks: 'unavailable-counter-missing', + }, + counters: { + ...base.renderer.counters, + changeDetectionTicks: null, }, }, }), - /cd-hook-hook-present-not-counted/ + /cd-ticks-unavailable-counter-missing/ + ); +}); + +test('refuses a launch without a complete, on-time idle window after the settle point', () => { + const base = measurement(); + const withIdle = ( + idle: Partial + ): LaunchJourneyMeasurement => ({ + ...base, + renderer: { + ...base.renderer, + idle: { ...base.renderer.idle, ...idle }, + }, + }); + // J2's launches skip the window; such a launch is not a J1 measurement. + assert.throws( + () => + toLaunchIterationRecord( + 0, + false, + withIdle({ status: 'disabled', ticks: null }) + ), + /idle-disabled/ + ); + assert.throws( + () => toLaunchIterationRecord(0, false, withIdle({ ticks: null })), + /idle-done/ + ); + assert.throws( + () => + toLaunchIterationRecord( + 0, + false, + withIdle({ endEpochMs: 33_000, startEpochMs: 3_000 }) + ), + /idle-before-settle/ + ); + // The settle point is 3_180.06: a window opened 120 ms after it left + // ticks uncounted in between. + assert.throws( + () => + toLaunchIterationRecord( + 0, + false, + withIdle({ endEpochMs: 33_300.1, startEpochMs: 3_300.1 }) + ), + /idle-start-late/ + ); + assert.doesNotThrow(() => + toLaunchIterationRecord( + 0, + false, + withIdle({ endEpochMs: 33_250, startEpochMs: 3_250 }) + ) + ); + assert.throws( + () => + toLaunchIterationRecord(0, false, withIdle({ endEpochMs: 33_000 })), + /idle-window-short/ + ); + assert.throws( + () => + toLaunchIterationRecord(0, false, withIdle({ endEpochMs: 34_500 })), + /idle-window-late/ + ); + assert.equal( + toLaunchIterationRecord(0, false, withIdle({ ticks: 0 })).counters[ + 'renderer.cdTicksIdle30s' + ], + 0 ); }); @@ -284,7 +373,7 @@ test('refuses a launch whose settle window did not end after the first-card cuto const capped = toLaunchIterationRecord( 0, false, - withSettle({ epochMs: 5_650, status: 'cap' }) + withSettle({ epochMs: 3_150, status: 'cap' }) ); assert.equal( (capped.evidence['settle'] as { reason: string }).reason, @@ -292,10 +381,8 @@ test('refuses a launch whose settle window did not end after the first-card cuto ); }); -test('names the counters the harness cannot measure yet', () => { - assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), [ - 'renderer.cdTicksToFirstCard', - ]); +test('measures every counter the plan lists for J1', () => { + assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), []); }); test('never reports a measured counter as unavailable', () => { diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index f0e02f86d..2349648fe 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -5,7 +5,10 @@ import { } from './journey-main-counters'; import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth'; import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture'; -import type { JourneyRendererProbeState } from './journey-renderer-probe'; +import { + JOURNEY_IDLE_WINDOW_MS, + type JourneyRendererProbeState, +} from './journey-renderer-probe'; import type { JourneyIterationRecord } from './journey-summary'; /** @@ -20,6 +23,8 @@ export const LAUNCH_JOURNEY_COUNTER = { JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW, SQL_STATEMENTS_BEFORE_READY_TO_SHOW: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW, + CD_TICKS: 'renderer.cdTicksToFirstCard', + CD_TICKS_IDLE: 'renderer.cdTicksIdle30s', DOM_MUTATIONS: 'renderer.domMutationsToFirstCard', IPC_CALLS: 'renderer.ipcCallsToFirstCard', IPC_SERIAL_DEPTH: 'renderer.ipcSerialDepthToFirstCard', @@ -34,15 +39,26 @@ export const LAUNCH_JOURNEY_WALL_CLOCK = { } as const; /** - * Counters the plan lists for J1 that this harness cannot measure without - * production changes. They are reported instead of faked. + * Counters the plan lists for J1 that this harness cannot measure. Every one + * is measured now; the list stays so a future gap is reported, not faked. */ export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< Record -> = Object.freeze({ - 'renderer.cdTicksToFirstCard': - 'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.', -}); +> = Object.freeze({}); + +/** + * A timer on an idle page runs within milliseconds of its deadline; a window + * that closed later than this measured a busy page, not an idle one. + */ +export const LAUNCH_JOURNEY_IDLE_LATE_TOLERANCE_MS = 1_000; + +/** + * The idle window opens in the settle timer's callback, but the settle point + * is that timer's deadline. A callback that ran later than this left ticks + * between the two outside both windows, and means the page was still busy + * at the settle point, so the iteration is refused rather than undercounted. + */ +export const LAUNCH_JOURNEY_IDLE_START_TOLERANCE_MS = 100; export interface LaunchJourneyMeasurement { readonly electronVersion: string; @@ -95,14 +111,16 @@ export function toLaunchIterationRecord( ) { throw new Error(`launch-journey-record-settle-${settle.status}`); } + const cdTicks = renderer.counters.changeDetectionTicks; if ( - renderer.capabilities.changeDetectionTicks !== - 'unavailable-ng-global-not-published' + renderer.capabilities.changeDetectionTicks !== 'counted' || + cdTicks === null ) { throw new Error( - `launch-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}` + `launch-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}` ); } + const idle = assertLaunchIdleWindow(renderer, settle.epochMs); return Object.freeze({ counters: Object.freeze({ [LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW]: @@ -113,6 +131,8 @@ export function toLaunchIterationRecord( mainCounters.counters[ LAUNCH_JOURNEY_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW ], + [LAUNCH_JOURNEY_COUNTER.CD_TICKS]: cdTicks, + [LAUNCH_JOURNEY_COUNTER.CD_TICKS_IDLE]: idle.ticks, [LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]: renderer.counters.domMutations, [LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel, @@ -153,6 +173,13 @@ export function toLaunchIterationRecord( cardTestId: renderer.terminal.cardTestId, pathname: renderer.terminal.pathname, }), + idle: Object.freeze({ + domMutations: idle.domMutations, + durationMs: roundTenth(idle.durationMs), + settledToIdleStartMs: roundTenth( + idle.startEpochMs - settle.epochMs + ), + }), ipcCallsAfterFirstCard: ipc.callsAfterSentinel, // Running totals when the counters were read, after the first card. mainCountersAtRead: mainCounters.counters, @@ -196,3 +223,53 @@ export function toLaunchIterationRecord( warmup, }); } + +/** + * The idle window must have opened at or after the settle point and closed + * on time; see performance-journeys.md. + */ +function assertLaunchIdleWindow( + renderer: JourneyRendererProbeState, + settledEpochMs: number +): { + readonly domMutations: number; + readonly durationMs: number; + readonly startEpochMs: number; + readonly ticks: number; +} { + const { idle } = renderer; + if ( + idle.status !== 'done' || + idle.ticks === null || + idle.startEpochMs === null || + idle.endEpochMs === null + ) { + throw new Error(`launch-journey-record-idle-${idle.status}`); + } + if (idle.startEpochMs < settledEpochMs) { + throw new Error('launch-journey-record-idle-before-settle'); + } + if ( + idle.startEpochMs - settledEpochMs > + LAUNCH_JOURNEY_IDLE_START_TOLERANCE_MS + ) { + throw new Error('launch-journey-record-idle-start-late'); + } + const durationMs = idle.endEpochMs - idle.startEpochMs; + // Timers may fire up to a millisecond early after clamping. + if (durationMs < JOURNEY_IDLE_WINDOW_MS - 1) { + throw new Error('launch-journey-record-idle-window-short'); + } + if ( + durationMs > + JOURNEY_IDLE_WINDOW_MS + LAUNCH_JOURNEY_IDLE_LATE_TOLERANCE_MS + ) { + throw new Error('launch-journey-record-idle-window-late'); + } + return { + domMutations: idle.domMutations, + durationMs, + startEpochMs: idle.startEpochMs, + ticks: idle.ticks, + }; +} 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 93f7fdb4e..95cb2fb4d 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 @@ -22,12 +22,13 @@ function measurement( ): OpenSourceJourneyMeasurement { const renderer: JourneyRendererProbeState = { capabilities: { - changeDetectionTicks: 'unavailable-ng-global-not-published', + changeDetectionTicks: 'counted', layoutShift: true, longTask: true, observedTarget: 'documentElement', }, counters: { + changeDetectionTicks: 9, domMutations: 1_596, layoutShiftScore: 0.0004, layoutShiftScoreSettled: 0, @@ -36,6 +37,13 @@ function measurement( }, final: true, firstCardPaintEpochMs: 10_090, + idle: { + domMutations: 0, + endEpochMs: null, + startEpochMs: null, + status: 'disabled', + ticks: null, + }, installed: { bridgePresent: true, documentElementPresent: true, @@ -127,6 +135,7 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters assert.equal(record.pid, 4343); assert.deepEqual(record.counters, { 'main.mockHttpRequestsToSettled': 2, + 'renderer.cdTicksToFirstPage': 9, 'renderer.domMutationsToFirstPage': 1_596, 'renderer.ipcCallsToFirstPage': 17, 'renderer.layoutShiftScore': 0.221, @@ -261,11 +270,15 @@ test('rejects measurements that did not start at the click or did not open the s ...renderer, capabilities: { ...renderer.capabilities, - changeDetectionTicks: 'hook-present-not-counted', + changeDetectionTicks: 'unavailable-counter-missing', + }, + counters: { + ...renderer.counters, + changeDetectionTicks: null, }, }, }), - /cd-hook-hook-present-not-counted/ + /cd-ticks-unavailable-counter-missing/ ); }); @@ -322,8 +335,8 @@ test('summarizes under the J2 counters with the unmeasurable ones listed', () => ); assert.equal(entry.wallClock['clickToFirstPageMs.p50'], 78.3); assert.equal(entry.wallClock['clickToFirstPagePaintMs.p90'], 89.8); + assert.equal(entry.counters['renderer.cdTicksToFirstPage'], 9); assert.deepEqual(Object.keys(entry.unavailable).sort(), [ 'main.sqlStatementsToFirstPage', - 'renderer.cdTicksToFirstPage', ]); }); 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 3d4241f16..4d68bdcaa 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 @@ -18,6 +18,7 @@ import type { JourneyIterationRecord } from './journey-summary'; export const OPEN_SOURCE_JOURNEY_ID = 'open-source'; export const OPEN_SOURCE_JOURNEY_COUNTER = { + CD_TICKS: 'renderer.cdTicksToFirstPage', DOM_MUTATIONS: 'renderer.domMutationsToFirstPage', IPC_CALLS: 'renderer.ipcCallsToFirstPage', LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore', @@ -37,8 +38,6 @@ export const OPEN_SOURCE_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< > = Object.freeze({ 'main.sqlStatementsToFirstPage': 'The main.sqlStatements running total is read from the test process through the journey gate, so it cannot be sampled at the click or at the first-page batch, and the worker count is ordered against worker responses rather than the renderer. A click-to-settled count is a follow-up.', - 'renderer.cdTicksToFirstPage': - 'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.', }); /** How long the app was left alone before the click, and what it did. */ @@ -117,12 +116,13 @@ export function toOpenSourceIterationRecord( `open-source-journey-record-activity-before-click-${lateActivity.join('-')}` ); } + const cdTicks = renderer.counters.changeDetectionTicks; if ( - renderer.capabilities.changeDetectionTicks !== - 'unavailable-ng-global-not-published' + renderer.capabilities.changeDetectionTicks !== 'counted' || + cdTicks === null ) { throw new Error( - `open-source-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}` + `open-source-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}` ); } // The ledger's clock is the test process's, the terminal's the @@ -137,6 +137,7 @@ export function toOpenSourceIterationRecord( .split('/')[1]; return Object.freeze({ counters: Object.freeze({ + [OPEN_SOURCE_JOURNEY_COUNTER.CD_TICKS]: cdTicks, [OPEN_SOURCE_JOURNEY_COUNTER.DOM_MUTATIONS]: renderer.counters.domMutations, [OPEN_SOURCE_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel, diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 86eddec02..0ff7caa34 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'; import test from 'node:test'; import { join } from 'node:path'; +import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe'; + interface TargetConfiguration { configurations?: Record>; dependsOn?: unknown; @@ -116,13 +118,59 @@ test('the web performance build keeps production renderer behavior with profilin assert.equal(performance['serviceWorker'], false); assert.deepEqual(performance['optimization'], production['optimization']); assert.equal(performance['outputHashing'], production['outputHashing']); - assert.deepEqual( - performance['fileReplacements'], - production['fileReplacements'] - ); + // The only difference is the environment: production values plus the + // change-detection tick counter the journeys read. + assert.deepEqual(performance['fileReplacements'], [ + { + replace: 'apps/web/src/environments/environment.ts', + with: 'apps/web/src/environments/environment.performance.ts', + }, + ]); + assert.deepEqual(production['fileReplacements'], [ + { + replace: 'apps/web/src/environments/environment.ts', + with: 'apps/web/src/environments/environment.prod.ts', + }, + ]); assert.equal(performance['sourceMap'], true); }); +test('only the web performance build installs the tick counter the journeys read', () => { + const environments = join(workspaceRoot, 'apps/web/src/environments'); + const performanceEnvironment = readFileSync( + join(environments, 'environment.performance.ts'), + 'utf8' + ); + assert.match( + performanceEnvironment, + /export \{ AppConfig \} from '\.\/environment\.prod';/ + ); + assert.match( + performanceEnvironment, + /installChangeDetectionTickCounter\(\);/ + ); + assert.match( + readFileSync( + join(environments, 'change-detection-tick-counter.ts'), + 'utf8' + ), + new RegExp( + `CHANGE_DETECTION_TICK_COUNTER_KEY = '${JOURNEY_CD_TICK_COUNTER_KEY}'` + ) + ); + // No other configuration may reference the performance environment. + for (const [name, configuration] of Object.entries( + webProject.targets['build'].configurations ?? {} + )) { + if (name === 'electron-performance') continue; + assert.doesNotMatch( + JSON.stringify(configuration['fileReplacements'] ?? []), + /environment\.performance/, + name + ); + } +}); + test('the resolved web build cache output is the renderer directory', () => { const task = readResolvedWebBuildTask(); 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 21bd8df3c..902b3af8c 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 @@ -27,12 +27,13 @@ function measurement( ): PlaybackJourneyMeasurement { const renderer: JourneyRendererProbeState = { capabilities: { - changeDetectionTicks: 'unavailable-ng-global-not-published', + changeDetectionTicks: 'counted', layoutShift: true, longTask: true, observedTarget: 'documentElement', }, counters: { + changeDetectionTicks: 14, domMutations: 6_188, layoutShiftScore: 0, layoutShiftScoreSettled: 0, @@ -41,6 +42,13 @@ function measurement( }, final: true, firstCardPaintEpochMs: 10_360, + idle: { + domMutations: 0, + endEpochMs: null, + startEpochMs: null, + status: 'disabled', + ticks: null, + }, installed: { bridgePresent: true, documentElementPresent: true, @@ -156,6 +164,7 @@ test('maps the media-terminated probe, IPC window and mock ledger to exact count assert.equal(record.warmup, false); assert.equal(record.pid, 5151); assert.deepEqual(record.counters, { + 'renderer.cdTicksToPlaying': 14, 'renderer.domMutationsToPlaying': 6_188, 'renderer.httpRequestsToPlaying': 2, 'renderer.ipcCallsToPlaying': 4, @@ -263,10 +272,14 @@ test('rejects measurements that did not start at a live channel or did not play withRenderer({ capabilities: { ...base.renderer.capabilities, - changeDetectionTicks: 'hook-present-not-counted', + changeDetectionTicks: 'unavailable-counter-missing', + }, + counters: { + ...base.renderer.counters, + changeDetectionTicks: null, }, }), - /cd-hook-hook-present-not-counted/, + /cd-ticks-unavailable-counter-missing/, ], ]; for (const [input, error] of cases) { @@ -337,8 +350,8 @@ test('summarizes playback iterations with J3 counters and unavailable reasons', }); assert.equal(entry.wallClock['clickToPlayingMs.p50'], 349.8); assert.equal(entry.wallClock['clickToLoadedMetadataMs.p90'], 95); + assert.equal(entry.counters['renderer.cdTicksToPlaying'], 14); assert.deepEqual(Object.keys(entry.unavailable).sort(), [ - 'renderer.cdTicksToPlaying', 'renderer.ipcSerialDepthToPlaying', ]); }); diff --git a/apps/electron-backend-e2e/src/performance/playback-journey-record.ts b/apps/electron-backend-e2e/src/performance/playback-journey-record.ts index ce0f59a44..9851580d8 100644 --- a/apps/electron-backend-e2e/src/performance/playback-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/playback-journey-record.ts @@ -19,6 +19,7 @@ import type { JourneyIterationRecord } from './journey-summary'; export const PLAYBACK_JOURNEY_ID = 'playback'; export const PLAYBACK_JOURNEY_COUNTER = { + CD_TICKS: 'renderer.cdTicksToPlaying', DOM_MUTATIONS: 'renderer.domMutationsToPlaying', HTTP_REQUESTS: 'renderer.httpRequestsToPlaying', IPC_CALLS: 'renderer.ipcCallsToPlaying', @@ -39,8 +40,6 @@ export const PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS = 1_000; export const PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS: Readonly< Record > = Object.freeze({ - 'renderer.cdTicksToPlaying': - 'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.', 'renderer.ipcSerialDepthToPlaying': 'The serial-depth helper is being added for J1 in a separate thread and is not on master yet; J3 adopts it once it lands.', }); @@ -140,16 +139,18 @@ export function toPlaybackIterationRecord( `playback-journey-record-activity-before-click-${lateActivity.join('-')}` ); } + const cdTicks = renderer.counters.changeDetectionTicks; if ( - renderer.capabilities.changeDetectionTicks !== - 'unavailable-ng-global-not-published' + renderer.capabilities.changeDetectionTicks !== 'counted' || + cdTicks === null ) { throw new Error( - `playback-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}` + `playback-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}` ); } return Object.freeze({ counters: Object.freeze({ + [PLAYBACK_JOURNEY_COUNTER.CD_TICKS]: cdTicks, [PLAYBACK_JOURNEY_COUNTER.DOM_MUTATIONS]: renderer.counters.domMutations, [PLAYBACK_JOURNEY_COUNTER.HTTP_REQUESTS]: http.toPlaying.length, diff --git a/apps/web/project.json b/apps/web/project.json index 72b30b14e..7244c4ac5 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -139,7 +139,7 @@ "fileReplacements": [ { "replace": "apps/web/src/environments/environment.ts", - "with": "apps/web/src/environments/environment.prod.ts" + "with": "apps/web/src/environments/environment.performance.ts" } ] }, diff --git a/apps/web/src/environments/change-detection-tick-counter.spec.ts b/apps/web/src/environments/change-detection-tick-counter.spec.ts new file mode 100644 index 000000000..36db09832 --- /dev/null +++ b/apps/web/src/environments/change-detection-tick-counter.spec.ts @@ -0,0 +1,72 @@ +import { ApplicationRef, Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + CHANGE_DETECTION_TICK_COUNTER_KEY, + installChangeDetectionTickCounter, +} from './change-detection-tick-counter'; + +describe('installChangeDetectionTickCounter', () => { + it('counts every _tick call and forwards to the original', () => { + const calls: unknown[] = []; + const prototype = { + _tick(this: unknown) { + calls.push(this); + }, + }; + const target: Record = {}; + + const counter = installChangeDetectionTickCounter(target, prototype); + const instance = {}; + prototype._tick.call(instance); + prototype._tick.call(instance); + + expect(counter.count).toBe(2); + expect(calls).toEqual([instance, instance]); + expect(target[CHANGE_DETECTION_TICK_COUNTER_KEY]).toBe(counter); + }); + + it('installs once per target', () => { + const prototype = { _tick: jest.fn() }; + const target: Record = {}; + + const first = installChangeDetectionTickCounter(target, prototype); + const second = installChangeDetectionTickCounter(target, prototype); + prototype._tick(); + + expect(second).toBe(first); + expect(first.count).toBe(1); + }); + + it('fails when the internal tick method is missing', () => { + expect(() => installChangeDetectionTickCounter({}, {})).toThrow( + 'change-detection-tick-counter-hook-missing' + ); + }); + + it('counts the ticks of the installed Angular version', () => { + @Component({ template: '{{ value() }}' }) + class CounterHostComponent { + readonly value = signal(0); + } + const prototype = ApplicationRef.prototype as unknown as { + _tick: () => void; + }; + const original = prototype._tick; + try { + const counter = installChangeDetectionTickCounter({}); + const fixture = TestBed.createComponent(CounterHostComponent); + const appRef = TestBed.inject(ApplicationRef); + appRef.attachView(fixture.componentRef.hostView); + const before = counter.count; + + appRef.tick(); + fixture.componentInstance.value.set(1); + appRef.tick(); + + expect(counter.count - before).toBe(2); + expect(fixture.nativeElement.textContent).toBe('1'); + } finally { + prototype._tick = original; + } + }); +}); diff --git a/apps/web/src/environments/change-detection-tick-counter.ts b/apps/web/src/environments/change-detection-tick-counter.ts new file mode 100644 index 000000000..98e759546 --- /dev/null +++ b/apps/web/src/environments/change-detection-tick-counter.ts @@ -0,0 +1,58 @@ +import { ApplicationRef } from '@angular/core'; + +/** + * Change-detection tick counter for the performance journeys. Only the + * `electron-performance` build of `apps/web` contains this module: it is + * imported by `environment.performance.ts`, which `fileReplacements` swaps + * in for `environment.ts` in that configuration alone. The production and + * PWA builds never reference it, so their output is unchanged. + * + * Angular's own hook, `ɵsetProfiler`, is reachable only through the dev-mode + * `window.ng` global, which the optimized build does not publish. Every + * tick, whether scheduled by zone.js (`onMicrotaskEmpty`), the zoneless + * scheduler or an explicit `ApplicationRef.tick()`, goes through the + * internal `ApplicationRef._tick`, which is also where Angular emits the + * profiler's `ChangeDetectionStart`. Counting its calls therefore matches + * the profiler's count and stays comparable across the zoneless migration. + * Contract: docs/architecture/performance-journeys.md. + */ +export const CHANGE_DETECTION_TICK_COUNTER_KEY = '__iptvnatorCdTicks'; + +export interface ChangeDetectionTickCounter { + /** `ApplicationRef._tick` calls since the counter was installed. */ + readonly count: number; + readonly schemaVersion: 1; +} + +type TickPrototype = { _tick?: (this: ApplicationRef) => void }; + +export function installChangeDetectionTickCounter( + target: Record = globalThis as unknown as Record< + string, + unknown + >, + prototype: TickPrototype = ApplicationRef.prototype as unknown as TickPrototype +): ChangeDetectionTickCounter { + const existing = target[CHANGE_DETECTION_TICK_COUNTER_KEY]; + if (existing !== undefined) { + return existing as ChangeDetectionTickCounter; + } + const original = prototype._tick; + // A renamed internal must fail the performance build loudly, never + // report zero ticks. + if (typeof original !== 'function') { + throw new Error('change-detection-tick-counter-hook-missing'); + } + const counter = { count: 0, schemaVersion: 1 as const }; + prototype._tick = function countedTick(this: ApplicationRef): void { + counter.count += 1; + original.call(this); + }; + Object.defineProperty(target, CHANGE_DETECTION_TICK_COUNTER_KEY, { + configurable: false, + enumerable: false, + value: counter, + writable: false, + }); + return counter; +} diff --git a/apps/web/src/environments/environment.performance.ts b/apps/web/src/environments/environment.performance.ts new file mode 100644 index 000000000..190c75617 --- /dev/null +++ b/apps/web/src/environments/environment.performance.ts @@ -0,0 +1,8 @@ +// The electron-performance build's environment: production values plus the +// change-detection tick counter the performance journeys read. See +// change-detection-tick-counter.ts; no other configuration imports this file. +import { installChangeDetectionTickCounter } from './change-detection-tick-counter'; + +installChangeDetectionTickCounter(); + +export { AppConfig } from './environment.prod'; diff --git a/docs/architecture/idle-work-audit-2026-09.md b/docs/architecture/idle-work-audit-2026-09.md index bffa76340..8552a9a7b 100644 --- a/docs/architecture/idle-work-audit-2026-09.md +++ b/docs/architecture/idle-work-audit-2026-09.md @@ -253,6 +253,10 @@ this in the same thread as the first worst offender. network, layout and DOM-mutation counts carry over; the Angular change-detection and template-update counts do not (production skips `checkNoChanges`), and per-firing milliseconds are upper bounds. + The tick count in the optimized build is now measured by J1's idle window: + `renderer.cdTicksIdle30s` read 3 ticks per 30 s on the journey's dashboard + (one M3U source, one Xtream portal), the baseline for plan item C6. See + [performance journeys](performance-journeys.md#idle-window). - Windows and Linux. Throttling and occlusion behavior differ per platform. - Idle during playback, and on routes other than the dashboard. The conditional table above is from code reading only. diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 8f65e3b8b..c35bfbf09 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -113,6 +113,8 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`: | `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. | +| `renderer.cdTicksToFirstCard` | `ApplicationRef` ticks from document start until the terminal batch, read from the `electron-performance` build's tick counter (see [Change-detection ticks](#change-detection-ticks)). The tick that rendered the card runs before the observer's microtask, so it is included. | +| `renderer.cdTicksIdle30s` | Ticks during the 30 s [idle window](#idle-window) that opens at the settle point, with nothing touching the page. The baseline for plan item C6 (zoneless change detection). | #### Settle window @@ -186,6 +188,89 @@ On the Linux CI runner (`Performance journeys` job of #1756, run after the first card), and the one hit shows the same two 316 px moves of the recent-sources rail. +#### Idle window + +After the settle point J1 leaves the dashboard alone for +`JOURNEY_IDLE_WINDOW_MS` (30 s) and counts what it does anyway: +`renderer.cdTicksIdle30s` is the number of change-detection ticks in that +window, and `evidence.idle.domMutations` the mutation records in the whole +document. The [idle work audit](idle-work-audit-2026-09.md) found Eager +components re-rendering on every such tick in a dev build; this counter +measures the ticks in the optimized build, so plan item C6 can show what +zoneless change detection removes. + +The window opens when the settle window closes, so startup data still +landing is not idle work, and it is timed by a renderer `setTimeout`. The +record refuses an iteration whose window opened before the settle point or +more than 100 ms after it (`launch-journey-record-idle-start-late`), or +lasted less than 30 s, or more than 1 s longer +(`launch-journey-record-idle-window-late`). The window opens in the settle +timer's callback while the settle point is that timer's deadline, so a late +callback would leave ticks between the two outside both windows; either late +timer means the page was busy, not idle. Locally the window opened 1-4 ms +after the settle point. `evidence.idle` keeps the measured `durationMs` and +`settledToIdleStartMs`. The main-process counters and the IPC capture are +read after the window, which does not move them: they are frozen earlier. +J2's launches skip the window (`runLaunchJourney` with `idleWindowMs: null`), +so its click does not wait 30 s; a record without a finished window is +refused as a J1 measurement. + +#### Change-detection ticks + +`window.ng` and Angular's profiler hook (`ɵsetProfiler`) exist only in dev +mode, and the `electron-performance` build is optimized like production. So +that build alone installs its own counter: its `fileReplacements` entry +swaps `apps/web/src/environments/environment.ts` for +`environment.performance.ts`, which re-exports the production `AppConfig` +and calls `installChangeDetectionTickCounter()` from +`change-detection-tick-counter.ts` while `main.js` is evaluated, before +Angular bootstraps. The counter wraps the internal `ApplicationRef._tick`, +the method every tick runs through: the zone scheduler's `onMicrotaskEmpty`, +the zoneless scheduler, `afterNextRender` idle buckets and the public +`ApplicationRef.tick()` all call it, and it is where Angular emits the +profiler's `ChangeDetectionStart`. The count therefore equals the profiler's +tick count and stays comparable across the zoneless migration. The running +total is `window.__iptvnatorCdTicks.count`; the probe subtracts it at the +journey's boundaries (zero at document start for J1, the value in the +capture-phase click listener for J2). If a future Angular renames `_tick`, +the performance build throws at startup instead of reporting zero. + +This is a fileReplacements swap rather than an environment flag checked in +`app.config.ts` on purpose: a flag, even one the optimizer folds, would put +an import and a branch into the production sources, while the swap leaves +every file the production and PWA builds compile unchanged. Their output is +byte-identical with and without the counter (every emitted file hashes the +same apart from the `ngsw.json` build timestamp), so +`renderer.initialBytes` cannot move. A build-config test fails if another +configuration references `environment.performance.ts`. The other benchmarks +built from `electron-performance` (M3U import, Xtream, cancellation) carry +the counter too; it adds one increment per tick. + +A build without the counter reports +`capabilities.changeDetectionTicks: "unavailable-counter-missing"` and the +record refuses the iteration, so a zero is never a missing hook. + +First local measurement (macOS, 2026-09-30, three `perf:journeys` runs, +18 iterations per journey including warm-ups): + +| Counter | Run 1 | Run 2 | Run 3 | +| ----------------------------- | ----- | ----- | ----- | +| `renderer.cdTicksToFirstCard` | 20 | 20 | 21 | +| `renderer.cdTicksIdle30s` | 3 | 3 | 3 | +| `renderer.cdTicksToFirstPage` | 22 | 22 | 22 | + +Every run marked all three `stable: true`. `renderer.cdTicksIdle30s` and +`renderer.cdTicksToFirstPage` were identical in all 18 iterations, and every +idle window saw 90 mutation records. `renderer.cdTicksToFirstCard` read 20 +in 13 iterations and 21 in the five measured iterations of the third run +(its warm-up read 20), with every other J1 counter unchanged +(`renderer.ipcCallsToFirstCard` 14, `renderer.domMutationsToFirstCard` 554). +With zone.js a tick follows every macrotask that ran in the Angular zone, so +two startup callbacks that land in one task on one launch and in two tasks +on another differ by one tick without any different work. Treat a one-tick +difference in J1 as that race, and confirm on the CI runner that the counter +is deterministic before it becomes a baseline. + #### Main-process counters With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets, @@ -240,14 +325,8 @@ 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. +No J1 counter from the plan is listed under `unavailable` any more; the +list stays in the record so a future gap is reported instead of faked. #### Serial IPC depth @@ -383,6 +462,8 @@ PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3). "journeys": { "launch": { "counters": { + "renderer.cdTicksIdle30s": 3, + "renderer.cdTicksToFirstCard": 20, "renderer.ipcCallsToFirstCard": 12, "renderer.layoutShiftScoreSettled": 0.236 }, @@ -400,7 +481,7 @@ PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3). "spawnToFirstCardMs.p50": 1234.5, "spawnToFirstCardMs.p90": 1300.1 }, - "unavailable": { "renderer.cdTicksToFirstCard": "reason" }, + "unavailable": {}, "iterations": [ { "index": 0, @@ -530,12 +611,12 @@ strings and stream paths carry credentials and are never stored. | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `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.cdTicksToFirstPage` | `ApplicationRef` ticks from the click until the terminal batch: the counter's running total read in the capture-phase click listener, before the app handles the click, subtracted from its value at the terminal batch (see [Change-detection ticks](#change-detection-ticks)). | | `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. +One counter is listed under `unavailable`. `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 @@ -626,6 +707,7 @@ ignored. | `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.cdTicksToPlaying` | `ApplicationRef` ticks from the click until the `playing` event, read like `renderer.cdTicksToFirstPage` in J2 (see [Change-detection ticks](#change-detection-ticks)). Not yet measured on a run; the counter shipped after J3's first measurements. | | `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. | @@ -643,8 +725,7 @@ above a sub-millisecond origin difference. `evidence.httpRequestsAfterPlayingByR 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. +One counter is listed under `unavailable`. `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. From 572034f3bed006f111d4b3d5b74b7e0b3bf977bc Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:02:50 +0200 Subject: [PATCH 6/7] fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775) --- .changes/ui-material-system-tokens.md | 6 + .codex/skills/iptvnator-theme-style/SKILL.md | 9 +- .github/workflows/ci.yml | 3 + .../src/theme-tokens.e2e.ts | 196 ++++++++++++++++++ apps/web/src/m3-theme.scss | 87 +++++++- apps/web/src/styles.scss | 19 +- docs/architecture/iptvnator-ui-guidelines.md | 26 ++- .../video-player/video-player.component.scss | 12 +- .../category-content-view.component.scss | 9 +- .../src/lib/download-library.component.scss | 3 +- .../unified-collection-page.component.scss | 11 +- .../unified-live-tab.component.scss | 8 +- .../category-management-dialog.component.scss | 3 +- .../channel-details-dialog.component.scss | 9 +- .../group-management-dialog.component.scss | 3 +- .../src/lib/resizable/resizable.scss | 10 +- .../epg-item-description.component.scss | 2 - .../epg-progress-panel.component.scss | 9 +- .../audio-player/audio-player.component.scss | 34 +-- .../fullscreen-channel-panel.component.scss | 25 +-- .../playback-diagnostic-panel.component.scss | 4 +- ...layback-navigation-controls.component.scss | 3 +- .../lib/rails/dashboard-rail.component.scss | 3 +- .../workspace-shell-rail.component.scss | 2 - package.json | 3 + tools/nx/check-material-token-overrides.mjs | 86 ++++++++ .../check-material-token-overrides.test.mjs | 78 +++++++ tools/performance/journey-baselines.json | 6 +- 28 files changed, 581 insertions(+), 88 deletions(-) create mode 100644 .changes/ui-material-system-tokens.md create mode 100644 apps/electron-backend-e2e/src/theme-tokens.e2e.ts create mode 100644 tools/nx/check-material-token-overrides.mjs create mode 100644 tools/nx/check-material-token-overrides.test.mjs diff --git a/.changes/ui-material-system-tokens.md b/.changes/ui-material-system-tokens.md new file mode 100644 index 000000000..8c5c7f868 --- /dev/null +++ b/.changes/ui-material-system-tokens.md @@ -0,0 +1,6 @@ +--- +type: fix +area: ui +--- + +Several theme styles that had silently stopped applying are back in both light and dark themes: rounded input fields, the red settings-error toast, panel borders and separators, and surface colors in lists, menus and dialogs. diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index 1f4e62418..bd52b37b7 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -25,9 +25,12 @@ consumers currently use relative `@use` paths to the needed partial. - Use `--app-selection-on-color` for foregrounds placed on the selection accent; do not assume white has enough contrast in both themes. - Angular Material mixins and Material-component overrides may use Material - tokens. Outside Material-owned components, use a `--mat-sys-*` token only - after proving it is emitted in both light and dark contexts and supplying a - real app-token or literal fallback. + tokens. `m3-theme.scss` declares `--mat-sys-*` for both theme contexts; use + them outside Material components only for roles without an app token. +- Set component tokens through `mat.*-overrides()`; retired `--mdc-*` names + do nothing and `pnpm run styles:material-tokens:validate` rejects them. +- Destructive buttons use `.app-destructive-button` (`color="warn"` is a no-op + with M3). - Local semantic status colors are acceptable. Existing hard-coded layout, selection, and EPG surface colors are migration debt, not precedent. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5944354e..f86e9c4be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,6 +375,9 @@ jobs: - name: Validate stylesheet Nx inputs run: pnpm run styles:inputs:validate + - name: Validate Angular Material token overrides + run: pnpm run styles:material-tokens:validate + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. diff --git a/apps/electron-backend-e2e/src/theme-tokens.e2e.ts b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts new file mode 100644 index 000000000..bcadf0f9d --- /dev/null +++ b/apps/electron-backend-e2e/src/theme-tokens.e2e.ts @@ -0,0 +1,196 @@ +import type { Locator, Page } from '@playwright/test'; +import { + closeElectronApp, + expect, + launchElectronApp, + openSettings, + test, +} from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; + +/** + * WCAG contrast between an element's outline colour and the fill inside it, + * both composited over the backgrounds behind the field. + */ +async function outlineContrast( + outline: Locator, + fillSelector: string +): Promise { + return outline.evaluate((el, selector) => { + const ctx = document.createElement('canvas').getContext('2d')!; + const field = el.closest('mat-form-field')!; + const layers = [ + getComputedStyle(field.querySelector(selector)!).backgroundColor, + ]; + for (let node = field.parentElement; node; node = node.parentElement) { + layers.unshift(getComputedStyle(node).backgroundColor); + } + const paint = (colors: string[]) => { + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, 1, 1); + for (const color of colors) { + ctx.fillStyle = color; + ctx.fillRect(0, 0, 1, 1); + } + const [r, g, b] = Array.from( + ctx.getImageData(0, 0, 1, 1).data.slice(0, 3) + ).map((channel) => { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const fill = paint(layers); + const line = paint([...layers, getComputedStyle(el).borderTopColor]); + return (Math.max(fill, line) + 0.05) / (Math.min(fill, line) + 0.05); + }, fillSelector); +} + +/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */ +async function resolveColor(page: Page, value: string): Promise { + return page.evaluate((css) => { + const probe = document.createElement('div'); + probe.style.color = css; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; + }, value); +} + +async function systemVariable(page: Page, name: string): Promise { + return page.evaluate( + (variable) => + getComputedStyle(document.body).getPropertyValue(variable).trim(), + name + ); +} + +test.describe('Theme tokens', () => { + test('@theme @electron declares Material system variables and applies app token overrides in both themes', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + try { + await openSettings(page); + + const surfaces: Record = {}; + for (const theme of ['light', 'dark'] as const) { + await applyTheme(page, theme); + + // The legacy define-theme config never emits these; app + // styles that read them used to resolve to nothing. + for (const variable of [ + '--mat-sys-surface', + '--mat-sys-on-surface', + '--mat-sys-outline-variant', + '--mat-sys-error', + '--mat-sys-corner-medium', + '--mat-sys-body-medium', + ]) { + expect( + await systemVariable(page, variable), + `${variable} in ${theme} theme` + ).not.toBe(''); + } + surfaces[theme] = await systemVariable( + page, + '--mat-sys-surface' + ); + + // The dark surface belongs to the app host. Components that + // carry `dark-theme` for its tokens (the fullscreen channel + // panel, the diagnostic's alternative sources) keep their own. + const backgrounds = await page.evaluate(() => { + const nested = document.createElement('div'); + nested.className = 'dark-theme'; + nested.style.background = 'rgb(22, 27, 36)'; + document.body.appendChild(nested); + const result = { + nested: getComputedStyle(nested).backgroundColor, + body: getComputedStyle(document.body).backgroundColor, + }; + nested.remove(); + return result; + }); + expect(backgrounds.nested, `nested in ${theme} theme`).toBe( + 'rgb(22, 27, 36)' + ); + if (theme === 'dark') { + expect(backgrounds.body).toBe( + await resolveColor(page, 'var(--mat-sys-surface)') + ); + } + + // Form-field overrides must reach Material's --mat-* tokens + // (the retired --mdc-* names were ignored): the outline takes + // the app's 10px shape instead of the 4px M3 default. + const outline = page + .locator( + 'mat-form-field.mat-form-field-appearance-outline .mdc-notched-outline__leading' + ) + .first(); + await expect(outline).toBeVisible(); + await expect + .poll(() => + outline.evaluate( + (el) => getComputedStyle(el).borderTopLeftRadius + ) + ) + .toBe('10px'); + // The outline is the field's only boundary (WCAG 1.4.11). + expect( + await outlineContrast( + outline, + '.mat-mdc-text-field-wrapper' + ), + `field outline contrast in ${theme} theme` + ).toBeGreaterThanOrEqual(3); + + // A floated label inherits the field's text size and renders + // at 75% of it; it must stay legible. + const floated = page + .locator('mat-form-field .mdc-floating-label--float-above') + .first(); + await expect(floated).toBeVisible(); + expect( + await floated.evaluate((el) => { + const style = getComputedStyle(el); + const scale = new DOMMatrixReadOnly(style.transform).a; + return parseFloat(style.fontSize) * scale; + }), + `floated label size in ${theme} theme` + ).toBeGreaterThanOrEqual(11); + + // The typography tokens carry the font stack through + // --app-font-family. A dangling var() voids the whole `font` + // shorthand; body text would still inherit the same family, + // so check the token's own size and weight. + expect( + await page.evaluate(() => { + const probe = document.createElement('span'); + probe.style.font = 'var(--mat-sys-label-small)'; + document.body.appendChild(probe); + const style = getComputedStyle(probe); + const font = { + family: style.fontFamily, + size: Math.round(parseFloat(style.fontSize)), + weight: style.fontWeight, + }; + probe.remove(); + return font; + }), + `label-small typography in ${theme} theme` + ).toEqual({ + family: expect.stringMatching(/^"?DM Sans"?,/), + size: 11, + weight: '500', + }); + } + expect(surfaces['light']).not.toBe(surfaces['dark']); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index 235010a23..e5190fc8a 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -6,8 +6,16 @@ @include mat.app-background(); // ─── Typography ───────────────────────────────────────────────────────────── -$app-font-stack: +// The stack is declared once on html; every Material typography token and the +// typography hierarchy read it through the variable. Inlined, the 103-char +// stack was copied into ~90 declarations (about 7 KB of the initial CSS). +$app-font-stack-value: "'DM Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; +$app-font-stack: var(--app-font-family); + +html { + --app-font-family: #{$app-font-stack-value}; +} $my-typography: mat.m2-define-typography-config( $font-family: $app-font-stack, @@ -53,6 +61,17 @@ $dark-theme: mat.define-theme( html { @include mat.all-component-themes($light-theme); + // The theme is built with the legacy `define-theme` config, whose + // component mixins emit resolved component tokens but never declare the + // `--mat-sys-*` system variables. App styles reference those variables + // directly, so declare them explicitly for each theme context; without + // this every such reference silently resolves to nothing. + @include mat.system-level-colors($light-theme); + @include mat.system-level-typography($light-theme); + @include mat.system-level-elevation($light-theme); + @include mat.system-level-shape($light-theme); + @include mat.system-level-state($light-theme); + // ── Light theme design tokens ───────────────────────────────────────── --app-shell-bg: #eef0f3; --app-rail-bg: #eef0f3; @@ -130,6 +149,9 @@ html { .dark-theme { @include mat.all-component-colors($dark-theme); + // Only the color roles differ per theme: the elevation shadows are + // the same black in both, so html's declarations already apply here. + @include mat.system-level-colors($dark-theme); // ── Dark graphite theme ──────────────────────────────────────────── // Inspired by both Slack (sidebar distinction) and GitButler (data density) @@ -228,16 +250,26 @@ $app-refined-input-density: mat.define-theme(( html { @include mat.form-field-density($app-refined-input-density); + // Declared on the field itself (not on html) so the var(--app-*) values + // resolve inside .dark-theme instead of being inherited as light values. + // The resting and hover outlines keep the theme's outline roles: the app + // hairline tokens fall far under the 3:1 boundary contrast a field needs. + // Labels use the body token, which clears 4.5:1 in both themes. + // Text sizes stay on the theme defaults: this theme leaves the floated + // label's size token undeclared, so it inherits the field's text size and + // scales it to 75%; a 13px field would float a 10px label. .mat-mdc-form-field { - --mdc-outlined-text-field-outline-color: var(--app-widget-header-border); - --mdc-outlined-text-field-hover-outline-color: var(--app-muted-color); - --mdc-outlined-text-field-focus-outline-color: var(--app-selection-color); - --mdc-outlined-text-field-container-shape: 10px; - --mdc-outlined-text-field-label-text-color: var(--app-muted-color); - --mdc-outlined-text-field-input-text-color: var(--app-heading-color); - --mdc-outlined-text-field-caret-color: var(--app-selection-color); - --mdc-outlined-text-field-label-text-size: 13px; - --mdc-outlined-text-field-input-text-size: 13.5px; + @include mat.form-field-overrides( + ( + outlined-focus-outline-color: var(--app-selection-color), + outlined-container-shape: 10px, + outlined-label-text-color: var(--app-body-color), + outlined-hover-label-text-color: var(--app-body-color), + outlined-focus-label-text-color: var(--app-selection-color), + outlined-input-text-color: var(--app-heading-color), + outlined-caret-color: var(--app-selection-color), + ) + ); } // Inputs sit slightly above the surrounding surface. color-mix is inlined @@ -254,6 +286,41 @@ html { font-size: 13.5px; } + // ─── Destructive actions ─────────────────────────────────────────────── + // Material only emits `.mat-warn` button colors for M2 themes, so + // `color="warn"` is a no-op with this M3 theme. Buttons that remove or + // discard user data opt in with this class instead; the error tokens are + // declared per theme context above, so light and dark each get their own + // error/on-error pair. + .app-destructive-button { + @include mat.button-overrides( + ( + filled-container-color: var(--mat-sys-error), + filled-label-text-color: var(--mat-sys-on-error), + filled-state-layer-color: var(--mat-sys-on-error), + filled-ripple-color: + color-mix(in srgb, var(--mat-sys-on-error) 12%, transparent), + text-label-text-color: var(--mat-sys-error), + text-state-layer-color: var(--mat-sys-error), + text-ripple-color: + color-mix(in srgb, var(--mat-sys-error) 12%, transparent), + outlined-label-text-color: var(--mat-sys-error), + outlined-outline-color: var(--mat-sys-error), + outlined-state-layer-color: var(--mat-sys-error), + outlined-ripple-color: + color-mix(in srgb, var(--mat-sys-error) 12%, transparent), + ) + ); + @include mat.icon-button-overrides( + ( + icon-color: var(--mat-sys-error), + state-layer-color: var(--mat-sys-error), + ripple-color: + color-mix(in srgb, var(--mat-sys-error) 12%, transparent), + ) + ); + } + .mat-mdc-form-field-subscript-wrapper { font-size: 11px; letter-spacing: 0.01em; diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 8b326b5a0..c0c09dafb 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../../libs/ui/components/src/lib/resizable/resizable.scss'; @use './settings-theme'; @use './cover-size'; @@ -163,8 +164,14 @@ textarea, } // ─── Dark Theme ─────────────────────────────────────────────────────────────── -.dark-theme { +// Only the app host takes the dark surface: components such as the fullscreen +// channel panel and the playback diagnostic's alternative sources carry +// `dark-theme` for its tokens while painting their own surfaces. +body.dark-theme { background: var(--mat-sys-surface) !important; +} + +.dark-theme { color-scheme: dark; // Refined scrollbar for dark mode @@ -236,9 +243,13 @@ textarea, // Failures (settings could not be saved/loaded) must not read as the usual // neutral confirmation toast. .mat-mdc-snack-bar-container.settings-snackbar--error { - --mdc-snackbar-container-color: var(--mat-sys-error-container); - --mdc-snackbar-supporting-text-color: var(--mat-sys-on-error-container); - --mat-snack-bar-button-color: var(--mat-sys-on-error-container); + @include mat.snack-bar-overrides( + ( + container-color: var(--mat-sys-error-container), + supporting-text-color: var(--mat-sys-on-error-container), + button-color: var(--mat-sys-on-error-container), + ) + ); } // WCAG-friendly visually-hidden helper — content stays in the diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 20ebe310c..877f48b17 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -68,14 +68,26 @@ in `apps/web/src/m3-theme.scss`): Angular Material mixins and Material-component overrides may use the tokens owned by that component. Outside a Material-owned component, prefer the -app-owned tokens above. A `--mat-sys-*` reference is acceptable there only -after the built light and dark theme contexts both prove that it is emitted, -and it must still have a real app-token or literal fallback, for example: -`var(--mat-sys-surface-container, var(--app-widget-bg))`. +app-owned tokens above. -Several existing app surfaces still reference Material system tokens without -that proof or use hard-coded layout/selection colors. Treat those references -as migration debt, not patterns to copy. +Both themes are built with the legacy `mat.define-theme` config, whose +component mixins never declare the `--mat-sys-*` system variables. The theme +therefore adds the `mat.system-level-*` mixins for the light (`html`) and dark +(`.dark-theme`) contexts, and `apps/electron-backend-e2e/src/theme-tokens.e2e.ts` +asserts they resolve in both. Use a `--mat-sys-*` token for Material-derived +roles that have no app token (error, outline, surface containers); keep app +chrome on `--app-*`. + +Set Material component tokens through the component's `mat.*-overrides()` +mixin: it rejects unknown names at build time, where a hand-written `--mat-*` +declaration with a typo fails silently. Material 22 reads only `--mat-*` +tokens, so the retired `--mdc-*` names compile but do nothing; +`pnpm run styles:material-tokens:validate` (CI) rejects them. A stylesheet that +a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*` +token directly and says why. + +Existing hard-coded layout and selection colors are migration debt, not +patterns to copy. Do not hardcode unrelated accent colors for selected state when these tokens already exist. diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index 1e3516285..f47a0cce7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -4,7 +4,7 @@ :host { min-height: 0; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } .sidebar { @@ -40,14 +40,14 @@ .content-container { min-width: 0; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } .video-player { background: #000; &:has(> app-audio-player) { - background: var(--mat-sys-surface); + background: var(--app-content-bg); } > app-audio-player, @@ -61,8 +61,8 @@ } .epg { - border-top: 1px solid var(--mat-sys-outline-variant); - background: var(--mat-sys-surface); + border-top: 1px solid var(--app-separator); + background: var(--app-content-bg); } .epg-content { @@ -126,7 +126,7 @@ // whole point of the bottom-drawer layout, so release the cap. max-height: none; border-right: none; - border-top: 1px solid var(--mat-sys-outline-variant); + border-top: 1px solid var(--app-separator); border-bottom: none; // Sidebar collapse uses width=0 on desktop; on mobile the rail flips diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index 65143b8f9..928cb24f9 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../../../../../ui/styles/panel-header' as panel; :host { @@ -74,8 +75,12 @@ min-height: 32px; border-radius: 999px; color: var(--app-body-color, var(--mat-sys-on-surface-variant)); - --mdc-text-button-label-text-color: currentColor; - --mat-text-button-state-layer-color: currentColor; + @include mat.button-overrides( + ( + text-label-text-color: currentColor, + text-state-layer-color: currentColor, + ) + ); mat-icon { width: 18px; diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index 015d10b6f..f6ef58daf 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../../../shared/ui/src/lib/styles/content-grid' as grid; :host { @@ -153,7 +154,7 @@ height: 32px; padding: 4px; color: #fff; - --mdc-icon-button-state-layer-size: 32px; + @include mat.icon-button-overrides((state-layer-size: 32px)); &::before { position: absolute; diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss index 2f4a35c8b..ee656f062 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; @use '../../styles/skeleton'; :host { @@ -42,9 +43,13 @@ left: 0; right: 0; bottom: -1px; - --mdc-linear-progress-track-height: 2px; - --mdc-linear-progress-active-indicator-height: 2px; - --mdc-linear-progress-active-indicator-color: var(--app-selection-color); + @include mat.progress-bar-overrides( + ( + track-height: 2px, + active-indicator-height: 2px, + active-indicator-color: var(--app-selection-color), + ) + ); } .collection-content { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss index 601b4957f..882e9482d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.scss @@ -18,7 +18,7 @@ } .content-container { - background: var(--mat-sys-surface); + background: var(--app-content-bg); &:has(> app-portal-empty-state) { align-items: center; @@ -33,7 +33,7 @@ &--radio { position: relative; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } > app-vjs-player, @@ -61,8 +61,8 @@ } .epg { - border-top: 1px solid var(--mat-sys-outline-variant); - background: var(--mat-sys-surface); + border-top: 1px solid var(--app-separator); + background: var(--app-content-bg); display: flex; flex-direction: column; } diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss index 3aeb313cb..ac78f2c7d 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; ::ng-deep .mat-mdc-dialog-content { overflow: hidden !important; } @@ -134,7 +135,7 @@ } mat-checkbox { - --mdc-checkbox-state-layer-size: 28px; + @include mat.checkbox-overrides((state-layer-size: 28px)); } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss index 7b53595d4..f030d5d0e 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-details-dialog/channel-details-dialog.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { display: block; color: var(--app-body-color, var(--mat-sys-on-surface)); @@ -227,8 +228,12 @@ flex-shrink: 0; width: 32px; height: 32px; - --mdc-icon-button-state-layer-size: 32px; - --mdc-icon-button-icon-size: 18px; + @include mat.icon-button-overrides( + ( + state-layer-size: 32px, + icon-size: 18px, + ) + ); color: var(--app-muted-color, var(--mat-sys-on-surface-variant)); } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss index 8e12d6b10..176f7964f 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/group-management-dialog/group-management-dialog.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; .mat-mdc-dialog-content { display: flex; gap: 10px; @@ -124,7 +125,7 @@ } mat-checkbox { - --mdc-checkbox-state-layer-size: 28px; + @include mat.checkbox-overrides((state-layer-size: 28px)); } } diff --git a/libs/ui/components/src/lib/resizable/resizable.scss b/libs/ui/components/src/lib/resizable/resizable.scss index 68d6d1d1c..a7b816fa9 100644 --- a/libs/ui/components/src/lib/resizable/resizable.scss +++ b/libs/ui/components/src/lib/resizable/resizable.scss @@ -94,8 +94,8 @@ body.resizing-active { &:active &__line { width: 2px; - background: var(--mdc-theme-primary, #6366f1); - box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); + background: var(--app-selection-color, #2f7bff); + box-shadow: 0 0 12px var(--app-selection-glow, rgba(47, 123, 255, 0.3)); } // Grip dots indicator @@ -129,7 +129,7 @@ body.resizing-active { opacity: 1; span { - background: var(--mdc-theme-primary, #6366f1); + background: var(--app-selection-color, #2f7bff); transform: scale(1.2); } } @@ -161,8 +161,8 @@ body.resizing-active { } &:active &__line { - background: var(--mdc-theme-primary, #6366f1); - box-shadow: 0 0 12px rgba(99, 102, 241, 0.2); + background: var(--app-selection-color, #2f7bff); + box-shadow: 0 0 12px var(--app-selection-glow, rgba(47, 123, 255, 0.2)); } &__grip { diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss index c24280448..b6b926e49 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss @@ -247,8 +247,6 @@ panel class from EPG_PROGRAMME_DIALOG_CONFIG so no other dialog loses its surface while this one is open. */ ::ng-deep .epg-programme-dialog-panel .mat-mdc-dialog-container { - --mdc-dialog-container-color: transparent; - .mat-mdc-dialog-surface, .mdc-dialog__surface { background: transparent !important; diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss index 66c053a5f..52eadd600 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { // Theme-aware tokens — map to app design tokens --epg-panel-bg: var(--app-widget-bg); @@ -28,8 +29,12 @@ // but fits inside the row padding (6px on each side, the tightest row has // 8px), so touch and motor-impaired users keep a forgiving hit area. @mixin compact-icon-button($size, $icon-size) { - --mdc-icon-button-state-layer-size: #{$size}; - --mdc-icon-button-icon-size: #{$icon-size}; + @include mat.icon-button-overrides( + ( + state-layer-size: $size, + icon-size: $icon-size, + ) + ); width: $size; height: $size; padding: 0; diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss index 148235b42..919f3991c 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.scss +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; // ─── Radio Player — cinematic hero layout ──────────────────────────────────── // Mirrors the content-hero/vod-details pattern: blurred backdrop, vignette, // floating artwork + controls centred in the available space. @@ -15,7 +16,7 @@ width: 100%; height: 100%; overflow: hidden; - background: var(--mat-sys-surface); + background: var(--app-content-bg); } // ─── Blurred backdrop (station logo, blown up) ────────────────────────────── @@ -205,13 +206,13 @@ // Play / Pause uses the unified app primary, not the station's dominant color // — same blue as every other CTA so radio doesn't feel like a different app. .play-btn { - --mdc-fab-container-color: var(--app-selection-color, var(--mat-sys-primary)); - --mat-fab-container-color: var(--app-selection-color, var(--mat-sys-primary)); - --mdc-fab-icon-color: #fff; - --mat-fab-foreground-color: #fff; - --mat-fab-icon-color: #fff; - --mat-fab-container-shape: 50%; - --mdc-fab-container-shape: 50%; + @include mat.fab-overrides( + ( + container-color: var(--app-selection-color, var(--mat-sys-primary)), + foreground-color: #fff, + container-shape: 50%, + ) + ); box-shadow: none !important; transition: transform 0.2s ease; @@ -243,13 +244,16 @@ .vol-slider { flex: 1; - --mdc-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary)); - --mdc-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary)); - --mat-slider-active-track-color: var(--app-selection-color, var(--mat-sys-primary)); - --mat-slider-handle-color: var(--app-selection-color, var(--mat-sys-primary)); - --mdc-slider-inactive-track-color: var(--mat-sys-outline-variant); - --mdc-slider-handle-width: 12px; - --mdc-slider-handle-height: 12px; + @include mat.slider-overrides( + ( + active-track-color: + var(--app-selection-color, var(--mat-sys-primary)), + handle-color: var(--app-selection-color, var(--mat-sys-primary)), + inactive-track-color: var(--mat-sys-outline-variant), + handle-width: 12px, + handle-height: 12px, + ) + ); } // ─── Entrance ──────────────────────────────────────────────────────────────── diff --git a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss index f97d532cf..6b75089d2 100644 --- a/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss +++ b/libs/ui/playback/src/lib/fullscreen-channel-panel/fullscreen-channel-panel.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; // Global styles on purpose (ViewEncapsulation.None): the host page's list is // stamped into the panel body through an ng-template, so its nodes carry the // host's scoping attribute and an emulated `.body > *` rule could never reach @@ -116,13 +117,10 @@ visibility 0s linear 220ms; } -// The controls' glass fill over the video. The panel carries `dark-theme` for its tokens, and the app's global -// `.dark-theme { background: var(--mat-sys-surface) !important }` -// (apps/web/src/styles.scss) would replace this gradient with the flat -// surface — or with no background at all where that token is not defined — -// so the compound selector and `!important` are required to out-rank it. +// The controls' glass fill over the video. The panel carries `dark-theme` for +// its tokens only; the app's dark surface is scoped to body.dark-theme. .fullscreen-channel-panel.dark-theme { - background: rgba(12, 16, 23, 0.72) !important; + background: rgba(12, 16, 23, 0.72); } .fullscreen-channel-panel--open { @@ -253,12 +251,15 @@ padding: 4px; border-radius: 10px; color: #e7ecf3; - --mdc-icon-button-icon-color: currentColor; - --mat-icon-button-icon-color: currentColor; - --mat-icon-button-state-layer-size: 32px; - --mat-icon-button-container-shape: 10px; - --mat-icon-button-icon-size: 20px; - --mat-icon-button-hover-state-layer-opacity: 0; + @include mat.icon-button-overrides( + ( + icon-color: currentColor, + state-layer-size: 32px, + container-shape: 10px, + icon-size: 20px, + hover-state-layer-opacity: 0, + ) + ); } .fullscreen-channel-panel__close mat-icon { diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss index d8633cfc7..7ee9db7a5 100644 --- a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss @@ -229,7 +229,9 @@ } .web-player-diagnostic__player-card mat-spinner { - --mdc-circular-progress-active-indicator-color: currentColor; + // Plain token instead of mat.progress-spinner-overrides(): the component + // spec loads this file as raw CSS, so it must stay free of Sass modules. + --mat-progress-spinner-active-indicator-color: currentColor; } .web-player-diagnostic__player-card:focus-visible, diff --git a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss index c8fd3ae5a..eb4db7f3e 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss +++ b/libs/ui/playback/src/lib/portal-inline-player/series-playback-navigation-controls.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { position: absolute; right: 50%; @@ -26,7 +27,7 @@ } .series-playback-navigation-controls__button { - --mdc-icon-button-state-layer-size: 40px; + @include mat.icon-button-overrides((state-layer-size: 40px)); width: 40px; height: 40px; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss index 0816357bb..608f761df 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss @@ -1,3 +1,4 @@ +@use '@angular/material' as mat; :host { display: block; --rail-card-width: var(--cover-rail-width, 172px); @@ -307,7 +308,7 @@ transition: opacity 0.15s ease, transform 0.15s ease; - --mdc-icon-button-state-layer-size: 30px; + @include mat.icon-button-overrides((state-layer-size: 30px)); mat-icon { font-size: 18px; diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss index 635913d5d..f1076624b 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-rail/workspace-shell-rail.component.scss @@ -100,8 +100,6 @@ .brand { margin-bottom: 10px; - background: var(--mat-sys-primary-container); - color: var(--mat-sys-on-primary-container); width: 40px; height: 40px; border-radius: 12px; diff --git a/package.json b/package.json index 251cba78c..92aa55fde 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,9 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", + "styles:material-tokens:test": "node --test tools/nx/check-material-token-overrides.test.mjs", + "styles:material-tokens:check": "node tools/nx/check-material-token-overrides.mjs", + "styles:material-tokens:validate": "pnpm run styles:material-tokens:test && pnpm run styles:material-tokens:check", "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", diff --git a/tools/nx/check-material-token-overrides.mjs b/tools/nx/check-material-token-overrides.mjs new file mode 100644 index 000000000..8c21a3ffb --- /dev/null +++ b/tools/nx/check-material-token-overrides.mjs @@ -0,0 +1,86 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Angular Material 19 renamed every `--mdc-*` component token to `--mat-*`, + * and Material 22 reads none of the old names. An `--mdc-*` declaration + * therefore compiles, looks intentional, and silently does nothing; a + * `var(--mdc-*)` read always resolves to its fallback. Component tokens must + * be set through the `mat.*-overrides()` mixins, which reject unknown names at + * build time. + */ +const DEAD_MATERIAL_TOKEN = /--mdc-[a-z0-9-]+/g; + +/** Source files whose styles or templates can carry component tokens. */ +const SCANNED_PATHSPECS = [ + 'apps/*.scss', + 'apps/*.css', + 'apps/*.ts', + 'apps/*.html', + 'libs/*.scss', + 'libs/*.css', + 'libs/*.ts', + 'libs/*.html', +]; + +/** Tests and this guard's own fixtures may name the retired prefix. */ +function isScanned(file) { + return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); +} + +export function findDeadMaterialTokens(file, source) { + const findings = []; + source.split('\n').forEach((line, index) => { + for (const match of line.matchAll(DEAD_MATERIAL_TOKEN)) { + findings.push({ file, line: index + 1, token: match[0] }); + } + }); + return findings; +} + +/** Tracked files under `rootDir` that the guard reads, at any depth. */ +export function listScannedFiles(rootDir) { + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean) + .filter(isScanned); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const files = listScannedFiles(rootDir); + + const findings = []; + for (const file of files) { + const source = await readFile(path.join(rootDir, file), 'utf8'); + findings.push(...findDeadMaterialTokens(file, source)); + } + + if (findings.length > 0) { + console.error( + 'Retired Angular Material --mdc-* tokens found. Material 22 ignores them; use the matching mat.*-overrides() mixin instead:' + ); + for (const { file, line, token } of findings) { + console.error(`- ${file}:${line} ${token}`); + } + process.exitCode = 1; + } else { + console.log( + `Checked ${files.length} source files; no retired --mdc-* Material tokens remain.` + ); + } +} diff --git a/tools/nx/check-material-token-overrides.test.mjs b/tools/nx/check-material-token-overrides.test.mjs new file mode 100644 index 000000000..7025fb060 --- /dev/null +++ b/tools/nx/check-material-token-overrides.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + findDeadMaterialTokens, + listScannedFiles, +} from './check-material-token-overrides.mjs'; + +test('reports retired --mdc-* declarations and reads with their location', () => { + const source = [ + '.field {', + ' --mdc-outlined-text-field-container-shape: 10px;', + ' color: var(--mdc-theme-primary, #6366f1);', + '}', + ].join('\n'); + + assert.deepEqual(findDeadMaterialTokens('a.scss', source), [ + { + file: 'a.scss', + line: 2, + token: '--mdc-outlined-text-field-container-shape', + }, + { file: 'a.scss', line: 3, token: '--mdc-theme-primary' }, + ]); +}); + +test('accepts current --mat-* tokens and override mixins', () => { + const source = [ + '.field {', + ' @include mat.form-field-overrides((outlined-container-shape: 10px));', + ' --mat-icon-button-state-layer-size: 32px;', + ' color: var(--mat-sys-error);', + '}', + ].join('\n'); + + assert.deepEqual(findDeadMaterialTokens('b.scss', source), []); +}); + +test('selects tracked app and lib sources at any depth, but not specs', async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), 'mat-token-guard-')); + const git = (...args) => + execFileSync('git', args, { cwd: rootDir, stdio: 'pipe' }); + try { + git('init', '-q'); + const files = { + 'libs/ui/feature/src/lib/deep/panel.component.scss': '', + 'libs/ui/feature/src/lib/deep/panel.component.html': '', + 'libs/ui/feature/src/lib/deep/panel.component.ts': '', + 'libs/ui/feature/src/lib/deep/panel.component.spec.ts': '', + 'apps/web/src/styles.scss': '', + 'apps/web/src/vendor.css': '', + 'tools/outside.scss': '', + }; + for (const [file, content] of Object.entries(files)) { + await mkdir(path.dirname(path.join(rootDir, file)), { + recursive: true, + }); + await writeFile(path.join(rootDir, file), content); + } + git('add', '.'); + // Untracked files are not part of the checkout CI sees. + await writeFile(path.join(rootDir, 'apps/web/src/untracked.scss'), ''); + + assert.deepEqual(listScannedFiles(rootDir).sort(), [ + 'apps/web/src/styles.scss', + 'apps/web/src/vendor.css', + 'libs/ui/feature/src/lib/deep/panel.component.html', + 'libs/ui/feature/src/lib/deep/panel.component.scss', + 'libs/ui/feature/src/lib/deep/panel.component.ts', + ]); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } +}); diff --git a/tools/performance/journey-baselines.json b/tools/performance/journey-baselines.json index b1f555bc3..56c319574 100644 --- a/tools/performance/journey-baselines.json +++ b/tools/performance/journey-baselines.json @@ -3,11 +3,11 @@ "journeys": { "launch": { "renderer.initialBytes": { - "value": 1598232, + "value": 1605046, "unit": "bytes", "slack": 4096, - "updatedAt": "2026-09-27", - "evidencePr": 1734, + "updatedAt": "2026-10-01", + "evidencePr": 1775, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" } } From 1653ffe9fbf1b4a30ea13000146fdf6f8b8a0f22 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:40:06 +0200 Subject: [PATCH 7/7] fix(epg): scroll the programme guide to now on open and on Now/N (#1781) --- .changes/epg-guide-jump-to-now.md | 9 ++ .../electron-backend-e2e/src/epg-guide.e2e.ts | 55 +++++++ .../epg-guide-keyboard.controller.spec.ts | 27 ++++ .../epg-guide-keyboard.controller.ts | 33 +++- .../epg-guide/epg-guide-scroll.util.spec.ts | 33 +++- .../lib/epg-guide/epg-guide-scroll.util.ts | 26 ++- .../epg-guide-viewport.controller.spec.ts | 153 ++++++++++++++++-- .../epg-guide-viewport.controller.ts | 91 +++++++++-- .../lib/epg-guide/epg-guide.component.spec.ts | 23 +++ .../src/lib/epg-guide/epg-guide.component.ts | 25 +-- 10 files changed, 423 insertions(+), 52 deletions(-) create mode 100644 .changes/epg-guide-jump-to-now.md diff --git a/.changes/epg-guide-jump-to-now.md b/.changes/epg-guide-jump-to-now.md new file mode 100644 index 000000000..10ad6ce83 --- /dev/null +++ b/.changes/epg-guide-jump-to-now.md @@ -0,0 +1,9 @@ +--- +type: fix +area: epg +issues: [1733] +--- + +The multi-channel programme guide now opens at the current time, and the Now +button and the N key scroll the timeline back to it instead of leaving it at +midnight. diff --git a/apps/electron-backend-e2e/src/epg-guide.e2e.ts b/apps/electron-backend-e2e/src/epg-guide.e2e.ts index 6e8b84692..c8c394209 100644 --- a/apps/electron-backend-e2e/src/epg-guide.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-guide.e2e.ts @@ -1,3 +1,4 @@ +import { Page } from '@playwright/test'; import { buildM3uContent, channelItemByTitle, @@ -40,6 +41,39 @@ function xmltvWithCurrentProgramme( `; } +/** True when the now-line is painted inside the visible programme lane. */ +function nowLineInLane(page: Page): Promise { + return page.evaluate(() => { + const lane = document + .querySelector('.epg-guide__now-clip') + ?.getBoundingClientRect(); + const line = document + .querySelector('.epg-guide__now-line') + ?.getBoundingClientRect(); + return ( + !!lane && + !!line && + line.left >= lane.left && + line.right <= lane.right + ); + }); +} + +/** Scroll the lane to whichever end of the day is farther from now. */ +async function scrollAwayFromNow(page: Page): Promise { + await page.evaluate(() => { + const viewport = document.querySelector( + '.epg-guide__viewport' + ) as HTMLElement; + const badge = document.querySelector( + '.epg-guide__now-badge' + ) as HTMLElement; + const nowLeft = parseFloat(badge.style.left); + const end = viewport.scrollWidth - viewport.clientWidth; + viewport.scrollTo({ left: nowLeft > end / 2 ? 0 : end }); + }); +} + test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({ dataDir, }) => { @@ -133,6 +167,27 @@ test('@epg @electron opens the programme guide with the playlist channels, switc timeout: 20000, }); + // The guide opens on "now", and the Now button and N jump back to it + // on both axes at once (#1733: the lane stayed at midnight). + await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true); + await scrollAwayFromNow(app.mainWindow); + await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false); + await guide.locator('.guide-toolbar__now').click(); + await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true); + + await scrollAwayFromNow(app.mainWindow); + await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false); + // Keys are left alone while a toolbar button holds the focus. + await app.mainWindow.evaluate(() => + (document.activeElement as HTMLElement | null)?.blur() + ); + await app.mainWindow.keyboard.press('n'); + await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true); + // The keyboard focus follows the jump to the playing row. + await expect( + rows.nth(0).locator('[data-epg-guide-grid][tabindex="0"]') + ).toBeFocused(); + // "Only with EPG" hides the silent channel once coverage is known. const toggle = guide.locator('.guide-toolbar__toggle input'); await expect(toggle).toBeEnabled({ timeout: 20000 }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts index 61350d2ce..466edf229 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts @@ -27,6 +27,7 @@ describe('EpgGuideKeyboardController', () => { isOwnedTarget: jest.fn((_target: EventTarget | null) => true), play: jest.fn(), details: jest.fn(), + revealFocus: jest.fn(), jumpNow: jest.fn(), stepDay: jest.fn(), close: jest.fn(), @@ -71,6 +72,32 @@ describe('EpgGuideKeyboardController', () => { expect(host.play).toHaveBeenLastCalledWith(3); }); + it('reveals the focus only for the keys that move it', () => { + controller.handle(key('ArrowDown')); + controller.handle(key('ArrowRight')); + expect(host.revealFocus).toHaveBeenCalledTimes(2); + + host.revealFocus.mockClear(); + controller.handle(key('n')); + controller.handle(key('PageDown')); + controller.handle(key('Enter')); + expect(host.revealFocus).not.toHaveBeenCalled(); + }); + + it('moves the focus to the playing row on N, where the jump scrolls', () => { + controller.focus.set({ row: 4, block: 1 }); + controller.handle(key('n')); + expect(controller.focus()).toEqual({ row: 2, block: null }); + expect(host.jumpNow).toHaveBeenCalledTimes(1); + + // Nothing playing: the jump stays on the focused row, and so does + // the focus. + host.activeRow.mockReturnValue(-1); + controller.focus.set({ row: 4, block: 1 }); + controller.handle(key('n')); + expect(controller.focus()).toEqual({ row: 4, block: 1 }); + }); + it('maps N, PageUp/PageDown and Escape', () => { controller.handle(key('n')); expect(host.jumpNow).toHaveBeenCalled(); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts index 63be8d988..5a6d26c39 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts @@ -22,6 +22,11 @@ export interface EpgGuideKeyboardHost { isOwnedTarget(target: EventTarget | null): boolean; play(row: number): void; details(row: number, block: number): void; + /** + * Scroll the focus moved by an arrow key into view. N and the day keys + * scroll on their own; a reveal after them would cancel their scroll. + */ + revealFocus(): void; jumpNow(): void; stepDay(direction: EpgDateNavigationDirection): void; close(): void; @@ -112,8 +117,7 @@ export class EpgGuideKeyboardController { return this.details(); case 'n': case 'N': - this.host.jumpNow(); - return true; + return this.jumpNow(); case 'PageUp': this.host.stepDay('prev'); return true; @@ -146,6 +150,7 @@ export class EpgGuideKeyboardController { : count - 1 : clamp(current + delta, 0, count - 1); this.focus.set({ row: next, block: null }); + this.host.revealFocus(); return true; } @@ -156,14 +161,28 @@ export class EpgGuideKeyboardController { } const row = clamp(Math.max(0, this.currentRow()), 0, count - 1); const blocks = this.host.blockCount(row); - if (blocks === 0) { - this.focus.set({ row, block: null }); - return true; - } const current = this.focus()?.row === row ? (this.focus()?.block ?? null) : null; const start = current ?? (delta > 0 ? -1 : blocks); - this.focus.set({ row, block: clamp(start + delta, 0, blocks - 1) }); + this.focus.set({ + row, + block: blocks === 0 ? null : clamp(start + delta, 0, blocks - 1), + }); + this.host.revealFocus(); + return true; + } + + /** + * The jump scrolls to the playing row, so the focus follows it there. Left + * on a far row it would be recycled during the scroll, dropping the DOM + * focus to the page, and the next arrow key would scroll all the way back. + */ + private jumpNow(): boolean { + const row = this.host.activeRow(); + if (row >= 0 && row < this.host.rowCount()) { + this.focus.set({ row, block: null }); + } + this.host.jumpNow(); return true; } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts index bc1463c88..b3bd38ba0 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.spec.ts @@ -2,7 +2,7 @@ import { guideBlockRevealScrollLeft, guideNowScrollLeft, guideRowNeedsReveal, - scrollElementLeft, + scrollElementTo, } from './epg-guide-scroll.util'; function block(leftPx: number, widthPx: number) { @@ -73,25 +73,44 @@ describe('guideBlockRevealScrollLeft', () => { }); }); -describe('scrollElementLeft', () => { +describe('scrollElementTo', () => { it('uses scrollTo when the element implements it', () => { const scrollTo = jest.fn(); const element = { scrollTo, scrollLeft: 0 } as unknown as HTMLElement; - scrollElementLeft(element, 120, true); + scrollElementTo(element, { left: 120 }, true); expect(scrollTo).toHaveBeenCalledWith({ left: 120, behavior: 'smooth', }); - scrollElementLeft(element, 10, false); + scrollElementTo(element, { left: 10 }, false); expect(scrollTo).toHaveBeenLastCalledWith({ left: 10, behavior: 'auto', }); }); - it('falls back to assigning scrollLeft (jsdom has no scrollTo)', () => { - const element = { scrollLeft: 0 } as unknown as HTMLElement; - scrollElementLeft(element, 42, true); + it('scrolls both axes in a single call so neither animation cancels the other', () => { + const scrollTo = jest.fn(); + const element = { scrollTo } as unknown as HTMLElement; + scrollElementTo(element, { left: 640, top: 180 }, true); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenCalledWith({ + left: 640, + top: 180, + behavior: 'smooth', + }); + }); + + it('falls back to assigning the offsets (jsdom has no scrollTo)', () => { + const element = { + scrollLeft: 0, + scrollTop: 5, + } as unknown as HTMLElement; + scrollElementTo(element, { left: 42 }, true); expect(element.scrollLeft).toBe(42); + expect(element.scrollTop).toBe(5); + scrollElementTo(element, { left: 7, top: 90 }, false); + expect(element.scrollLeft).toBe(7); + expect(element.scrollTop).toBe(90); }); }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts index a7691e128..3b7f394c5 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-scroll.util.ts @@ -48,18 +48,34 @@ export function guideBlockRevealScrollLeft( return Math.max(0, block.leftPx - REVEAL_PADDING_PX); } +/** A scroll target; an omitted `top` leaves the vertical offset alone. */ +export interface GuideScrollTarget { + readonly left: number; + readonly top?: number; +} + /** - * `Element.scrollTo` is not implemented everywhere the guide renders (jsdom in - * unit tests), so fall back to assigning `scrollLeft` directly. + * Scroll both axes with one call: a second smooth scroll on the same element + * cancels the first one's animation in Chromium, which left the lane at + * midnight whenever "now" also moved to the playing row. `Element.scrollTo` + * is not implemented everywhere the guide renders (jsdom in unit tests), so + * fall back to assigning the offsets directly. */ -export function scrollElementLeft( +export function scrollElementTo( element: HTMLElement, - left: number, + { left, top }: GuideScrollTarget, animate: boolean ): void { if (typeof element.scrollTo === 'function') { - element.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' }); + element.scrollTo({ + left, + ...(top === undefined ? {} : { top }), + behavior: animate ? 'smooth' : 'auto', + }); return; } element.scrollLeft = left; + if (top !== undefined) { + element.scrollTop = top; + } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts index 5dca1419b..f0b36f7a5 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.spec.ts @@ -1,7 +1,7 @@ import { ListRange } from '@angular/cdk/collections'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { DestroyRef } from '@angular/core'; -import { Subject } from 'rxjs'; +import { config, Subject } from 'rxjs'; import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util'; import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util'; import { EpgGuideChannel } from './epg-guide-source'; @@ -106,6 +106,7 @@ function harness(rowCount = 100): Harness { activeRow: () => 40, ensureLoaded, setScrollLeft, + afterRender: (callback) => callback(), }; return { controller: new EpgGuideViewportController(host), @@ -173,21 +174,91 @@ describe('EpgGuideViewportController', () => { expect(test.ensureLoaded).not.toHaveBeenCalled(); }); - it('scrolls the lane and the playing row to now, and does nothing off-day', () => { + it('scrolls the lane and the playing row to now in one call, and does nothing off-day', () => { const test = harness(); + test.controller.scrollToNow(900, true); + // 1000 - 200 visible, a third of it kept to the left of the line; the + // playing row 40 keeps three rows above it. A second, vertical smooth + // scroll would cancel the horizontal one in Chromium (#1733). + expect(test.scrollTo).toHaveBeenCalledTimes(1); + expect(test.scrollTo).toHaveBeenCalledWith({ + left: 900 - 800 / 3, + top: 37 * 60, + behavior: 'smooth', + }); + expect(test.scrollToIndex).not.toHaveBeenCalled(); + + test.scrollTo.mockClear(); + test.controller.scrollToNow(null, false); + expect(test.scrollTo).not.toHaveBeenCalled(); + }); + + it('scrolls only the lane to now when no channel is playing', () => { + const test = harness(); + test.host.activeRow = () => -1; test.controller.scrollToNow(900, false); - // 1000 - 200 visible, a third of it kept to the left of the line. expect(test.scrollTo).toHaveBeenCalledWith({ left: 900 - 800 / 3, behavior: 'auto', }); - expect(test.scrollToIndex).toHaveBeenCalledWith(37, 'auto'); + }); - test.scrollTo.mockClear(); - test.scrollToIndex.mockClear(); - test.controller.scrollToNow(null, false); - expect(test.scrollTo).not.toHaveBeenCalled(); - expect(test.scrollToIndex).not.toHaveBeenCalled(); + it('waits for the first rendered rows before the initial jump, once', () => { + const test = harness(); + const callback = jest.fn(); + test.controller.whenRowsRendered( + test.viewport, + test.destroyRef, + callback + ); + + // The CDK reports an empty range before it has measured itself. + test.renderedRange$.next({ start: 0, end: 0 }); + expect(callback).not.toHaveBeenCalled(); + + test.renderedRange$.next({ start: 0, end: 12 }); + test.renderedRange$.next({ start: 4, end: 16 }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('closes cleanly when the viewport completes without ever rendering rows', async () => { + const test = harness(); + const callback = jest.fn(); + const onUnhandledError = jest.fn(); + const previous = config.onUnhandledError; + config.onUnhandledError = onUnhandledError; + try { + test.controller.whenRowsRendered( + test.viewport, + test.destroyRef, + callback + ); + // An empty scope: the CDK only ever reports an empty range, then + // completes the stream when the guide closes. + test.renderedRange$.next({ start: 0, end: 0 }); + test.renderedRange$.complete(); + // RxJS reports unhandled errors from a timeout. + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + config.onUnhandledError = previous; + } + + expect(onUnhandledError).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + + it('drops the initial jump when the host is destroyed first', () => { + const test = harness(); + const callback = jest.fn(); + test.controller.whenRowsRendered( + test.viewport, + test.destroyRef, + callback + ); + test.destroy(); + + test.renderedRange$.next({ start: 0, end: 12 }); + expect(callback).not.toHaveBeenCalled(); }); it('gives the DOM focus to the cell holding the roving tabindex', () => { @@ -217,6 +288,70 @@ describe('EpgGuideViewportController', () => { test.element.remove(); }); + it('focuses the roving target only once its row is rendered', () => { + const test = harness(); + test.controller.watch(test.viewport, test.destroyRef); + test.renderedRange$.next({ start: 0, end: 20 }); + const cell = document.createElement('button'); + cell.setAttribute('data-epg-guide-grid', ''); + cell.tabIndex = 0; + const focus = jest.spyOn(cell, 'focus'); + + // A smooth jump to row 40: the row is not rendered yet. + test.controller.focusRovingTargetOnRow(40); + expect(focus).not.toHaveBeenCalled(); + test.renderedRange$.next({ start: 20, end: 35 }); + expect(focus).not.toHaveBeenCalled(); + test.element.appendChild(cell); + test.renderedRange$.next({ start: 30, end: 50 }); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + + // Already rendered: focused after the next render, and only once. + focus.mockClear(); + test.controller.focusRovingTargetOnRow(35); + expect(focus).toHaveBeenCalledTimes(1); + test.renderedRange$.next({ start: 30, end: 60 }); + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('drops a pending roving focus when a newer one is requested', () => { + const test = harness(); + test.controller.watch(test.viewport, test.destroyRef); + test.renderedRange$.next({ start: 0, end: 20 }); + const cell = document.createElement('button'); + cell.setAttribute('data-epg-guide-grid', ''); + cell.tabIndex = 0; + test.element.appendChild(cell); + const focus = jest.spyOn(cell, 'focus'); + + test.controller.focusRovingTargetOnRow(40); + test.controller.focusRovingTargetOnRow(60); + focus.mockClear(); + test.renderedRange$.next({ start: 30, end: 50 }); + expect(focus).not.toHaveBeenCalled(); + test.renderedRange$.next({ start: 50, end: 70 }); + expect(focus).toHaveBeenCalledTimes(1); + }); + + it('does not take the focus from a control outside the grid', () => { + const test = harness(); + const cell = document.createElement('button'); + cell.setAttribute('data-epg-guide-grid', ''); + cell.tabIndex = 0; + test.element.appendChild(cell); + const focus = jest.spyOn(cell, 'focus'); + const field = document.createElement('input'); + document.body.appendChild(field); + field.focus(); + try { + test.controller.focusRovingTarget(); + expect(focus).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(field); + } finally { + field.remove(); + } + }); + it('reveals the focused row and block, and ignores a null focus', () => { const test = harness(); test.controller.revealFocus({ row: 40, block: 1 }); diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts index 157d884b1..406e0c830 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide-viewport.controller.ts @@ -2,6 +2,7 @@ import { ListRange } from '@angular/cdk/collections'; import { DestroyRef } from '@angular/core'; import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { filter, Subscription, take } from 'rxjs'; import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util'; import { EpgGuideFocus } from './epg-guide-keyboard.controller'; import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util'; @@ -9,7 +10,7 @@ import { guideBlockRevealScrollLeft, guideNowScrollLeft, guideRowNeedsReveal, - scrollElementLeft, + scrollElementTo, } from './epg-guide-scroll.util'; import { EpgGuideChannel } from './epg-guide-source'; @@ -30,6 +31,8 @@ export interface EpgGuideViewportHost { ensureLoaded(channels: readonly EpgGuideChannel[]): void; /** Reports the viewport's horizontal offset; drives the ruler and now-line. */ setScrollLeft(left: number): void; + /** Run `callback` after the next render (`afterNextRender`). */ + afterRender(callback: () => void): void; } /** @@ -40,6 +43,7 @@ export interface EpgGuideViewportHost { */ export class EpgGuideViewportController { private renderedRange: ListRange | null = null; + private pendingFocus: Subscription | null = null; constructor(private readonly host: EpgGuideViewportHost) {} @@ -99,6 +103,31 @@ export class EpgGuideViewportController { this.host.ensureLoaded(rows.slice(start, end)); } + /** + * Call `callback` once, when the viewport first reports rows to render. + * The CDK attaches its scroll strategy a microtask after init and renders + * rows in a later pass, so a scroll issued on the guide's first render + * finds neither content width nor height and is clamped to the top-left — + * the guide then opened at midnight. The callback still has to wait for + * that render (`afterNextRender`) before it scrolls. + */ + whenRowsRendered( + viewport: CdkVirtualScrollViewport, + destroyRef: DestroyRef, + callback: () => void + ): void { + viewport.renderedRangeStream + .pipe( + // Not `first(predicate)`: the CDK completes the stream on + // destroy, and a guide closed without ever having rows would + // then raise an `EmptyError`. + filter((range) => range.end > range.start), + take(1), + takeUntilDestroyed(destroyRef) + ) + .subscribe(() => callback()); + } + /** Put the now-line into view, and the playing channel's row with it. */ scrollToNow(nowLeftPx: number | null, animate: boolean): void { const viewport = this.host.viewport(); @@ -106,22 +135,25 @@ export class EpgGuideViewportController { return; } const element = viewport.elementRef.nativeElement; - scrollElementLeft( + const activeRow = this.host.activeRow(); + scrollElementTo( element, - guideNowScrollLeft( - element.clientWidth, - nowLeftPx, - this.host.channelColumnPx() - ), + { + left: guideNowScrollLeft( + element.clientWidth, + nowLeftPx, + this.host.channelColumnPx() + ), + // The fixed-size strategy's `scrollToIndex` offset, applied in + // the same call as the horizontal one (see `scrollElementTo`). + top: + activeRow >= 0 + ? Math.max(0, activeRow - ACTIVE_ROW_MARGIN) * + this.host.rowHeightPx() + : undefined, + }, animate ); - const activeRow = this.host.activeRow(); - if (activeRow >= 0) { - viewport.scrollToIndex( - Math.max(0, activeRow - ACTIVE_ROW_MARGIN), - animate ? 'smooth' : 'auto' - ); - } } /** @@ -132,6 +164,12 @@ export class EpgGuideViewportController { */ focusRovingTarget(): void { const element = this.host.viewport()?.elementRef.nativeElement; + const active = document.activeElement; + // Only a focus inside the grid, or one already lost to the page, is + // moved: a deferred call must not take it from a control used since. + if (active && active !== document.body && !element?.contains(active)) { + return; + } const target = element?.querySelector( '[data-epg-guide-grid][tabindex="0"]' ); @@ -140,6 +178,29 @@ export class EpgGuideViewportController { } } + /** + * `focusRovingTarget` once `row` is rendered. A smooth jump renders a far + * row only towards its end, and only a rendered cell can take the focus; + * the CDK may recycle the previously focused one meanwhile. Before the + * viewport has reported a range (jsdom), the next render is used. + */ + focusRovingTargetOnRow(row: number): void { + this.pendingFocus?.unsubscribe(); + this.pendingFocus = null; + const viewport = this.host.viewport(); + const focus = () => + this.host.afterRender(() => this.focusRovingTarget()); + const rendered = (range: ListRange | null) => + range === null || (range.start <= row && row < range.end); + if (!viewport || rendered(this.renderedRange)) { + focus(); + return; + } + this.pendingFocus = viewport.renderedRangeStream + .pipe(filter(rendered), take(1)) + .subscribe(focus); + } + /** Keep the keyboard focus target inside the viewport, both axes. */ revealFocus(focused: EpgGuideFocus | null): void { const viewport = this.host.viewport(); @@ -170,7 +231,7 @@ export class EpgGuideViewportController { this.host.channelColumnPx() ); if (typeof left === 'number') { - scrollElementLeft(element, left, true); + scrollElementTo(element, { left }, true); } } } diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts index 3eab4e372..49044789a 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts @@ -447,6 +447,29 @@ describe('EpgGuideComponent', () => { ]); }); + it('jumps to now on N without scrolling back to a focus left off-screen', async () => { + await settle(fixture); + const viewportEl: HTMLElement = fixture.debugElement.query( + By.css('cdk-virtual-scroll-viewport') + ).nativeElement; + const scrollTo = jest.fn(); + viewportEl.scrollTo = scrollTo as unknown as HTMLElement['scrollTo']; + // jsdom reports a zero-sized lane, so this programme counts as hidden. + component.focusCell(0, 0); + await settle(fixture); + scrollTo.mockClear(); + + component.onKeydown(keydown('n')); + + // One combined smooth scroll; a reveal after it would cancel it. The + // focus follows the jump to the playing row. + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ top: 0, behavior: 'smooth' }) + ); + expect(component.focus()).toEqual({ row: 0, block: null }); + }); + it('moves the roving focus to a clicked programme card', async () => { await settle(fixture); const card = fixture.debugElement.query( diff --git a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts index 512f01303..dfed6d379 100644 --- a/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +++ b/libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts @@ -151,6 +151,7 @@ export class EpgGuideComponent implements OnDestroy { play: (row) => this.commitRow(this.rows()[row]), details: (row, block) => this.openDetails(this.rows()[row], this.blocksFor(row)[block]), + revealFocus: () => this.viewportController.revealFocus(this.focus()), jumpNow: () => this.jumpNow(), stepDay: (direction) => this.stepDay(direction), close: () => this.close.emit(), @@ -184,6 +185,8 @@ export class EpgGuideComponent implements OnDestroy { activeRow: () => this.activeRowIndex(), ensureLoaded: (channels) => this.programsService.ensureLoaded(channels), setScrollLeft: (left) => this.view.scrollLeft.set(left), + afterRender: (callback) => + afterNextRender(callback, { injector: this.injector }), }); private readonly dialogs = new EpgGuideDialogController( @@ -229,11 +232,18 @@ export class EpgGuideComponent implements OnDestroy { if (!viewport) { return; } - untracked(() => - this.viewportController.watch(viewport, this.destroyRef) - ); + untracked(() => { + this.viewportController.watch(viewport, this.destroyRef); + this.viewportController.whenRowsRendered( + viewport, + this.destroyRef, + () => + afterNextRender(() => this.jumpNow(false), { + injector: this.injector, + }) + ); + }); }); - afterNextRender(() => this.jumpNow(false)); } ngOnDestroy(): void { @@ -246,7 +256,7 @@ export class EpgGuideComponent implements OnDestroy { * listener of its own — but it must own the DOM focus, or a screen reader * would still announce whatever the user tabbed from. The roving * `tabindex="0"` moves with the signal, so the element to focus only exists - * after the next render. + * after the next render — after N's smooth jump, once its row is rendered. */ @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { @@ -254,10 +264,7 @@ export class EpgGuideComponent implements OnDestroy { return; } event.preventDefault(); - this.viewportController.revealFocus(this.focus()); - afterNextRender(() => this.viewportController.focusRovingTarget(), { - injector: this.injector, - }); + this.viewportController.focusRovingTargetOnRow(this.tabbableRow()); } trackRow(_index: number, channel: EpgGuideChannel): string {