Merge remote-tracking branch 'origin/master' into agent/portal-playlist-handoff

This commit is contained in:
4gray committed 2026-10-06 23:04:07 +02:00
commit 278245121c
204 files changed
+11190 -4519

No files matched your search

@@ -0,0 +1,10 @@
---
type: fix
area: dashboard
---
The dashboard hero stays readable in the light theme: titles without artwork
get a light tinted backdrop instead of a dark slab, text keeps a solid backing
over busy artwork (also in narrow windows), and the star rating is darker.
Screen readers now find one stable "Dashboard" page heading and hear slide
changes they make.
@@ -0,0 +1,8 @@
---
type: perf
area: electron
---
On Linux the desktop app's window no longer sometimes appears about a second
late at launch: it now opens as soon as the app has loaded, showing the
loading screen until the dashboard is ready.
@@ -0,0 +1,6 @@
---
type: fix
area: workspace
---
On macOS, zooming the app out no longer slides the Back button and the playlist switcher under the window's close, minimize and zoom buttons; the top bar also stays tall enough that the buttons never overlap the page below.
@@ -72,5 +72,11 @@ runs:
(($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(" → "))", ""))
"Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", ""),
([($j.iterations // [])[] | select(.warmup | not) | .evidence.perKeystroke // empty][0] // empty |
"Per keystroke (first measured iteration):", "",
"| Key | Query calls | Bridge calls | SQL statements | DOM mutations | CD ticks |",
"| --- | ---: | ---: | ---: | ---: | ---: |",
(.[] | "| `\(.key)` | \(.queryCalls) | \(.ipcCalls) | \(.sqlStatements) | \(.domMutations) | \(.cdTicks) |"),
""))
' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -416,7 +416,7 @@ jobs:
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
+34 -6
View File
@@ -98,7 +98,7 @@ jobs:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -140,7 +140,7 @@ jobs:
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -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, playback) is six fresh Electron processes plus
# one seeding run.
# (launch, open-source, playback, search) 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.
@@ -310,7 +310,7 @@ jobs:
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -324,8 +324,36 @@ jobs:
# Electron dependency check, the xvfb run, the summary lookup and
# the job-summary report; shared with performance-ratchet.yml.
- name: Run the performance journeys
id: journeys
uses: ./.github/actions/performance-journeys
# Only the counters identical in every measured iteration of
# recent master runs; their entries say whether a counter is
# validated against wall-clock or a guard only (see Ratchet in
# docs/architecture/performance-journeys.md). Here and not in the
# composite action, so the weekly tightening still measures a run
# that would fail it. tools/performance tests keep this list equal
# to the journey entries of journey-baselines.json. It also runs
# when a later step of the action (the job-summary report) failed
# after the summary was written, so the counters are still checked.
- name: Check the journey counters against the baselines
if: ${{ !cancelled() && steps.journeys.outputs.summary != '' }}
env:
SUMMARY: ${{ steps.journeys.outputs.summary }}
run: >-
node tools/performance/check-journey-ratchet.mjs
--summary "$SUMMARY"
--only launch/renderer.ipcCallsToFirstCard
--only launch/renderer.domMutationsToFirstCard
--only launch/main.modulesRegisteredBeforeWindow
--only launch/renderer.layoutShiftScore
--only launch/renderer.layoutShiftScoreSettled
--only open-source/main.mockHttpRequestsToSettled
--only open-source/renderer.ipcCallsToFirstPage
--only open-source/renderer.layoutShiftScore
--only playback/renderer.httpRequestsToPlaying
--only playback/renderer.layoutShiftScore
- name: Upload journey summaries
if: always()
uses: actions/upload-artifact@v7
@@ -349,7 +377,7 @@ jobs:
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
+2 -2
View File
@@ -54,7 +54,7 @@ jobs:
# commit checked out above (the modern default); JavaScript is
# interpreted, so no build step is needed before analysis.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.7
uses: github/codeql-action/init@v4.38.2
with:
languages: ${{ matrix.language }}
# Excludes the localhost dev/E2E mock servers from analysis; see the
@@ -66,4 +66,4 @@ jobs:
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.37.7
uses: github/codeql-action/analyze@v4.38.2
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
+2 -2
View File
@@ -67,7 +67,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -201,7 +201,7 @@ jobs:
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
+2 -2
View File
@@ -43,7 +43,7 @@ jobs:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
- name: Setup Node.js
uses: actions/setup-node@v7
@@ -240,7 +240,7 @@ jobs:
git diff "$HEAD_SHA" HEAD -- "$baselines"
echo '```'
echo
echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet job passes on this PR before merging."
echo "If \`master\` moved since \`$HEAD_SHA\`, make sure the Initial bytes ratchet and Performance journeys jobs pass on this PR before merging."
} > "$BODY"
gh api -X PATCH "repos/$REPOSITORY/pulls/$pr" -F "body=@$BODY" --silent
echo "Pull request: ${GITHUB_SERVER_URL}/$REPOSITORY/pull/$pr"
@@ -29,6 +29,11 @@ import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
@@ -305,38 +310,6 @@ function dashboardRailCardByTitle(
.first();
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
async function expectInlineCollectionDetail(
page: Page,
params: {
@@ -383,20 +356,3 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,54 @@
import type { Page } from '@playwright/test';
import { expect } from './electron-test-fixtures';
// Steps that put content on the dashboard: favourites from the Live TV list
// and from a detail page, and the way back from that detail page.
export async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
export async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({
timeout: 20000,
});
}
export async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -0,0 +1,355 @@
import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import sharp = require('sharp');
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
import { applyTheme, measureBackdropTextContrast } from './theme-contrast';
import {
addCurrentDetailToFavorites,
goBackFromDetail,
toggleFavoriteForChannel,
} from './dashboard-e2e-flows';
// ---------------------------------------------------------------------------
// The dashboard hero's text must stay legible over any artwork, in both
// themes and in the narrow layout where the slide spans the whole width.
// Every mock image is replaced by a black-and-white checkerboard, the worst
// case for either theme's scrim; series images fail, so the favourited
// series falls back to the generated gradient. Each slide kind (16:9
// backdrop, blurred poster, no artwork, live channel) is measured from the
// screen at a wide and a narrow content width.
// ---------------------------------------------------------------------------
type SlideKind = 'backdrop' | 'poster' | 'fallback' | 'live';
const widths = { wide: 1280, narrow: 760 } as const;
const minimumContrast = 4.5;
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
/** A full TMDB slide has a rating and a two-line overview; the mock has no
* TMDB, so the measurement adds both, styled by the hero's own rules. */
const sampleOverview =
'A long synopsis that wraps onto a second line, so the body text of a ' +
'fully enriched slide is measured where it really sits over the artwork.';
async function busyArtwork(): Promise<Buffer> {
const width = 1280;
const height = 720;
const square = 40;
const pixels = Buffer.alloc(width * height * 3);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const white =
(Math.floor(x / square) + Math.floor(y / square)) % 2 === 0;
pixels.fill(
white ? 255 : 0,
(y * width + x) * 3,
(y * width + x) * 3 + 3
);
}
}
return sharp(pixels, { raw: { width, height, channels: 3 } })
.png()
.toBuffer();
}
/** Serves the checkerboard for every mock image except series artwork,
* which fails: an image the page has already shown is reused from memory,
* so the failure has to be in place before the series is first opened. */
async function routeArtwork(page: Page): Promise<void> {
const image = await busyArtwork();
await page.route(
(url) => url.hostname === 'picsum.photos',
(route) =>
/\/seed\/series-/.test(route.request().url())
? route.fulfill({ status: 404, body: '' })
: route.fulfill({
status: 200,
contentType: 'image/png',
body: image,
})
);
}
async function slideKinds(page: Page): Promise<SlideKind[]> {
return page.locator('.hero__backdrop').evaluateAll((backdrops) =>
backdrops.map((backdrop): SlideKind => {
if (backdrop.classList.contains('hero__backdrop--live')) {
return 'live';
}
if (!backdrop.querySelector('.hero__backdrop-image')) {
return 'fallback';
}
return backdrop.classList.contains('hero__backdrop--poster')
? 'poster'
: 'backdrop';
})
);
}
async function showSlide(page: Page, index: number): Promise<Locator> {
const dot = page.getByTestId('dashboard-hero-dot').nth(index);
await dot.click();
await expect(dot).toHaveAttribute('aria-current', 'true');
// The backdrop crossfade and the slide's entrance have finished (not the
// image's slow zoom, which never changes what is under the text).
await expect
.poll(() =>
page.evaluate(() =>
[
document.querySelector('.hero__backdrop--active'),
document.querySelector('.hero__content'),
].every(
(element) =>
element &&
element
.getAnimations()
.every(
(animation) => animation.playState !== 'running'
)
)
)
)
.toBe(true);
return page.getByTestId('dashboard-hero-slide');
}
/** Sum of non-input layout shifts while the hero runs through every slide
* on its own, at a shortened interval. */
async function rotationLayoutShift(page: Page): Promise<number> {
const hero = page.getByTestId('dashboard-hero');
const dots = page.getByTestId('dashboard-hero-dot');
const count = await dots.count();
await page.mouse.move(1, 1);
await hero.evaluate((element) => {
(element as HTMLElement).style.setProperty(
'--hero-rotation-ms',
'600ms'
);
const shifts: number[] = [];
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
hadRecentInput: boolean;
})[]) {
if (!entry.hadRecentInput) {
shifts.push(entry.value);
}
}
}).observe({ type: 'layout-shift' });
(window as unknown as { __heroShifts: number[] }).__heroShifts = shifts;
});
// Back to the first slide after one full cycle, then a quiet moment.
const first = await dots.evaluateAll((all) =>
all.findIndex((dot) => dot.getAttribute('aria-current') === 'true')
);
for (let step = 1; step <= count; step++) {
await expect(dots.nth((first + step) % count)).toHaveAttribute(
'aria-current',
'true',
{ timeout: 5_000 }
);
}
await page.waitForTimeout(500);
return page.evaluate(() =>
(window as unknown as { __heroShifts: number[] }).__heroShifts.reduce(
(sum, value) => sum + value,
0
)
);
}
/** Adds what a TMDB-enriched slide shows: a rating chip and an overview. */
async function enrichSlide(slide: Locator): Promise<void> {
await slide.evaluate((content, overview) => {
const chip = content.querySelector('.hero__pill');
if (chip) {
const rating = chip.cloneNode() as HTMLElement;
rating.classList.add('meta-chip--rating');
rating.textContent = '★ 7.4';
chip.before(rating);
}
// The overview takes the title's view encapsulation attribute, so the
// hero's `.hero__description` rule styles it.
const title = content.querySelector('.hero__title');
const scope = Array.from(title?.attributes ?? []).find((attribute) =>
attribute.name.startsWith('_ngcontent')
);
const actions = content.querySelector('.hero__actions');
if (scope && actions && !content.querySelector('.hero__description')) {
const description = document.createElement('p');
description.setAttribute(scope.name, '');
description.className = 'hero__description';
description.textContent = overview;
actions.before(description);
}
}, sampleOverview);
await expect(slide.locator('.hero__description')).toHaveCount(1);
}
/** Every piece of slide text a viewer reads, one element per colour. */
function slideTexts(slide: Locator): Record<string, Locator> {
return {
eyebrow: slide.locator('.hero__eyebrow > span:not(.hero__eyebrow-sep)'),
title: slide.locator('.hero__title'),
pill: slide.locator('.hero__pill'),
programme: slide.locator('.hero__programme'),
description: slide.locator('.hero__description'),
button: slide.locator('.hero__button > span'),
};
}
test.describe('Dashboard hero legibility', () => {
test('keeps slide text at 4.5:1 over any artwork in both themes and widths', async ({
dataDir,
request,
}, testInfo) => {
test.setTimeout(240_000);
await resetMockServers(request, ['xtream']);
const live = await fetchXtreamLiveFixture(request, xtreamCredentials);
const vod = await fetchXtreamVodFixture(request, xtreamCredentials);
const series = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
const results: string[] = [];
try {
await routeArtwork(page);
await page.setViewportSize({ width: widths.wide, height: 800 });
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
// Live slide: a favourite channel with a programme on air.
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, live.categoryName);
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
// Backdrop slide: favouriting from the detail page stores the
// movie's 16:9 backdrop.
await page
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(page, vod.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
// No-artwork slide: series images fail, poster and backdrop.
await page
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(page, series.categoryName);
await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goToDashboard(page);
await expect(page.getByTestId('dashboard-hero')).toBeVisible({
timeout: 20_000,
});
// Poster-only slides come from the Xtream "recently added" list.
await expect
.poll(async () => [...new Set(await slideKinds(page))].sort(), {
timeout: 20_000,
})
.toEqual(['backdrop', 'fallback', 'live', 'poster']);
// One stable page heading; the rotating slide title is an h2.
await expect(page.locator('h1')).toHaveCount(1);
await expect(page.getByTestId('dashboard-page-heading')).toHaveText(
'Dashboard'
);
await expect(
page
.getByTestId('dashboard-hero-slide')
.locator('h2.hero__title')
).toHaveCount(1);
// An unattended rotation, counted like the launch journey's
// settled layout-shift counter (non-input shifts only). Slides of
// different heights still resize the hero by a few pixels and
// move the rails below (0.005 here, 0.013 before this change);
// a scrim or heading that reflowed the slide would add lines.
const shift = await rotationLayoutShift(page);
results.push(`rotation layout shift ${shift.toFixed(3)}`);
expect(shift).toBeLessThan(0.02);
await page.getByTestId('dashboard-hero-pause').click();
const kinds = await slideKinds(page);
for (const theme of ['light', 'dark'] as const) {
await applyTheme(page, theme);
for (const [layout, width] of Object.entries(widths)) {
await page.setViewportSize({ width, height: 800 });
// The narrow layout is the dashboard container's
// ≤720px query, not the window width.
const narrow = await page
.locator('.hero__content')
.evaluate(
(content) =>
getComputedStyle(content).maxWidth === 'none'
);
expect(narrow).toBe(layout === 'narrow');
for (const [index, kind] of kinds.entries()) {
const slide = await showSlide(page, index);
await enrichSlide(slide);
await page.mouse.move(1, 1);
const name = `${theme}-${layout}-${kind}`;
const shot = testInfo.outputPath(`hero-${name}.png`);
await page
.getByTestId('dashboard-hero')
.screenshot({ path: shot });
await testInfo.attach(name, {
path: shot,
contentType: 'image/png',
});
for (const [part, texts] of Object.entries(
slideTexts(slide)
)) {
for (const text of await texts.all()) {
const ratio = await measureBackdropTextContrast(
page,
text
);
results.push(
`${name} ${part} ${ratio.toFixed(2)}`
);
expect
.soft(ratio, `${name} ${part}`)
.toBeGreaterThanOrEqual(minimumContrast);
}
}
}
}
}
} finally {
const report = testInfo.outputPath('contrast.txt');
writeFileSync(report, results.join('\n'));
await testInfo.attach('contrast', {
path: report,
contentType: 'text/plain',
});
await closeElectronApp(app);
}
});
});
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
readonly gatedEpochMs: number | null;
readonly gatedMethod: string | null;
readonly passThroughLoads: number;
/** `did-finish-load` events kept from the app's listeners on about:blank. */
readonly didFinishLoadHeldOnBlank: number;
/** `ready-to-show` events dropped while the window was on about:blank. */
readonly readyToShowHeldOnBlank: number;
readonly releasedEpochMs: number | null;
@@ -1,10 +1,14 @@
import type { ElectronApplication, Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { configureLiveFormat } from '../xtream-live-format.fixture';
import {
JOURNEY_CLICK_QUIET_MS,
waitForJourneyClickQuiet,
} from '../performance/journey-click-settle';
import {
blockJourneyExternalArtwork,
readJourneyExternalArtworkCancelled,
} from '../performance/journey-external-artwork';
import {
detachJourneyMainIpcCapture,
installJourneyMainIpcCapture,
@@ -47,14 +51,6 @@ 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 = {
@@ -68,45 +64,6 @@ export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = {
},
};
async function blockExternalArtwork(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ session }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
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<number> {
return electronApp.evaluate(
(_electron, key) =>
(
(globalThis as unknown as Record<string, unknown>)[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<void> {
await page
@@ -143,7 +100,7 @@ export async function measurePlaybackJourney(
if (!startClick) {
throw new Error('playback-journey-probe-without-start');
}
await blockExternalArtwork(electronApp);
await blockJourneyExternalArtwork(electronApp);
await openLiveCategory(mainWindow, timeoutMs);
const channel = mainWindow.locator(startClick.selector).first();
await channel.waitFor({ state: 'visible', timeout: timeoutMs });
@@ -199,7 +156,7 @@ export async function measurePlaybackJourney(
);
return {
externalArtworkCancelled:
await readCancelledExternalArtwork(electronApp),
await readJourneyExternalArtworkCancelled(electronApp),
http: {
afterPlaying: sinceSpawn.filter(
(entry) =>
@@ -0,0 +1,435 @@
import type { ElectronApplication, Page } from '@playwright/test';
import {
blockJourneyExternalArtwork,
readJourneyExternalArtworkCancelled,
} from '../performance/journey-external-artwork';
import {
JOURNEY_MAIN_COUNTER,
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
} from '../performance/journey-main-counters';
import {
countJourneyMainIpcInFlight,
detachJourneyMainIpcCapture,
installJourneyMainIpcCapture,
JOURNEY_MAIN_IPC_STATE_KEY,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
peekJourneyMainIpcCaptures,
readJourneyMainIpcCapture,
} from '../performance/journey-main-ipc-capture';
import { waitForJourneyQuiet } from '../performance/journey-quiet-wait';
import {
armSearchJourneyProbe,
createSearchJourneyProbeOptions,
readSearchJourneyPreStartMutations,
type SearchJourneyProbeOptions,
SEARCH_JOURNEY_INPUT_SELECTOR,
SEARCH_JOURNEY_ROUTE_PATH,
waitForSearchJourneyProbe,
} from '../performance/search-journey-probe';
import {
SEARCH_JOURNEY_QUERY_METHOD,
type SearchJourneyActivitySample,
type SearchJourneyMeasurement,
type SearchJourneyQueryTraceEntry,
type SearchJourneySettle,
} from '../performance/search-journey-record';
import { JOURNEY_RENDERER_GATE_KEY } from './journey-renderer-gate-client';
import type {
LaunchJourneySeedOptions,
LaunchJourneySession,
} from './launch-journey-app';
/**
* J4 "Search": runs inside a process that J1 has just launched with the
* main-process counters on (SQL statements are counted). The test opens
* global search from the rail and focuses the header search box (not
* measured), lets the app settle, then types the query one key at a time
* at a fixed interval and measures until the results have settled.
*
* Main-process activity (bridge calls, SQL statements) is sampled before
* every keystroke, so the record can show what each key caused: with the
* shell's debounce, only the last interval should run a query. Contract:
* docs/architecture/performance-journeys.md.
*/
export const SEARCH_JOURNEY_MAIN_IPC_STATE_KEY =
'__iptvnatorJourneySearchMainIpcCapture';
export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
/**
* Six characters; on the mock's `large` account (12,000 items) the term
* matches 170 series titles, more than the first page of 100.
*/
export const SEARCH_JOURNEY_QUERY = 'system';
/** Well below the shell's 350 ms input debounce, like steady typing. */
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
const QUIET_MS = 1_000;
const QUIET_POLL_MS = 100;
const QUIET_TIMEOUT_MS = 30_000;
const AFTER_SETTLED_WINDOW_MS = 500;
const ERROR_PREFIX = 'search-journey';
/** J1's M3U source plus the mock's existing 12,000-item `large` catalog. */
export const SEARCH_JOURNEY_SEED: LaunchJourneySeedOptions = {
portal: {
name: SEARCH_JOURNEY_PORTAL_NAME,
password: 'large',
username: 'large',
},
};
/**
* One synchronous pass in the main process: the capture's counts and the
* registered counters handler (which reads the registry synchronously), so
* no bridge call or SQL report can land between the two reads.
*/
async function sampleActivity(
electronApp: ElectronApplication
): Promise<SearchJourneyActivitySample> {
return electronApp.evaluate(
async (_electron, input) => {
const target = globalThis as unknown as Record<string, unknown>;
const capture = target[input.captureKey] as
| {
callsBeforeSentinel: number;
callsByMethod: Record<string, number>;
}
| undefined;
const gate = target[input.gateKey] as
| { invokeHandler?: (channel: string) => Promise<unknown> }
| undefined;
if (!capture || typeof gate?.invokeHandler !== 'function') {
throw new Error('search-journey-sample-unavailable');
}
const ipcCalls = capture.callsBeforeSentinel;
const queryCalls = capture.callsByMethod[input.queryMethod] ?? 0;
const pending = gate.invokeHandler(input.channel);
const snapshot = (await pending) as {
counters?: Record<string, number>;
} | null;
const sqlStatements = snapshot?.counters?.[input.sqlCounter];
if (typeof sqlStatements !== 'number') {
throw new Error('search-journey-sql-counter-missing');
}
return { ipcCalls, queryCalls, sqlStatements };
},
{
captureKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
channel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
gateKey: JOURNEY_RENDERER_GATE_KEY,
queryMethod: SEARCH_JOURNEY_QUERY_METHOD,
sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
}
);
}
/**
* Waits until DOM, bridge calls (started and in flight) and SQL statements
* have all been unchanged for `QUIET_MS`, so leftovers of the launch and of
* the navigation to global search are not attributed to the first key.
*/
async function waitForSearchQuiet(
electronApp: ElectronApplication,
page: Page,
probeStateKey: string
): Promise<SearchJourneySettle> {
const { sample, waitedMs } = await waitForJourneyQuiet({
inFlight: (activity) => activity.ipcInFlight,
pollMs: QUIET_POLL_MS,
quietMs: QUIET_MS,
sample: async () => {
const [launchCapture, journeyCapture] =
await peekJourneyMainIpcCaptures(electronApp, [
JOURNEY_MAIN_IPC_STATE_KEY,
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
]);
if (launchCapture.unmatchedCompletions > 0) {
throw new Error(`${ERROR_PREFIX}-bridge-completions-unmatched`);
}
return {
domMutations: await readSearchJourneyPreStartMutations(
page,
probeStateKey
),
ipcCalls: journeyCapture.callsBeforeStart,
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
sqlStatements: (await sampleActivity(electronApp))
.sqlStatements,
};
},
timeoutError: (activity) =>
new Error(`${ERROR_PREFIX}-not-quiet: ${JSON.stringify(activity)}`),
timeoutMs: QUIET_TIMEOUT_MS,
});
return {
preStartDomMutations: sample.domMutations,
preStartIpcCalls: sample.ipcCalls,
quietMs: QUIET_MS,
sqlStatements: sample.sqlStatements,
waitedMs,
};
}
function sleepUntil(epochMs: number): Promise<void> {
const remainingMs = epochMs - Date.now();
return remainingMs > 0
? new Promise((resolve) => setTimeout(resolve, remainingMs))
: Promise.resolve();
}
const QUERY_TRACE_KEY = '__iptvnatorJourneySearchQueryTrace';
/**
* Records every trace event of the query method in the main process, with
* the term and result length the preload's summaries carry and the main
* process's arrival epoch (the clock the IPC capture stamps its sentinels
* with). The record uses it to prove the final term's query completed
* before the settle, so results of an earlier term cannot end the journey.
* The summaries hold the search term and counts only.
*/
async function traceQueryCalls(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ ipcMain }, input) => {
const entries: unknown[] = [];
(globalThis as unknown as Record<string, unknown>)[input.key] =
entries;
ipcMain.on(input.channel, (_event, payload: unknown) => {
const record = payload as Record<string, unknown> | null;
if (record?.['method'] !== input.method) return;
const args = record['args'] as { items?: unknown[] } | null;
const result = record['result'] as { length?: unknown } | null;
const term = args?.items?.[0];
entries.push({
epochMs: Date.now(),
phase: String(record['phase']),
resultLength:
typeof result?.length === 'number'
? result.length
: null,
term: typeof term === 'string' ? term : null,
});
});
},
{
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
key: QUERY_TRACE_KEY,
method: SEARCH_JOURNEY_QUERY_METHOD,
}
);
}
const SENTINEL_SQL_KEY = '__iptvnatorJourneySearchSentinelSql';
/**
* Reads `main.sqlStatements` in the main process when the start and the end
* sentinel arrive, so the SQL counter covers exactly the IPC capture's
* window: database work just before the first key or after the settle is
* not counted. The gate's `invokeHandler` runs the counters handler
* synchronously, so the value is the total at the sentinel's arrival even
* though it is stored when the promise settles.
*/
async function stampSqlAtSentinels(
electronApp: ElectronApplication,
probeOptions: SearchJourneyProbeOptions
): Promise<void> {
await electronApp.evaluate(
({ ipcMain }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
const gate = target[input.gateKey] as {
invokeHandler: (channel: string) => Promise<unknown>;
};
const state: Record<'end' | 'start', number | null> = {
end: null,
start: null,
};
target[input.key] = state;
ipcMain.on(input.channel, (_event, payload: unknown) => {
const record = payload as Record<string, unknown> | null;
if (
record?.['method'] !== input.sentinelMethod ||
record['phase'] !== 'start'
) {
return;
}
const args = JSON.stringify(record['args'] ?? null);
const which = args.includes(input.startId)
? 'start'
: args.includes(input.endId)
? 'end'
: null;
if (which === null || state[which] !== null) return;
void gate
.invokeHandler(input.countersChannel)
.then((snapshot) => {
const value = (
snapshot as { counters?: Record<string, number> }
)?.counters?.[input.sqlCounter];
state[which] = typeof value === 'number' ? value : null;
});
});
},
{
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
countersChannel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
endId: probeOptions.endSentinelId,
gateKey: JOURNEY_RENDERER_GATE_KEY,
key: SENTINEL_SQL_KEY,
sentinelMethod: probeOptions.sentinelMethod,
sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
startId: probeOptions.startSentinelId,
}
);
}
async function readQueryTrace(
electronApp: ElectronApplication
): Promise<SearchJourneyQueryTraceEntry[]> {
return electronApp.evaluate(
(_electron, key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as SearchJourneyQueryTraceEntry[],
QUERY_TRACE_KEY
);
}
/**
* What a failed settle saw: the bridge calls of the search, renderer errors
* (a search that threw shows the same empty view as one that found
* nothing), SQL statements before each key and now, and the traced query
* calls with their terms and result lengths.
*/
async function describeSettleFailure(
electronApp: ElectronApplication,
samples: readonly SearchJourneyActivitySample[],
consoleErrors: readonly string[]
): Promise<string> {
const [capture] = await peekJourneyMainIpcCaptures(electronApp, [
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
]);
const now = await sampleActivity(electronApp);
const queryTrace = await readQueryTrace(electronApp);
return JSON.stringify({
bridgeCalls: capture.callsByMethod,
consoleErrors,
queryTrace,
sqlBeforeKeysAndNow: [...samples, now].map(
(entry) => entry.sqlStatements
),
});
}
/** Rail link → global search, then focus the header box. Not measured. */
async function openGlobalSearch(page: Page, timeoutMs: number): Promise<void> {
await page
.getByRole('link', { name: 'Global search', exact: true })
.click({ timeout: timeoutMs });
// A router navigation, not a document load: wait on the path itself.
await page
.waitForFunction(
(path) => location.pathname.endsWith(path),
SEARCH_JOURNEY_ROUTE_PATH,
{ timeout: timeoutMs }
)
.catch((failure: unknown) => {
throw new Error(
`${ERROR_PREFIX}-route-not-reached: ${page.url()} (${String(failure)})`
);
});
const input = page.locator(SEARCH_JOURNEY_INPUT_SELECTOR);
await input.waitFor({ state: 'visible', timeout: timeoutMs });
await input.focus({ timeout: timeoutMs });
}
export async function measureSearchJourney(
session: LaunchJourneySession,
timeoutMs: number
): Promise<SearchJourneyMeasurement> {
const { electronApp, mainWindow } = session;
const query = SEARCH_JOURNEY_QUERY;
const probeOptions = createSearchJourneyProbeOptions(query);
// Kept for the message of a failed settle.
const consoleErrors: string[] = [];
mainWindow.on('console', (message) => {
if (message.type() === 'error' && consoleErrors.length < 10) {
consoleErrors.push(message.text().slice(0, 300));
}
});
await blockJourneyExternalArtwork(electronApp);
await traceQueryCalls(electronApp);
await stampSqlAtSentinels(electronApp, probeOptions);
await openGlobalSearch(mainWindow, timeoutMs);
await installJourneyMainIpcCapture(electronApp, {
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
sentinelId: probeOptions.endSentinelId,
sentinelMethod: probeOptions.sentinelMethod,
startSentinelId: probeOptions.startSentinelId,
stateKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
});
await armSearchJourneyProbe(mainWindow, probeOptions);
const settle = await waitForSearchQuiet(
electronApp,
mainWindow,
probeOptions.stateKey
);
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
const focused = await mainWindow
.locator(SEARCH_JOURNEY_INPUT_SELECTOR)
.evaluate((input) => input === document.activeElement);
if (!focused) {
throw new Error(`${ERROR_PREFIX}-input-not-focused`);
}
// One `keyboard.type` per character on a fixed schedule, so the
// main-process sample before each key sits between two keystrokes.
const samples: SearchJourneyActivitySample[] = [];
const firstKeyAtMs = Date.now();
for (let position = 0; position < query.length; position += 1) {
await sleepUntil(firstKeyAtMs + position * SEARCH_JOURNEY_KEY_DELAY_MS);
samples.push(await sampleActivity(electronApp));
await mainWindow.keyboard.type(query[position]);
}
const renderer = await waitForSearchJourneyProbe(
mainWindow,
probeOptions.stateKey,
timeoutMs
).catch(async (failure: unknown) => {
throw new Error(
`${String(failure)} ${await describeSettleFailure(electronApp, samples, consoleErrors)}`
);
});
const ipc = await readJourneyMainIpcCapture(
electronApp,
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
10_000
);
samples.push(await sampleActivity(electronApp));
await new Promise((resolve) =>
setTimeout(resolve, AFTER_SETTLED_WINDOW_MS)
);
return {
afterSettled: await sampleActivity(electronApp),
afterSettledWindowMs: AFTER_SETTLED_WINDOW_MS,
externalArtworkCancelled:
await readJourneyExternalArtworkCancelled(electronApp),
ipc,
keyDelayMs: SEARCH_JOURNEY_KEY_DELAY_MS,
pid: session.launch.pid,
query,
queryTrace: await readQueryTrace(electronApp),
sqlAtSentinels: await electronApp.evaluate(
(_electron, key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as SearchJourneyMeasurement['sqlAtSentinels'],
SENTINEL_SQL_KEY
),
renderer,
samples,
settle,
};
}
@@ -0,0 +1,79 @@
import { test } from '@playwright/test';
import type { JourneyIterationRecord } from '../performance/journey-summary';
import {
SEARCH_JOURNEY_ID,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
toSearchIterationRecord,
} from '../performance/search-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 {
measureSearchJourney,
SEARCH_JOURNEY_SEED,
} from './search-journey-app';
/**
* J4 "Search": type a six-character query into the header search box on
* /workspace/search until the global search results have settled. Every
* iteration is a fresh J1 launch on a copy of the seeded profile (one M3U
* source and the mock's 12,000-item `large` Xtream catalog), with the
* main-process counters on so SQL statements are counted.
* Contract: docs/architecture/performance-journeys.md.
*/
test.describe.configure({ mode: 'serial' });
test('J4 search', async () => {
const iterations: JourneyIterationRecord[] = [];
let electronVersion = 'unknown';
const templateDirectory = await seedLaunchJourneyProfile(
LAUNCH_JOURNEY_MOCK_ORIGIN,
SEARCH_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 { continuation, launch } = await runLaunchJourney(
templateDirectory,
JOURNEY_ITERATION_TIMEOUT_MS,
// SQL statements are a J4 counter, so unlike J2 and J3 the
// launch runs with the main-process counters and SQL hook.
{ idleWindowMs: null, mainCounters: true },
(session) =>
measureSearchJourney(
session,
JOURNEY_ITERATION_TIMEOUT_MS
).catch((failure: unknown) => {
throw new Error(
`iteration ${index}: ${String(failure)}`
);
})
);
electronVersion = launch.electronVersion;
const record = toSearchIterationRecord(index, warmup, continuation);
iterations.push(record);
logJourneyIteration(SEARCH_JOURNEY_ID, record);
}
} finally {
await removeLaunchJourneyProfile(templateDirectory);
}
await writeJourneyRunEntry(
SEARCH_JOURNEY_ID,
iterations,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
electronVersion
);
});
@@ -0,0 +1,51 @@
import type { ElectronApplication } from '@playwright/test';
/**
* The mock's generated catalogs point channel, category, poster and cover
* artwork at picsum.photos. Journeys that render that artwork (J3, J4)
* cancel those requests in the main process, so no request of the journey
* leaves the machine and an image never loads, or fails, at a different
* moment on a runner with a different network. Contract:
* docs/architecture/performance-journeys.md.
*/
const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork';
const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*'];
export async function blockJourneyExternalArtwork(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ session }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
if (target[input.key] !== undefined) {
throw new Error('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 }
);
}
export async function readJourneyExternalArtworkCancelled(
electronApp: ElectronApplication
): Promise<number> {
return electronApp.evaluate(
(_electron, key) =>
(
(globalThis as unknown as Record<string, unknown>)[key] as {
cancelled: number;
}
).cancelled,
EXTERNAL_ARTWORK_STATE_KEY
);
}
@@ -2,8 +2,8 @@
* Instrumentation flags a journey launch sets on the Electron process.
*
* Every journey needs the renderer-API trace (`IPTVNATOR_TRACE_IPC`) for its
* IPC counters. Only J1 records the main-process counters:
* `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and
* IPC counters. Only J1 and J4 (for its SQL count) record the main-process
* counters: `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and
* `IPTVNATOR_PERF_COUNT_SQL` wraps every main-thread and worker SQLite
* statement to count it (see journey-main-counters.ts). A journey that
* continues from the launch without reading them (J2) leaves both off, so
@@ -127,7 +127,7 @@ test('rejects snapshots that were not frozen at the moments they claim', () => {
);
});
test('only the launch journey opts into SQL statement counting', () => {
test('only the launch and search journeys opt into SQL statement counting', () => {
// The import benchmarks also run with IPTVNATOR_PERF_CAPTURE=1; the SQL
// hook wraps every row of a bulk insert, so they must not enable it.
const sourceRoot = resolve(__dirname, '..');
@@ -146,12 +146,25 @@ test('only the launch journey opts into SQL statement counting', () => {
assert.deepEqual(containing(/IPTVNATOR_PERF_COUNT_SQL/), [
join('performance', 'journey-launch-environment.ts'),
]);
// ...and only J1's launch asks for them there. Another journey that
// ...and only J1's launch and J4, which records the count as
// renderer.sqlStatementsPerSearch, ask for them. Another journey that
// passed `mainCounters: true` to runLaunchJourney would be measured
// under the statement hook without recording its count.
assert.deepEqual(containing(/mainCounters:\s*true/), [
assert.deepEqual(containing(/mainCounters:\s*true/).sort(), [
join('journeys', 'launch-journey-app.ts'),
join('journeys', 'search.journey.ts'),
]);
assert.match(
readFileSync(join(sourceRoot, 'journeys', 'search.journey.ts'), 'utf8'),
/\{ idleWindowMs: null, mainCounters: true \}/
);
assert.match(
readFileSync(
join(sourceRoot, 'performance', 'search-journey-record.ts'),
'utf8'
),
/SQL_STATEMENTS: 'renderer\.sqlStatementsPerSearch'/
);
const launchApp = readFileSync(
join(sourceRoot, 'journeys', 'launch-journey-app.ts'),
'utf8'
@@ -14,6 +14,7 @@ function gate(
blankLoadedEpochMs: 1_050,
errors: [],
gatedEpochMs: 1_020,
didFinishLoadHeldOnBlank: 1,
gatedMethod: 'loadFile',
passThroughLoads: 0,
readyToShowHeldOnBlank: 1,
@@ -22,6 +22,11 @@
* therefore drops `ready-to-show` while the window is on `about:blank`;
* Electron emits it again for the real document's first paint, because the
* window is still hidden, which is the moment production sees.
* The app also shows its window at the main frame's `did-finish-load`
* when that comes first, so the gate keeps the app's `did-finish-load`
* listeners (those registered before the gated load) away from the
* about:blank load too. Electron's own listener that resolves
* `loadURL(about:blank)` is registered later and still runs.
*
* With `ipcMain` passed in, the gate also keeps the listeners registered
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
@@ -53,6 +58,38 @@ function holdReadyToShowWhileBlank(window, state) {
};
}
function holdDidFinishLoadWhileBlank(window, state) {
const contents = window.webContents;
if (
!contents ||
typeof contents.emit !== 'function' ||
typeof contents.rawListeners !== 'function'
) {
return;
}
const appListeners = contents.rawListeners('did-finish-load');
const originalEmit = contents.emit;
contents.emit = function gatedContentsEmit(eventName, ...args) {
if (eventName !== 'did-finish-load' || !isShowingBlank(window)) {
return originalEmit.call(this, eventName, ...args);
}
state.didFinishLoadHeldOnBlank += 1;
const attached = this.rawListeners(eventName);
const held = appListeners.filter((listener) =>
attached.includes(listener)
);
for (const listener of held) this.removeListener(eventName, listener);
try {
return originalEmit.call(this, eventName, ...args);
} finally {
// Raw listeners keep their `once` wrappers, so a re-added once
// listener still fires once for the real document.
for (const listener of held)
this.prependListener(eventName, listener);
}
};
}
function tapIpcHandlers(ipcMain, channels) {
const handlers = new Map();
const originalHandle = ipcMain.handle;
@@ -78,6 +115,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
errors: [],
gatedEpochMs: null,
gatedMethod: null,
didFinishLoadHeldOnBlank: 0,
passThroughLoads: 0,
readyToShowHeldOnBlank: 0,
releasedEpochMs: null,
@@ -128,6 +166,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
state.gatedEpochMs = now();
state.gatedMethod = method;
holdReadyToShowWhileBlank(this, state);
holdDidFinishLoadWhileBlank(this, state);
try {
await this.webContents.loadURL(BLANK_URL);
state.blankLoadedEpochMs = now();
@@ -4,6 +4,7 @@ import test from 'node:test';
interface GateState {
blankLoadedEpochMs: number | null;
didFinishLoadHeldOnBlank: number;
errors: string[];
gatedEpochMs: number | null;
gatedMethod: string | null;
@@ -155,13 +156,13 @@ test('records a failed about:blank navigation and still loads after release', as
function createEmittingBrowserWindow(log: string[]) {
class EmittingBrowserWindow extends EventEmitter {
url = '';
webContents = {
webContents = Object.assign(new EventEmitter(), {
getURL: () => this.url,
loadURL: async (url: string) => {
this.url = url;
log.push(`webContents.loadURL:${url}`);
},
};
});
async loadFile(file: string): Promise<void> {
this.url = `file:///${file}`;
log.push(`loadFile:${file}`);
@@ -201,6 +202,47 @@ test('holds ready-to-show while the window shows about:blank, then lets the real
assert.equal(api.state.readyToShowHeldOnBlank, 1);
});
test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => {
const log: string[] = [];
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
const api = gateModule.installJourneyRendererGate(
EmittingBrowserWindow as unknown as {
prototype: Record<string, unknown>;
},
{},
{ timeoutMs: 60_000 }
);
const window = new EmittingBrowserWindow();
// The app shows its window at the first did-finish-load.
window.webContents.once('did-finish-load', () =>
log.push('app:did-finish-load')
);
const load = window.loadFile('index.html');
await settle();
// Registered after the gated load, like Electron's own listener that
// resolves loadURL(about:blank).
window.webContents.on('did-finish-load', () =>
log.push('electron:did-finish-load')
);
window.webContents.emit('did-finish-load');
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
api.release();
await load;
window.webContents.emit('did-finish-load');
window.webContents.emit('did-finish-load');
assert.deepEqual(log, [
'webContents.loadURL:about:blank',
'electron:did-finish-load',
'loadFile:index.html',
'app:did-finish-load',
'electron:did-finish-load',
'electron:did-finish-load',
]);
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
});
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
const registered: string[] = [];
const ipcMain: FakeIpcMain = {
@@ -341,6 +341,8 @@ test('keeps summing shifts without recent input after the first-card cutoff unti
[
{
deltaHeight: 0,
deltaWidth: 0,
deltaX: 0,
deltaY: -240,
node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]',
},
@@ -798,6 +800,13 @@ test('drops performance entries from before the click and keeps recent-input shi
{
entryType: 'layout-shift',
hadRecentInput: true,
sources: [
{
currentRect: { height: 40, width: 300, x: 48, y: 152 },
node: fixture.card,
previousRect: { height: 40, width: 320, x: 0, y: 100 },
},
],
startTime: now(),
value: 0.25,
},
@@ -823,6 +832,25 @@ test('drops performance entries from before the click and keeps recent-input shi
assert.equal(state.final, true);
assert.equal(state.counters.layoutShiftScore, 0.125);
assert.equal(state.counters.recentInputLayoutShiftScore, 0.25);
// Each counted shift keeps its nodes; the pre-click ones are not listed.
assert.deepEqual(
state.shifts.map((shift) => [
shift.hadRecentInput,
shift.value,
shift.sources.map((source) => [
source.deltaX,
source.deltaY,
source.deltaWidth,
]),
]),
[
[true, 0.25, [[48, 52, -20]]],
[false, 0.125, []],
]
);
assert.equal(state.shiftCount, 2);
assert.ok(state.shifts.every((shift) => shift.sinceStartMs >= 0));
assert.match(state.shifts[0]?.sources[0]?.node ?? '', /^[a-z-]+/);
// J2 has no settle window: the observers close at the cutoff.
assert.equal(state.settle.status, 'disabled');
assert.equal(state.counters.layoutShiftScoreSettled, 0);
@@ -869,6 +897,8 @@ test('rejects a click start whose sentinel could not be sent', async () => {
const started = {
...state,
sentinel: { epochMs: 1, status: 'sent' as const },
shiftCount: 0,
shifts: [],
};
assert.throws(
() => assertJourneyRendererProbeState(started),
@@ -17,9 +17,9 @@ export interface FakeEntry {
entryType: string;
hadRecentInput?: boolean;
sources?: {
currentRect: { height: number; y: number };
currentRect: { height: number; width?: number; x?: number; y: number };
node: unknown;
previousRect: { height: number; y: number };
previousRect: { height: number; width?: number; x?: number; y: number };
}[];
startTime: number;
value?: number;
@@ -166,12 +166,24 @@ export interface JourneyRendererProbeCounters {
recentInputLayoutShiftScore: number;
}
/** A shift counted in `layoutShiftScore` or `recentInputLayoutShiftScore`. */
export interface JourneyRendererProbeShift {
readonly hadRecentInput: boolean;
/** Entry start minus the journey start (navigation start for J1). */
readonly sinceStartMs: number;
/** `tag.class[data-test-id]` and the move of each source. */
readonly sources: JourneyRendererProbeLateShift['sources'];
readonly value: number;
}
export interface JourneyRendererProbeLateShift {
/** Entry start minus the first-card terminal epoch. */
readonly afterFirstCardMs: number;
/** `tag.class[data-test-id]` and the vertical move of each source. */
/** `tag.class[data-test-id]` and the move of each source. */
readonly sources: readonly {
readonly deltaHeight: number;
readonly deltaWidth: number;
readonly deltaX: number;
readonly deltaY: number;
readonly node: string;
}[];
@@ -237,6 +249,13 @@ export interface JourneyRendererProbeState {
readonly epochMs: number | null;
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
};
/** Every shift counted until the cutoff; `shifts` keeps the first 20. */
shiftCount: number;
/**
* The first 20 shifts counted until the cutoff, with the nodes that
* moved, so a layout-shift score can be traced to its components.
*/
shifts: JourneyRendererProbeShift[];
/** `final` freezes the first-card counters; the settle window ends later. */
settle: {
/** Mutation records under the settle root after the cutoff. */
@@ -329,6 +348,8 @@ export function journeyRendererProbeScript(
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
shiftCount: 0,
shifts: [],
settle: {
domMutations: 0,
epochMs: null,
@@ -389,6 +410,7 @@ export function journeyRendererProbeScript(
for (const entry of entries) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
sources?: readonly LateShiftSource[];
value?: number;
};
if (
@@ -397,6 +419,18 @@ export function journeyRendererProbeScript(
) {
continue;
}
state.shiftCount += 1;
if (state.shifts.length < 20) {
state.shifts.push({
hadRecentInput: shift.hadRecentInput === true,
sinceStartMs:
performance.timeOrigin +
entry.startTime -
(state.start?.epochMs ?? performance.timeOrigin),
sources: (shift.sources ?? []).map(describeSource),
value: shift.value,
});
}
if (shift.hadRecentInput === true) {
state.counters.recentInputLayoutShiftScore += shift.value;
continue;
@@ -530,10 +564,11 @@ export function journeyRendererProbeScript(
state.idle.status = 'done';
}, idle.durationMs);
};
type ShiftRect = { height: number; width?: number; x?: number; y: number };
type LateShiftSource = {
currentRect?: { height: number; y: number };
currentRect?: ShiftRect;
node?: Node | null;
previousRect?: { height: number; y: number };
previousRect?: ShiftRect;
};
const describeSource = (source: LateShiftSource) => {
const node = source.node;
@@ -550,9 +585,13 @@ export function journeyRendererProbeScript(
}
const before = source.previousRect;
const after = source.currentRect;
const delta = (key: keyof ShiftRect) =>
before && after ? (after[key] ?? 0) - (before[key] ?? 0) : 0;
return {
deltaHeight: before && after ? after.height - before.height : 0,
deltaY: before && after ? after.y - before.y : 0,
deltaHeight: delta('height'),
deltaWidth: delta('width'),
deltaX: delta('x'),
deltaY: delta('y'),
node: label,
};
};
@@ -54,6 +54,8 @@ function measurement(
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: 2_601, status: 'sent' },
shiftCount: 0,
shifts: [],
settle: {
domMutations: 37,
epochMs: 3_180.06,
@@ -64,6 +66,8 @@ function measurement(
sources: [
{
deltaHeight: 0,
deltaWidth: 0,
deltaX: 0,
deltaY: -240,
node: 'section.dashboard-rail',
},
@@ -112,6 +116,7 @@ function measurement(
blankLoadedEpochMs: 1_050,
errors: [],
gatedEpochMs: 1_020,
didFinishLoadHeldOnBlank: 1,
gatedMethod: 'loadFile',
passThroughLoads: 0,
readyToShowHeldOnBlank: 1,
@@ -185,6 +190,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
'main.startupPhases': 9,
});
assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1);
assert.equal(record.evidence['rendererGateDidFinishLoadHeldOnBlank'], 1);
assert.deepEqual(record.evidence['epochs'], {
firstCard: 2_600.04,
firstCardPaint: 2_650,
@@ -208,6 +214,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
sources: [
{
deltaHeight: 0,
deltaWidth: 0,
deltaX: 0,
deltaY: -240,
node: 'section.dashboard-rail',
},
@@ -185,6 +185,8 @@ export function toLaunchIterationRecord(
mainCountersAtRead: mainCounters.counters,
rendererGateReadyToShowHeldOnBlank:
measurement.gate.readyToShowHeldOnBlank,
rendererGateDidFinishLoadHeldOnBlank:
measurement.gate.didFinishLoadHeldOnBlank,
ipcCallsByMethod: ipc.callsByMethod,
ipcSerialDepth: serialDepth,
ipcTimelineAmbiguousCompletions: ipc.ambiguousTimelineCompletions,
@@ -59,6 +59,23 @@ function measurement(
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
schemaVersion: 1,
sentinel: { epochMs: 10_080.5, status: 'sent' },
shiftCount: 23,
shifts: [
{
hadRecentInput: true,
sinceStartMs: 41.26,
sources: [
{
deltaHeight: 0,
deltaWidth: 0,
deltaX: 0,
deltaY: 52,
node: 'div.content[data-test-id="category-list"]',
},
],
value: 0.22106,
},
],
settle: {
domMutations: 0,
epochMs: null,
@@ -147,6 +164,24 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters
});
assert.deepEqual(record.evidence['layoutShift'], {
recentInput: 0.221,
// More shifts were counted than listed.
shiftCount: 23,
shifts: [
{
hadRecentInput: true,
sinceStartMs: 41.3,
sources: [
{
deltaHeight: 0,
deltaWidth: 0,
deltaX: 0,
deltaY: 52,
node: 'div.content[data-test-id="category-list"]',
},
],
value: 0.2211,
},
],
withoutRecentInput: 0,
});
assert.deepEqual(record.evidence['httpRequestsByRoute'], {
@@ -184,6 +184,17 @@ export function toOpenSourceIterationRecord(
recentInput: roundThousandth(
renderer.counters.recentInputLayoutShiftScore
),
// Every counted shift; `shifts` lists the first 20.
shiftCount: renderer.shiftCount,
// The first 20 counted shifts and the nodes that moved.
shifts: renderer.shifts.map((shift) =>
Object.freeze({
hadRecentInput: shift.hadRecentInput,
sinceStartMs: roundTenth(shift.sinceStartMs),
sources: shift.sources,
value: Math.round(shift.value * 10_000) / 10_000,
})
),
withoutRecentInput: roundThousandth(
renderer.counters.layoutShiftScore
),
@@ -10,6 +10,7 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe';
interface TargetConfiguration {
configurations?: Record<string, Record<string, unknown>>;
defaultConfiguration?: string;
dependsOn?: unknown;
executor?: unknown;
options?: Record<string, unknown>;
@@ -162,7 +163,12 @@ test('only the web performance build installs the tick counter the journeys read
for (const [name, configuration] of Object.entries(
webProject.targets['build'].configurations ?? {}
)) {
if (name === 'electron-performance') continue;
if (
name === 'electron-performance' ||
name === 'electron-performance-zoneless'
) {
continue;
}
assert.doesNotMatch(
JSON.stringify(configuration['fileReplacements'] ?? []),
/environment\.performance/,
@@ -171,6 +177,44 @@ test('only the web performance build installs the tick counter the journeys read
}
});
// Plan item C6 measures zoneless change detection behind a build-time flag:
// each *-zoneless configuration is its base configuration plus one swap of
// the change-detection providers, and nothing else selects that swap.
test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => {
const configurations = webProject.targets['build'].configurations ?? {};
const zonelessReplacement = {
replace: 'apps/web/src/environments/change-detection.providers.ts',
with: 'apps/web/src/environments/change-detection.providers.zoneless.ts',
};
for (const base of ['electron-performance', 'electron-e2e']) {
const baseConfiguration = configurations[base];
const zoneless = configurations[`${base}-zoneless`];
assert.ok(zoneless, `web:build must define ${base}-zoneless`);
assert.ok(baseConfiguration, `web:build must define ${base}`);
const { fileReplacements: baseReplacements, ...baseRest } =
baseConfiguration;
const { fileReplacements, ...rest } = zoneless;
assert.deepEqual(rest, baseRest, base);
assert.deepEqual(fileReplacements, [
...((baseReplacements as unknown[] | undefined) ?? []),
zonelessReplacement,
]);
}
for (const [name, configuration] of Object.entries(configurations)) {
if (name.endsWith('-zoneless')) continue;
assert.doesNotMatch(
JSON.stringify(configuration['fileReplacements'] ?? []),
/change-detection\.providers/,
name
);
}
assert.equal(
webProject.targets['build'].defaultConfiguration,
'production'
);
});
test('the resolved web build cache output is the renderer directory', () => {
const task = readResolvedWebBuildTask();
@@ -74,6 +74,8 @@ function measurement(
preStart: { domMutations: 53, lastMutationEpochMs: 8_900 },
schemaVersion: 1,
sentinel: { epochMs: 10_350, status: 'sent' },
shiftCount: 0,
shifts: [],
settle: {
domMutations: 0,
epochMs: null,
@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import {
JOURNEY_CD_TICK_COUNTER_KEY,
JOURNEY_IPC_SENTINEL_METHOD,
JOURNEY_PLAYBACK_PROBE_STATE_KEY,
JOURNEY_PROBE_STATE_KEY,
} from './journey-renderer-probe';
import {
installFakePerformance,
type FakeObserver,
} from './journey-renderer-probe.test-helpers';
import {
assertSearchJourneyProbeState,
createSearchJourneyProbeOptions,
SEARCH_JOURNEY_END_SENTINEL_ID,
SEARCH_JOURNEY_PROBE_STATE_KEY,
SEARCH_JOURNEY_START_SENTINEL_ID,
searchJourneyProbeScript,
type SearchJourneyProbeOptions,
type SearchJourneyProbeState,
} from './search-journey-probe';
const QUERY = 'system';
interface SearchFixture {
readonly bridgeCalls: unknown[];
readonly input: HTMLInputElement;
readonly observers: FakeObserver[];
readonly results: HTMLElement;
readonly state: () => SearchJourneyProbeState;
readonly ticks: { count: number };
readonly window: JSDOM['window'];
}
function createSearchFixture(
overrides: Partial<SearchJourneyProbeOptions> = {}
): SearchFixture {
const dom = new JSDOM(
`<!doctype html><html><body><app-root>
<app-workspace-shell-header><label class="search-field">
<input type="search" /></label></app-workspace-shell-header>
<button id="elsewhere">x</button>
<app-search-results><div class="results-container"></div></app-search-results>
</app-root></body></html>`,
{
pretendToBeVisual: true,
runScripts: 'outside-only',
url: 'http://localhost/workspace/search',
}
);
const { window } = dom;
const observers: FakeObserver[] = [];
const bridgeCalls: unknown[] = [];
installFakePerformance(window, observers);
// See journey-renderer-probe.test-helpers.ts: tsx keeps names.
Object.defineProperty(window, '__name', {
configurable: true,
value: (target: unknown) => target,
});
Object.defineProperty(window, 'electron', {
configurable: true,
value: Object.freeze({
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
bridgeCalls.push(id);
return Promise.resolve(null);
},
}),
});
const ticks = { count: 40 };
Object.defineProperty(window, JOURNEY_CD_TICK_COUNTER_KEY, {
configurable: true,
value: ticks,
});
const options = {
...createSearchJourneyProbeOptions(QUERY),
quietMs: 30,
...overrides,
};
window.eval(
`(${searchJourneyProbeScript.toString()})(${JSON.stringify(options)})`
);
const { document } = window;
return {
bridgeCalls,
input: document.querySelector('input') as HTMLInputElement,
observers,
results: document.querySelector('.results-container') as HTMLElement,
state: () =>
JSON.parse(
JSON.stringify(
(window as unknown as Record<string, unknown>)[
options.stateKey
]
)
) as SearchJourneyProbeState,
ticks,
window,
};
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** A keydown in the input plus what the shell renders for it. */
async function typeKey(fixture: SearchFixture, key: string): Promise<void> {
fixture.input.dispatchEvent(
new fixture.window.KeyboardEvent('keydown', { bubbles: true, key })
);
fixture.input.setAttribute('data-value', fixture.input.value + key);
fixture.input.value += key;
await wait(0);
}
function setQuery(fixture: SearchFixture, query: string): void {
fixture.window.history.replaceState(
null,
'',
`/workspace/search?q=${encodeURIComponent(query)}`
);
}
function addCards(fixture: SearchFixture, count: number): void {
for (let index = 0; index < count; index += 1) {
fixture.results.append(
fixture.window.document.createElement('app-content-card')
);
}
}
async function waitForFinal(fixture: SearchFixture): Promise<void> {
const deadline = Date.now() + 2_000;
while (!fixture.state().final && Date.now() < deadline) {
await wait(5);
}
}
test('search options use their own state key, sentinels and the query length', () => {
const options = createSearchJourneyProbeOptions(QUERY);
assert.equal(options.keystrokes, 6);
assert.equal(options.query, QUERY);
assert.equal(options.quietMs, 200);
assert.equal(options.stateKey, SEARCH_JOURNEY_PROBE_STATE_KEY);
assert.notEqual(options.stateKey, JOURNEY_PROBE_STATE_KEY);
assert.notEqual(options.stateKey, JOURNEY_PLAYBACK_PROBE_STATE_KEY);
assert.equal(options.startSentinelId, SEARCH_JOURNEY_START_SENTINEL_ID);
assert.equal(options.endSentinelId, SEARCH_JOURNEY_END_SENTINEL_ID);
assert.equal(options.sentinelMethod, JOURNEY_IPC_SENTINEL_METHOD);
assert.equal(options.cdTickCounterKey, JOURNEY_CD_TICK_COUNTER_KEY);
});
test('starts at the first keydown in the search box and buckets work per key', async () => {
const fixture = createSearchFixture();
fixture.results.setAttribute('data-before', '1');
await wait(0);
// A key elsewhere does not start the journey.
fixture.window.document.getElementById('elsewhere')?.dispatchEvent(
new fixture.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'x',
})
);
assert.equal(fixture.state().start, null);
assert.deepEqual(fixture.bridgeCalls, []);
for (const key of QUERY) {
fixture.ticks.count += 1;
await typeKey(fixture, key);
}
let state = fixture.state();
assert.equal(state.preStart.domMutations, 1);
assert.deepEqual(fixture.bridgeCalls, [SEARCH_JOURNEY_START_SENTINEL_ID]);
assert.deepEqual(
state.keystrokes.map((key) => key.key),
[...QUERY]
);
assert.deepEqual(
state.keystrokes.map((key) => key.ticks),
[41, 42, 43, 44, 45, 46]
);
// One attribute record per key, each in its own bucket.
assert.deepEqual(state.domMutationsByKeystroke, [1, 1, 1, 1, 1, 1]);
// The debounced term lands: results for the final query.
setQuery(fixture, QUERY);
fixture.ticks.count += 4;
addCards(fixture, 3);
await waitForFinal(fixture);
state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.settle.status, 'quiet');
assert.equal(state.settle.query, QUERY);
assert.equal(state.settle.cardCount, 3);
assert.equal(state.settle.ticks, 50);
assert.equal(state.capabilities.changeDetectionTicks, 'counted');
assert.deepEqual(state.domMutationsByKeystroke, [1, 1, 1, 1, 1, 4]);
assert.equal(state.counters.domMutations, 9);
assert.equal(state.firstResult?.query, QUERY);
assert.equal(state.firstResult?.cardCount, 3);
assert.ok(
(state.settle.confirmedEpochMs ?? 0) - (state.settle.epochMs ?? 0) >= 25
);
assert.deepEqual(fixture.bridgeCalls, [
SEARCH_JOURNEY_START_SENTINEL_ID,
SEARCH_JOURNEY_END_SENTINEL_ID,
]);
});
test('does not settle on results for an intermediate term or while loading', async () => {
const fixture = createSearchFixture();
for (const key of QUERY) {
await typeKey(fixture, key);
}
// Results of an earlier term are a first result, not the settle.
setQuery(fixture, 'syst');
addCards(fixture, 2);
await wait(80);
let state = fixture.state();
assert.equal(state.final, false);
assert.equal(state.firstResult?.query, 'syst');
// The final term's spinner keeps the window closed.
setQuery(fixture, QUERY);
const spinner = fixture.window.document.createElement('div');
spinner.className = 'loading-state';
fixture.window.document
.querySelector('app-search-results')
?.append(spinner);
await wait(80);
assert.equal(fixture.state().final, false);
spinner.remove();
await waitForFinal(fixture);
state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.settle.query, QUERY);
assert.equal(state.settle.cardCount, 2);
});
test('a mutation inside the quiet window restarts it', async () => {
const fixture = createSearchFixture({ quietMs: 60 });
for (const key of QUERY) {
await typeKey(fixture, key);
}
setQuery(fixture, QUERY);
addCards(fixture, 1);
await wait(30);
addCards(fixture, 1);
const lastMutationAt = Date.now();
await waitForFinal(fixture);
const state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.settle.cardCount, 2);
assert.ok(Date.now() - lastMutationAt >= 55);
});
test('fails the iteration when results never settle', async () => {
const fixture = createSearchFixture({ settleTimeoutMs: 40 });
for (const key of QUERY) {
await typeKey(fixture, key);
}
await waitForFinal(fixture);
const state = fixture.state();
assert.equal(state.settle.status, 'timeout');
assert.throws(
() => assertSearchJourneyProbeState(state),
/search-journey-probe-invalid: settle-timeout/
);
});
test('flags a keystroke beyond the expected count', async () => {
const fixture = createSearchFixture({ keystrokes: 2 });
await typeKey(fixture, 'a');
await typeKey(fixture, 'b');
await typeKey(fixture, 'c');
assert.deepEqual(fixture.state().invalidReasons, ['unexpected-keystroke']);
});
test('counts shifts and long tasks from the first key until the settle only', async () => {
const fixture = createSearchFixture();
const shifts = fixture.observers.find(
(observer) => observer.type === 'layout-shift'
);
const tasks = fixture.observers.find(
(observer) => observer.type === 'longtask'
);
assert.ok(shifts && tasks);
// Buffered launch entries before the first key are dropped.
shifts.emit([{ entryType: 'layout-shift', startTime: 0, value: 0.5 }]);
tasks.emit([{ duration: 60, entryType: 'longtask', startTime: 0 }]);
// jsdom's clock starts with the fixture: let that task end first.
await wait(100);
for (const key of QUERY) {
await typeKey(fixture, key);
}
const now = fixture.window.performance.now();
shifts.emit([
{
entryType: 'layout-shift',
hadRecentInput: true,
startTime: now,
value: 0.02,
},
{ entryType: 'layout-shift', startTime: now, value: 0.01 },
]);
tasks.emit([
{ duration: 30, entryType: 'longtask', startTime: now },
{ duration: 80, entryType: 'longtask', startTime: now },
]);
setQuery(fixture, QUERY);
addCards(fixture, 1);
await waitForFinal(fixture);
const state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.counters.recentInputLayoutShiftScore, 0.02);
assert.equal(state.counters.layoutShiftScore, 0.01);
assert.equal(state.counters.longTasks, 1);
assert.deepEqual(state.longTaskDurationsMs, [80]);
assert.ok(shifts.disconnected && tasks.disconnected);
});
test('rejects a state without the tick counter or the end sentinel', () => {
assert.throws(
() => assertSearchJourneyProbeState({ schemaVersion: 1 }),
/search-journey-probe-incomplete/
);
const base = {
capabilities: { layoutShift: true, longTask: true },
final: true,
invalidReasons: [],
schemaVersion: 1,
sentinel: { status: 'bridge-missing' },
start: { sentinelStatus: 'sent' },
};
assert.throws(
() => assertSearchJourneyProbeState(base),
/search-journey-probe-sentinel-bridge-missing/
);
assert.throws(
() =>
assertSearchJourneyProbeState({
...base,
capabilities: { layoutShift: false, longTask: true },
sentinel: { status: 'sent' },
}),
/search-journey-probe-observer-unavailable/
);
});
@@ -0,0 +1,507 @@
import type { Page } from '@playwright/test';
import {
JOURNEY_CD_TICK_COUNTER_KEY,
JOURNEY_IPC_SENTINEL_METHOD,
} from './journey-renderer-probe';
/**
* Renderer-side probe for J4 "Search" (docs/architecture/performance-journeys.md).
*
* Armed in the loaded `/workspace/search` document after the header search
* input has focus. A capture-phase `keydown` listener on `window`, which runs
* before every listener of the app, stamps each keystroke in the input; the
* first one starts the journey and sends the start sentinel
* (`cancelSourceProbe`, as in J2 and J3). DOM mutation records and
* change-detection ticks are bucketed by the keystroke they follow, so the
* debounce behaviour is visible per key.
*
* End: after the last expected keystroke, the results for the final term are
* shown (URL `q` equals the query, a result card is visible and no loading
* state is rendered) and then no DOM mutation arrives for `quietMs`. The
* settled moment is the last mutation before that quiet window; the counters
* run until the quiet window is confirmed, when the end sentinel is sent. A
* plain "quiet after the last keystroke" would close inside the shell's
* debounce before any query ran. Without a settle by `settleTimeoutMs`
* after the last keystroke the iteration is invalid.
*
* The script must stay self-contained: Playwright serializes it with
* `toString()`, so it may only use its argument and browser globals.
*/
export const SEARCH_JOURNEY_PROBE_STATE_KEY = '__iptvnatorJourneySearchProbe';
export const SEARCH_JOURNEY_PROBE_SCHEMA_VERSION = 1;
export const SEARCH_JOURNEY_START_SENTINEL_ID =
'__iptvnator-journey-search-start__';
export const SEARCH_JOURNEY_END_SENTINEL_ID =
'__iptvnator-journey-search-end__';
/** The workspace shell header's search box. */
export const SEARCH_JOURNEY_INPUT_SELECTOR =
'app-workspace-shell-header .search-field input[type="search"]';
/** A rendered global search result (grouped or flat). */
export const SEARCH_JOURNEY_RESULT_SELECTOR =
'app-search-results .results-container app-content-card';
/** The search layout's spinner while a query runs. */
export const SEARCH_JOURNEY_LOADING_SELECTOR =
'app-search-results .loading-state';
export const SEARCH_JOURNEY_ROUTE_PATH = '/workspace/search';
export const SEARCH_JOURNEY_QUIET_MS = 200;
export const SEARCH_JOURNEY_SETTLE_TIMEOUT_MS = 15_000;
export interface SearchJourneyProbeOptions {
readonly cdTickCounterKey: string;
readonly endSentinelId: string;
readonly inputSelector: string;
/** Keystrokes the test types; the settle waits for the last one. */
readonly keystrokes: number;
readonly loadingSelector: string;
/** Term the URL `q` must carry when the results count as final. */
readonly query: string;
readonly quietMs: number;
readonly resultSelector: string;
/** The route the results must be on; the renderer path ends with it. */
readonly routePath: string;
readonly sentinelMethod: string;
/** Hard limit from the last keystroke to the settle. */
readonly settleTimeoutMs: number;
readonly startSentinelId: string;
readonly stateKey: string;
}
export interface SearchJourneyProbeKeystroke {
/** `min(event.timeStamp, listener time)` as epoch milliseconds. */
readonly epochMs: number;
readonly key: string;
/** Running tick total at the keydown, before the app handles it. */
readonly ticks: number | null;
}
export type SentinelStatus = 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
export interface SearchJourneyProbeState {
readonly capabilities: {
changeDetectionTicks:
'counted' | 'pending' | 'unavailable-counter-missing';
layoutShift: boolean;
longTask: boolean;
};
readonly counters: {
domMutations: number;
layoutShiftScore: number;
longTasks: number;
/** Shifts with `hadRecentInput === true`; typing is input. */
recentInputLayoutShiftScore: number;
};
/** Mutation records after each keystroke, until the next or the end. */
readonly domMutationsByKeystroke: number[];
final: boolean;
/** First batch with a visible result card, for any term. */
firstResult: {
readonly cardCount: number;
readonly epochMs: number;
readonly query: string | null;
} | null;
readonly invalidReasons: string[];
readonly keystrokes: SearchJourneyProbeKeystroke[];
readonly longTaskDurationsMs: number[];
readonly preStart: {
domMutations: number;
lastMutationEpochMs: number | null;
};
readonly schemaVersion: number;
sentinel: { epochMs: number | null; status: SentinelStatus };
settle: {
cardCount: number;
/** When the quiet window was confirmed; the counters stop here. */
confirmedEpochMs: number | null;
/** Last mutation batch before the quiet window: "settled". */
epochMs: number | null;
query: string | null;
status: 'pending' | 'quiet' | 'timeout';
ticks: number | null;
};
start: {
readonly epochMs: number;
readonly pathname: string;
readonly sentinelStatus: SentinelStatus;
} | null;
}
export function searchJourneyProbeScript(
options: SearchJourneyProbeOptions
): void {
const target = globalThis as unknown as Record<string, unknown>;
if (target[options.stateKey] !== undefined) {
return;
}
const epoch = (): number => performance.timeOrigin + performance.now();
const bridge = target['electron'] as Record<string, unknown> | undefined;
const state: SearchJourneyProbeState = {
capabilities: {
changeDetectionTicks: 'pending',
layoutShift: false,
longTask: false,
},
counters: {
domMutations: 0,
layoutShiftScore: 0,
longTasks: 0,
recentInputLayoutShiftScore: 0,
},
domMutationsByKeystroke: [],
final: false,
firstResult: null,
invalidReasons: [],
keystrokes: [],
longTaskDurationsMs: [],
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
settle: {
cardCount: 0,
confirmedEpochMs: null,
epochMs: null,
query: null,
status: 'pending',
ticks: null,
},
start: null,
};
target[options.stateKey] = state;
const readTicks = (): number | null => {
const counter = target[options.cdTickCounterKey] as
{ count?: unknown } | undefined;
return typeof counter?.count === 'number' ? counter.count : null;
};
const callSentinel = (id: string): SentinelStatus => {
const method = bridge?.[options.sentinelMethod];
if (typeof method !== 'function') return 'bridge-missing';
try {
void Promise.resolve(method.call(bridge, id)).catch(
() => undefined
);
return 'sent';
} catch {
return 'failed';
}
};
const readQuery = (): string | null =>
new URLSearchParams(location.search).get('q');
const visibleCards = (): number => {
let count = 0;
for (const card of Array.from(
document.querySelectorAll(options.resultSelector)
)) {
if (card.getClientRects().length > 0) count += 1;
}
return count;
};
// Entries before the first keystroke belong to the launch (buffered
// entries included) and are dropped.
let fromEpochMs = Number.POSITIVE_INFINITY;
const shifts: PerformanceEntry[] = [];
const tasks: PerformanceEntry[] = [];
const observe = (
type: string,
sink: PerformanceEntry[]
): PerformanceObserver | null => {
try {
const observer = new PerformanceObserver((list) => {
if (!state.final) sink.push(...list.getEntries());
});
observer.observe({ type, buffered: true });
return observer;
} catch {
return null;
}
};
const shiftObserver = observe('layout-shift', shifts);
const taskObserver = observe('longtask', tasks);
state.capabilities.layoutShift = shiftObserver !== null;
state.capabilities.longTask = taskObserver !== null;
const countEntries = (untilEpochMs: number): void => {
if (shiftObserver) shifts.push(...shiftObserver.takeRecords());
if (taskObserver) tasks.push(...taskObserver.takeRecords());
shiftObserver?.disconnect();
taskObserver?.disconnect();
for (const entry of shifts) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
value?: number;
};
const at = performance.timeOrigin + entry.startTime;
if (
typeof shift.value !== 'number' ||
at < fromEpochMs ||
at > untilEpochMs
) {
continue;
}
if (shift.hadRecentInput === true) {
state.counters.recentInputLayoutShiftScore += shift.value;
} else {
state.counters.layoutShiftScore += shift.value;
}
}
// A task overlaps the window when it ends after the first keydown:
// the task that dispatched it still counts.
for (const entry of tasks) {
const at = performance.timeOrigin + entry.startTime;
if (
entry.duration <= 50 ||
at + entry.duration < fromEpochMs ||
at > untilEpochMs
) {
continue;
}
state.counters.longTasks += 1;
state.longTaskDurationsMs.push(entry.duration);
}
};
let quietTimer: ReturnType<typeof setTimeout> | undefined;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
let lastMutationEpochMs: number | null = null;
const accept = (count: number): void => {
if (count === 0) return;
if (state.start === null) {
state.preStart.domMutations += count;
state.preStart.lastMutationEpochMs = epoch();
return;
}
state.counters.domMutations += count;
const bucket = state.keystrokes.length - 1;
state.domMutationsByKeystroke[bucket] =
(state.domMutationsByKeystroke[bucket] ?? 0) + count;
lastMutationEpochMs = epoch();
};
const ready = (): boolean =>
location.pathname.endsWith(options.routePath) &&
readQuery() === options.query &&
document.querySelector(options.loadingSelector) === null &&
visibleCards() > 0;
const end = (status: 'quiet' | 'timeout'): void => {
if (state.settle.status !== 'pending') return;
clearTimeout(quietTimer);
clearTimeout(timeoutTimer);
accept(mutationObserver.takeRecords().length);
mutationObserver.disconnect();
const confirmedEpochMs = epoch();
const ticks = readTicks();
state.settle = {
cardCount: visibleCards(),
confirmedEpochMs,
epochMs: lastMutationEpochMs,
query: readQuery(),
status,
ticks,
};
if (status === 'timeout') {
// What the ready condition saw, so a timeout says which part
// never held.
const input = document.querySelector(options.inputSelector);
state.invalidReasons.push(
`settle-timeout ${JSON.stringify({
cards: visibleCards(),
input:
input instanceof HTMLInputElement ? input.value : null,
loading:
document.querySelector(options.loadingSelector) !==
null,
path: location.pathname.slice(-40),
q: readQuery(),
view:
document.querySelector(
'app-search-results .results-container'
)?.firstElementChild?.className ?? null,
})}`
);
}
state.capabilities.changeDetectionTicks =
ticks === null || state.keystrokes[0]?.ticks === null
? 'unavailable-counter-missing'
: 'counted';
countEntries(confirmedEpochMs);
const sentinelStatus = callSentinel(options.endSentinelId);
state.sentinel = {
epochMs: sentinelStatus === 'sent' ? epoch() : null,
status: sentinelStatus,
};
window.removeEventListener('keydown', onKeydown, true);
state.final = true;
};
const mutationObserver = new MutationObserver((records) => {
if (state.settle.status !== 'pending') return;
accept(records.length);
if (state.start === null) return;
if (state.firstResult === null) {
const cardCount = visibleCards();
if (cardCount > 0) {
state.firstResult = {
cardCount,
epochMs: epoch(),
query: readQuery(),
};
}
}
// Every batch after the last keystroke restarts the quiet window,
// which only opens once the final term's results are shown.
clearTimeout(quietTimer);
if (state.keystrokes.length >= options.keystrokes && ready()) {
quietTimer = setTimeout(() => end('quiet'), options.quietMs);
}
});
mutationObserver.observe(document.documentElement ?? document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
// Capture phase on window runs before every listener of the app, so the
// start sentinel precedes any bridge call the first key causes, and the
// records queued before a key belong to the previous bucket.
const onKeydown = (event: Event): void => {
const origin =
event.target instanceof Element
? event.target.closest(options.inputSelector)
: null;
if (origin === null || state.settle.status !== 'pending') return;
if (state.keystrokes.length >= options.keystrokes) {
state.invalidReasons.push('unexpected-keystroke');
return;
}
accept(mutationObserver.takeRecords().length);
const listenerEpochMs = epoch();
const eventEpochMs = performance.timeOrigin + event.timeStamp;
const epochMs =
Number.isFinite(eventEpochMs) && eventEpochMs <= listenerEpochMs
? eventEpochMs
: listenerEpochMs;
if (state.start === null) {
state.start = {
epochMs,
pathname: location.pathname,
sentinelStatus: callSentinel(options.startSentinelId),
};
fromEpochMs = epochMs;
}
state.keystrokes.push({
epochMs,
key: (event as KeyboardEvent).key ?? '',
ticks: readTicks(),
});
state.domMutationsByKeystroke.push(0);
if (state.keystrokes.length === options.keystrokes) {
timeoutTimer = setTimeout(
() => end('timeout'),
options.settleTimeoutMs
);
}
};
window.addEventListener('keydown', onKeydown, true);
}
export function createSearchJourneyProbeOptions(
query: string
): SearchJourneyProbeOptions {
return {
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
endSentinelId: SEARCH_JOURNEY_END_SENTINEL_ID,
inputSelector: SEARCH_JOURNEY_INPUT_SELECTOR,
keystrokes: query.length,
loadingSelector: SEARCH_JOURNEY_LOADING_SELECTOR,
query,
quietMs: SEARCH_JOURNEY_QUIET_MS,
resultSelector: SEARCH_JOURNEY_RESULT_SELECTOR,
routePath: SEARCH_JOURNEY_ROUTE_PATH,
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
settleTimeoutMs: SEARCH_JOURNEY_SETTLE_TIMEOUT_MS,
startSentinelId: SEARCH_JOURNEY_START_SENTINEL_ID,
stateKey: SEARCH_JOURNEY_PROBE_STATE_KEY,
};
}
export async function armSearchJourneyProbe(
page: Page,
options: SearchJourneyProbeOptions
): Promise<void> {
await page.evaluate(searchJourneyProbeScript, options);
}
export async function readSearchJourneyPreStartMutations(
page: Page,
stateKey: string
): Promise<number> {
return page.evaluate((key) => {
const state = (globalThis as unknown as Record<string, unknown>)[
key
] as { preStart?: { domMutations?: number } } | undefined;
const count = state?.preStart?.domMutations;
if (typeof count !== 'number') {
throw new Error('search-journey-probe-not-armed');
}
return count;
}, stateKey);
}
export async function waitForSearchJourneyProbe(
page: Page,
stateKey: string,
timeoutMs: number
): Promise<SearchJourneyProbeState> {
await page.waitForFunction(
(key) =>
(
(globalThis as unknown as Record<string, unknown>)[key] as
{ final?: boolean } | undefined
)?.final === true,
stateKey,
{ polling: 50, timeout: timeoutMs }
);
const state = await page.evaluate(
(key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as unknown,
stateKey
);
return assertSearchJourneyProbeState(state);
}
export function assertSearchJourneyProbeState(
value: unknown
): SearchJourneyProbeState {
const state = value as SearchJourneyProbeState | null;
if (
!state ||
state.schemaVersion !== SEARCH_JOURNEY_PROBE_SCHEMA_VERSION ||
state.final !== true ||
state.start === null
) {
throw new Error('search-journey-probe-incomplete');
}
if (state.invalidReasons.length > 0) {
throw new Error(
`search-journey-probe-invalid: ${state.invalidReasons.join(', ')}`
);
}
if (state.start.sentinelStatus !== 'sent') {
throw new Error(
`search-journey-probe-start-sentinel-${state.start.sentinelStatus}`
);
}
if (state.sentinel.status !== 'sent') {
throw new Error(
`search-journey-probe-sentinel-${state.sentinel.status}`
);
}
// A zero from an observer that never ran is not a measurement.
if (!state.capabilities.layoutShift || !state.capabilities.longTask) {
throw new Error('search-journey-probe-observer-unavailable');
}
return state;
}
@@ -0,0 +1,451 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import { summarizeJourneyIterations } from './journey-summary';
import type { SearchJourneyProbeState } from './search-journey-probe';
import {
SEARCH_JOURNEY_COUNTER,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
SEARCH_JOURNEY_WALL_CLOCK,
toSearchIterationRecord,
type SearchJourneyActivitySample,
type SearchJourneyMeasurement,
} from './search-journey-record';
const QUERY = 'system';
function sample(
ipcCalls: number,
queryCalls: number,
sqlStatements: number
): SearchJourneyActivitySample {
return { ipcCalls, queryCalls, sqlStatements };
}
/** The shape of a debounced search: only the last key runs a query. */
function measurement(
overrides: Partial<SearchJourneyMeasurement> = {},
rendererOverrides: Partial<SearchJourneyProbeState> = {}
): SearchJourneyMeasurement {
const keystrokes = [...QUERY].map((key, position) => ({
epochMs: 10_000 + position * 100,
key,
ticks: 40 + position,
}));
const renderer: SearchJourneyProbeState = {
capabilities: {
changeDetectionTicks: 'counted',
layoutShift: true,
longTask: true,
},
counters: {
domMutations: 402,
layoutShiftScore: 0.0004,
longTasks: 0,
recentInputLayoutShiftScore: 0.0011,
},
domMutationsByKeystroke: [0, 0, 0, 0, 0, 402],
final: true,
firstResult: { cardCount: 100, epochMs: 11_180, query: QUERY },
invalidReasons: [],
keystrokes,
longTaskDurationsMs: [],
preStart: { domMutations: 5, lastMutationEpochMs: 8_000 },
schemaVersion: 1,
sentinel: { epochMs: 11_400, status: 'sent' },
settle: {
cardCount: 100,
confirmedEpochMs: 11_399.6,
epochMs: 11_192.54,
query: QUERY,
status: 'quiet',
ticks: 54,
},
start: {
epochMs: 10_000,
pathname: '/w/search',
sentinelStatus: 'sent',
},
...rendererOverrides,
};
const ipc: JourneyMainIpcCaptureState = {
ambiguousTimelineCompletions: 0,
callsAfterSentinel: 0,
callsBeforeStart: 0,
callsBeforeSentinel: 1,
callsByMethod: { dbGlobalSearch: 1 },
inFlightByMethod: {},
installedEpochMs: 9_000,
malformedEvents: 0,
processStartEpochMs: 1_000,
senderIds: [1],
sentinel: { occurrences: 1, receivedEpochMs: 11_401 },
start: { occurrences: 1, receivedEpochMs: 10_001 },
timeline: [
{ method: 'dbGlobalSearch', phase: 'start' },
{ method: 'dbGlobalSearch', phase: 'end' },
],
unmatchedCompletions: 0,
};
return {
afterSettled: sample(1, 1, 121),
afterSettledWindowMs: 500,
externalArtworkCancelled: 42,
ipc,
keyDelayMs: 100,
pid: 4242,
query: QUERY,
sqlAtSentinels: { end: 121, start: 119 },
queryTrace: [
{
epochMs: 11_150,
phase: 'start',
resultLength: null,
term: QUERY,
},
{
epochMs: 11_170,
phase: 'success',
resultLength: 101,
term: null,
},
],
renderer,
samples: [
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(1, 1, 121),
],
settle: {
preStartDomMutations: 5,
preStartIpcCalls: 0,
quietMs: 1_000,
sqlStatements: 119,
waitedMs: 1_226,
},
...overrides,
};
}
test('maps a settled search to exact counters and wall-clock', () => {
const record = toSearchIterationRecord(1, false, measurement());
assert.deepEqual(record.counters, {
[SEARCH_JOURNEY_COUNTER.CD_TICKS]: 14,
[SEARCH_JOURNEY_COUNTER.DOM_MUTATIONS]: 402,
[SEARCH_JOURNEY_COUNTER.IPC_CALLS]: 1,
[SEARCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: 1,
[SEARCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: 0.002,
[SEARCH_JOURNEY_COUNTER.LONG_TASKS]: 0,
[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]: 2,
});
assert.deepEqual(record.wallClock, {
[SEARCH_JOURNEY_WALL_CLOCK.FIRST_KEYSTROKE_TO_FIRST_RESULT]: 1_180,
[SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED]: 692.5,
});
assert.equal(record.pid, 4242);
assert.equal(record.warmup, false);
});
test('breaks every counter down by keystroke', () => {
const record = toSearchIterationRecord(1, false, measurement());
const perKeystroke = record.evidence['perKeystroke'] as {
cdTicks: number;
domMutations: number;
ipcCalls: number;
key: string;
queryCalls: number;
sqlStatements: number;
}[];
assert.deepEqual(
perKeystroke.map((entry) => entry.key),
[...QUERY]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.cdTicks),
[1, 1, 1, 1, 1, 9]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.queryCalls),
[0, 0, 0, 0, 0, 1]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.sqlStatements),
[0, 0, 0, 0, 0, 2]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.domMutations),
[0, 0, 0, 0, 0, 402]
);
assert.deepEqual(record.evidence['sqlStatementsAfterSettled'], {
count: 0,
windowMs: 500,
});
assert.deepEqual(record.evidence['ipcTimeline'], [
'+dbGlobalSearch',
'-dbGlobalSearch',
]);
});
test('a query per keystroke shows up per key, not only in the total', () => {
const record = toSearchIterationRecord(
1,
false,
measurement({
samples: [
sample(0, 0, 100),
sample(0, 0, 100),
sample(1, 1, 102),
sample(2, 2, 104),
sample(3, 3, 106),
sample(4, 4, 108),
sample(5, 5, 110),
],
settle: {
preStartDomMutations: 5,
preStartIpcCalls: 0,
quietMs: 1_000,
sqlStatements: 100,
waitedMs: 1_000,
},
sqlAtSentinels: { end: 110, start: 100 },
ipc: {
...measurement().ipc,
callsBeforeSentinel: 5,
callsByMethod: { dbGlobalSearch: 5 },
},
queryTrace: ['sy', 'sys', 'syst', 'syste', QUERY].flatMap(
(term, position) => [
{
epochMs: 10_150 + position * 100,
phase: 'start',
resultLength: null,
term,
},
{
epochMs: 10_170 + position * 100,
phase: 'success',
resultLength: 101,
term: null,
},
]
),
})
);
assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.IPC_CALLS], 5);
assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS], 10);
const perKeystroke = record.evidence['perKeystroke'] as {
queryCalls: number;
}[];
assert.deepEqual(
perKeystroke.map((entry) => entry.queryCalls),
[0, 1, 1, 1, 1, 1]
);
});
test('rejects iterations that did not measure a settled search', () => {
const cases: [Partial<SearchJourneyProbeState>, RegExp][] = [
[{ start: null }, /incomplete-probe/],
[
{
settle: { ...measurement().renderer.settle, status: 'timeout' },
},
/incomplete-probe/,
],
[
{ keystrokes: measurement().renderer.keystrokes.slice(1) },
/keystroke-count/,
],
[
{
keystrokes: measurement().renderer.keystrokes.map((key) => ({
...key,
key: 'x',
})),
},
/typed-text/,
],
[
{ settle: { ...measurement().renderer.settle, query: 'syste' } },
/no-results/,
],
[{ firstResult: null }, /no-results/],
[
{ settle: { ...measurement().renderer.settle, epochMs: 9_000 } },
/clock-order/,
],
[{ preStart: { domMutations: 6, lastMutationEpochMs: 9_990 } }, /dom/],
[
{
capabilities: {
changeDetectionTicks: 'unavailable-counter-missing',
layoutShift: true,
longTask: true,
},
},
/cd-ticks-unavailable-counter-missing/,
],
];
for (const [overrides, error] of cases) {
assert.throws(
() => toSearchIterationRecord(1, false, measurement({}, overrides)),
error
);
}
});
test('rejects work that moved between the quiet snapshot and the first key', () => {
const base = measurement();
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ ipc: { ...base.ipc, callsBeforeStart: 1 } })
),
/activity-before-first-key-ipc/
);
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ sqlAtSentinels: { end: 122, start: 120 } })
),
/activity-before-first-key-sql/
);
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({
samples: [...base.samples.slice(0, 6), sample(2, 1, 121)],
})
),
/ipc-sample-mismatch/
);
});
test('summarizes with the shared summary and nothing unavailable', () => {
const iterations = [0, 1, 2].map((index) =>
toSearchIterationRecord(
index,
index === 0,
measurement({ pid: 4_000 + index })
)
);
const entry = summarizeJourneyIterations(
iterations,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS
);
assert.equal(entry.counters[SEARCH_JOURNEY_COUNTER.IPC_CALLS], 1);
assert.equal(
entry.counterStability[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]?.stable,
true
);
assert.equal(
entry.wallClock[
`${SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED}.p50`
],
692.5
);
assert.deepEqual(entry.unavailable, {});
});
test('rejects a settle that did not wait for the final query', () => {
const start = (epochMs: number, term: string) => ({
epochMs,
phase: 'start',
resultLength: null,
term,
});
const success = (epochMs: number) => ({
epochMs,
phase: 'success',
resultLength: 101,
term: null,
});
// Results of an earlier term were shown; the final query never ran
// before the end sentinel.
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({
queryTrace: [
start(10_500, 'syste'),
success(10_520),
start(11_500, QUERY),
success(11_520),
],
})
),
/final-query-not-run/
);
// The final query started but had not completed at the end sentinel.
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ queryTrace: [start(11_390, QUERY)] })
),
/final-query-incomplete/
);
const record = toSearchIterationRecord(1, false, measurement());
assert.deepEqual(record.evidence['finalQuery'], {
durationMs: 20,
resultLength: 101,
term: QUERY,
});
});
test('rejects typing slower than the accepted cadence', () => {
const base = measurement();
const keystrokes = base.renderer.keystrokes.map((key, position) => ({
...key,
epochMs: key.epochMs + (position >= 3 ? 200 : 0),
}));
assert.throws(
() =>
toSearchIterationRecord(1, false, measurement({}, { keystrokes })),
/typing-cadence: 100, 100, 300, 100, 100/
);
assert.deepEqual(
toSearchIterationRecord(1, false, base).evidence['keyIntervalsMs'],
[100, 100, 100, 100, 100]
);
});
test('counts SQL between the sentinels only', () => {
// Background statements after the end sentinel reach the final sample
// but not the counter; they show up after the settle instead.
const record = toSearchIterationRecord(
1,
false,
measurement({
afterSettled: sample(1, 1, 126),
samples: [...measurement().samples.slice(0, 6), sample(1, 1, 125)],
})
);
assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS], 2);
assert.deepEqual(record.evidence['sqlStatementsAfterSettled'], {
count: 5,
windowMs: 500,
});
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ sqlAtSentinels: { end: null, start: 119 } })
),
/sql-at-sentinels-missing/
);
});
@@ -0,0 +1,330 @@
import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyIterationRecord } from './journey-summary';
import type { SearchJourneyProbeState } from './search-journey-probe';
/**
* Maps one measured global search (renderer probe from the first keystroke
* until the results settled, main IPC capture between the start and end
* sentinels, main-process activity sampled before every keystroke) to the
* journey summary's iteration record for J4.
*/
export const SEARCH_JOURNEY_ID = 'search';
export const SEARCH_JOURNEY_COUNTER = {
CD_TICKS: 'renderer.cdTicksToResults',
DOM_MUTATIONS: 'renderer.domMutationsToResults',
IPC_CALLS: 'renderer.ipcCallsPerSearch',
IPC_SERIAL_DEPTH: 'renderer.ipcSerialDepthToResults',
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
LONG_TASKS: 'renderer.longTasks',
SQL_STATEMENTS: 'renderer.sqlStatementsPerSearch',
} as const;
export const SEARCH_JOURNEY_WALL_CLOCK = {
FIRST_KEYSTROKE_TO_FIRST_RESULT: 'firstKeystrokeToFirstResultMs',
LAST_KEYSTROKE_TO_SETTLED: 'lastKeystrokeToSettledMs',
} as const;
export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
Record<string, string>
> = Object.freeze({});
/** Bridge method of a global search query (`DatabaseService`). */
export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch';
/**
* Longest accepted gap between two keydowns. Below the shell's 350 ms input
* debounce, so a late key on a busy machine cannot let an intermediate term
* run a query that steady typing would not.
*/
export const SEARCH_JOURNEY_MAX_KEY_INTERVAL_MS = 250;
/** One traced query event, stamped in the main process on arrival. */
export interface SearchJourneyQueryTraceEntry {
readonly epochMs: number;
readonly phase: string;
/** Length of the returned array, on `success`. */
readonly resultLength: number | null;
readonly term: string | null;
}
/**
* Main-process activity read in one synchronous pass: the journey capture's
* call counts and the running `main.sqlStatements` total.
*/
export interface SearchJourneyActivitySample {
readonly ipcCalls: number;
readonly queryCalls: number;
readonly sqlStatements: number;
}
/** How long the app was left alone before the first keystroke. */
export interface SearchJourneySettle {
readonly preStartDomMutations: number;
readonly preStartIpcCalls: number;
readonly quietMs: number;
readonly sqlStatements: number;
readonly waitedMs: number;
}
export interface SearchJourneyMeasurement {
readonly externalArtworkCancelled: number;
readonly ipc: JourneyMainIpcCaptureState;
readonly keyDelayMs: number;
readonly pid: number;
readonly query: string;
readonly renderer: SearchJourneyProbeState;
/**
* Before each keystroke (index i before key i + 1), then once after the
* probe settled and once more `afterSettledWindowMs` later.
*/
readonly samples: readonly SearchJourneyActivitySample[];
readonly afterSettled: SearchJourneyActivitySample;
readonly afterSettledWindowMs: number;
readonly settle: SearchJourneySettle;
/**
* `main.sqlStatements` read in the main process when the start and the
* end sentinel arrived; null when it was not read.
*/
readonly sqlAtSentinels: {
readonly end: number | null;
readonly start: number | null;
};
/** Every traced query event of the process, in arrival order. */
readonly queryTrace: readonly SearchJourneyQueryTraceEntry[];
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
function roundThousandth(value: number): number {
return Math.round(value * 1_000) / 1_000;
}
function difference(
samples: readonly SearchJourneyActivitySample[],
index: number,
key: keyof SearchJourneyActivitySample
): number {
return samples[index + 1][key] - samples[index][key];
}
/**
* The query the settle waited for: the last one started between the start
* and end sentinels. It must be for the final term and must have completed
* before the end sentinel, otherwise the probe settled on results of an
* earlier term (still shown while the final term debounced).
*/
function finalQuery(
trace: readonly SearchJourneyQueryTraceEntry[],
fromEpochMs: number,
untilEpochMs: number,
query: string
) {
const inWindow = trace.filter(
(entry) => entry.epochMs >= fromEpochMs && entry.epochMs <= untilEpochMs
);
const starts = inWindow.filter((entry) => entry.phase === 'start');
const completions = inWindow.filter(
(entry) => entry.phase === 'success' || entry.phase === 'error'
);
const last = starts.at(-1);
if (last === undefined || last.term !== query) {
throw new Error('search-journey-record-final-query-not-run');
}
const completion = completions.find(
(entry) => entry.epochMs >= last.epochMs && entry.phase === 'success'
);
if (completion === undefined || completions.length < starts.length) {
throw new Error('search-journey-record-final-query-incomplete');
}
return Object.freeze({
durationMs: completion.epochMs - last.epochMs,
resultLength: completion.resultLength,
term: last.term,
});
}
export function toSearchIterationRecord(
index: number,
warmup: boolean,
measurement: SearchJourneyMeasurement
): JourneyIterationRecord {
const { ipc, renderer, samples, settle } = measurement;
const { keystrokes, start } = renderer;
const keys = measurement.query.length;
if (start === null || renderer.settle.status !== 'quiet') {
throw new Error('search-journey-record-incomplete-probe');
}
if (ipc.start === null) {
throw new Error('search-journey-record-ipc-without-start');
}
if (keystrokes.length !== keys || samples.length !== keys + 1) {
throw new Error('search-journey-record-keystroke-count');
}
if (keystrokes.map((key) => key.key).join('') !== measurement.query) {
throw new Error('search-journey-record-typed-text');
}
if (
renderer.settle.query !== measurement.query ||
renderer.settle.cardCount === 0 ||
renderer.firstResult === null
) {
throw new Error('search-journey-record-no-results');
}
const settledEpochMs = renderer.settle.epochMs;
const lastKeyEpochMs = keystrokes[keys - 1].epochMs;
if (
settledEpochMs === null ||
settledEpochMs < lastKeyEpochMs ||
renderer.firstResult.epochMs < start.epochMs
) {
throw new Error('search-journey-record-clock-order');
}
const { end: sqlAtEnd, start: sqlAtStart } = measurement.sqlAtSentinels;
if (sqlAtStart === null || sqlAtEnd === null || sqlAtEnd < sqlAtStart) {
throw new Error('search-journey-record-sql-at-sentinels-missing');
}
// Activity between the quiet snapshot and the first key could finish
// after it and be counted as the search's. The probe, the capture and
// the SQL total read at the start sentinel all reflect the first
// keydown, so they must still match the snapshot.
const moved = [
renderer.preStart.domMutations !== settle.preStartDomMutations
? 'dom'
: null,
ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
sqlAtStart !== settle.sqlStatements ? 'sql' : null,
].filter((kind): kind is string => kind !== null);
if (moved.length > 0) {
throw new Error(
`search-journey-record-activity-before-first-key-${moved.join('-')}`
);
}
const keyIntervalsMs = keystrokes
.slice(1)
.map((key, position) =>
roundTenth(key.epochMs - keystrokes[position].epochMs)
);
if (
keyIntervalsMs.some(
(interval) => interval > SEARCH_JOURNEY_MAX_KEY_INTERVAL_MS
)
) {
throw new Error(
`search-journey-record-typing-cadence: ${keyIntervalsMs.join(', ')}`
);
}
if (ipc.sentinel.receivedEpochMs === null) {
throw new Error('search-journey-record-ipc-without-end');
}
const lastQuery = finalQuery(
measurement.queryTrace,
ipc.start.receivedEpochMs ?? Number.POSITIVE_INFINITY,
ipc.sentinel.receivedEpochMs,
measurement.query
);
const finalSample = samples[keys];
if (finalSample.ipcCalls !== ipc.callsBeforeSentinel) {
throw new Error('search-journey-record-ipc-sample-mismatch');
}
const settleTicks = renderer.settle.ticks;
const ticks = keystrokes.map((key) => key.ticks);
if (
renderer.capabilities.changeDetectionTicks !== 'counted' ||
settleTicks === null ||
ticks.some((value) => value === null)
) {
throw new Error(
`search-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}`
);
}
const tickAt = (position: number): number =>
position < keys ? (ticks[position] as number) : settleTicks;
const serialDepth = computeJourneyIpcSerialDepth(ipc.timeline);
const perKeystroke = keystrokes.map((key, position) =>
Object.freeze({
atMs: roundTenth(key.epochMs - start.epochMs),
cdTicks: tickAt(position + 1) - tickAt(position),
domMutations: renderer.domMutationsByKeystroke[position] ?? 0,
ipcCalls: difference(samples, position, 'ipcCalls'),
key: key.key,
queryCalls: difference(samples, position, 'queryCalls'),
sqlStatements: difference(samples, position, 'sqlStatements'),
})
);
return Object.freeze({
counters: Object.freeze({
[SEARCH_JOURNEY_COUNTER.CD_TICKS]: settleTicks - tickAt(0),
[SEARCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[SEARCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
[SEARCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: serialDepth.depth,
// Typing is input, so shifts flagged hadRecentInput are
// included, as in J2 and J3.
[SEARCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth(
renderer.counters.layoutShiftScore +
renderer.counters.recentInputLayoutShiftScore
),
[SEARCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]: sqlAtEnd - sqlAtStart,
}),
evidence: Object.freeze({
capabilities: renderer.capabilities,
epochs: Object.freeze({
firstKeystroke: start.epochMs,
firstResult: renderer.firstResult.epochMs,
lastKeystroke: lastKeyEpochMs,
mainIpcSentinel: ipc.sentinel.receivedEpochMs,
mainIpcStart: ipc.start.receivedEpochMs,
settleConfirmed: renderer.settle.confirmedEpochMs,
settled: settledEpochMs,
}),
externalArtworkCancelled: measurement.externalArtworkCancelled,
firstResult: Object.freeze({
cardCount: renderer.firstResult.cardCount,
query: renderer.firstResult.query,
}),
ipcCallsAfterSettled: ipc.callsAfterSentinel,
ipcCallsByMethod: ipc.callsByMethod,
ipcSerialDepth: serialDepth,
ipcTimeline: ipc.timeline.map(
(event) =>
`${event.phase === 'start' ? '+' : '-'}${event.method}`
),
finalQuery: lastQuery,
keyDelayMs: measurement.keyDelayMs,
keyIntervalsMs,
layoutShift: Object.freeze({
recentInput: roundThousandth(
renderer.counters.recentInputLayoutShiftScore
),
withoutRecentInput: roundThousandth(
renderer.counters.layoutShiftScore
),
}),
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
perKeystroke,
query: measurement.query,
results: Object.freeze({ cardCount: renderer.settle.cardCount }),
settle,
sqlAtSentinels: measurement.sqlAtSentinels,
sqlStatementsAfterSettled: Object.freeze({
count: measurement.afterSettled.sqlStatements - sqlAtEnd,
windowMs: measurement.afterSettledWindowMs,
}),
}),
index,
pid: measurement.pid,
wallClock: Object.freeze({
[SEARCH_JOURNEY_WALL_CLOCK.FIRST_KEYSTROKE_TO_FIRST_RESULT]:
roundTenth(renderer.firstResult.epochMs - start.epochMs),
[SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED]: roundTenth(
settledEpochMs - lastKeyEpochMs
),
}),
warmup,
});
}
@@ -0,0 +1,148 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based repository contract tests. */
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// docs/architecture/zoneless-migration.md lists every component that still
// opts out of OnPush. This keeps the checklist and the code in step: a new
// Eager component fails here, and so does a converted one left unticked.
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
const checklistPath = 'docs/architecture/zoneless-migration.md';
const sourceRoots = ['apps', 'libs'];
const skippedDirectories = new Set([
'node_modules',
'dist',
'coverage',
'test-stubs',
]);
// Test-only files follow the repository's `.spec` / `.test` naming, with an
// optional suffix of one or more segments (`.spec-stubs.ts`,
// `.test-helpers.ts`, `.test-data-stubs.ts`).
const testOnlyFile = /(\.(spec|test)(-\w+)*|^test-setup)\.ts$/;
function listProductionSources(directory: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
if (skippedDirectories.has(entry.name)) continue;
if (entry.name.endsWith('-e2e')) continue;
files.push(...listProductionSources(path));
} else if (
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.d.ts') &&
!testOnlyFile.test(entry.name)
) {
files.push(relative(workspaceRoot, path).split(sep).join('/'));
}
}
return files;
}
function readSources(): Map<string, string> {
const sources = new Map<string, string>();
for (const root of sourceRoots) {
for (const file of listProductionSources(join(workspaceRoot, root))) {
sources.set(file, readFileSync(join(workspaceRoot, file), 'utf8'));
}
}
return sources;
}
function readEagerChecklist(): { open: string[]; done: string[] } {
const markdown = readFileSync(join(workspaceRoot, checklistPath), 'utf8');
const section = markdown.split(/^## Eager components$/m)[1];
assert.ok(
section,
`${checklistPath} must have an "Eager components" section`
);
const body = section.split(/^## /m)[0];
const open: string[] = [];
const done: string[] = [];
for (const match of body.matchAll(/^- \[( |x)\] `([^`]+\.ts)`/gm)) {
(match[1] === 'x' ? done : open).push(match[2]);
}
return { open: open.sort(), done: done.sort() };
}
const sources = readSources();
// Component metadata only: a comment or string that names the strategy is
// not an Eager component.
const eagerMetadata = /changeDetection\s*:\s*ChangeDetectionStrategy\.Eager\b/;
function withoutComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
}
function isEagerComponent(text: string): boolean {
return eagerMetadata.test(withoutComments(text));
}
test('the zoneless checklist lists exactly the components that are still Eager', () => {
const eager = [...sources]
.filter(([, text]) => isEagerComponent(text))
.map(([file]) => file)
.sort();
const { open } = readEagerChecklist();
assert.deepEqual(
eager,
open,
`Production files with ChangeDetectionStrategy.Eager must match the unticked entries in ${checklistPath}. ` +
'Do not add Eager components; tick an entry when its component is converted.'
);
});
test('the guard skips test-only file names and keeps production ones', () => {
for (const name of [
'player.component.spec.ts',
'serial-details.test-stubs.ts',
'dashboard.spec-stubs.ts',
'rail.test-data-stubs.ts',
'test-setup.ts',
]) {
assert.ok(testOnlyFile.test(name), `${name} is test-only`);
}
for (const name of [
'player.component.ts',
'spec-utils.ts',
'contest.ts',
'latest-setup.ts',
'testing.service.ts',
]) {
assert.ok(!testOnlyFile.test(name), `${name} ships`);
}
});
test('a comment that names the Eager strategy is not an Eager component', () => {
assert.equal(
isEagerComponent(
'// was ChangeDetectionStrategy.Eager before C6\n' +
'/* changeDetection: ChangeDetectionStrategy.Eager */\n' +
'@Component({ changeDetection: ChangeDetectionStrategy.OnPush })'
),
false
);
assert.equal(
isEagerComponent(
'@Component({\n changeDetection: ChangeDetectionStrategy.Eager,\n})'
),
true
);
});
test('ticked checklist entries name files that exist', () => {
for (const file of readEagerChecklist().done) {
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
}
});
test('no production component uses the deprecated Default strategy alias', () => {
for (const [file, text] of sources) {
assert.doesNotMatch(text, /ChangeDetectionStrategy\.Default\b/, file);
}
});
@@ -198,3 +198,98 @@ export async function expectSkeletonContrast(
).toBeGreaterThanOrEqual(1.3);
}
}
/** Contrast of an element's own text against whatever is painted behind it
* (images, gradient scrims, translucent chips), read back from the screen.
* The text is made transparent for the capture, so its `text-shadow` stays
* in the backdrop it is meant to support; its CSS colour (with alpha and
* ancestor opacity) is then composited over every pixel under its line
* boxes. Returns the worst ratio, so one dark patch of artwork under one
* letter fails it. */
export async function measureBackdropTextContrast(
page: Page,
text: Locator
): Promise<number> {
await expect(text).toBeVisible();
const probe = await text.evaluate((element) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = getComputedStyle(element).color;
ctx.fillRect(0, 0, 1, 1);
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
let alpha = a / 255;
for (
let node: Element | null = element;
node;
node = node.parentElement
) {
alpha *= Number(getComputedStyle(node).opacity);
}
const range = document.createRange();
range.selectNodeContents(element);
const box = range.getBoundingClientRect();
const style = (element as HTMLElement).style;
const previous = style.getPropertyValue('color');
style.setProperty('color', 'transparent', 'important');
(element as HTMLElement).dataset['contrastPrevious'] = previous;
return {
color: [r, g, b, alpha],
// Whole pixels inside the line boxes, clear of glyph edges that
// spill past them.
clip: {
x: Math.ceil(box.left),
y: Math.ceil(box.top),
width: Math.max(1, Math.floor(box.width) - 1),
height: Math.max(1, Math.floor(box.height) - 1),
},
};
});
try {
const { data, info } = await sharp(
await page.screenshot({ clip: probe.clip })
)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const [fr, fg, fb, fa] = probe.color;
const luminance = (r: number, g: number, b: number) =>
[r, g, b]
.map((value) => {
const s = value / 255;
return s <= 0.04045
? s / 12.92
: ((s + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, value, index) =>
sum + value * [0.2126, 0.7152, 0.0722][index],
0
);
let worst = Infinity;
for (let i = 0; i < data.length; i += info.channels) {
const [br, bg, bb] = [data[i], data[i + 1], data[i + 2]];
const front = luminance(
fr * fa + br * (1 - fa),
fg * fa + bg * (1 - fa),
fb * fa + bb * (1 - fa)
);
const back = luminance(br, bg, bb);
worst = Math.min(
worst,
(Math.max(front, back) + 0.05) / (Math.min(front, back) + 0.05)
);
}
return worst;
} finally {
await text.evaluate((element) => {
const style = (element as HTMLElement).style;
const previous = (element as HTMLElement).dataset[
'contrastPrevious'
];
style.removeProperty('color');
if (previous) style.setProperty('color', previous);
delete (element as HTMLElement).dataset['contrastPrevious'];
});
}
}
@@ -1,8 +1,14 @@
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickFirstGridListCard,
closeElectronApp,
expect,
launchElectronApp,
LaunchedElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
// Custom window controls are only rendered on Windows/Linux; macOS keeps
@@ -188,6 +194,93 @@ test.describe('Custom window controls', () => {
});
});
// The native buttons are 14pt circles. At 100 % zoom the first header
// control starts 60pt right of their origin, where macOS 26 ends them
// (earlier releases end them at 52pt).
const lightsHeight = 14;
const headerControlOffset = 60;
/** Where macOS drew the native window buttons, in window points. */
async function trafficLights(
app: LaunchedElectronApp
): Promise<{ x: number; y: number }> {
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
);
expect(lights, 'native window button position').toBeTruthy();
return lights ?? { x: Number.NaN, y: Number.NaN };
}
/** Window pixels per CSS pixel. */
function zoomFactor(page: Page): Promise<number> {
return page.evaluate(() => window.outerWidth / window.innerWidth);
}
/**
* Steps the app zoom to its minimum (−4, ≈48 %). App zoom scales CSS pixels
* but not the native buttons.
*/
async function zoomOutFully(page: Page): Promise<void> {
for (let step = 0; step < 8; step++) {
await page.evaluate(() => window.electron.adjustZoomLevel('out'));
}
await expect.poll(() => zoomFactor(page)).toBeLessThan(0.6);
}
async function resetZoom(page: Page): Promise<void> {
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
await expect.poll(() => zoomFactor(page)).toBeCloseTo(1, 2);
}
/**
* The header's first rendered control and the top of the content area, in
* window pixels (CSS pixels times the zoom factor).
*/
function headerLayout(
page: Page
): Promise<{ control?: string; left: number; contentTop: number }> {
return page.locator('.workspace-header').evaluate((header) => {
const zoom = window.outerWidth / window.innerWidth;
const first = [...header.children].find(
(child) => child.getBoundingClientRect().width > 0
);
const body = document.querySelector('.workspace-body');
return {
control:
first?.getAttribute('data-test-id') ??
first?.tagName.toLowerCase(),
left: (first?.getBoundingClientRect().left ?? 0) * zoom,
contentTop: (body?.getBoundingClientRect().top ?? 0) * zoom,
};
});
}
/**
* The first header control starts right of the lights and the content area
* below them. Polled: the layout follows a zoom change after its resize.
*/
async function expectHeaderClearOfLights(
page: Page,
lights: { x: number; y: number },
control: string,
label: string
): Promise<void> {
const layout = () => headerLayout(page);
await expect
.poll(async () => (await layout()).control, { message: label })
.toBe(control);
await expect
.poll(async () => (await layout()).left, {
message: `${label}: first control`,
})
.toBeGreaterThanOrEqual(lights.x + headerControlOffset);
await expect
.poll(async () => (await layout()).contentTop, {
message: `${label}: content top`,
})
.toBeGreaterThanOrEqual(lights.y + lightsHeight);
}
test.describe('macOS traffic lights', () => {
test.skip(
process.platform !== 'darwin',
@@ -205,49 +298,88 @@ test.describe('macOS traffic lights', () => {
const firstLink = page.locator('.app-rail a').first();
await expect(firstLink).toBeVisible();
const [linkBox, contentBox] = await Promise.all([
firstLink.boundingBox(),
page.locator('.workspace-content').boundingBox(),
]);
// Aligned with the content area, where the dashboard hero starts.
expect(
Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100))
).toBeLessThanOrEqual(1);
const linkOffsetFromContent = async (): Promise<number> => {
const [linkBox, contentBox] = await Promise.all([
firstLink.boundingBox(),
page.locator('.workspace-content').boundingBox(),
]);
return Math.abs(
(linkBox?.y ?? 0) - (contentBox?.y ?? Number.NaN)
);
};
expect(await linkOffsetFromContent()).toBeLessThanOrEqual(1);
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
);
expect(lights, 'native window button position').toBeTruthy();
const lightsY = lights?.y ?? Number.NaN;
// The buttons are about 14pt tall; keep a visible gap below them,
// measured in window pixels (CSS pixels times the zoom factor).
const lights = await trafficLights(app);
// Keep a visible gap below the buttons, measured in window pixels
// (CSS pixels times the zoom factor).
const linkTopInWindowPixels = async (): Promise<number> => {
const [box, zoom] = await Promise.all([
firstLink.boundingBox(),
page.evaluate(() => window.outerWidth / window.innerWidth),
zoomFactor(page),
]);
return (box?.y ?? Number.NaN) * zoom;
};
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
lightsY + 14 + 16
lights.y + lightsHeight + 16
);
// App zoom scales CSS pixels but not the native buttons: at the
// smallest zoom the inset must still clear them.
for (let step = 0; step < 8; step++) {
await page.evaluate(() =>
window.electron.adjustZoomLevel('out')
);
}
await expect
.poll(() =>
page.evaluate(() => window.outerWidth / window.innerWidth)
)
.toBeLessThan(0.6);
// At the smallest zoom the inset must still clear the buttons,
// and the link still starts with the content area.
await zoomOutFully(page);
await expect
.poll(linkTopInWindowPixels)
.toBeGreaterThanOrEqual(lightsY + 14 + 8);
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
.toBeGreaterThanOrEqual(lights.y + lightsHeight + 8);
await expect.poll(linkOffsetFromContent).toBeLessThanOrEqual(1);
await resetZoom(page);
} finally {
await closeElectronApp(app);
}
});
test('@xtream @electron the first header control clears the lights at default and minimum zoom', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const lights = await trafficLights(app);
const switcher = 'app-playlist-switcher';
const back = 'workspace-header-back';
// The first page has no history to go back to: the switcher leads.
await expectHeaderClearOfLights(page, lights, switcher, 'start');
await zoomOutFully(page);
await expectHeaderClearOfLights(
page,
lights,
switcher,
'start at min zoom'
);
await resetZoom(page);
// A detail page puts its Back first, pulled 8px toward the edge.
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickFirstGridListCard(page);
await expect(page.getByTestId(back)).toBeVisible({
timeout: 20_000,
});
await expectHeaderClearOfLights(page, lights, back, 'detail');
await zoomOutFully(page);
await expectHeaderClearOfLights(
page,
lights,
back,
'detail at min zoom'
);
await resetZoom(page);
} finally {
await closeElectronApp(app);
}
@@ -27,8 +27,9 @@ const setWindowState = (
);
/**
* The app creates its window with `show: false` and shows it on
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
* The app creates its window with `show: false` and shows it at
* `ready-to-show` or `did-finish-load`, whichever comes first; a `hide()`
* sent earlier would be undone by that `show()`.
*/
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
await expect
+33
View File
@@ -75,11 +75,14 @@ type MockMainWindow = {
maximize: jest.Mock<void, []>;
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
removeListener: jest.Mock<void, [string, (...args: unknown[]) => void]>;
setFullScreen: jest.Mock<void, [boolean]>;
setMenu: jest.Mock<void, [unknown]>;
show: jest.Mock<void, []>;
webContents: {
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
removeListener: jest.Mock<void, [string, (...args: unknown[]) => void]>;
openDevTools: jest.Mock<void, []>;
setWindowOpenHandler: jest.Mock<void, [unknown]>;
getZoomLevel: jest.Mock<number, []>;
@@ -98,12 +101,18 @@ function createMockMainWindow(): MockMainWindow {
maximize: jest.fn<void, []>(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
removeListener: jest.fn<void, [string, (...args: unknown[]) => void]>(),
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
setFullScreen: jest.fn<void, [boolean]>(),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: {
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
removeListener: jest.fn<
void,
[string, (...args: unknown[]) => void]
>(),
openDevTools: jest.fn<void, []>(),
setWindowOpenHandler: jest.fn<void, [unknown]>(),
getZoomLevel: jest.fn<number, []>().mockReturnValue(0),
@@ -366,6 +375,30 @@ describe('Electron app security helpers', () => {
expect(mainWindow.show).toHaveBeenCalledTimes(1);
});
it('shows the window at did-finish-load when ready-to-show has not come yet', () => {
storeStartupWindowMode('maximized');
const mainWindow = createWindowViaOnReady();
expect(BrowserWindow).toHaveBeenCalledWith(
expect.objectContaining({
show: false,
backgroundColor: '#1f1f23',
})
);
const [loadHandler] = mainWindow.webContents.once.mock.calls
.filter(([eventName]) => eventName === 'did-finish-load')
.map(([, handler]) => handler);
loadHandler();
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
expect(mainWindow.show).toHaveBeenCalledTimes(1);
// The later ready-to-show is a no-op.
fireReadyToShow(mainWindow);
expect(mainWindow.show).toHaveBeenCalledTimes(1);
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
});
it('creates the window fullscreen when the stored mode says so', () => {
storeStartupWindowMode('fullscreen');
+19 -9
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
import {
ElectronBridgeWindowState,
MACOS_TRAFFIC_LIGHTS_POSITION,
WINDOW_STATE_CHANGED,
} from '@iptvnator/shared/interfaces';
import { join, resolve } from 'path';
@@ -15,6 +16,10 @@ import {
trace,
traceStartupPhase,
} from './services/debug-trace';
import {
MAIN_WINDOW_BACKGROUND_COLOR,
showMainWindowWhenLoaded,
} from './services/main-window-first-show';
import { attachMainWindowPerformanceCounters } from './services/performance-counters';
import {
STARTUP_WINDOW_MODE,
@@ -397,7 +402,7 @@ export default class App {
return {
titleBarStyle: 'hidden',
titleBarOverlay: true,
trafficLightPosition: { x: 16, y: 20 },
trafficLightPosition: { ...MACOS_TRAFFIC_LIGHTS_POSITION },
};
}
@@ -512,6 +517,9 @@ export default class App {
width: width,
height: height,
show: false,
// The splash colour: the window can be shown before its first
// paint (main-window-first-show.ts).
backgroundColor: MAIN_WINDOW_BACKGROUND_COLOR,
webPreferences: getMainWindowWebPreferences(),
...savedWindowBounds,
// Fullscreen is a constructor option: the window is created
@@ -543,15 +551,17 @@ export default class App {
App.mainWindow.center();
}
// if main window is ready to show, close the splash window and show the main window
App.mainWindow.once('ready-to-show', () => {
// Shown at ready-to-show or did-finish-load, whichever comes first
// (see main-window-first-show.ts).
const mainWindow = App.mainWindow;
showMainWindowWhenLoaded(mainWindow, () => {
// maximize() on a hidden window shows it (Electron docs), so it
// has to wait for ready-to-show like show() does — any earlier
// and a blank window flashes before the renderer paints.
// waits for the document like show() does — any earlier and a
// blank window flashes before the splash is there.
if (startupWindowMode === 'maximized') {
App.mainWindow.maximize();
mainWindow.maximize();
}
App.mainWindow.show();
mainWindow.show();
// macOS ignores the constructor's `fullscreen` while the window
// is hidden — an NSWindow can only toggle fullscreen once it is
// on screen — so the request is repeated after show() wherever
@@ -562,9 +572,9 @@ export default class App {
// asking for it again.
if (
startupWindowMode === 'fullscreen' &&
!App.mainWindow.isFullScreen()
!mainWindow.isFullScreen()
) {
requestFullScreen(App.mainWindow, true);
requestFullScreen(mainWindow, true);
}
});
@@ -0,0 +1,71 @@
import { EventEmitter } from 'events';
import {
type FirstShowWindow,
showMainWindowWhenLoaded,
} from './main-window-first-show';
function createWindow(): FirstShowWindow &
EventEmitter & {
webContents: EventEmitter;
destroyed: boolean;
} {
const window = Object.assign(new EventEmitter(), {
destroyed: false,
webContents: new EventEmitter(),
isDestroyed(): boolean {
return window.destroyed;
},
});
return window;
}
describe('showMainWindowWhenLoaded', () => {
it('shows the window at did-finish-load when ready-to-show has not fired', () => {
// The Linux race: the hidden window gets no frame for its first
// paint, so ready-to-show (and the splash's animation frame) would
// wait about a second after the document has loaded.
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.webContents.emit('did-finish-load');
expect(show).toHaveBeenCalledTimes(1);
});
it('shows the window at ready-to-show when that comes first', () => {
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.emit('ready-to-show');
expect(show).toHaveBeenCalledTimes(1);
});
it('shows the window only once and detaches the other listener', () => {
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.webContents.emit('did-finish-load');
window.emit('ready-to-show');
// A reload loads the document again; the window is already shown.
window.webContents.emit('did-finish-load');
expect(show).toHaveBeenCalledTimes(1);
expect(window.listenerCount('ready-to-show')).toBe(0);
expect(window.webContents.listenerCount('did-finish-load')).toBe(0);
});
it('does not show a window that was destroyed before it loaded', () => {
const window = createWindow();
const show = jest.fn();
showMainWindowWhenLoaded(window, show);
window.destroyed = true;
window.emit('ready-to-show');
expect(show).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,52 @@
/**
* When the hidden main window is first shown.
*
* The window is created with `show: false` and used to be shown on
* `ready-to-show` only. That event needs the window's first visually
* non-empty paint, and a hidden window does not always get a frame for it:
* on Linux under X11, when the startup scripts run before that frame, the
* next one comes about a second later. Until then nothing is on screen and
* the renderer gets no animation frames, so the splash that `main.ts`
* removes in a `requestAnimationFrame` stays even after the dashboard has
* rendered (J1 on the CI runner: about 450 ms later to the first card, in
* roughly one launch out of three, see docs/architecture/performance-journeys.md).
*
* The window is therefore shown at whichever comes first: `ready-to-show`
* or the main frame's `did-finish-load`. At `did-finish-load` the inline
* splash is parsed and styled, and the window's `backgroundColor` matches
* it, so showing before the first paint does not flash.
*/
/** Matches `#initial-splash` in `apps/web/src/index.html`. */
export const MAIN_WINDOW_BACKGROUND_COLOR = '#1f1f23';
type OnceEmitter = {
once(event: string, listener: () => void): unknown;
removeListener(event: string, listener: () => void): unknown;
};
export type FirstShowWindow = OnceEmitter & {
isDestroyed(): boolean;
readonly webContents: OnceEmitter;
};
/** Calls `show` once, at `ready-to-show` or `did-finish-load`, whichever comes first. */
export function showMainWindowWhenLoaded(
window: FirstShowWindow,
show: () => void
): void {
let shown = false;
const showOnce = (): void => {
if (shown) {
return;
}
shown = true;
window.removeListener('ready-to-show', showOnce);
window.webContents.removeListener('did-finish-load', showOnce);
if (!window.isDestroyed()) {
show();
}
};
window.once('ready-to-show', showOnce);
window.webContents.once('did-finish-load', showOnce);
}
@@ -0,0 +1,104 @@
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from './fixtures';
import {
BACKEND_PROXY,
EMBEDDED_SERIES_MAC,
MOCK_SERVER,
} from './stalker-portal.fixture';
/** A VOD row of the embedded-series scenario carrying a `series[]` array. */
interface EmbeddedSeriesItem {
name: string;
series: unknown[];
}
/**
* Finds a VOD item carrying an embedded series[] array in the mock catalog
* and reports how many episodes it currently has.
*/
export async function findEmbeddedSeriesItem(
request: APIRequestContext
): Promise<{ embeddedItem: EmbeddedSeriesItem; episodeCount: number }> {
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
return { embeddedItem, episodeCount };
}
/**
* Opens the embedded-series item from its category (the first row is "All")
* and waits until the series detail lists its last episode. Returns the
* item's card in the category grid.
*/
export async function openEmbeddedSeriesItem(
page: Page,
itemName: string,
episodeCount: number
): Promise<Locator> {
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(itemName).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
return card;
}
/**
* From now on the portal has "released" one more episode: extend series[] in
* every search response (the background snapshot refresh re-fetches the item
* via a title search).
*/
export async function releaseExtraEpisodeInSearchResponses(
page: Page
): Promise<void> {
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
if (!originalUrl.searchParams.get('search')) {
await route.fallback();
return;
}
const mockUrl = new URL(BACKEND_PROXY);
const targetId = originalUrl.searchParams.get('targetId');
const providerUrl = targetId
? Buffer.from(targetId, 'base64url').toString()
: originalUrl.searchParams.get('url');
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = await response.json();
const rows: { series?: string[] }[] =
body?.payload?.js?.data ?? body?.js?.data ?? [];
for (const row of rows) {
if (Array.isArray(row.series) && row.series.length > 0) {
row.series = [...row.series, String(row.series.length + 1)];
}
}
await route.fulfill({ response, body: JSON.stringify(body) });
});
}
+238
View File
@@ -0,0 +1,238 @@
import type { Page } from '@playwright/test';
import { setInputValue } from './e2e-helpers';
import { expect } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
/**
* Mock-portal endpoints, scenario MACs and page helpers of `stalker.e2e.ts`.
*
* The scenario MACs declared here belong to that spec alone: it lists them in
* its `OWNED_MACS` and resets them before every test, so a sibling spec that
* reused one would have its mock state cleared mid-run. See the isolation
* notes at the top of `stalker.e2e.ts`.
*/
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
export const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
/**
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
* "simple" portal (no handshake, no token, no watchdog); this shape is the
* authenticated branch, which the mock guards like the real middleware.
*/
export const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
export const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
export const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
export const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
export const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */
export const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
/**
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
* expects no `create_link` call at all.
*/
export const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* The full-portal authentication tests assert state transitions within one
* portal session, so a reset from a concurrent browser project or repeat
* worker would invalidate the assertion itself. Giving every concurrent
* worker slot its own MAC range preserves browser parallelism and also keeps
* `--repeat-each` runs isolated.
*/
export interface StatefulAuthMacs {
authenticatedFlow: string;
loginRequired: string;
tokenReuse: string;
deviceConflict: string;
reauthentication: string;
}
export function getStatefulAuthMacs({
parallelIndex,
}: {
parallelIndex: number;
}): StatefulAuthMacs {
if (
!Number.isSafeInteger(parallelIndex) ||
parallelIndex < 0 ||
parallelIndex > 255
) {
throw new Error(
`Unsupported Playwright parallel index: ${parallelIndex}`
);
}
const workerOctet = parallelIndex
.toString(16)
.padStart(2, '0')
.toUpperCase();
const workerPrefix = `00:1A:79:AE:${workerOctet}`;
return {
authenticatedFlow: `${workerPrefix}:01`,
loginRequired: `${workerPrefix}:02`,
tokenReuse: `${workerPrefix}:03`,
deviceConflict: `${workerPrefix}:04`,
reauthentication: `${workerPrefix}:05`,
};
}
/**
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
* it, so no token is ever adopted and content requests fail permanently.
*/
export const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
/**
* Intercept calls to the Angular dev backend (/stalker proxy) and redirect
* them to the mock server. This avoids needing a real backend or changing
* any app environment configuration.
*/
export async function interceptStalkerRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(BACKEND_PROXY);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button to open the unified dialog
* 2. Select "Stalker" toggle
* 3. Fill in the form and submit
*/
export async function addStalkerPortal(
page: Page,
options: { name?: string; mac?: string } = {}
): Promise<void> {
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/stalker.*vod/);
}
/**
* Add a Stalker portal through the canonical Ministra URL, which the app
* imports as a FULL portal: handshake, Bearer token and watchdog.
*/
export async function addFullStalkerPortal(
page: Page,
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
if (expectContent) {
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
}
}
export const CONTENT_ACTIONS = [
'get_categories',
'get_genres',
'get_ordered_list',
'get_all_channels',
];
/** Every portal request in order, with the token it carried. */
export function recordPortalRequests(
page: Page
): Array<{ action: string; token: string | null }> {
const requests: Array<{ action: string; token: string | null }> = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
const action = url.searchParams.get('action');
if (!action) {
return;
}
requests.push({ action, token: url.searchParams.get('token') });
});
return requests;
}
+38 -328
View File
@@ -1,4 +1,4 @@
import { type APIRequestContext, type Page } from '@playwright/test';
import { type APIRequestContext } from '@playwright/test';
import {
closeSeriesMenu,
expectSeriesSurfacesInBothThemes,
@@ -10,14 +10,33 @@ import {
verifyStalkerPlaybackCategoryReturn,
verifyUncachedStalkerSearch,
} from './stalker-category-search.fixture';
import {
findEmbeddedSeriesItem,
openEmbeddedSeriesItem,
releaseExtraEpisodeInSearchResponses,
} from './stalker-embedded-series.fixture';
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
import { playFirstItvChannel } from './stalker-itv-playback.fixture';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
AUTH_REJECTED_MAC,
BACKEND_PROXY,
CONTENT_ACTIONS,
DEFAULT_MAC,
EMBEDDED_SERIES_MAC,
FULL_PORTAL_URL,
LEGACY_PAGINATION_MAC,
MINIMAL_MAC,
MOCK_SERVER,
STATIC_CMD_MAC,
type StatefulAuthMacs,
addFullStalkerPortal,
addStalkerPortal,
getStatefulAuthMacs,
interceptStalkerRequests,
recordPortalRequests,
} from './stalker-portal.fixture';
import { expect, test } from './fixtures';
/**
* Stalker Portal E2E Tests
@@ -58,121 +77,10 @@ import {
test.describe.configure({ mode: 'serial' });
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
/**
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
* "simple" portal (no handshake, no token, no watchdog); this shape is the
* authenticated branch, which the mock guards like the real middleware.
*/
const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
const DEFAULT_MAC = '00:1A:79:00:00:01';
/** Minimal scenario MAC — 2 categories, 5 items (edge case testing) */
const MINIMAL_MAC = '00:1A:79:00:00:03';
/** Embedded-series MAC — 50% of VOD items carry an embedded series[] array */
const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
/**
* Static-cmd MAC — ITV rows carrying a directly playable `cmd` with
* `use_http_tmp_link` and `use_load_balancing` both `'0'`, i.e. a portal that
* expects no `create_link` call at all.
*/
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* These tests assert state transitions within one portal session, so a reset
* from a concurrent browser project or repeat worker would invalidate the
* assertion itself. Giving every concurrent worker slot its own MAC range
* preserves browser parallelism and also keeps `--repeat-each` runs isolated.
*/
interface StatefulAuthMacs {
authenticatedFlow: string;
loginRequired: string;
tokenReuse: string;
deviceConflict: string;
reauthentication: string;
}
function getStatefulAuthMacs({
parallelIndex,
}: {
parallelIndex: number;
}): StatefulAuthMacs {
if (
!Number.isSafeInteger(parallelIndex) ||
parallelIndex < 0 ||
parallelIndex > 255
) {
throw new Error(
`Unsupported Playwright parallel index: ${parallelIndex}`
);
}
const workerOctet = parallelIndex
.toString(16)
.padStart(2, '0')
.toUpperCase();
const workerPrefix = `00:1A:79:AE:${workerOctet}`;
return {
authenticatedFlow: `${workerPrefix}:01`,
loginRequired: `${workerPrefix}:02`,
tokenReuse: `${workerPrefix}:03`,
deviceConflict: `${workerPrefix}:04`,
reauthentication: `${workerPrefix}:05`,
};
}
/**
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
* it, so no token is ever adopted and content requests fail permanently.
*/
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Intercept calls to the Angular dev backend (/stalker proxy) and redirect
* them to the mock server. This avoids needing a real backend or changing
* any app environment configuration.
*/
async function interceptStalkerRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(BACKEND_PROXY);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/** Every MAC this file owns; all are cleared in one batched reset request. */
const OWNED_MACS = [
DEFAULT_MAC,
@@ -223,116 +131,6 @@ async function resetMockServer(
throw lastError;
}
/**
* Add a Stalker portal via the UI:
* 1. Click the "add playlist" button to open the unified dialog
* 2. Select "Stalker" toggle
* 3. Fill in the form and submit
*/
async function addStalkerPortal(
page: Page,
options: { name?: string; mac?: string } = {}
): Promise<void> {
const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/stalker.*vod/);
}
/**
* Add a Stalker portal through the canonical Ministra URL, which the app
* imports as a FULL portal: handshake, Bearer token and watchdog.
*/
async function addFullStalkerPortal(
page: Page,
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', {
name: 'Add playlist',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
if (expectContent) {
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
}
}
const CONTENT_ACTIONS = [
'get_categories',
'get_genres',
'get_ordered_list',
'get_all_channels',
];
/** Every portal request in order, with the token it carried. */
function recordPortalRequests(
page: Page
): Array<{ action: string; token: string | null }> {
const requests: Array<{ action: string; token: string | null }> = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
const action = url.searchParams.get('action');
if (!action) {
return;
}
requests.push({ action, token: url.searchParams.get('token') });
});
return requests;
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
@@ -967,16 +765,8 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
request,
}) => {
// Find an embedded-series VOD item in the mock catalog first
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
const { embeddedItem, episodeCount } =
await findEmbeddedSeriesItem(request);
await addStalkerPortal(page, {
name: 'Embedded Series Portal',
@@ -985,19 +775,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
// Open the embedded-series item from its category and favorite it —
// this persists a snapshot with the current episode list
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount);
await page.getByRole('button', { name: 'Add to favorites' }).click();
// Wait for the async favorite persistence before navigating away
await expect(
@@ -1007,39 +785,7 @@ test('@stalker favorites — embedded-series favorite refreshes newly released e
// From now on the portal has "released" one more episode: extend
// series[] in every search response (the background snapshot refresh
// re-fetches the item via a title search)
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
if (!originalUrl.searchParams.get('search')) {
await route.fallback();
return;
}
const mockUrl = new URL(BACKEND_PROXY);
const targetId = originalUrl.searchParams.get('targetId');
const providerUrl = targetId
? Buffer.from(targetId, 'base64url').toString()
: originalUrl.searchParams.get('url');
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = await response.json();
const rows: { series?: string[] }[] =
body?.payload?.js?.data ?? body?.js?.data ?? [];
for (const row of rows) {
if (Array.isArray(row.series) && row.series.length > 0) {
row.series = [...row.series, String(row.series.length + 1)];
}
}
await route.fulfill({ response, body: JSON.stringify(body) });
});
await releaseExtraEpisodeInSearchResponses(page);
// Open the item from the Favorites view: the stored snapshot renders
// first, then the background refresh patches in the new episode
@@ -1083,35 +829,15 @@ test('@stalker season watched toggle — embedded series marks and clears every
// Reuse the modeled embedded-series flow: find a VOD item carrying an
// embedded series[] array, open it from its category, and land on the
// series detail with its episode list.
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
const { embeddedItem, episodeCount } =
await findEmbeddedSeriesItem(request);
await addStalkerPortal(page, {
name: 'Embedded Series Watch Portal',
mac: EMBEDDED_SERIES_MAC,
});
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
await openEmbeddedSeriesItem(page, embeddedItem.name, episodeCount);
await expectSeriesSurfacesInBothThemes(page, testInfo);
@@ -1153,35 +879,19 @@ test('@stalker series watched toggle — embedded series marks and clears from t
}) => {
// Same modeled embedded-series flow as the season test above, driven
// through the series-level ⋮ menu instead of the season button.
const listResponse = await request.get(
`${MOCK_SERVER}/stalker?action=get_ordered_list&type=vod&category=2001&p=1&macAddress=${EMBEDDED_SERIES_MAC}&JsHttpRequest=1-xml`
);
const listBody = await listResponse.json();
const embeddedItem = listBody.payload.js.data.find(
(item: { series?: unknown[] }) =>
Array.isArray(item.series) && item.series.length > 0
);
expect(embeddedItem).toBeDefined();
const episodeCount: number = embeddedItem.series.length;
const { embeddedItem, episodeCount } =
await findEmbeddedSeriesItem(request);
await addStalkerPortal(page, {
name: 'Embedded Series Watch Menu Portal',
mac: EMBEDDED_SERIES_MAC,
});
const categories = page.locator('.category-item');
await expect(categories.first()).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const card = page.getByText(embeddedItem.name).first();
await expect(card).toBeVisible({ timeout: 10_000 });
await card.click();
await expect(
page.getByRole('heading', {
name: `${episodeCount}. Episode ${episodeCount}`,
exact: true,
})
).toBeVisible({ timeout: 10_000 });
const card = await openEmbeddedSeriesItem(
page,
embeddedItem.name,
episodeCount
);
// The series row sits in the hero's "…" menu (data-test-id with a dash —
// getByTestId only matches data-testid in this suite; the row renders
+38
View File
@@ -143,12 +143,50 @@
}
]
},
"electron-performance-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false,
"removeSpecialComments": true
},
"fonts": true
},
"outputHashing": "all",
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/environment.ts",
"with": "apps/web/src/environments/environment.performance.ts"
},
{
"replace": "apps/web/src/environments/change-detection.providers.ts",
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
}
]
},
"electron-e2e": {
"baseHref": "./",
"serviceWorker": false,
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"electron-e2e-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/change-detection.providers.ts",
"with": "apps/web/src/environments/change-detection.providers.zoneless.ts"
}
]
}
},
"defaultConfiguration": "production"
@@ -101,8 +101,7 @@ import { AppUpdateInstallService } from './services/app-update-install.service';
</section>
}
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.app-update-notification {
+1 -2
View File
@@ -55,8 +55,7 @@ const debugAppComponent = createDevLogger('AppComponent');
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
AppStartupStatusComponent,
AppUpdateNotificationPanelComponent,
+3 -7
View File
@@ -8,12 +8,7 @@ import {
FullscreenOverlayContainer,
OverlayContainer,
} from '@angular/cdk/overlay';
import {
ApplicationConfig,
inject,
importProvidersFrom,
provideZoneChangeDetection,
} from '@angular/core';
import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core';
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withComponentInputBinding } from '@angular/router';
@@ -39,6 +34,7 @@ import {
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
import { changeDetectionProviders } from '../environments/change-detection.providers';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service';
@@ -110,7 +106,7 @@ export function DataFactory() {
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
...changeDetectionProviders,
provideRouter(routes, withComponentInputBinding()),
provideAnimations(),
// CDK overlays (menus, tooltips, dialogs) live in a container under
@@ -178,8 +178,7 @@ function decorateReleaseNotesHtml(html: string): string {
</button>
</mat-dialog-actions>
`,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.release-notes-dialog {
@@ -19,6 +19,7 @@ import {
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { UpdateChannelOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-about-section',
@@ -32,8 +33,7 @@ import { UpdateChannelOption } from './settings.models';
],
templateUrl: './settings-about-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
':host { display: contents; }',
'.version-block .build-commit { opacity: 0.65; font-size: 0.85em; }',
@@ -58,6 +58,13 @@ export class SettingsAboutSectionComponent {
* setting. Absent in hosts that only render the version block.
*/
readonly form = input<FormGroup | null>(null);
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
readonly updateChannelOptions = input<UpdateChannelOption[]>([]);
readonly buildCommitShort = computed(() => {
@@ -20,8 +20,7 @@ import { TranslateModule } from '@ngx-translate/core';
],
templateUrl: './settings-backup-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsBackupSectionComponent {
@@ -9,6 +9,7 @@ import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-dashboard-section',
@@ -21,10 +22,15 @@ import { TranslateModule } from '@ngx-translate/core';
],
templateUrl: './settings-dashboard-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsDashboardSectionComponent {
readonly form = input.required<FormGroup>();
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
}
@@ -31,8 +31,7 @@ type SettingsDeleteSummaryItem = {
selector: 'app-settings-delete-all-playlists-dialog',
templateUrl: './settings-delete-all-playlists-dialog.component.html',
styleUrls: ['./settings-delete-all-playlists-dialog.component.scss'],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
MatButtonModule,
@@ -17,6 +17,7 @@ import { EpgViewMode } from '@iptvnator/shared/interfaces';
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
import { TranslateModule } from '@ngx-translate/core';
import { EpgViewModeOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-epg-section',
@@ -34,12 +35,18 @@ import { EpgViewModeOption } from './settings.models';
],
templateUrl: './settings-epg-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsEpgSectionComponent {
readonly form = input.required<FormGroup>();
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
readonly epgUrl = input.required<FormArray>();
readonly isClearingEpgData = input(false);
readonly canBrowseFiles = input(false);
@@ -19,6 +19,7 @@ import {
StartupWindowModeOption,
ThemeOption,
} from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-general-section',
@@ -33,12 +34,18 @@ import {
],
templateUrl: './settings-general-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsGeneralSectionComponent {
readonly form = input.required<FormGroup>();
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
readonly languageEnum = input.required<typeof Language>();
readonly themeOptions = input.required<ThemeOption[]>();
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
@@ -20,6 +20,7 @@ import {
reportsPlaybackFailures,
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-playback-section',
@@ -36,8 +37,7 @@ import { SettingsPlayerOption } from './settings.models';
],
templateUrl: './settings-playback-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsPlaybackSectionComponent {
@@ -53,6 +53,13 @@ export class SettingsPlaybackSectionComponent {
].join('\n');
readonly form = input.required<FormGroup>();
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
readonly players = input.required<SettingsPlayerOption[]>();
readonly streamFormatEnum = input.required<typeof StreamFormat>();
readonly isDesktop = input(false);
@@ -14,6 +14,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import { QRCodeComponent } from 'angularx-qrcode';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
@Component({
selector: 'app-settings-remote-control-section',
@@ -30,12 +31,18 @@ import { QRCodeComponent } from 'angularx-qrcode';
],
templateUrl: './settings-remote-control-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsRemoteControlSectionComponent {
readonly form = input.required<FormGroup>();
constructor() {
// Parent patches (Discard, backup import) change the form outside
// this OnPush section's events.
markSectionForCheckOnFormEvents(this.form);
}
readonly localIpAddresses = input.required<string[]>();
readonly visibleQrCodeIp = input<string | null>(null);
@@ -22,8 +22,7 @@ import { SettingsPlaylistDeleteSummary } from './settings.models';
],
templateUrl: './settings-reset-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [':host { display: contents; }'],
})
export class SettingsResetSectionComponent {
@@ -0,0 +1,30 @@
import { ChangeDetectorRef, inject, type Signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import type { AbstractControl } from '@angular/forms';
import { EMPTY, switchMap } from 'rxjs';
/**
* Marks an OnPush settings section for check on every event of its form.
*
* The sections read form values and states in their templates (selected
* theme, `epgField.value`, `form().value.player`), which are not signals.
* The parent changes the form outside the section's template events: Discard
* and backup import patch it, the store hydrates it, and the EPG file picker
* sets a control after an `await`. Without this the section keeps showing
* the previous value until some unrelated event marks it. `events` covers
* value, status, touched and pristine changes, including those of child
* controls, which bubble up to the group.
*
* Call it from a field initializer or the constructor.
*/
export function markSectionForCheckOnFormEvents(
form: Signal<AbstractControl | null>
): void {
const changeDetector = inject(ChangeDetectorRef);
toObservable(form)
.pipe(
switchMap((control) => control?.events ?? EMPTY),
takeUntilDestroyed()
)
.subscribe(() => changeDetector.markForCheck());
}
@@ -16,6 +16,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslateModule } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import type { TmdbCacheStats } from '@iptvnator/shared/interfaces';
import { markSectionForCheckOnFormEvents } from './settings-section-form-render';
type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
@@ -33,8 +34,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error';
],
templateUrl: './settings-tmdb-section.component.html',
encapsulation: ViewEncapsulation.None,
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
app-settings-tmdb-section {
@@ -92,6 +92,7 @@ export class SettingsTmdbSectionComponent {
readonly isClearing = signal(false);
constructor() {
markSectionForCheckOnFormEvents(this.form);
// Sizing the cache is a full table scan, but this component only
// exists while its section page is open, so loading on construction
// preserves the old "wait until the user is actually looking"
@@ -54,8 +54,7 @@ export interface SettingsUnsavedChangesDialogData {
}
`,
],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2 mat-dialog-title>
{{ 'SETTINGS.UNSAVED_DIALOG_TITLE' | translate }}
@@ -1,3 +1,4 @@
import { FormArray, FormControl } from '@angular/forms';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
@@ -143,6 +144,25 @@ describe('SettingsComponent form', () => {
});
});
// The sections are OnPush and a Discard or backup import patches the
// form outside their template events, so the section must mark
// itself on the form's events. The fixture renders on its own here:
// a forced detectChanges() would hide a section that is not marked.
it('re-renders section selections after a value-only form patch', async () => {
const darkTheme = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'[data-test-id="DARK_THEME"]'
);
fixture.autoDetectChanges();
await fixture.whenStable();
expect(darkTheme()?.getAttribute('aria-checked')).toBe('false');
component.settingsForm.patchValue({ theme: Theme.DarkTheme });
await fixture.whenStable();
expect(darkTheme()?.getAttribute('aria-checked')).toBe('true');
});
it('hydrates a shared web controls opt-out from the settings store', () => {
settingsStore._setSettings({
webPlayerSharedControls: false,
@@ -185,6 +205,26 @@ describe('SettingsComponent form', () => {
expect(settingsStore.updateSettings).not.toHaveBeenCalled();
});
// The native file picker sets the EPG control after an await, with
// no template event in the OnPush section; its status must follow.
it('shows the source status after a control is set outside the section', async () => {
setSettingsSection('epg');
fixture.autoDetectChanges();
const epgUrls = component.settingsForm.get('epgUrl') as FormArray;
epgUrls.push(new FormControl(''));
await fixture.whenStable();
const status = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'app-epg-source-status'
);
expect(status()).toBeNull();
epgUrls.at(epgUrls.length - 1).setValue('/tmp/guide.xml');
await fixture.whenStable();
expect(status()).not.toBeNull();
});
it('stages the EPG view mode without writing to the store until Save', () => {
setSettingsSection('epg');
fixture.detectChanges();
@@ -314,6 +354,29 @@ describe('SettingsComponent form', () => {
expect(unsavedBar()).toBeNull();
});
// The page owns the bar and is OnPush, and Save marks the form
// pristine after an async store write, also on a page without a form
// section. `pristine` and `valid` read the form's state signals, so
// the page re-renders without a form subscription; no forced render
// here, so a regression shows.
it('hides after a save on a page without a form section', async () => {
settingsStore.updateSettings.mockResolvedValue(undefined);
setSettingsSection('backup');
fixture.autoDetectChanges();
component.settingsForm.get('theme')?.setValue(Theme.DarkTheme);
component.settingsForm.markAsDirty();
await fixture.whenStable();
expect(unsavedBar()).not.toBeNull();
component.onSubmit();
await fixture.whenStable();
// The render the form event scheduled runs in the next macrotask.
await new Promise((resolve) => setTimeout(resolve));
expect(component.settingsForm.pristine).toBe(true);
expect(unsavedBar()).toBeNull();
});
it('discard reverts a staged cover size (regression: eager persist made it stick)', () => {
const largeCoverButton = (
fixture.nativeElement as HTMLElement
@@ -102,8 +102,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general';
SettingsSearchResultsComponent,
SettingsTmdbSectionComponent,
],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
SettingsAppUpdateFacade,
SettingsBackupFacade,
@@ -0,0 +1,12 @@
import {
EnvironmentProviders,
provideZoneChangeDetection,
} from '@angular/core';
// Change detection for every build: zone.js schedules the ticks. The
// *-zoneless build configurations replace this file with
// change-detection.providers.zoneless.ts while plan item C6 measures
// zoneless change detection; see docs/architecture/zoneless-migration.md.
export const changeDetectionProviders: EnvironmentProviders[] = [
provideZoneChangeDetection({ eventCoalescing: true }),
];
@@ -0,0 +1,11 @@
import {
EnvironmentProviders,
provideZonelessChangeDetection,
} from '@angular/core';
// Swapped in for change-detection.providers.ts by the *-zoneless build
// configurations only. zone.js stays in the polyfills until the flip, so
// Angular logs NG0914 in these builds; nothing patches through it.
export const changeDetectionProviders: EnvironmentProviders[] = [
provideZonelessChangeDetection(),
];
+3 -2
View File
@@ -157,8 +157,9 @@ html {
--app-cta-fg: #f5f6f8;
--app-cta-hover-bg: #2a2f3a;
--app-cta-meta-fg: rgba(245, 246, 248, 0.68);
// Star rating chip: amber on white needs a deeper tone than gold on dark.
--app-rating-color: #a16207;
// Star rating chip: a deep amber, since the chip's 12px text needs
// 4.5:1 over hero artwork under the light scrim (#a16207 measured 3.4:1).
--app-rating-color: #7a4a00;
.dark-theme {
@include mat.all-component-colors($dark-theme);
@@ -1,16 +1,16 @@
---
title: "Keep Your Library Tidy: Sources, Favorites and Watched Titles"
description: Review source availability, return from a favorite channel to its playlist, mark titles watched and simplify catalog views in IPTVnator without confusing hidden items with missing data.
pubDate: 2026-09-27
pubDate: 2026-10-06
author: 4gray
heroImage: /iptvnator/blog/feature-guides/screenshots/guide-library-watched-dark.png
tags:
- guide
draft: true
draft: false
faq:
- q: Does an unavailable source mean my channels were deleted?
a: No. Availability describes a connection check. A timeout, a temporary outage or an unverified account is different from deleting a saved source or losing its channels.
- q: Does library watched state delete entries automatically?
- q: Does source cleanup delete entries automatically?
a: No. The desktop cleanup dialog checks sources and lets you review the selection before deleting. Confirmed expired or disabled accounts can be preselected; uncertain results need review.
- q: What happens when I delete a source?
a: Its saved favorites, history and playback progress are removed with it. Downloaded files are kept. Export a playlist backup first if you want a restorable copy of supported source state.
@@ -111,6 +111,7 @@ permanent labels are more useful than an extra row of posters.
## Related
- [Back up your playlists before removing sources](/iptvnator/blog/playlist-backup-restore-guide/)
- [Movie and series metadata with TMDB](/iptvnator/blog/tmdb-metadata-guide/)
- [Alternative movie sources](/iptvnator/blog/alternative-sources-guide/)
- [Library changes in 0.24](/iptvnator/blog/v0-24-release-notes/)
@@ -1,13 +1,13 @@
---
title: "Subtitles, Quality and Picture-in-Picture: Get More from the Player"
description: Use IPTVnator's unified player controls to choose stream quality, load subtitles, adjust their timing and keep video visible in picture-in-picture. Learn why some options depend on the stream or player.
pubDate: 2026-09-27
pubDate: 2026-10-06
author: 4gray
heroImage: /iptvnator/blog/feature-guides/screenshots/guide-player-subtitles-dark.png
tags:
- guide
- playback
draft: true
draft: false
faq:
- q: Why is there no quality menu?
a: The menu appears when the stream exposes more than one video quality. A single video file or a channel with only one rendition has no alternatives for the player to select.
+62 -7
View File
@@ -383,15 +383,17 @@ meanwhile. The host reports busy-state back through the
A series-level counterpart lives in a `⋮` menu at the end of the same
header row (`SeasonWatchPresenter` in `libs/ui/components` owns the state
math for both scopes; the container component sits at the max-lines cap).
math for both scopes, which keeps the container component under the
max-lines cap).
`buildSeriesWatchToggleRequest` flattens every LOADED season with the same
mark/unmark semantics, and the direction is always the one the label
advertised (`markWatched: !seriesFullyWatched()`), never re-inferred from
data at persist time. Hosts route the request through the same machinery
as the season toggle — Xtream via the scope-parameterized
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via the
extracted `runWatchToggleBatch` core — sharing the busy flag, the
ownership guards, and the catalog-badge refresh. Stalker lazy-VOD is the
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via
`StalkerSeriesWatchToggleService` and its `runStalkerWatchToggleBatch`
core — sharing the busy flag, the ownership guards, and the catalog-badge
refresh. Stalker lazy-VOD is the
special case: unopened seasons have empty episode lists, so the container
reports them through the `hasUnloadedSeasons` input (blocks the
"fully watched" verdict and switches the label to its countless variant),
@@ -412,7 +414,8 @@ series-toggle hydration join one in-flight request instead of
duplicating it (a second request's failure could abort a toggle whose
original request succeeded).
The host synchronously re-runs the position reconcile
(`applyReconciledSeriesPositions` — the effect-fed maps only update on
(`StalkerSeriesPositionsService.applyReconciledSeriesPositions` — the
effect-fed maps only update on
the next change-detection tick, and enqueuing against stale maps would
miss the hydrated episodes' legacy rows), rebuilds the request from the
now-complete seasons keeping the captured direction, and reports an
@@ -468,6 +471,58 @@ position telemetry overwrites this launch marker when available. This keeps the
last-watched season and episode correct even when an external player's progress
interface is unavailable; exact external timestamps remain best-effort.
## Forced External Launches From Detail Pages
The detail "…" menu's "Open in external player" sends the title to MPV/VLC
through `PortalPlayer.openExternalPlayback(playback, player)` whatever the
configured player is. The launch IPC cannot be cancelled, and until it
resolves the session is at most `launching` and may not have a closer yet.
Every detail host therefore keeps these rules:
- **One external player per owner.** Before launching, the host closes the
external session the page owns: the session of the same title on the
Stalker pages and the Xtream series page; the session it launched, else the
one matching its movie, on the Xtream movie page. Sessions the page does not
own are left alone. With instance reuse off, a second detached player would
otherwise start beside the first. Stalker hosts use
`replaceOwnedExternalSession` from
`@iptvnator/portal/shared/util`; the Xtream pages use
`closeRunningExternalSession` with the same outcome rules.
- **Unconfirmed teardown cancels the launch.** A live session without a
closer, or a close that rejects, leaves the running player in place and
nothing new launches.
- **Ownership is rechecked after every await.** Stream resolution, the close
and the launch IPC can each outlive the page or be superseded by a newer
start. A stale step stops without reporting, and a launch that resolves
stale closes the session it just opened.
- **No second player while a launch settles.** A repeat of the same launch is
ignored, or its control stays disabled. Movie pages refuse or disable every
other start of that title until the launch settles. Series pages hold the
latest episode choice and, once the launch settled, replace the player it
opened, only while that series is still on screen.
- **Pending starts are owner-scoped.** A start still resolving holds the
actions of its own title only: another title shown by the reused page is
not blocked by it. Movie hosts track starts with
`createPendingPlaybackStart` (`@iptvnator/portal/shared/util`): only the
latest start may clear the flag, and `isPendingFor(owner)` answers for one
owner. The Stalker movie hosts, whose starts wait on a portal round trip,
also `retire(owner)` when the selection leaves it, so a start that never
settles does not keep the flag set on a return to the same title. A movie's
"Reset progress" is scoped the same way.
- **Two gates are page-wide.** The Xtream movie page refuses Play, Start
over, source switches and the menu launch while an external launch it made
has not settled. A series page runs one watched or reset batch at a time,
whichever series is shown; the Stalker page also holds episode starts until
that batch settles.
Owner keys and queueing are provider contracts:
| Host | Contract |
| --- | --- |
| Xtream series | [Forced external launches from detail pages](./xtream-portal-compatibility.md#forced-external-launches-from-detail-pages) |
| Xtream movie | [Menu launch and reset follow the primary button](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button) |
| Stalker series and movies | [Forced External Launches](./stalker-portal.md#forced-external-launches) |
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
@@ -640,7 +695,7 @@ external-player workflows; it is not copied from the HLS error payload into
the evidence or technical details. HLS startup development logs are event-only:
they do not include provider-supplied channel names or source URLs.
Shaka Player `5.2.4` errors cross a separate structured boundary before the
Shaka Player `5.2.12` errors cross a separate structured boundary before the
HTML5 or ArtPlayer DASH session emits a diagnostic. Version-locked tests assert
the installed Shaka version plus the public `Severity`, `Category`, and selected
online-playback `Code` values used by the boundary. Evidence retains only
@@ -694,7 +749,7 @@ allowlisted display name.
`network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
mpegts.js `1.8.1` errors cross one shared structured boundary before the HTML5,
mpegts.js `1.8.2` errors cross one shared structured boundary before the HTML5,
Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare
the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted
contract. Evidence retains only an exact type/detail pair, terminal
+1 -1
View File
@@ -1513,7 +1513,7 @@ does not change the saved player preference. Clear DASH needs this routing too.
engine: lazy `import('shaka-player')` on first use (the module is a separate
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
queue + generation guard against channel-switch races. The DOM-free Shaka
`5.2.4` public-error boundary lives in `libs/playback/util`; it version-locks
`5.2.12` public-error boundary lives in `libs/playback/util`; it version-locks
its allowlisted
severity/category/code values, emits only structured sanitized
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
+374 -43
View File
@@ -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 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.
runtime counters of the launch benchmark below. J2, J3 and J4 are
instrumented by their own specs (below). Each thread names its journey and
counter in the PR description.
## Running the journeys
@@ -89,7 +89,14 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
show a blank window and freeze its `ready-to-show` counter before its own
document exists. Electron emits the event again for the real document's
first paint because the window is still hidden, which is the moment
production sees. The gate also keeps the listener the app registers with
production sees. The app also shows its window at the main frame's
`did-finish-load` when that comes first (see
[When the window is shown](#when-the-window-is-shown)), so the gate keeps
the `did-finish-load` listeners registered before the gated load (the
app's) away from the `about:blank` load as well
(`evidence.rendererGateDidFinishLoadHeldOnBlank`, 1 per launch); Electron's
own listener that resolves `loadURL('about:blank')` is registered later
and still runs. The gate also keeps the listener the app registers with
`ipcMain.handle('performance:read-counters')`, so the test can call it from
the main process.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
@@ -168,8 +175,8 @@ closed or closed before the cutoff. J2's probe has no settle window
most 20): the time after the first card, the value and, for each source the
browser attributes the shift to, the node (`tag.class[data-test-id]`; a
component host such as `lib-dashboard-rail` takes its first child's test id)
and its vertical move. A late shift can therefore be traced to its component
from the summary alone.
and its move (`deltaX`, `deltaY`, `deltaWidth`, `deltaHeight`). A late shift
can therefore be traced to its component from the summary alone.
First local measurement (macOS, 2026-09-29, `master` with #1738): all
windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and
@@ -199,6 +206,12 @@ the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both
counters were 0 in all 12 iterations of two runs, every window closed on
`quiet` and `lateShifts` was empty.
On the runner the flicker was only visible on J1's fast path: on the slow
path the window got its first frame only after the hero had already
changed, so the settle window opened after the shifts (see
[When the window is shown](#when-the-window-is-shown)). With both fixes,
all 18 iterations of three runner runs read 0 (`stable: true`).
#### Idle window
After the settle point J1 leaves the dashboard alone for
@@ -208,7 +221,8 @@ 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.
zoneless change detection removes; its checklist is the
[zoneless migration](zoneless-migration.md).
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
@@ -291,10 +305,12 @@ registers the `performance:read-counters` IPC handler. Without the flag
nothing is counted, no listener is attached and the handler does not exist;
the preload never exposes the channel. SQL statements are counted only with
`IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement
execution: the launch journey sets both (the flags are built in
`journey-launch-environment.ts`), while J2's launches and the M3U, refresh
and Xtream benchmarks do not set the SQL flag and keep measuring unwrapped
statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes,
execution: the launch journey and J4, which reports
`renderer.sqlStatementsPerSearch`, set both (the flags are built in
`journey-launch-environment.ts`), while J2's and J3's launches and the M3U,
refresh and Xtream benchmarks do not set the SQL flag and keep measuring
unwrapped statements. A harness test fails if any other source sets the SQL
flag. After the renderer probe completes,
`journey-main-counters.ts` calls the handler through `electronApp.evaluate`
and the gate's tap.
@@ -468,6 +484,76 @@ 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).
### When the window is shown
J1 on the CI runner was bimodal from the first runner measurements (#1717)
until 2026-10-01: 6 of 14 `master` runs between 2026-09-30 and 2026-10-01
mixed two paths. On the slow path the first card came with 18 bridge
calls and 1,018 DOM mutations, about 940 ms after the load event. On the
fast path it came with 15 calls and 559 mutations, 280-500 ms after it.
The race also marked `renderer.ipcSerialDepthToFirstCard` (9 vs 6),
`renderer.cdTicksToFirstCard` (31 vs 21), `renderer.cdTicksIdle30s`,
`main.sqlStatementsBeforeReadyToShow` (119 vs 93) and
`renderer.layoutShiftScoreSettled` as `stable: false`.
The three extra calls (`downloadsGetDefaultFolder` and two
`dbGetGlobalRecentlyAdded`, after `dbGetAllGlobalFavorites`) were not what
the card waited for. They only had time to finish before the card. What
ordered the card was when the hidden window got a frame. In every one of
the 48 iterations of those eight runs (two of them #1782's), `ready-to-show`
came within 180 ms of the load event on the fast path (usually about 15 ms),
and 4-5 ms after the first card on the slow path. The app showed its window only on
`ready-to-show`, and `main.ts` removes the splash in a
`requestAnimationFrame`, which the journey's end condition waits for. On
the slow path the dashboard had rendered and its data had arrived, but the
window was still hidden, no frame came, and the splash stayed.
A minimal Electron 43.3.0 app under Xvfb in a Debian container reproduces
it deterministically. It has the same hidden window, splash and
`requestAnimationFrame` removal, plus a 3.5 MB module script before the
first frame. Its window got no frame for about a second after load, and the
`requestAnimationFrame` and `ready-to-show` both landed at about 1.25 s, in
5 of 5 launches. Without the large script, `ready-to-show` came at load. A
`backgroundColor` alone changed nothing. Showing the window at
`did-finish-load` made the `requestAnimationFrame` run on time in 5 of 5.
#1782's skeleton gates do not touch this ordering: its own run 36917107231
still had one fast iteration among slow ones.
The fix is in the app, so it applies to users and not only to the
journey. `apps/electron-backend/src/app/services/main-window-first-show.ts`
shows the window at `ready-to-show` or the main frame's `did-finish-load`,
whichever comes first. The window's `backgroundColor` is the splash colour,
so showing it before the first paint does not flash. `ready-to-show` still
fires after the early show (on the runner 10-190 ms after load), so
`main.sqlStatementsBeforeReadyToShow` keeps its meaning.
Validation (Principle 3, the same journey on the same runner): three
dispatched runs of the fix (36928706097, 36928716010, 36928725392) and the
run of the commit that added the baselines (36930457538) took the fast path
in all 24 iterations, with 15 calls and 559 mutations each.
| Runs | Slow iterations | `spawnToFirstCardMs.p50` | load → card |
| ----------------------------------------------------------- | --------------- | ------------------------- | ----------------------------- |
| `master` and #1782, 2026-09-30 to 10-01 (8 runs, see above) | 29 of 40 | 1,478-1,613 ms (one 760) | ~940 ms slow, 280-500 ms fast |
| this fix (4 runs) | 0 of 20 | 988, 1,139, 923, 1,205 ms | 360-515 ms |
The eight earlier runs are `master` 36768881838, 36814964563, 36842198653,
36861129953, 36861409057 and 36915979562, and #1782's 36816552353 and
36917107231. The runner's own speed moves `spawnToDidFinishLoadMs.p50` between 430 and
710 ms from run to run, so compare load → card rather than absolute numbers.
The one fast master run (36915979562, P50 760 ms) had a fast runner and four
fast iterations.
The fix first merged (#1788) into #1782's branch after #1782 had already
reached `master`, so it landed again on its own. Measured again on `master`
at bc5a7fcbf, which by then carried the redesigned dashboard hero (#1792):
three dispatched runs (37192092882, 37192097790, 37192103151) took the fast
path in all 18 iterations, with 15 calls and 558 mutations each, 466-528 ms
from load to the first card and `spawnToFirstCardMs.p50` 1,149, 1,122 and
1,170 ms. `master` without the fix was still bimodal then: its last six push
runs before 2738bc28a had 30 of 36 iterations on the slow path (18 calls,
1,031-1,033 mutations, about 940 ms from load to the card).
### Summary schema
```json
@@ -543,9 +629,10 @@ PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3).
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
`tools/performance/journey-baselines.json`. The summary writer checks only
that every measured iteration reports the same counter names with finite
values, so a new counter needs no schema change. A J1 runtime baseline is added
once its counter is deterministic on the CI runner; the launch counters are
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
values, so a new counter needs no schema change. A runtime baseline is added
once its counter is deterministic on the CI runner; the enforced ones and the
reasons for the others are under
[Enforced journey counters](#enforced-journey-counters).
J3 adds the `journeys.playback` entry with the same shape and no schema
version change: `counters` and `wallClock` hold only plain numbers, and its
@@ -554,6 +641,13 @@ iterations carry `evidence.media` (the video element at `playing`) and
a `media` field (`null` for J1 and J2), which the probe's
`schemaVersion` 1 readers ignore.
J4 adds the `journeys.search` entry, again with the same shape. Its
iterations carry `evidence.perKeystroke` (one entry per typed key, see
[J4](#j4-search-type-a-query-until-the-results-settle)), which the CI job
summary prints as a table for the first measured iteration. J4 has its own
renderer probe (`search-journey-probe.ts`, `schemaVersion` 1); the shared
probe is unchanged.
## J2 `open-source`: open a source to a browsable list
`open-source.journey.ts` reuses the J1 profile and process pattern: the
@@ -634,7 +728,7 @@ 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.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`, and `evidence.layoutShift.shifts` lists the first 20 counted shifts (`shiftCount` is the total) with their value, `hadRecentInput`, time since the click and the nodes that moved (`tag.class[data-test-id]` and their `deltaX`, `deltaY`, `deltaWidth` and `deltaHeight`, as J1's late shifts). |
| `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. |
@@ -779,8 +873,168 @@ 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.
mutation and request counts. On the runner `renderer.httpRequestsToPlaying`
and `renderer.layoutShiftScore` are enforced as guards; see
[Enforced journey counters](#enforced-journey-counters).
## J4 `search`: type a query until the results settle
`search.journey.ts` follows J3: the profile is seeded once through the "Add
playlist" dialogs (`seedLaunchJourneyProfile` with `SEARCH_JOURNEY_SEED`),
every iteration copies it, spawns a fresh process through `runLaunchJourney`
and hands the running app to `measureSearchJourney` in
`src/journeys/search-journey-app.ts`. One warm-up and five measured
iterations. Unlike J2 and J3 the launch runs with the main-process counters
(`mainCounters: true`, so `IPTVNATOR_PERF_CAPTURE` and
`IPTVNATOR_PERF_COUNT_SQL`), because SQL statements are a J4 counter; every
statement then runs through the counting hook.
**Profile.** J1's M3U source plus an Xtream portal ("Journey search portal")
on the mock's existing `large:large` scenario: 60 categories of 200 items,
so 4,000 live channels, 4,000 movies and 4,000 series. No fixture was added
for the journey. The query is `system`, which matches 170 series titles of
that deterministic catalog: more than global search's first page of 100, so
the page is full and more results are available. Six characters take the
title FTS path of `globalSearch`
(`apps/electron-backend/src/app/database/operations/content.operations.ts`);
the M3U arm runs as well, because live content is included, but none of the
four fixture channels matches. Poster artwork points at `picsum.photos` and
is cancelled in the main process as in J3
(`src/performance/journey-external-artwork.ts`, shared by both journeys);
the count is kept as `evidence.externalArtworkCancelled`. The mock is not
put behind the request ledger: global search reads only the local database.
**What typing does.** The header search box
(`app-workspace-shell-header .search-field input[type="search"]`) applies
its term after `SEARCH_INPUT_DEBOUNCE_MS` (350 ms,
`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On
`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent`
adopts `q` and runs `executeSearch` after its own 300 ms debounce, which
calls the `dbGlobalSearch` bridge method once with a limit of 101.
**Start.** After J1 has ended, the test clicks the rail's **Global search**
link and focuses the header search box (not measured), installs the IPC
capture with a start sentinel, arms the probe
(`src/performance/search-journey-probe.ts`) and waits until the app has
been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an
unchanged `main.sqlStatements` total (30 s timeout, which fails the
iteration). J1's capture is detached. The test then types the query with one
`keyboard.type` call per character on a fixed schedule, 100 ms apart from
the first key (well below the 350 ms debounce, as steady typing would be),
and samples the main process just before each key. The probe's
capture-phase `keydown` listener on `window` stamps every key in the search
box before the app sees it; the first one starts the journey and sends
`cancelSourceProbe('__iptvnator-journey-search-start__')`. The record
rejects an iteration whose DOM mutations, bridge calls or SQL statements
moved between the quiet snapshot and the first key.
**End.** "No DOM mutation for 200 ms after the last keystroke" alone would
end inside the 650 ms of debounce, before any query ran: nothing in the DOM
changes while the term waits. So after the sixth key the probe waits for
the results of the final term to be shown (the path ends with
`/workspace/search`, the URL `q` is the query, no `.loading-state` is
rendered and an `app-content-card` in the results container is visible). The
mutation batch that first meets that condition opens a 200 ms quiet window,
and every later batch restarts it. When the window elapses the journey has
settled: the settled moment is the last mutation batch, the counters stop at
the confirmation, and the probe sends
`cancelSourceProbe('__iptvnator-journey-search-end__')`. Without a settle
within 15 s of the last key the probe marks the iteration invalid
(`settle-timeout`).
The DOM alone cannot tell the final term's results from an earlier term's
that are still shown while the final term debounces, so the main process
also checks. A listener on the renderer-API trace channel stamps every
`dbGlobalSearch` event on arrival, with the term and result length that the
preload's summaries carry. The record requires that the last query started
between the start and end sentinels is for the final term and completed
before the end sentinel (`final-query-not-run`, `final-query-incomplete`).
The final query's start shows the loading state, which keeps the quiet
window closed until its results replace the old ones.
`evidence.finalQuery` keeps its term, result length and duration. The
record also rejects an iteration in which two keydowns were more than
250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`).
At that point a late key on a busy machine could let the 350 ms debounce
apply an intermediate term, which steady typing does not.
A settle that times out fails the iteration with what the probe and the
main process saw: the URL `q`, the input's value, the results view, the
bridge calls, renderer console errors, the SQL totals before each key, and
the traced `dbGlobalSearch` calls with their terms and result lengths.
### Counters
| Counter | Source |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsPerSearch` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. `evidence.ipcCallsByMethod` names them. |
| `renderer.sqlStatementsPerSearch` | `main.sqlStatements` (main thread and database worker, see [Main-process counters](#main-process-counters)) read in the main process when the start sentinel and the end sentinel arrive (a listener on the trace channel calls the counters handler through the gate, which reads the registry synchronously), so the count covers exactly the IPC capture's window and never work just before the first key or after the settle. The worker reports its count before the response it belongs to, so the statements of a query are counted before its results reach the renderer. The two values are `evidence.sqlAtSentinels`; statements from the end sentinel until 500 ms after the test read the summary are kept as `evidence.sqlStatementsAfterSettled`. |
| `renderer.ipcSerialDepthToResults` | `computeJourneyIpcSerialDepth` over the capture's timeline between the sentinels (see [Serial IPC depth](#serial-ipc-depth)); `evidence.ipcSerialDepth.chain` and `evidence.ipcTimeline` show the calls. |
| `renderer.domMutationsToResults` | `MutationRecord`s from the first keydown until the quiet window was confirmed (by definition none arrive inside it). |
| `renderer.cdTicksToResults` | `ApplicationRef` ticks from the first keydown (read in the capture-phase listener, before the app handles the key) until the confirmation (see [Change-detection ticks](#change-detection-ticks)). |
| `renderer.layoutShiftScore` | All `layout-shift` entries from the first keydown until the confirmation, including `hadRecentInput` ones (typing is input, as in J2 and J3), rounded to three decimals; the split is under `evidence.layoutShift`. |
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the first keydown to the confirmation. Evidence until shown to be stable on the runner. |
`evidence.perKeystroke` breaks the journey down by key: for each typed
character, what happened from that key until the next one (the last entry:
until the settle). `domMutations` and `cdTicks` are split at the renderer's
keydown stamps. `ipcCalls`, `queryCalls` (`dbGlobalSearch` calls) and
`sqlStatements` are differences of the main-process samples taken just
before each key, so their boundaries sit a few milliseconds before the
renderer's. A search with working debounce shows zeros for the first five
keys and one query after the last; a search that queried on every key from
the second character on would show a `dbGlobalSearch` call and its
statements in each of those entries, even when a later key superseded the
result.
No counter is listed under `unavailable`. HTTP requests are not a J4
counter: global search does not touch the network.
### Wall-clock
| Entry | Derivation |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. |
| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. |
Both are taken in the renderer. With the current debounces both include
the 650 ms the term waits (350 ms in the shell, then 300 ms in the results
component); the first also includes the 500 ms of typing.
### First measurement
Local, macOS, 2026-10-04, three full `perf:journeys` runs plus repeated J4
runs. In the runs where J4 completed (the first and third full runs), every
counter was identical in all ten measured iterations except one:
`renderer.ipcCallsPerSearch` 1 (`dbGlobalSearch`),
`renderer.sqlStatementsPerSearch` 2, `renderer.ipcSerialDepthToResults` 1,
`renderer.domMutationsToResults` 402 (100 cards on the first page),
`renderer.layoutShiftScore` 0 and `renderer.longTasks` 0.
`renderer.cdTicksToResults` read 14 in all five iterations of the third run
and 13, 18, 13, 15, 13 in the first, so check the runner's
`counterStability` before trusting it. P50/P90
`lastKeystrokeToSettledMs` 686/692 ms and `firstKeystrokeToFirstResultMs`
1,178/1,184 ms in the third run (699/720 and 1,193/1,210 in the first).
Per keystroke, the first five keys caused one change-detection tick each
and nothing else: no bridge call, no SQL statement and no DOM mutation.
All of the work followed the sixth key. Search therefore does not run a
query per keystroke. The debounce does dominate the wall clock: about
650 ms of the 686 ms from the last key to settled is the two stacked
debounces (350 ms in the shell, then 300 ms in the results component). The
query itself, from the bridge call to the rendered first page, takes the
remaining 30-40 ms.
About one launch in sixty showed the empty-results view: the single
`dbGlobalSearch` call went out with the same arguments (`system`, all three
types, hidden categories included, limit 101) and the main process answered
with an empty array in about 20 ms, with no renderer error. The database
was unchanged from passing launches (the same 119 statements before the
first key, the usual 2 for the query, none afterwards), and every failure
traced was the first launch after seeding. The journey fails such an
iteration instead of measuring it, so a full run occasionally fails J4.
The cause is in the app, not the harness, and is not fixed here. No J4
baseline exists yet; J4 counters join the ratchet once three runner runs
agree.
## `renderer.initialBytes`
@@ -858,7 +1112,10 @@ that file:
- a measurement below its baseline passes and prints a "tighten" hint;
- a measured counter without a baseline is noted, not failed;
- checking nothing fails: an empty baselines file, or `--only` naming an
entry that does not exist, cannot exit 0.
entry that does not exist, cannot exit 0;
- an optional `note` (a string) is printed with the entry's failure; journey
entries use it to mark a guard that is not validated against wall-clock
(see [Enforced journey counters](#enforced-journey-counters)).
`--only <journey>/<counter>` (repeatable) restricts the check to the named
baselines. A script that measures one counter writes its own summary file
@@ -956,25 +1213,85 @@ Pushes to `master` and manual dispatches always run it. The job is warn-only (`c
weeks (plan item B3): a regression marks the job failed without failing the
workflow. Making it required is a maintainer decision.
No J1 runtime counter is enforced yet. Three dispatched runs on 2026-09-27
(CI runs 36271875209, 36271879955 and 36271884616) reported the same summary
values, `renderer.ipcCallsToFirstCard` 16 and
`renderer.domMutationsToFirstCard` 939, but the third run marked both
`stable: false`: its warm-up and one measured iteration reached the first
card in about 750 ms with 13 bridge calls and 576 mutations, the others in
about 1,400 ms with 16 and 939. The three extra calls
(`downloadsGetDefaultFolder` and two `dbGetGlobalRecentlyAdded`) land before
or after the first card depending on that race, so neither counter is
promoted until the race is understood and the counters are deterministic.
`renderer.layoutShiftScore` (0) and `renderer.longTasks` (2) were identical
in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
differ from a Mac (12 and 571 there, the fast path without the Linux-only
`getWindowState` call), so take J1 baseline values from the runner only.
`renderer.layoutShiftScoreSettled` has no baseline either: the runner read
it as `stable: false` because the dashboard hero flicker it reported was a
race there (see [Settle window](#settle-window)). That flicker is fixed; add
the runner's number once runner runs read it as `stable` too.
After the `Run the performance journeys` step, the job runs
`check-journey-ratchet.mjs --only …` on the summary that step wrote, for the
journey entries of `journey-baselines.json` (every entry except
`renderer.initialBytes`, which the `Initial bytes ratchet` job checks). A
`performance-tools` test keeps that `--only` list equal to those entries, so
a baseline cannot be added without being enforced. The step is in the job,
not in the composite action, so the weekly tightening still measures a run
that would fail it. While the job is warn-only, a regression fails the job
and not the workflow.
#### Enforced journey counters
Two J1 entries are validated (Principle 3) and carry no note:
`launch/renderer.ipcCallsToFirstCard` 15 and
`launch/renderer.domMutationsToFirstCard` 558 (#1828, `evidenceRun`
37192092882). #1828 removed the launch race (the window shown at
`did-finish-load`, see [When the window is shown](#when-the-window-is-shown)):
three dispatched runs on `master` read 15 / 558 in all 18 iterations, and
load to the first card went from about 940 ms to 466-528 ms. Slow-path
summaries (18 / 1,018 or more) fail the check.
A counter is enforced once it was identical in every measured iteration of
every recent `master` run. The other entries were identical in all 55
measured iterations of the 11 `master` runs from 2026-10-03 08:25 to
2026-10-04 06:55 (CI runs 37109621784 to 37184230956), with `slack` 0:
| Entry | Value | Week (69 runs since 2026-09-27) |
| -------------------------------------------- | ----- | ------------------------------------------------------------------------- |
| `launch/main.modulesRegisteredBeforeWindow` | 2 | identical |
| `launch/renderer.layoutShiftScore` | 0 | identical |
| `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) |
| `open-source/main.mockHttpRequestsToSettled` | 1 | identical |
| `open-source/renderer.ipcCallsToFirstPage` | 17 | identical |
| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run |
| `playback/renderer.httpRequestsToPlaying` | 2 | identical |
| `playback/renderer.layoutShiftScore` | 0.001 | identical |
`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233
in every iteration of every `master` run from 84aef83a6 (#1814, page Back
buttons moved into the header) on, so its value is 0.233 with `evidenceRun`
37372780064 (b78224376). The 0.011 that #1814 added is not explained yet;
lowering it back is a separate change.
Being deterministic is not the same as being validated. Principle 3 of the
plan promotes a counter to a guardrail once a PR has shown that lowering it
lowered the journey's wall-clock. None of these counters has that evidence
yet: `main.modulesRegisteredBeforeWindow` waits for the deferred IPC
registration (plan item C4), the layout-shift scores measure visual
stability rather than time, and no PR has moved a J2 or J3 counter. Each
entry therefore carries
`"note": "guard only, not validated: …"`. A guard stops a regression of a
deterministic number, and the checker prints the note with a failure; it
says nothing about whether lowering that number makes the journey faster.
When a PR shows that link, it drops the note and names the evidence in
`evidencePr`. `renderer.initialBytes` predates the note and carries none;
this document records no wall-clock change for it either.
Not enforced, with the reason:
- J1 `renderer.ipcSerialDepthToFirstCard` (6): bimodal on `master` until
#1828 (9 or 6) and identical in its three dispatched runs; a candidate
once `master` runs agree.
- J1 `main.sqlStatementsBeforeReadyToShow`: 93 or 95 even without the launch
race, because the download and recording recovery races `ready-to-show`
(plan item A2).
- Every `renderer.longTasks` (J1 2 or 1, J2 0 with one 1 earlier in the
week, J3 1 or 2): a long task is a task over 50 ms, so the count follows
runner speed, not work.
- Every `cdTicks` counter: the zoneless migration (plan item C6) changes
them.
- J2 `renderer.domMutationsToFirstPage`: 1,602 or 1,603 between runs of
recent commits.
- J3 `renderer.ipcCallsToPlaying` (4 or 5) and
`renderer.domMutationsToPlaying` (6,182, 6,183 or 6,199): not identical,
and the EPG rendering work changes the mutation count.
- J4: not measured yet.
Runner counters differ from a Mac (the Linux-only `getWindowState` call, for
one), so take every journey baseline value from the runner only.
### Weekly tightening
@@ -999,7 +1316,11 @@ its job; its entries are then unmeasured in that run. A final job runs
`check-baseline-direction.mjs` without `--allow-increase`, which both the
script and the job check;
- a lowered entry gets `updatedAt`, `measuredWith` and `evidenceRun` (the
workflow run URL); `evidencePr` is set to the tightening PR once it exists.
workflow run URL); `evidencePr` is set to the tightening PR once it exists;
every other field, including a `note`, is kept, so a guard stays marked as
not validated after it is lowered;
- a counter already at 0 is never lowered; a layout-shift score is lowered
to the three-decimal value the summary reports.
When the file changed and the run is on `master`, the job pushes
`automation/performance-ratchet` and opens (or updates) a pull request with
@@ -1018,8 +1339,10 @@ dispatches workflows that exist on the default branch, so before the first
merge of a new or renamed workflow add a temporary `push` trigger for the
branch and drop it before review, as #1760 did. Review the pull request like a manual
tightening: if `master` moved since the measured commit, the
`Initial bytes ratchet` job on the pull request is what shows that the new
value still holds (the concurrent-merge effect above).
`Initial bytes ratchet` job (for `renderer.initialBytes`) and the
`Performance journeys` job (for the journey counters) on the pull request
are what show that the new values still hold (the concurrent-merge effect
above).
## Charset parse benchmark
@@ -1066,7 +1389,13 @@ reports slow imports of non-Latin playlists.
3. Cover the extraction and the failure modes with `node --test` and register
the test file in `tools/performance/project.json`.
4. Validate the counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
lowering it moved wall-clock in the same journey. A counter that is
deterministic but not validated may be enforced as a guard: its baseline
entry carries a `note` saying so (see
[Enforced journey counters](#enforced-journey-counters)).
5. A journey counter's baseline is enforced only when it is also in the
`--only` list of the `Performance journeys` job in `ci.yml`; the
`performance-tools` tests fail when the two differ.
## Adding a journey
@@ -1079,8 +1408,10 @@ reports slow imports of non-Latin playlists.
2. Give the journey its own probe options (`cardSelector`,
`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
`journey-renderer-probe.ts` when the end condition is neither. A journey
whose start or end does not fit that probe gets a probe of its own, as
J4's typed start and quiet-window end do (`search-journey-probe.ts`). 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
+2 -1
View File
@@ -408,7 +408,8 @@ file running in parallel workers, so isolation is per-MAC rather than global:
else is talking to the server — a spec that used it would wipe a sibling
spec's session mid-test.
- `apps/web-e2e/src/stalker.e2e.ts` declares its shared scenario MACs in
`OWNED_MACS` and clears them in one batched request. The sibling specs that
`OWNED_MACS` (the constants live in `stalker-portal.fixture.ts`) and clears
them in one batched request. The sibling specs that
reach this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a
disjoint `00:1A:79:5F:*` range, so neither file can clear the other's state.
- Within each browser project, tests deliberately share content-scenario MACs
+65 -1
View File
@@ -492,7 +492,8 @@ the format at all, so a large share of working installations use a non-Infomir
MAC. Refusing one would stop those users adding or editing a portal that works
for them. The mock encodes the same split (`enforceMacFormat` is set only on
the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in
`stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict
`stalker-portal.fixture.ts` (used by `stalker.e2e.ts`) depends on it — a
non-Infomir MAC that must reach the strict
endpoint and be refused _there_, not in the form.
In the edit dialog **both** passes — blur and submit — normalize only a MAC the
@@ -1566,6 +1567,69 @@ Core decision logic and normalization are centralized in:
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
- `libs/portal/stalker/data-access/src/lib/models/*.ts`
## Forced External Launches
"Open in external player" needs a `create_link` round trip before it reaches
MPV/VLC. The shared rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages);
the Stalker keys and queues are:
Series (`StalkerSeriesViewComponent`, `stalker-series-launch-queue.ts`):
- Pending starts and the launch queue's held choices are keyed by
`playlist:series` (`currentSeriesKey`). The view is reused across series
and provider ids collide across playlists, so one series settling never
drops what another holds.
- A start is pending for its series from the click until it settles. A forced
launch stays pending through the close of the previous player, the launch
and the release of a held choice. The pending flag disables the hero button
and the menu's external-player and watched rows.
- Before launching, an episode of the same series still running externally is
closed (`replaceOwnedExternalSession`). The request is rechecked after that
close and after the launch IPC; a superseded launch closes the session it
opened.
- An episode chosen while a forced launch of its series is mid-flight is held
(`StalkerSeriesLaunchQueue.hold`); the latest choice per series wins. On
release it is dropped when the series is no longer shown. Otherwise
`replacePlayer` closes what the launch opened before the choice starts, and
an unconfirmed close drops the choice.
- An episode chosen while a watched or reset batch runs is held in one slot
tagged with its series; the last choice wins. When the batch settles it
goes through the usual gates only if that series is still shown: episode
identities overlap across series.
Movies (`createStalkerVodDetailActions`, used by the catalog detail, the
collection detail and search):
- A repeat for the same `playlist:movie` while its launch is in flight is
ignored, also after leaving the movie and returning to it. Launches of
other movies are not held back.
- The launch joins the host's starts (`beginPendingStart`): it supersedes an
earlier start, is dropped once a later one begins, and keeps Play, Start
over, the watched toggle and the menu rows disabled until it settles.
- The resolved stream is discarded when the movie is no longer selected or a
newer start took over. Movie and series ids collide, so the catalog and
collection details include the content type in the selection check; in
search, a switch to a series changes the playback owner instead, which
supersedes the launch. Otherwise the movie's own external
session is replaced, the host's `beforeExternalLaunch` hook runs (the
catalog and collection details close their inline player there), and the
launch is sent. A launch that resolves after either condition changed
closes the session it opened; one that fails by then is not reported.
- "Reset progress" counts as a pending start of the movie until the write
lands, so a start made meanwhile cannot resume from the row being cleared.
- The pending start is owner-scoped (`createPendingPlaybackStart`). Each host
retires it when the selection leaves the owner; that clears the pending
flag, not the repeat guard of a launch still in flight.
Regression coverage: `stalker-series-launch-queue.spec.ts`,
`stalker-series-view.component.spec.ts`,
`stalker-series-view.season-watch.spec.ts`,
`stalker-vod-detail-actions.spec.ts`,
`stalker-vod-playback-controller.spec.ts` and, in
`libs/portal/shared/util/src/lib/`, `pending-playback-start.spec.ts` and
`replace-owned-external-session.spec.ts`.
## Favorites and Recently Viewed
Current implementation is shared via Stalker-specific helpers:
+1 -1
View File
@@ -257,7 +257,7 @@ Zero i18n keys. Zero UI change.
- populated in all three of `mergeVodInfoWithTmdb` (`:168`), `mergeSerieInfoWithTmdb` (`:215`), `mergeStalkerInfoWithTmdb` (`:257`)
- through `NormalizedVodMeta` (`libs/shared/interfaces/src/lib/vod-details-item.interface.ts`) + **both** normalizers in `vod-details-adapters.ts` — the single convergence point where Xtream and Stalker meet
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **388 lines and is NOT in `tools/eslint/max-lines-baseline.mjs`** — roughly 12 lines of headroom against the hard 400 lint cap.
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **NOT in `tools/eslint/max-lines-baseline.mjs`** and stays under the hard 400 lint cap only because its state lives in sibling helpers (about 340 counted lines today).
**Render** into the `detail-extras` projection slot at **four** sites (`vod-details.component.html:225`, `serial-details.component.html:201`, `vod-details-route.component.html:247`, `stalker-series-view.component.html:202`) — note Xtream `serial-details` has no trailer block today, so it either gains one or the shelf lands inconsistently. Reuse the existing nocookie iframe + `| safe` pipe so the Electron Referer shim keeps working; clicking swaps the embed `src` rather than opening a new player. Cards use `https://img.youtube.com/vi/{key}/hqdefault.jpg` (CSP verified: `img-src` covers it, `frame-src https://www.youtube-nocookie.com` covers the embed). `@if (extras().length > 1)` … `@else` the existing single-trailer markup **verbatim**.
+7 -2
View File
@@ -276,7 +276,7 @@ pnpm nx build web
pnpm run perf:initial-bytes # breakdown only
pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline
pnpm nx test performance-tools
pnpm run perf:journeys # J1 launch + J2 open-source journeys, one dist/performance/journeys/<timestamp>/summary.json
pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys/<timestamp>/summary.json
```
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
@@ -285,7 +285,12 @@ bytes on the initial path (the J1 counter `renderer.initialBytes`).
`tools/performance/journey-baselines.json`; baselines only move down. CI runs
the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that
target `master` and for `master` pushes (dispatch it with
`gh workflow run ci.yml --ref <branch>` for a stacked branch). The weekly
`gh workflow run ci.yml --ref <branch>` for a stacked branch). The
`Performance journeys` job checks the journey counters listed with `--only`
in its `Check the journey counters against the baselines` step against the
same file (warn-only); a new journey baseline
must be added to that list too, which `pnpm nx test performance-tools`
checks. The weekly
`performance-ratchet.yml` workflow lowers baselines through a bot PR; validate
a change to it with `gh workflow run performance-ratchet.yml --ref <branch>`,
which measures but opens no PR off `master`. Dispatch needs the workflow file
+26
View File
@@ -757,6 +757,32 @@ lookup comes back empty, because "never watched" is an answer: the button must
read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the
route's own row changes nothing; the loaded position already IS that copy's.
## Menu launch and reset follow the primary button
The "…" menu acts on the copy the primary button acts on. The shared launch
rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
- "Open in external player" and "Start over" are host-owned
(`VodDetailsMenuBindings.openExternal` / `restart`); the menu service never
builds a playback itself. The route forces MPV/VLC for the pinned copy from
that copy's own resume point (`playPinnedSource` with `player` and
`replacePlaying`), also while that copy already plays: it is relaunched,
never swapped for the route copy. Only an `unavailable` pin falls through
to the route copy's Resume or Play.
- "Reset progress" clears the row of `primaryTarget`: the pinned copy's own
row, otherwise the route copy's.
- Resets in flight are a list of targets
(`VodDetailsPlaybackService.pendingResets`, `vod-details-reset-target.ts`),
not one flag: the reused page can show another movie and come back, and
resets of one copy can overlap, so each reset removes only its own entry.
- A start is refused (`startResolvedPlayback`) while a launch this page made
has not settled or the list holds the copy the page currently acts on
(`resetTarget`). `startBlocked` disables Play, Start over and the menu
launch on those conditions and while a matched session is still
`launching`; the menu rows are also held while a start is pending. A reset
still writing for another copy does not block it.
## Provider codec metadata
`info.video` / `info.audio` come back in two shapes: the declared string array
+27 -1
View File
@@ -137,7 +137,33 @@ edges (no second, sharp copy); a live channel's logo sits on the right as key
art over its own wash. Series titles drop their season marker
(`splitSeasonSuffix`) — the `S1·E1` chip names the season. Chips are
`app-meta-chip`; the primary is the details pages' light primary
(`light-primary-button` from `libs/ui/styles`).
(`light-primary-button` from `libs/ui/styles`). With no artwork at all the
stage is a gradient in the title's hue (`--hero-hue`), light in the light
theme and near-black in the dark one; a dark gradient under the light
theme's page-coloured scrim read as a grey slab behind dark text.
Legibility: slide text stays at 4.5:1 or more over any artwork. The side
scrim holds 88% of the page colour up to the slide's right edge
(`--hero-text-edge`: the inset plus `min(560px, 55%)`, the slide's own
`max-width`) before it opens onto the art. In the narrow layout (`dashboard`
container ≤ 720px) the slide spans the width, so a full-bleed scrim sits
behind the text block (90%, fading in just above the eyebrow), the copy gets
a scrim-coloured text shadow, and the slide enters without a fade so that
scrim never flashes the art on a rotation. Body text is 85% of the heading
colour; the rating chip uses `--app-rating-color`, set per theme in
`m3-theme.scss`. Buttons end long labels in an ellipsis.
`dashboard-hero-legibility.e2e.ts` replaces every image with a black-and-white
checkerboard and measures each piece of slide text from the screen in both
themes, at a wide and a narrow width, for a backdrop, a blurred-poster, a
no-artwork and a live slide.
Semantics: the page has one stable, visually hidden `h1` ("Dashboard",
`dashboard-page-heading`); each slide title is an `h2`, like the rail titles.
Slide changes are announced by one polite live region
(`dashboard-hero-announcement`, position and title) that lives outside the
re-created slide and is silent while the slides rotate on their own. A
slide's progress bar is named after its title (a live slide: the programme)
and a title's reads "N% watched". The dots are 24px targets (WCAG 2.5.8).
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
advances. The fill animates `transform` only (a bar sliding in under the
+39 -13
View File
@@ -390,15 +390,32 @@ The Electron window hides the native title bar on all desktop platforms
(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`):
1. macOS keeps the native traffic lights (`titleBarOverlay: true`,
`trafficLightPosition`); the renderer draws no window buttons. The lights
sit in the 56 px header band above the rail, so the macOS rail
(`.app-rail.is-macos`) starts its first link at 56 px: level with the
content area and the dashboard hero, with its hover surface clear of the
lights. App zoom scales CSS pixels but not the lights, so the rail
publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on
`resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when
zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at
default and minimum zoom on macOS.
`trafficLightPosition` from `MACOS_TRAFFIC_LIGHTS_POSITION` in
`@iptvnator/shared/interfaces`); the renderer draws no window buttons.
The lights sit in the header band (`--workspace-header-band`, 56 px) over
the rail and the header's leading padding. The macOS rail
(`.app-rail.is-macos`) starts its first link below the band: level with
the content area and the dashboard hero, with its hover surface clear of
the lights. The header's content starts 84 window pixels from the
window's left edge (60 px rail plus 24 px padding). macOS 26 ends the
lights at 76 (earlier releases at 68), which is where Back's left edge
sits at 100 % because of its 8 px pull-in.
App zoom (see "Zoom level") scales CSS pixels but not the lights. On
macOS, `TrafficLightsClearanceDirective` on `.workspace-shell` reads the
page zoom factor (`outerWidth / innerWidth`, refreshed on `resize`) and
publishes the clearance in CSS pixels as `--traffic-lights-clear-x` (84
window pixels) and `--traffic-lights-clear-y` (48: the lights' bottom
plus a gap). Zoomed out, the band grows to the vertical clearance, so the
lights never overlap the content area. The header's leading padding grows
to the horizontal clearance, less the rail column
(`--workspace-header-lights-inset`). At 100 % both match the default
layout. Off macOS nothing is published and the defaults apply. The phone
layout, which puts the rail in a row above the header, ignores the
inset. `window-controls.e2e.ts` ("macOS traffic lights") checks the rail
alignment, the first header control (the switcher on the first page,
which has no history fallback yet, then a detail page's Back) and the
content top at default and minimum zoom.
2. Windows and Linux use renderer-drawn window controls
(`app-window-controls`, `libs/ui/components/src/lib/window-controls/`).
`frame` is intentionally left untouched so native resize borders and
@@ -510,15 +527,24 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455):
3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux
the window is created hidden and enters fullscreen before its first
paint. macOS ignores the option while the window is hidden (an NSWindow
only toggles fullscreen once it is on screen), so `ready-to-show` repeats
only toggles fullscreen once it is on screen), so the first show repeats
the request with `setFullScreen(true)` right after `show()` wherever
`isFullScreen()` is still false — never unconditionally, or the
platforms that honoured the option would animate a second toggle. The
saved bounds stay spread into the options — they are the normal bounds
the window returns to, and the close handler keeps persisting
`getNormalBounds()`. `maximized` calls `maximize()` inside
`ready-to-show` right before `show()`, never earlier: `maximize()` on a
hidden window shows it, and a blank window would flash.
`getNormalBounds()`. `maximized` calls `maximize()` right before the
first `show()`, never earlier: `maximize()` on a hidden window shows it,
and a blank window would flash. That first show happens at
`ready-to-show` or the main frame's `did-finish-load`, whichever comes
first (`services/main-window-first-show.ts`): on Linux a hidden window
whose startup scripts ran before its first frame gets the next one about
a second later, so `ready-to-show` alone left the window off screen and
the splash's animation frame waiting. At `did-finish-load` the inline
splash is parsed, and the window's `backgroundColor` is the splash colour
(`MAIN_WINDOW_BACKGROUND_COLOR`, keep it in sync with `#initial-splash`
in `apps/web/src/index.html`), so showing before the first paint does
not flash.
4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can
sit anywhere in argv; the playlist-path extractor already skips every
`-`-prefixed argument) forces `fullscreen` for that launch only and is
@@ -405,3 +405,40 @@ deduplicated list, `hasMoreContent` derives from accumulated length vs
and the facade maps page 0 to the skeleton and later pages to the tail
spinner. These catalog/search surfaces use incremental loading instead of
page buttons.
## Forced external launches from detail pages
The "…" menu's MPV/VLC launch follows the shared rules in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
The movie page's pin and reset rules are in
[VOD Multi-Source](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button).
The series page keeps its launch state at module level in
`serial-details-external-launch.ts`, so it outlives a recreated page:
- The owner is `playlist:series` (`launchOwner()`). It changes when the page
shows another series and is null once the page is gone.
- Forced launches of one owner run on one chain. A later launch waits for the
earlier one to settle, closes the owner's running episode session and then
launches. Each step rechecks the owner, and a launch that resolves after the
page left the owner closes the session it opened.
- The duplicate guard is keyed by page token plus episode. The token
(`pageToken()`) is owner, page instance and visit, so a launch left behind
by an earlier visit of the same series does not swallow a launch from the
reopened page; that launch queues on the owner's chain.
- While a forced launch of the owner is pending (`forcedLaunchPending`), a
start that does not force a player is queued instead of started. One choice
is kept per owner, the latest wins, and it carries the host and `start` of
the page that made it. Once the chain settles, the player the launch opened
is closed first while the owner stays pending. The choice is dropped when
that page no longer shows the owner or the close was not confirmed.
- The pending flag also disables the menu's external-player row and the
season and series watched actions, and counts as active playback for "Reset
progress".
- The launch-position marker and a launch-failure message apply only while
the page token is unchanged.
Regression coverage: `serial-details-external-launch.spec.ts` (chain,
duplicate guard, queued choice), `serial-details-playback.service.spec.ts`
(page token) and, for the external-player and reset rows,
`libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts`.
+324
View File
@@ -0,0 +1,324 @@
# Zoneless change-detection migration
Working checklist for plan item C6 of the performance journeys plan: move the
renderer (`apps/web`) from zone.js to `provideZonelessChangeDetection()`.
The win is measured with the change-detection tick counters described in
[performance journeys](performance-journeys.md#change-detection-ticks); the
[idle work audit](idle-work-audit-2026-09.md) found the Eager roots that
re-render on every tick. Update this file in the same PR that converts an item.
The inventory was taken on `e8b181fce` (2026-10-04, Angular 22.1.6). Run
`pnpm nx run electron-backend-e2e:test-performance-harness` after editing the
Eager list: `zoneless-migration.spec.ts` fails when the list and the code
disagree, so a new Eager component cannot land unnoticed and a converted one
must be ticked here.
## Starting point
- **OnPush is already the default.** Since Angular 22 an unset
`changeDetection` means OnPush, and the old `Default` strategy is spelled
`ChangeDetectionStrategy.Eager`. Only components that set `Eager` are
checked on every tick. `ChangeDetectionStrategy.Default` is not used.
- **Renderer bootstrap.** `apps/web/src/app/app.config.ts` provides
`provideZoneChangeDetection({ eventCoalescing: true })` and
`apps/web/project.json` builds with `"polyfills": ["zone.js"]`.
`apps/remote-control-web` does the same; it is a separate app and outside
this migration unless a step says otherwise.
- **Unit tests already run zoneless.** Every `src/test-setup.ts` (apps/web,
apps/remote-control-web and 24 libs) calls `setupZonelessTestEnv` and loads
`zone.js`/`zone.js/testing` only for `fakeAsync` and `waitForAsync`. A
component that passes its specs is therefore not proof of zone-free
production behavior when the spec calls `fixture.detectChanges()` itself.
- **IPC callbacks never ran in the Angular zone.** `window.electron.on*`
listeners arrive through `contextBridge` and are not zone-patched, so every
one that works today already writes signals or calls `NgZone.run`.
- **Counters before the migration** (macOS, from
[performance journeys](performance-journeys.md#change-detection-ticks)):
`renderer.cdTicksToFirstCard` 20–21 (the one-tick zone.js race),
`renderer.cdTicksIdle30s` 3, `renderer.cdTicksToFirstPage` 22;
`renderer.cdTicksToPlaying` has no recorded run yet.
## PR sequence
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a
zone change; it lowered `renderer.initialBytes` before the migration
starts moving it.
2. [x] This inventory and its guard spec.
3. [ ] Per-project PRs, in this order, each converting the project's Eager
components and fixing its zone-dependent sites while zone.js stays on:
`libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`,
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
`apps/web`. Each reports the tick counters before and after and runs the
affected unit and E2E tests.
4. [x] `provideZonelessChangeDetection()` behind a build-time
`fileReplacements` flag, off by default; the three implemented journeys
(J1-J3; J4 is still planned) and the Electron E2E suite run with it on
(see [Zoneless flag](#zoneless-flag)).
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
the zone.js race is gone).
## Eager components
66 production files, 67 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
files whose component metadata still sets
`changeDetection: ChangeDetectionStrategy.Eager` (comments do not count).
The settings sections read form values in their templates and the parent
patches the form outside their events (Discard, backup import, the EPG file
picker), so each marks itself on the form's `events` through
`markSectionForCheckOnFormEvents` (`apps/web/src/app/settings`).
### apps/web (15)
- [x] `apps/web/src/app/app.component.ts` (idle audit root)
- [x] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root)
- [x] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts`
- [x] `apps/web/src/app/settings/settings.component.ts`
- [x] `apps/web/src/app/settings/settings-about-section.component.ts`
- [x] `apps/web/src/app/settings/settings-backup-section.component.ts`
- [x] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
- [x] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
- [x] `apps/web/src/app/settings/settings-epg-section.component.ts`
- [x] `apps/web/src/app/settings/settings-general-section.component.ts`
- [x] `apps/web/src/app/settings/settings-playback-section.component.ts`
- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
- [x] `apps/web/src/app/settings/settings-reset-section.component.ts`
- [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
- [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`
### libs/ui (20 files, 21 components)
- [x] `libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts`
- [x] `libs/ui/components/src/lib/content-hero/content-hero.component.ts`
- [x] `libs/ui/components/src/lib/expandable-text/expandable-text.component.ts`
- [x] `libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts`
- [x] `libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`
- [x] `libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts`
- [x] `libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts`
- [x] `libs/ui/components/src/lib/watched-badge/watched-badge.component.ts`
- [x] `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts`
- [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`)
- [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts`
- [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only)
- [x] `libs/ui/playback/src/lib/art-player/art-player.component.ts`
- [x] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- [x] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts`
- [x] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
- [x] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts`
- [x] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts`
- [x] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts`
- [x] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
`libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one.
### apps/remote-control-web (1)
- [ ] `apps/remote-control-web/src/app/app.ts` (separate app; converts with `remote-control.component.ts`)
### libs/workspace (7)
- [x] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [x] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
### libs/playlist (14)
- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
- [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
### libs/portal (9)
- [x] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts`
- [x] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
- [x] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts`
- [x] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- [x] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts`
- [x] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts`
Test-only files that set Eager are not listed; they do not ship. The guard
skips every `*.spec.ts` / `*.test.ts` file with or without a suffix of one
or more segments (`*.spec-stubs.ts`, `*.test-helpers.ts`,
`*.test-data-stubs.ts`, …),
`test-setup.ts` and `test-stubs/` directories.
## Zone-dependent sites
Plain (non-signal) fields read by a template and written from a callback
that is not an Angular template event. Under zone.js the next tick happens to
refresh an Eager view; under zoneless nothing schedules one. Each fix makes
the field a signal (or a `computed`), or writes it through one.
| Done | Site | What depends on the zone | Owning PR |
| --- | --- | --- | --- |
| [x] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [x] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist |
| [x] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
| [x] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal |
| [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
| [x] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize`; each section now marks itself on its form's `events` (`markSectionForCheckOnFormEvents`), which `settings.component.form.spec.ts` guards without a forced render | apps/web |
| [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag |
| [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless |
## Explicit zone and change-detector calls
They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through). Remove them in the flip PR, not
before: with zone.js on they still matter.
- [x] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two
`zone.run` calls around the window-close dialog (IPC
`onWindowCloseRequested` and `beforeunload`).
- [x] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`:
`runOutsideAngular(() => setInterval(...))` for the position poll;
`embedded-mpv-session-controller.position.spec.ts` asserts the call and
changes with it.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal
writes.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
one `ngZone.run` around a signal update.
- `ChangeDetectorRef` in `stalker-live-stream-layout.component.ts`
(4 × `markForCheck`, 1 × `detectChanges` before measuring a row) and
`portal-channels-list.component.ts` (3 × `markForCheck`, 2 ×
`detectChanges`): correct under zoneless; replace the Maps with signals in
the portal PR if it stays small.
## Checked and signal-safe
No change needed; recorded so the flag PR knows where to look if a journey
regresses. Embedded MPV and external players are the riskiest paths because
their events arrive over IPC.
- **IPC listeners** (17 registrations): app update status, external player
sessions, window close and window state, player errors, playlist open
requests, embedded MPV sessions, playback history gate, downloads,
recordings, playlist refresh, DB operation and save progress, EPG progress,
playback position updates, channel change and remote-control commands. All
write signals, signal stores or NgRx, or have no UI state.
- **Player libraries** (video.js, mpegts.js, hls.js, artplayer, shaka, native
`<video>`): callbacks bump signals in the control adapters or emit outputs
whose parent handlers write signals.
- **Observers** (13 Intersection/Resize/Mutation observers) and **document
and window listeners** (~40): signals or DOM only. `@HostListener`
bindings are Angular listeners and mark their view.
- **Timers** (~130 `setTimeout`/`setInterval`/rAF/`queueMicrotask`): all
write signals, touch the DOM or focus, or have no UI state, apart from the
two in the table above.
- **Dialogs and snackbars** (20 `afterClosed`/`onAction` sites): signals,
stores, outputs or navigation, apart from the one in the table above.
- No production code uses `NgZone.onStable`, `onMicrotaskEmpty`, `isStable`,
`ApplicationRef.tick()`, `Zone.current` or `ngDoCheck`.
## Build, tests and runtime details
- [ ] `provideServiceWorker(..., { registrationStrategy:
'registerWhenStable:30000' })`: under zoneless "stable" means no pending
tasks. The 30 s bound still registers the worker; check the PWA build in
the flag PR.
- [ ] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
the zoneless scheduler also calls, so the counters stay comparable.
- [ ] Specs that need zone.js: `fakeAsync` in
`playlist-switcher.component.spec.ts` and `stalker-live-navigation.spec.ts`,
`waitForAsync` in 13 files. They keep `zone.js/testing` until rewritten;
removing zone.js from the build polyfills does not affect them.
- [ ] Unreferenced leftovers to delete in the flip PR:
`apps/web/src/polyfills.ts`, `apps/web/src/polyfills-test.ts`,
`apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses
them).
## Zoneless flag
`app.config.ts` takes its change-detection providers from
`apps/web/src/environments/change-detection.providers.ts`
(`provideZoneChangeDetection({ eventCoalescing: true })`). The
`electron-performance-zoneless` and `electron-e2e-zoneless` web
configurations are their base configuration plus one `fileReplacements`
swap to `change-detection.providers.zoneless.ts`
(`provideZonelessChangeDetection()`); a test in
`performance-build-config.spec.ts` pins that and refuses the swap in any
other configuration. zone.js stays in the polyfills, so these builds log
NG0914 in dev mode and nothing schedules through the zone. The Electron app
loads the renderer from `dist/apps/web`, so rebuilding only the web app
switches an existing Electron build:
```bash
pnpm nx run electron-backend:build-performance # or build-e2e
pnpm nx run web:build:electron-performance-zoneless # or electron-e2e-zoneless
cd apps/electron-backend-e2e
../../node_modules/.bin/playwright test --config=playwright.journeys.config.ts
../../node_modules/.bin/playwright test --grep-invert packaged
```
Do not run `pnpm run perf:journeys` or `pnpm nx run electron-backend-e2e:e2e`
afterwards: their build dependencies restore the zone.js renderer.
First measurement (macOS, 2026-10-04, the six OnPush PRs merged locally on
the flag branch; the same integration build measured with the flag off and
on, five iterations each):
| Counter | flag off | flag on |
| --- | --- | --- |
| `renderer.cdTicksToFirstCard` | 22, 20, 22, 21, 21 | 7, 7, 7, 7, 7 |
| `renderer.cdTicksIdle30s` | 4, 4, 4, 4, 4 | 3, 3, 3, 3, 3 |
| `renderer.cdTicksToFirstPage` | 22 (all) | 8 (all) |
| `renderer.cdTicksToPlaying` | 16, 15, 15, 17, 15 | 6, 8, 10, 10, 7 |
| DOM mutations J1 / J2 / J3 | 553 / 1,603 / 6,182 | 553 / 1,603 / 6,182 (one J3 iteration 6,199, as on master) |
| `spawnToFirstCardMs` p50 | 3,071 | 828 |
| `clickToFirstPageMs` p50 | 82.8 | 80.4 |
| `clickToLoadedMetadataMs` / `clickToPlayingMs` p50 | 94.8 / 268.7 | 158.3 / 409.3 |
J1 and J2 tick counts become deterministic without the zone.js one-tick
race, and the DOM mutations are unchanged, so nothing renders differently.
J3's tick count still varies with player events and its wall-clock
numbers rose locally; the machine was shared with other runs (load 26 to 58
during these two runs, master itself read 363.8 ms `clickToPlayingMs` p50
earlier the same day), so judge J3 on the CI runner before the flip.
Electron E2E suite on `electron-e2e-zoneless` (all specs except the
packaged frame-copy ones): 203 passed, 7 skipped, 3 failed. `epg-guide`
failed on every run and is fixed above; `playlist-auto-refresh` passed on
`--repeat-each=2`; `dash-clearkey` "reopens from recent and favorites" is the
known local flake (it fails as often on master). The IPC-driven paths
passed zoneless: external-player launch states and the MPV/VLC DASH
fallbacks in `dash-clearkey`, the MPV double-click gate in `settings`,
`remote-control`, `picture-in-picture` and `stream-info`. Their state reaches
the renderer over IPC into signals and never ran in the zone. Embedded MPV
playback itself is covered only by the packaged frame-copy E2E, which these
runs left out; run it on a packaged zoneless build before the flip.
## Measuring a PR
Build `electron-performance` and run the journeys as described in
[performance journeys](performance-journeys.md), then paste
`renderer.cdTicksToFirstCard`, `renderer.cdTicksIdle30s`,
`renderer.cdTicksToFirstPage` and `renderer.cdTicksToPlaying` before and
after. While zone.js is on, removing Eager does not change the number of
ticks, only the work per tick; expect the counters to stay put until the flag
PR and the template work (DOM mutations, profile time) to drop.
+1
View File
@@ -15,6 +15,7 @@ are not prerequisites for reading repository contracts.
| Angular conventions; docs and skills maintenance; local review before a pull request | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Zoneless change detection, `ChangeDetectionStrategy.Eager` components, `NgZone` usage | [Zoneless migration](../architecture/zoneless-migration.md) | Read the checklist directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
@@ -21,7 +21,7 @@ const METADATA = createPlaybackSourceMetadata({
});
describe('mpegts.js playback evidence', () => {
it('locks the accepted public contract to mpegts.js 1.8.1', () => {
it('locks the accepted public contract to mpegts.js 1.8.2', () => {
expect(mpegts.version).toBe(MPEGTS_DIAGNOSTIC_VERSION);
expect(mpegts.ErrorTypes).toEqual({
NETWORK_ERROR: MpegTsPlaybackEngineType.Network,
@@ -11,7 +11,7 @@ import {
MpegTsPlaybackStage,
} from './mpegts-playback-evidence.model';
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.1';
export const MPEGTS_DIAGNOSTIC_VERSION = '1.8.2';
interface MpegTsPlaybackCause {
readonly stage: MpegTsPlaybackStageValue;
@@ -1,10 +1,10 @@
/**
* Public Shaka error values audited against the locked 5.2.4 runtime.
* Public Shaka error values audited against the locked 5.2.12 runtime.
*
* Keep the version assertion in the contract spec: a Shaka upgrade must stop
* here for a new audit instead of silently accepting new error layouts.
*/
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.4';
export const SHAKA_DIAGNOSTIC_VERSION = 'v5.2.12';
export const SHAKA_ERROR_SEVERITY = {
RECOVERABLE: 1,
@@ -123,7 +123,7 @@ export const SHAKA_ERROR_CODE = {
MISSING_EME_SUPPORT: 6020,
LOAD_INTERRUPTED: 7000,
} as const;
/** Shaka 5.2.4 public NetworkingEngine request types used by diagnostics. */
/** Shaka 5.2.12 public NetworkingEngine request types used by diagnostics. */
export const SHAKA_REQUEST_TYPE = {
MANIFEST: 0,
SEGMENT: 1,
@@ -65,7 +65,7 @@ describe('Shaka playback evidence', () => {
}
);
it('matches the installed public Shaka 5.2.4 error contract', () => {
it('matches the installed public Shaka 5.2.12 error contract', () => {
const installed = getInstalledShakaContract();
expect(installed.requestTypes).toEqual(
expect.objectContaining(SHAKA_REQUEST_TYPE)
@@ -51,7 +51,7 @@ export function createShakaPlaybackEvidence(
return httpStatus === undefined ? evidence : { ...evidence, httpStatus };
}
/** Public error.data request-type slots in Shaka 5.2.4; no URL inspection. */
/** Public error.data request-type slots in Shaka 5.2.12; no URL inspection. */
function getNetworkStage(error: Partial<ShakaErrorLike> | null | undefined) {
if (
error?.category !== SHAKA_ERROR_CATEGORY.NETWORK ||
@@ -71,7 +71,7 @@ import {
TranslatePipe,
],
templateUrl: './m3u-vod-detail.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./m3u-vod-detail.component.scss'],
})
export class M3uVodDetailComponent {
@@ -231,8 +231,12 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [],
template:
'<ng-template #fullscreenChannelPanel></ng-template>',
// The real template's channel-number overlay, so the
// OnPush timer test below can read the rendered state.
template: `<ng-template #fullscreenChannelPanel></ng-template>
@if (showChannelNumberOverlay()) {
<div class="channel-number-overlay">{{ channelNumberInput() }}</div>
}`,
},
})
.compileComponents();
@@ -247,6 +251,33 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
fixture.destroy();
});
// OnPush: the overlay hides from a 2 s timer, outside any template
// event, so the signal write itself must schedule the render. The test
// never forces one after the timer: a plain-field write would leave the
// overlay in the DOM.
it('hides the channel-number overlay when its debounce fires', async () => {
jest.useFakeTimers();
try {
const overlay = () =>
(fixture.nativeElement as HTMLElement).querySelector(
'.channel-number-overlay'
);
fixture.autoDetectChanges();
component.handleChannelNumberInput('2');
await jest.advanceTimersByTimeAsync(50);
expect(overlay()?.textContent).toBe('2');
await jest.advanceTimersByTimeAsync(2000);
expect(overlay()).toBeNull();
expect(storeMock.dispatch).toHaveBeenCalledWith(
setActiveChannelDispatch(nextChannel)
);
} finally {
jest.useRealTimers();
}
});
describe('FULLSCREEN_CHANNEL_PANEL host', () => {
it.each([VideoPlayer.MPV, VideoPlayer.VLC])(
'withholds rows that would leave forced-inline DASH for %s',
@@ -260,7 +291,7 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
url: 'http://localhost/next.mpd',
};
player.set(externalPlayer);
component.playerSettings.player = externalPlayer;
component.playerSettings.set({ player: externalPlayer });
setActive(dashChannel);
channels.set([dashChannel, sampleChannel, nextDashChannel]);
@@ -85,7 +85,7 @@
[playbackSessionKey]="playbackSessionKey()"
[inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)"
[volume]="volume()"
[playerOverride]="playerSettings.player ?? null"
[playerOverride]="playerSettings().player ?? null"
(playbackStarted)="refreshVolumeFromBus()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -124,7 +124,7 @@
[playerOverride]="
activeChannelIsDash()
? dashPlayerOverride()
: (playerSettings.player ?? null)
: (playerSettings().player ?? null)
"
[volume]="volume()"
[timelineSegments]="catchupTimelineSegments()"
@@ -255,10 +255,10 @@
}
}
@if (showChannelNumberOverlay) {
@if (showChannelNumberOverlay()) {
<div class="channel-number-overlay">
<div class="channel-number-display">
{{ channelNumberInput }}
{{ channelNumberInput() }}
</div>
</div>
}
@@ -231,7 +231,7 @@ function isInsideScrollableRegion(
{ provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService },
],
templateUrl: './video-player.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './video-player.component.scss',
})
export class VideoPlayerComponent
@@ -713,9 +713,9 @@ export class VideoPlayerComponent
);
/** Selected video player options */
playerSettings: Partial<Settings> = {
readonly playerSettings = signal<Partial<Settings>>({
player: VideoPlayer.VideoJs,
};
});
readonly isDesktop = this.runtime.isElectron;
readonly supportsEpg = this.runtime.supportsEpg;
@@ -731,8 +731,8 @@ export class VideoPlayerComponent
);
/** Channel number input state */
channelNumberInput = '';
showChannelNumberOverlay = false;
readonly channelNumberInput = signal('');
readonly showChannelNumberOverlay = signal(false);
private channelNumberTimeout?: number;
/**
@@ -795,9 +795,9 @@ export class VideoPlayerComponent
// React to settings changes
effect(() => {
this.playerSettings = {
this.playerSettings.set({
player: this.settingsStore.player(),
};
});
});
// Keep "now" fresh so EPG state re-evaluates over time.
@@ -1191,10 +1191,10 @@ export class VideoPlayerComponent
applySettings(): void {
this.storage.get(STORE_KEY.Settings).subscribe((settings: unknown) => {
if (settings && Object.keys(settings as Settings).length > 0) {
this.playerSettings = {
this.playerSettings.set({
player:
(settings as Settings).player || VideoPlayer.VideoJs,
};
});
}
});
}
@@ -1472,12 +1472,14 @@ export class VideoPlayerComponent
}
// Add digit to current input
this.channelNumberInput += digit;
this.showChannelNumberOverlay = true;
this.channelNumberInput.update((input) => input + digit);
this.showChannelNumberOverlay.set(true);
// Set timeout to switch channel after 2 seconds of no input
this.channelNumberTimeout = window.setTimeout(() => {
this.switchToChannelByNumber(parseInt(this.channelNumberInput, 10));
this.switchToChannelByNumber(
parseInt(this.channelNumberInput(), 10)
);
this.clearChannelNumberInput();
}, 2000);
}
@@ -1547,8 +1549,8 @@ export class VideoPlayerComponent
* Clear channel number input and hide overlay
*/
clearChannelNumberInput(): void {
this.channelNumberInput = '';
this.showChannelNumberOverlay = false;
this.channelNumberInput.set('');
this.showChannelNumberOverlay.set(false);
if (this.channelNumberTimeout) {
clearTimeout(this.channelNumberTimeout);
this.channelNumberTimeout = undefined;
@@ -1663,7 +1665,7 @@ export class VideoPlayerComponent
return true;
}
const player = this.playerSettings.player;
const player = this.playerSettings().player;
return (
!this.isExternalPlayer(player) && player !== VideoPlayer.EmbeddedMpv
);
@@ -1685,7 +1687,7 @@ export class VideoPlayerComponent
return true;
}
return !this.isExternalPlayer(this.playerSettings.player);
return !this.isExternalPlayer(this.playerSettings().player);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -56,7 +56,7 @@ export interface FavoriteLayoutItem {
'./favorites-layout.component.scss',
'../../styles/portal-sidebar.scss',
],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CategoryViewComponent,
ContentCardComponent,
@@ -25,7 +25,7 @@ import { DialogService } from '@iptvnator/ui/components';
selector: 'app-playlist-error-view',
templateUrl: './playlist-error-view.component.html',
styleUrls: ['./playlist-error-view.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule],
})
export class PlaylistErrorViewComponent {
@@ -39,7 +39,7 @@ export interface SearchFilter {
TranslatePipe,
],
templateUrl: './search-form.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
.search-container {
@@ -22,7 +22,7 @@ import {
selector: 'app-portal-rail-links',
imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive],
templateUrl: './portal-rail-links.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './portal-rail-links.component.scss',
})
export class PortalRailLinksComponent {
@@ -38,7 +38,6 @@ import {
PlaylistsService,
} from '@iptvnator/services';
import {
PlaybackPositionData,
ResolvedPortalPlayback,
StalkerVodDetails,
VodDetailsItem,
@@ -47,6 +46,8 @@ import {
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { startStalkerCatalogVodPlayback } from './stalker-catalog-vod-playback';
import { StalkerCatalogVodPosition } from './stalker-catalog-vod-position';
import { startStalkerVodDownload } from './stalker-vod-download';
import { createStalkerVodWatchedToggle } from '../stalker-vod-watched-toggle';
import {
@@ -59,7 +60,7 @@ import { createPlaybackSessionKey } from '@iptvnator/playback/util';
selector: 'app-stalker-catalog-detail',
imports: [StalkerSeriesViewComponent, VodDetailsComponent],
templateUrl: './stalker-catalog-detail.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
@@ -107,13 +108,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.contentType()])
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
private unsubscribePositionUpdates: (() => void) | null = null;
private positionLoadGeneration = 0;
private readonly vodPosition = new StalkerCatalogVodPosition({
playbackPositions: this.playbackPositions,
playbackPositionBridge: this.playbackPositionBridge,
playlistId: () => this.catalog.playlist()?.id,
selectedItem: this.selectedItem,
contentType: this.contentType,
isSeriesDetail: () => this.isSeriesDetail(),
});
/** The stored row is in hand (not the placeholder shown while reading). */
readonly positionLoaded = signal(false);
readonly positionLoaded = this.vodPosition.loaded;
/**
* The start still waiting on the portal between the click and playback,
* keyed by its owner: a stale resolution for the previous movie must not
@@ -146,13 +150,13 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
});
readonly selectedVodPlaybackDuration = computed<number | null>(
() => this.selectedVodPosition()?.durationSeconds ?? null
() => this.vodPosition.position()?.durationSeconds ?? null
);
readonly sourceLabel = computed(
() => this.catalog.playlist()?.title ?? null
);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
() => this.vodPosition.position()?.positionSeconds ?? null
);
/** Manual watched toggle; the child gates it on live playback itself. */
@@ -165,7 +169,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
: null;
},
playbackPositions: this.playbackPositions,
position: this.selectedVodPosition,
position: this.vodPosition.position,
playingNow: computed(
() => this.inlinePlayback() !== null || this.playbackStartPending()
),
@@ -173,8 +177,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
applyPosition: (position) => {
// A read still in flight started from the pre-write row; letting
// it land would revert the toggle it never saw.
this.positionLoadGeneration++;
this.selectedVodPosition.set(position);
this.vodPosition.discardPendingLoad();
this.vodPosition.position.set(position);
},
snackBar: this.snackBar,
translateService: this.translateService,
@@ -196,22 +200,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
);
constructor() {
effect(() => {
const item = this.selectedItem();
const playlistId = this.catalog.playlist()?.id;
if (
!item ||
!playlistId ||
this.contentType() !== 'vod' ||
this.isSeriesDetail()
) {
this.selectedVodPosition.set(null);
return;
}
void this.loadSelectedVodPosition(playlistId, Number(item.id));
});
this.vodPosition.connect();
effect(() => {
const ownerKey = this.playbackOwnerKey();
@@ -222,22 +211,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
});
this.unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.catalog.playlist()?.id ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.selectedVodPosition.set(data);
}
) ?? null;
}
onVodPlay(item: VodDetailsItem, positionSeconds?: number): void {
@@ -281,8 +254,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.contentType() === 'vod' && !this.isSeriesDetail()
? Number(this.selectedItem()?.id) || null
: null,
selectedVodPosition: this.selectedVodPosition,
discardPendingPositionLoad: () => ++this.positionLoadGeneration,
selectedVodPosition: this.vodPosition.position,
discardPendingPositionLoad: () => this.vodPosition.discardPendingLoad(),
beforeExternalLaunch: () => this.closeInlinePlayer(),
beginPendingStart: () => beginTrackedExternalLaunch(this),
afterProgressReset: (playlistId) =>
@@ -338,7 +311,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
playlistId,
position
),
onSaved: (position) => this.selectedVodPosition.set(position),
onSaved: (position) => this.vodPosition.position.set(position),
});
handleInlineTimeUpdate(event: {
@@ -356,36 +329,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
ngOnDestroy(): void {
this.closeInlinePlayer();
this.unsubscribePositionUpdates?.();
}
private async loadSelectedVodPosition(
playlistId: string,
vodId: number
): Promise<void> {
const generation = ++this.positionLoadGeneration;
this.positionLoaded.set(false);
if (Number.isNaN(vodId)) {
this.selectedVodPosition.set(null);
return;
}
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
// Only the newest read for the item still on screen may land: an
// older one would revert a watched toggle or a later selection.
if (
generation !== this.positionLoadGeneration ||
this.catalog.playlist()?.id !== playlistId ||
Number(this.selectedItem()?.id) !== vodId
) {
return;
}
this.selectedVodPosition.set(position ?? null);
this.positionLoaded.set(true);
this.vodPosition.disconnect();
}
private async startStalkerVodPlayback(
@@ -394,54 +338,19 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
thumbnail?: string,
startTime?: number
): Promise<void> {
const requestId = ++this.playbackRequestId;
const sessionKey = this.playbackSessionKey();
const ownerKey = this.playbackOwnerKey();
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
const startId = this.pendingStart.begin(ownerKey);
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
);
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.positionWriter.reset();
if (usesEmbeddedPlayer) {
this.inlinePlayback.set(playback);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? this.translateService.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: this.translateService.instant('PORTALS.PLAYBACK_ERROR');
this.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
} finally {
this.pendingStart.settle(startId);
}
await startStalkerCatalogVodPlayback(this, {
resolvePlayback: () =>
this.catalog.resolveVodPlayback(
cmd,
title,
thumbnail,
startTime
),
portalPlayer: this.portalPlayer,
resetPositionWriter: () => this.positionWriter.reset(),
logger: this.logger,
translate: this.translateService,
snackBar: this.snackBar,
});
}
}
@@ -0,0 +1,84 @@
import type { WritableSignal } from '@angular/core';
import type { MatSnackBar } from '@angular/material/snack-bar';
import type { TranslateService } from '@ngx-translate/core';
import type { Logger, PortalPlayer } from '@iptvnator/portal/shared/util';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
/** The detail page as the owner of its inline player: it tracks its own request ids. */
interface StalkerCatalogVodPlaybackHost {
playbackRequestId: number;
readonly pendingStart: {
begin(owner: string): number;
settle(startId: number): void;
};
playbackOwnerKey(): string;
playbackSessionKey(): string;
readonly inlinePlayback: WritableSignal<ResolvedPortalPlayback | null>;
closeInlinePlayer(): void;
}
interface StalkerCatalogVodPlaybackDeps {
readonly resolvePlayback: () => Promise<ResolvedPortalPlayback>;
readonly portalPlayer: Pick<
PortalPlayer,
'isEmbeddedPlayer' | 'openResolvedPlayback'
>;
/** Clears the inline position writer for the playback about to mount. */
readonly resetPositionWriter: () => void;
readonly logger: Pick<Logger, 'error'>;
readonly translate: Pick<TranslateService, 'instant'>;
readonly snackBar: Pick<MatSnackBar, 'open'>;
}
/**
* Play/Resume of the movie on the routed catalog detail: resolves the
* stream and hands it to the inline or the configured player, unless the
* page moved on or a newer start took over while the portal answered.
*/
export async function startStalkerCatalogVodPlayback(
host: StalkerCatalogVodPlaybackHost,
deps: StalkerCatalogVodPlaybackDeps
): Promise<void> {
const requestId = ++host.playbackRequestId;
const sessionKey = host.playbackSessionKey();
const ownerKey = host.playbackOwnerKey();
const usesEmbeddedPlayer = deps.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
const startId = host.pendingStart.begin(ownerKey);
try {
const playback = await deps.resolvePlayback();
if (
requestId !== host.playbackRequestId ||
host.playbackOwnerKey() !== ownerKey
) {
return;
}
deps.resetPositionWriter();
if (usesEmbeddedPlayer) {
host.inlinePlayback.set(playback);
return;
}
host.closeInlinePlayer();
void deps.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (
requestId !== host.playbackRequestId ||
host.playbackOwnerKey() !== ownerKey
) {
return;
}
deps.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
? deps.translate.instant('PORTALS.CONTENT_NOT_AVAILABLE')
: deps.translate.instant('PORTALS.PLAYBACK_ERROR');
deps.snackBar.open(errorMessage, undefined, {
duration: 3000,
});
} finally {
host.pendingStart.settle(startId);
}
}
@@ -0,0 +1,110 @@
import { type Signal, effect, signal } from '@angular/core';
import type { PortalPlaybackPositions } from '@iptvnator/portal/shared/util';
import type { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import type { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type { PlaybackPositionData } from '@iptvnator/shared/interfaces';
interface StalkerCatalogVodPositionConfig {
readonly playbackPositions: Pick<
PortalPlaybackPositions,
'getPlaybackPosition'
>;
readonly playbackPositionBridge: Pick<
PlaybackPositionRuntimeBridgeService,
'onPlaybackPositionUpdate'
>;
readonly playlistId: () => string | undefined;
readonly selectedItem: Signal<StalkerSelectedVodItem | null>;
readonly contentType: () => string;
readonly isSeriesDetail: () => boolean;
}
/**
* The stored playback position of the movie the routed catalog detail
* shows: read whenever the selection changes and kept current from the
* playback runtime while that movie stays on screen.
*/
export class StalkerCatalogVodPosition {
readonly position = signal<PlaybackPositionData | null>(null);
/** The stored row is in hand (not the placeholder shown while reading). */
readonly loaded = signal(false);
private unsubscribePositionUpdates: (() => void) | null = null;
private loadGeneration = 0;
constructor(private readonly config: StalkerCatalogVodPositionConfig) {}
/**
* Starts following the selection and the playback runtime. Registers an
* effect, so the host calls it from its constructor.
*/
connect(): void {
effect(() => {
const item = this.config.selectedItem();
const playlistId = this.config.playlistId();
if (
!item ||
!playlistId ||
this.config.contentType() !== 'vod' ||
this.config.isSeriesDetail()
) {
this.position.set(null);
return;
}
void this.load(playlistId, Number(item.id));
});
this.unsubscribePositionUpdates =
this.config.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const currentItem = this.config.selectedItem();
if (
data.contentType !== 'vod' ||
data.playlistId !== this.config.playlistId() ||
data.contentXtreamId !== Number(currentItem?.id)
) {
return;
}
this.position.set(data);
}
) ?? null;
}
disconnect(): void {
this.unsubscribePositionUpdates?.();
}
/** Retires a stored-position read still in flight (a row was written since). */
discardPendingLoad(): void {
this.loadGeneration++;
}
private async load(playlistId: string, vodId: number): Promise<void> {
const generation = ++this.loadGeneration;
this.loaded.set(false);
if (Number.isNaN(vodId)) {
this.position.set(null);
return;
}
const position =
await this.config.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
// Only the newest read for the item still on screen may land: an
// older one would revert a watched toggle or a later selection.
if (
generation !== this.loadGeneration ||
this.config.playlistId() !== playlistId ||
Number(this.config.selectedItem()?.id) !== vodId
) {
return;
}
this.position.set(position ?? null);
this.loaded.set(true);
}
}
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more