Merge remote-tracking branch 'origin/master' into claude/dazzling-davinci-7c5d3a

This commit is contained in:
4gray committed 2026-10-08 18:08:29 +02:00
commit 520fd0c069
340 files changed
+18267 -5030

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,8 @@
---
type: perf
area: epg
---
Live channels start playing with less work competing for the first frame: the
programme guide below the player now draws only the programmes around the part
you are looking at, and fills in the rest as you scroll.
@@ -0,0 +1,7 @@
---
type: perf
area: performance
---
The app loads slightly less code at startup: a developer-only state
inspection tool is no longer shipped in the desktop and web builds.
@@ -0,0 +1,8 @@
---
type: fix
area: playback
---
On Linux, the app no longer switches a saved Embedded MPV player back to the
default player when your login shell is slow to start. It now keeps your
choice until it knows for certain whether mpv is installed.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: portals
---
Opening a title in its portal from favorites, history or dashboard keeps the correct playlist and categories. Switching portals no longer lets an older loading request replace the current selection, and Stalker details wait for their destination portal before opening.
@@ -0,0 +1,6 @@
---
type: fix
area: workspace
---
Back on Settings, search, Discover and actor pages now works when that page is the first one you opened, for example after a reload or from a link: it takes you to the dashboard or to the playlist's movies or series instead of doing nothing or leaving the app.
@@ -0,0 +1,6 @@
---
type: feature
area: workspace
---
Back is now always in the same place: Settings, search, Discover and actor pages use the Back button at the start of the top bar instead of their own. Any page you reached from another one also shows it, and it takes you to the previous page.
@@ -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.
@@ -0,0 +1,6 @@
---
type: fix
area: workspace
---
On macOS the side rail now starts level with the page content and the dashboard banner, so its first button no longer crowds the window's close, minimize and zoom buttons.
@@ -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);
}
});
});
@@ -13,6 +13,10 @@ import {
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
// ---------------------------------------------------------------------------
// A detail page's Back lives in the workspace header's leading slot, not in
@@ -24,6 +28,15 @@ import {
// The "Episodes" heading must also stay on one line: the detail pane is far
// narrower than the window beside the rail and category panel, and the
// heading used to wrap beside its actions.
//
// Pages without a Back of their own (the list a detail returns to) get the
// header's history fallback while an in-app previous page exists; on a
// phone it yields to the drawer toggle, and with nowhere to go the slot is
// empty rather than a disabled arrow.
//
// A page with a parent route (settings here) that opened the session, as
// after a reload, has no in-app entry for history Back: its Back leads to
// the parent instead and replaces the page's entry.
// ---------------------------------------------------------------------------
const widths = [1280, 780, 375];
@@ -45,6 +58,16 @@ function headerBack(page: Page): Locator {
return page.getByTestId('workspace-header-back');
}
/**
* The generic history Back. A detail's own Back advertises Escape in browse;
* the fallback runs no page handler, so it advertises none.
*/
async function expectHistoryBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(headerBack(page)).toHaveAccessibleName('Back');
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page
@@ -228,12 +251,15 @@ async function startFirstEpisode(page: Page): Promise<void> {
).toBeVisible({ timeout: 20_000 });
}
/** The header Back leaves the detail and is gone from the list it opens. */
/**
* The header Back leaves the detail; the list it opens keeps only the
* history fallback (it was itself reached by navigation).
*/
async function expectHeaderBackReturnsToList(page: Page): Promise<void> {
await headerBack(page).click();
await expect(page).not.toHaveURL(detailUrlPattern);
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
}
test.describe('Portal detail header Back', () => {
@@ -248,7 +274,7 @@ test.describe('Portal detail header Back', () => {
const page = app.mainWindow;
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
const detailUrl = await openFirstSeries(page);
await expectBackInHeader(page, 'browse');
@@ -287,4 +313,74 @@ test.describe('Portal detail header Back', () => {
await closeElectronApp(app);
}
});
test('@xtream @electron falls back to history where no page offers Back', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.waitForURL(/\/workspace\//);
const startUrl = page.url();
// The first page of the session has nowhere to go back to: the
// slot is empty, not a disabled arrow.
await expect(headerBack(page)).toHaveCount(0);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
const listUrl = page.url();
await expectHistoryBack(page);
// On a phone the list's drawer toggle keeps the slot: it is the
// only way into the categories.
await page.setViewportSize({ width: 375, height: 800 });
await expect(page.getByTestId('context-drawer-toggle')).toBeVisible();
await expect(headerBack(page)).toBeHidden();
await page.setViewportSize({ width: widths[0], height: 800 });
await headerBack(page).click();
await expect(page).toHaveURL(startUrl);
await expect(headerBack(page)).toHaveCount(0);
// Forward history is not offered; browser Forward still works and
// brings the fallback back.
await page.goForward();
await expect(page).toHaveURL(listUrl);
await expectHistoryBack(page);
} finally {
await closeElectronApp(app);
}
});
test('@electron @settings settings Back after a reload leads to the dashboard', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.waitForURL(/\/workspace\/dashboard$/);
await openSettings(page);
// The reloaded document re-boots on the settings route; the
// dashboard entry before it belongs to the old document.
await reloadFromMainProcess(app);
await expectRendererReloadedOnRoute(
page,
/\/workspace\/settings\/general$/
);
await expect(page.getByTestId('settings-container')).toBeVisible();
await headerBack(page).click();
await expect(page).toHaveURL(/\/workspace\/dashboard$/);
// The dashboard replaced the settings entry: nothing in this
// document precedes it, so the header offers no Back.
await expect(headerBack(page)).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
@@ -17,6 +17,7 @@ import {
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
fetchXtreamLiveFixture,
fetchStalkerCategoryFixture,
} from './portal-mock-fixtures';
@@ -463,3 +464,100 @@ test('@epg @stalker @theme @electron applies live themes to the shared Stalker g
await closeElectronApp(app);
}
});
/** Title of the programme block holding keyboard focus, if any. */
async function focusedTimelineBlockTitle(page: Page): Promise<string | null> {
return page.evaluate(() => {
const block = document.activeElement?.closest(
'app-epg-timeline .epg-timeline__block'
);
return (
block
?.querySelector('.epg-timeline__block-title')
?.textContent?.trim() ?? null
);
});
}
/** Two frames: the focus scroll's measure, then the re-rendered blocks. */
async function nextFrames(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
);
}
test('@epg @xtream @electron reaches programmes beyond the rendered range with Tab', async ({
dataDir,
request,
}) => {
// The ribbon renders only the programmes near the visible range; keyboard
// focus scrolls a block into view, which renders its neighbours before
// the next key press. A generated schedule (240 half-hour slots) is far
// wider than that range.
test.setTimeout(180000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamLiveFixture(request, credentials);
const app = await launchElectronApp(dataDir);
try {
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
// Keep the external demo request pending; guide data is local.
});
await addXtreamPortal(app.mainWindow, {
name: 'Xtream Timeline Keyboard',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const channelRow = channelItemByTitle(
app.mainWindow,
fixture.items[0]?.name ?? ''
).first();
await expect(channelRow).toBeVisible({ timeout: 20000 });
await channelRow.click();
const timeline = app.mainWindow.locator('app-epg-timeline');
const nowBlock = timeline.locator('.epg-timeline__block.is-now');
await expect(nowBlock).toBeVisible({ timeout: 20000 });
const titles = timeline.locator('.epg-timeline__block-title');
const initial = new Set(
(await titles.allInnerTexts()).map((title) => title.trim())
);
expect(initial.size).toBeLessThan(120);
// Walk well past the rendered range in both directions: ten
// programmes that were not in the DOM when the walk started.
for (const key of ['Tab', 'Shift+Tab']) {
// The previous walk moved the window away from now.
await timeline.locator('.epg-timeline__jump').click();
await expect(nowBlock).toBeVisible();
await nowBlock.focus();
const reached = new Set<string>();
for (let press = 0; press < 400 && reached.size < 10; press++) {
await app.mainWindow.keyboard.press(key);
await nextFrames(app.mainWindow);
const inRibbon = await app.mainWindow.evaluate(
() =>
!!document.activeElement?.closest(
'app-epg-timeline .epg-timeline__ribbon'
)
);
expect(inRibbon, `${key} press ${press} left the ribbon`).toBe(
true
);
const title = await focusedTimelineBlockTitle(app.mainWindow);
if (title && !initial.has(title)) {
reached.add(title);
}
}
expect(reached.size, `${key} programmes beyond the range`).toBe(10);
}
} finally {
await closeElectronApp(app);
}
});
@@ -17,6 +17,8 @@ import {
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
goToDashboard,
openGlobalRecent,
openWorkspaceSection,
openPlaylistFavorites,
openSources,
@@ -41,8 +43,158 @@ import {
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
import { readVisibleSidebarCategoryNames } from './sidebar-categories.e2e-support';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
test.describe('Electron Favorites', () => {
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
test(`@stalker @electron portal handoff from ${origin} restores the owning categories after opening a different source`, async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const vod = await fetchStalkerCategoryFixture(request, 'vod');
const [title] = pickDistinctTitles(vod.items, getStalkerTitle);
const app = await launchElectronApp(dataDir);
const page = app.mainWindow;
await routePlayableStreams(page);
try {
await addStalkerPortal(page, { name: 'Portal A' });
await waitForStalkerCatalog(page);
await clickCategoryById(page, vod.categoryId);
const portalPath = new URL(page.url()).pathname.replace(
/\/vod(?:\/[^/]+)?$/,
`/vod/${vod.categoryId}`
);
const categoriesA = await readVisibleSidebarCategoryNames(page);
await clickGridListCardByTitle(page, title);
await addCurrentDetailToFavorites(page);
if (origin !== 'favorites') {
await startAndConfirmPlayback(page, () =>
page.locator('button.play-btn').first().click()
);
}
await goBackFromDetail(page);
if (origin !== 'favorites') {
// Represent an already saved viewing position. The short
// media fixture ends before the player's 15 s save interval.
const playlistId = /\/stalker\/([^/]+)/.exec(
portalPath
)?.[1];
const contentId = Number(
vod.items.find(
(item) => getStalkerTitle(item) === title
)?.id
);
expect(playlistId).toBeTruthy();
await page.evaluate(
async ({ playlistId, contentId }) => {
await window.electron.dbSavePlaybackPosition(
playlistId!,
{
contentXtreamId: contentId,
contentType: 'vod',
playlistType: 'stalker',
positionSeconds: 2,
durationSeconds: 6,
}
);
},
{ playlistId, contentId }
);
}
await openSources(page);
await addStalkerPortal(page, {
name: 'Portal B',
macAddress: '00:1A:79:00:00:03',
});
await waitForStalkerCatalog(page);
const categoriesB = await readVisibleSidebarCategoryNames(page);
expect(categoriesB).not.toEqual(categoriesA);
if (origin === 'dashboard') {
await goToDashboard(page);
await page
.locator(
'[data-test-id="dashboard-continue-watching-rail-card"]'
)
.filter({ hasText: title })
.first()
.click();
} else {
if (origin === 'favorites')
await openWorkspaceSection(page, 'Global favorites');
else await openGlobalRecent(page);
await switchUnifiedCollectionScope(page, 'All playlists');
await contentCardByTitle(page, title).first().click();
}
await expect(page.locator('app-content-hero')).toContainText(
title
);
if (origin !== 'favorites') {
await startAndConfirmPlayback(
page,
() =>
page
.getByRole('button', { name: /^Continue/ })
.first()
.click(),
0.5
);
await page
.getByRole('button', {
name: 'Close player',
exact: true,
})
.click();
}
await page
.getByRole('button', {
name: 'View in portal',
exact: true,
})
.click();
await expect
.poll(() => new URL(page.url()).pathname)
.toBe(portalPath);
await expect(page.locator('app-content-hero')).toContainText(
title
);
await expect
.poll(() => readVisibleSidebarCategoryNames(page))
.toEqual(categoriesA);
await goBackFromDetail(page);
await expectPathname(
page,
origin === 'favorites'
? /\/workspace\/global-favorites$/
: /\/workspace\/global-recent$/
);
await expect(page.locator('app-content-hero')).toContainText(
title
);
for (const [name, expected] of [
['Portal B', categoriesB],
['Portal A', categoriesA],
] as const) {
await openSources(page);
await sourceRowByTitle(page, name).first().click();
await waitForStalkerCatalog(page);
await expect
.poll(() => readVisibleSidebarCategoryNames(page))
.toEqual(expected);
}
} finally {
await closeElectronApp(app);
}
});
}
test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
}) => {
@@ -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,97 @@ test('only the web performance build installs the tick counter the journeys read
}
});
// @ngrx/store-devtools must not reach the production, PWA or performance
// bundles (renderer.initialBytes): the default providers file is empty and
// only the development configurations swap in the devtools.
test('only development web builds provide the NgRx store devtools', () => {
const environments = join(workspaceRoot, 'apps/web/src/environments');
const devtoolsReplacement = {
replace: 'apps/web/src/environments/store-devtools.providers.ts',
with: 'apps/web/src/environments/store-devtools.providers.dev.ts',
};
// electron-e2e-zoneless is electron-e2e plus the zoneless swap.
const developmentConfigurations = new Set([
'development',
'electron-e2e',
'electron-e2e-zoneless',
]);
assert.match(
readFileSync(join(environments, 'store-devtools.providers.ts'), 'utf8'),
/export const storeDevtoolsProviders: EnvironmentProviders\[\] = \[\];/
);
// The replacement must still provide them, or development builds would
// lose the devtools while this test passed.
assert.match(
readFileSync(
join(environments, 'store-devtools.providers.dev.ts'),
'utf8'
),
/storeDevtoolsProviders: EnvironmentProviders\[\] = \[\s*provideStoreDevtools\(/
);
assert.doesNotMatch(
readFileSync(
join(workspaceRoot, 'apps/web/src/app/app.config.ts'),
'utf8'
),
/@ngrx\/store-devtools/
);
for (const [name, configuration] of Object.entries(
webProject.targets['build'].configurations ?? {}
)) {
const replacements = (configuration['fileReplacements'] ??
[]) as unknown[];
if (developmentConfigurations.has(name)) {
assert.deepEqual(replacements[0], devtoolsReplacement, name);
} else {
assert.doesNotMatch(
JSON.stringify(replacements),
/store-devtools/,
name
);
}
}
});
// 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);
}
});
+38 -1
View File
@@ -371,7 +371,7 @@ test.describe('Electron Recently Viewed', () => {
}
});
test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', async ({
test('opens Xtream recent movies and series inline and preserves history through the movie portal handoff', async ({
dataDir,
request,
}) => {
@@ -405,6 +405,7 @@ test.describe('Electron Recently Viewed', () => {
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
const moviePath = new URL(app.mainWindow.url()).pathname;
await goBackFromDetail(app.mainWindow);
await app.mainWindow
@@ -435,6 +436,42 @@ test.describe('Electron Recently Viewed', () => {
playlistTitle: portalTitle,
});
const viewInPortal = app.mainWindow.getByRole('button', {
name: 'View in portal',
exact: true,
});
await viewInPortal.click();
await expect
.poll(() => new URL(app.mainWindow.url()).pathname)
.toBe(moviePath);
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(movieTitle);
await expect(viewInPortal).toHaveCount(0);
await startAndConfirmPlayback(app.mainWindow, () =>
playCurrentDetail(app.mainWindow)
);
await goBackFromDetail(app.mainWindow);
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: movieTitle,
playlistTitle: portalTitle,
});
await app.mainWindow.goForward();
await expect
.poll(() => new URL(app.mainWindow.url()).pathname)
.toBe(moviePath);
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(movieTitle);
await app.mainWindow.goBack();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: movieTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
@@ -638,6 +638,18 @@ test.describe('Electron Workspace Search', () => {
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
// The search page's Back is the header's leading button, not an
// arrow beside its title, and it returns to the dashboard.
const headerBack = app.mainWindow.getByTestId(
'workspace-header-back'
);
await expect(headerBack).toBeVisible();
await expect(
app.mainWindow.getByRole('button', { name: 'Back', exact: true })
).toHaveCount(1);
await headerBack.click();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
@@ -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
@@ -187,3 +193,195 @@ 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',
'Only macOS draws the native traffic lights over the rail'
);
test('@electron the first rail link starts with the content area, clear of the lights', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await expect(page.locator('.app-rail')).toHaveClass(/is-macos/);
const firstLink = page.locator('.app-rail a').first();
await expect(firstLink).toBeVisible();
// Aligned with the content area, where the dashboard hero starts.
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 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(),
zoomFactor(page),
]);
return (box?.y ?? Number.NaN) * zoom;
};
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
lights.y + lightsHeight + 16
);
// 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(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
@@ -107,9 +107,10 @@ test('@epg @xtream @electron removes uploaded guide data and restores provider E
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.not.toContain('Temporary XMLTV Bulletin');
// The programme on now: the ribbon renders the blocks around it.
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toContain(fixture.fullEpg[0].title);
.toContain(fixture.shortEpg[0].title);
} finally {
await closeElectronApp(app);
await source.close();
@@ -281,14 +282,28 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
app.mainWindow.locator('app-epg-timeline')
).toBeVisible({ timeout: 20000 });
// The timeline renders the full multi-day window as blocks,
// sorted by start time (no per-day filtering — it scrolls).
// The timeline lays out the full multi-day window sorted by start
// time (no per-day filtering — it scrolls) and renders the blocks
// near the visible range: an ordered run around the current
// programme, and every programme once the ribbon is swept.
const allTitles = [...fixture.fullEpg]
.sort((a, b) => a.startTimestamp - b.startTimestamp)
.map((listing) => listing.title);
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toEqual(allTitles);
.poll(async () => {
const titles = await timelineBlockTitles(app.mainWindow);
return (
titles.includes(currentProgram.title) &&
isContiguousRun(titles, allTitles)
);
})
.toBe(true);
expect(await sweepTimelineTitles(app.mainWindow)).toEqual(
new Set(allTitles)
);
await app.mainWindow
.locator('app-epg-timeline .epg-timeline__jump')
.click();
// The current programme is highlighted as the "now" block.
await expect(
@@ -866,6 +881,56 @@ async function timelineBlockTitles(
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
/**
* Scroll the ribbon from its start to its end in half-viewport steps and
* collect every programme title rendered on the way.
*/
async function sweepTimelineTitles(
page: Parameters<typeof channelItemByTitle>[0]
): Promise<Set<string>> {
const ribbon = page.locator('app-epg-timeline .epg-timeline__ribbon');
const seen = new Set<string>();
let left = 0;
for (;;) {
const { scrollLeft, maxLeft, step } = await ribbon.evaluate(
(element, target) => {
element.scrollLeft = target;
return {
scrollLeft: element.scrollLeft,
maxLeft: element.scrollWidth - element.clientWidth,
step: Math.max(1, element.clientWidth / 2),
};
},
left
);
// Two frames: the scroll's measure, then the re-rendered blocks.
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
);
(await timelineBlockTitles(page)).forEach((title) => seen.add(title));
if (scrollLeft >= maxLeft) {
return seen;
}
left = scrollLeft + step;
}
}
/** Whether `run` is a non-empty, in-order slice of `all`. */
function isContiguousRun(run: string[], all: string[]): boolean {
if (run.length === 0) {
return false;
}
for (let start = 0; start + run.length <= all.length; start++) {
if (run.every((title, index) => all[start + index] === title)) {
return true;
}
}
return false;
}
async function getProgressWidthPercent(
row: ReturnType<typeof channelItemByTitle>
) {
+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,198 @@
/**
* The Linux native-view support check end to end in the main process: the
* real IPC handlers, native service and login shell PATH lookup. Only the
* process boundary is faked: the shell (`readPath`), `mpv --version`
* (`spawnSync`) and Electron. `process.platform` is forced here, so the
* Linux branch runs on every host.
*/
import {
EMBEDDED_MPV_PREPARE,
EMBEDDED_MPV_SUPPORT,
type EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
const mockSpawnSync = jest.fn();
const mockIpcHandle = jest.fn();
jest.mock('child_process', () => ({ spawnSync: mockSpawnSync }));
jest.mock('electron', () => ({
app: {
isPackaged: true,
getAppPath: () => '/mock/app.asar',
commandLine: { getSwitchValue: () => '' },
},
ipcMain: { handle: mockIpcHandle },
powerSaveBlocker: {
start: jest.fn(),
stop: jest.fn(),
isStarted: jest.fn(),
},
screen: { getDisplayMatching: jest.fn() },
}));
jest.mock('../app', () => ({
__esModule: true,
default: { mainWindow: null },
}));
jest.mock('../services/embedded-mpv-session-options', () => ({
readEmbeddedMpvSessionOptions: () => ({
extraOptions: [],
autoReconnect: true,
}),
}));
jest.mock('../services/embedded-mpv-frame-copy-platform.util', () => ({
...jest.requireActual('../services/embedded-mpv-frame-copy-platform.util'),
getFrameCopyRuntimeAvailability: () => ({
usable: false,
reason: 'helper-probe-failed',
}),
isFrameCopyRuntimeUsable: () => false,
}));
const INHERITED_PATH = '/usr/bin:/bin';
const LOGIN_SHELL_ONLY_DIR = '/home/user/.local/bin';
const LOGIN_SHELL_PATH = `${LOGIN_SHELL_ONLY_DIR}:${INHERITED_PATH}`;
/** Budget of the lookup; the shell in these tests never answers within it. */
const LOOKUP_BUDGET_MS = 5;
type SupportHandler = (event: unknown) => Promise<EmbeddedMpvSupport>;
async function flushLookup(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
describe('Embedded MPV support and a slow login shell (Linux native-view)', () => {
const originalPlatform = process.platform;
const originalEnv = {
PATH: process.env.PATH,
DISPLAY: process.env.DISPLAY,
WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY,
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY:
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY,
};
let answerShell: (path: string) => void;
function handlerFor(channel: string): SupportHandler {
const registration = mockIpcHandle.mock.calls.find(
([registered]) => registered === channel
);
if (!registration) {
throw new Error(`Missing ipcMain handler for ${channel}`);
}
return registration[1] as SupportHandler;
}
beforeEach(async () => {
jest.resetModules();
mockIpcHandle.mockReset();
mockSpawnSync.mockReset();
// mpv is installed where only the login shell PATH reaches it.
mockSpawnSync.mockImplementation(() => ({
status: (process.env.PATH ?? '')
.split(':')
.includes(LOGIN_SHELL_ONLY_DIR)
? 0
: 1,
}));
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.PATH = INHERITED_PATH;
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
const { scheduleDeferredFixPath } =
await import('../startup/login-shell-path');
const { embeddedMpvNativeService } =
await import('../services/embedded-mpv-native.service');
await import('./embedded-mpv.events');
// The addon is normally a vendored .node file; with it in place,
// mpv on PATH is the only thing support depends on.
(
embeddedMpvNativeService as unknown as {
addon: { isSupported(): boolean };
}
).addon = { isSupported: () => true };
scheduleDeferredFixPath(
() =>
new Promise((resolve) => {
answerShell = resolve;
}),
LOOKUP_BUDGET_MS
);
// The lookup starts on the next tick; only then can it be answered.
await flushLookup();
});
afterEach(async () => {
// Let the lookup finish, so no test leaves a pending shell behind.
answerShell(INHERITED_PATH);
await flushLookup();
});
afterAll(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
'%s reports a missing mpv as inconclusive until the shell answers',
async (channel) => {
const check = handlerFor(channel);
// The lookup runs out of budget: this probe sees the inherited
// PATH, where mpv is missing.
await expect(check({})).resolves.toMatchObject({
supported: false,
inconclusive: true,
});
// Asked again meanwhile: the cached answer is still not final.
await expect(check({})).resolves.toMatchObject({
supported: false,
inconclusive: true,
});
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
answerShell(LOGIN_SHELL_PATH);
await flushLookup();
const settled = await check({});
expect(settled.supported).toBe(true);
expect(settled.inconclusive).toBeUndefined();
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
}
);
it('reports a missing mpv as final once the shell answered without it', async () => {
const support = handlerFor(EMBEDDED_MPV_SUPPORT);
await expect(support({})).resolves.toMatchObject({
supported: false,
inconclusive: true,
});
answerShell(INHERITED_PATH);
await flushLookup();
const settled = await support({});
expect(settled.supported).toBe(false);
expect(settled.reason).toContain('mpv executable');
expect(settled.inconclusive).toBeUndefined();
});
it('reports a missing mpv as final when the shell answered in time', async () => {
answerShell(INHERITED_PATH);
await flushLookup();
const answer = await handlerFor(EMBEDDED_MPV_SUPPORT)({});
expect(answer.supported).toBe(false);
expect(answer.inconclusive).toBeUndefined();
});
});
@@ -10,6 +10,7 @@ const mockEmbeddedMpvService = {
getSupport: jest.fn(),
willProbeLinuxMpvExecutable: jest.fn(() => false),
forgetLinuxMpvExecutableProbe: jest.fn(),
markLinuxMpvExecutableProbeProvisional: jest.fn(),
setPaused: jest.fn(),
};
const mockSessionOptions = {
@@ -26,9 +27,7 @@ jest.mock('../services/embedded-mpv-session-options', () => ({
}));
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
let settleLookup: () => void = () => undefined;
const mockLookupSettled = new Promise<void>((resolve) => {
settleLookup = resolve;
});
let mockLookupSettled = Promise.resolve();
jest.mock('../startup/login-shell-path', () => ({
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
whenLoginShellPathSettled: () => mockLookupSettled,
@@ -69,6 +68,16 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
});
describe('support checks and the login shell PATH', () => {
beforeEach(() => {
// A lookup of its own per test: the pending re-probe of one test
// must not answer for the next.
mockLookupSettled = new Promise<void>((resolve) => {
settleLookup = resolve;
});
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe.mockClear();
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional.mockClear();
});
afterEach(() => {
mockWaitForLoginShellPath.mockClear();
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
@@ -110,6 +119,10 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).not.toHaveBeenCalled();
// The probe saw the login shell PATH: its answer is final.
expect(
mockEmbeddedMpvService.markLinuxMpvExecutableProbeProvisional
).not.toHaveBeenCalled();
}
);
@@ -125,6 +138,17 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
await expect(
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
).resolves.toEqual({ supported: false });
// The service is told before it probes, so the answer of this
// very check is already marked as not final.
const { markLinuxMpvExecutableProbeProvisional, getSupport } =
mockEmbeddedMpvService;
expect(
markLinuxMpvExecutableProbeProvisional
).toHaveBeenCalledTimes(1);
expect(
markLinuxMpvExecutableProbeProvisional.mock
.invocationCallOrder[0]
).toBeLessThan(getSupport.mock.invocationCallOrder[0]);
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).not.toHaveBeenCalled();
@@ -136,6 +160,33 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
).toHaveBeenCalledTimes(1);
});
it('still re-probes when the check on the inherited PATH throws', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation();
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
true
);
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
mockEmbeddedMpvService.getSupport.mockImplementation(() => {
throw new Error('probe failed');
});
try {
await expect(
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
).rejects.toThrow('probe failed');
// Otherwise the provisional state would outlive the lookup.
settleLookup();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
).toHaveBeenCalledTimes(1);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('does not wait when no probe runs, nor for session calls', async () => {
mockEmbeddedMpvService.getSupport.mockReturnValue({
supported: true,
@@ -80,13 +80,14 @@ async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
) {
return check();
}
// The lookup ran out of budget, so this probe sees the inherited PATH.
// Once the shell does answer, the result is probed again.
const result = check();
// The lookup ran out of budget, so this probe sees the inherited PATH: a
// missing mpv is answered as inconclusive, never as a verdict the
// renderer may persist. Once the shell does answer, it is probed again.
getService().markLinuxMpvExecutableProbeProvisional();
void whenLoginShellPathSettled().then(() =>
getService().forgetLinuxMpvExecutableProbe()
);
return result;
return check();
}
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
@@ -414,6 +414,56 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
});
it('reports a missing mpv as inconclusive only while its probe is provisional', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 1 });
mockRuntimeUsable();
// The login shell has not answered: mpv is looked up on the
// inherited PATH.
service.markLinuxMpvExecutableProbeProvisional();
expect(service.getSupport()).toEqual(
expect.objectContaining({
supported: false,
inconclusive: true,
})
);
expect(service.prepareAddon()).toEqual(
expect.objectContaining({
supported: false,
inconclusive: true,
})
);
// It answered: the next probe is a verdict again.
service.forgetLinuxMpvExecutableProbe();
const settled = service.getSupport();
expect(settled.supported).toBe(false);
expect(settled.reason).toContain('mpv executable');
expect(settled.inconclusive).toBeUndefined();
});
it('keeps every other answer final while the mpv probe is provisional', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 0 });
mockRuntimeUsable();
service.markLinuxMpvExecutableProbeProvisional();
const found = service.getSupport();
expect(found.supported).toBe(true);
expect(found.inconclusive).toBeUndefined();
// mpv is there, the addon is not: the PATH cannot change that.
addon.isSupported.mockReturnValue(false);
const unsupported = service.getSupport();
expect(unsupported.supported).toBe(false);
expect(unsupported.inconclusive).toBeUndefined();
});
it('predicts no probe for the frame-copy engine or native Wayland', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
@@ -174,6 +174,12 @@ export class EmbeddedMpvNativeService {
private powerBlockerId: number | null = null;
private readonly loadAddonModule = createRequire(__filename);
private cachedLinuxMpvExecutableReason: string | null | undefined;
/**
* True while `mpv --version` runs, or was cached, on the inherited PATH
* because the login shell had not answered: a missing mpv is then no
* verdict yet.
*/
private linuxMpvExecutableProbeIsProvisional = false;
private frameCopyAdapter: EmbeddedMpvFrameCopyAdapter | null = null;
private sessionOptionsDirectory: string | null = null;
/**
@@ -401,6 +407,16 @@ export class EmbeddedMpvNativeService {
*/
forgetLinuxMpvExecutableProbe(): void {
this.cachedLinuxMpvExecutableReason = undefined;
this.linuxMpvExecutableProbeIsProvisional = false;
}
/**
* Declares that the probe sees the inherited PATH, the login shell one
* not having arrived. Until `forgetLinuxMpvExecutableProbe()`, a missing
* mpv is reported as `inconclusive`, so no caller settles on it.
*/
markLinuxMpvExecutableProbeProvisional(): void {
this.linuxMpvExecutableProbeIsProvisional = true;
}
getSupport(): EmbeddedMpvSupport {
@@ -458,6 +474,9 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
...(this.linuxMpvExecutableProbeIsProvisional
? { inconclusive: true }
: {}),
...this.getFrameCopySupportDetails(),
};
}
@@ -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);
}
+8 -4
View File
@@ -406,17 +406,21 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
)
)
.toBe(0.25);
// M3U has no browse Back target, so the header shows no arrow in either
// state; the now-playing bar's own Close button returns to browse.
// M3U registers no Back target in either state: the header's only arrow
// is the history fallback to the dashboard the import started from,
// which claims no Escape. The now-playing bar's own Close button
// returns to browse.
const shell = detail(page).locator('app-portal-detail-shell');
const headerBack = page.locator('[data-test-id="workspace-header-back"]');
await expect(headerBack).toHaveCount(0);
await expect(headerBack).toHaveCount(1);
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
await shell
.locator('app-portal-inline-player')
.getByRole('button', { name: 'Close player', exact: true })
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(headerBack).toHaveCount(0);
await expect(headerBack).toHaveCount(1);
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
// The hero keeps its own inset (32px, or 20px in a pane narrower than
// 760px) in both states.
expect(
+30 -12
View File
@@ -21,8 +21,9 @@ import {
* by default so the content keeps the full viewport width, opened from
* the header toggle (winning over the persisted desktop inline width),
* and closed again by picking a category or tapping the backdrop.
* 4. The settings section list scrolls instead of painting over the
* Back footer — now inside the open drawer.
* 4. Settings keeps its drawer toggle beside the header Back (the drawer
* holds the sections), and the section list scrolls inside the drawer.
* On a portal list, the header's history Back yields to the toggle.
* 5. On a 640x360 landscape phone the live route keeps the channel
* sidebar at least 72px tall and the player container inside the
* viewport.
@@ -107,17 +108,25 @@ test.describe('portrait phone 375x812', () => {
await expectRailLinksInsideTopBar(page);
});
test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({
test('@mobile settings keeps the drawer toggle beside the header Back and the section list inside the drawer', async ({
page,
}) => {
await page.goto('/workspace/settings');
// Back is the header's leading button. The drawer holds the section
// list, so its toggle stays beside Back instead of giving way.
const back = page.locator('[data-test-id="workspace-header-back"]');
const toggle = page.locator('[data-test-id="context-drawer-toggle"]');
await expect(back).toBeVisible();
await expect(toggle).toBeVisible();
expect((await boxOf(back)).x).toBeLessThan((await boxOf(toggle)).x);
// The phone context panel is an off-canvas drawer: hidden until the
// header toggle opens it, so the settings content owns the pane.
const panel = page.locator('.context-panel--settings');
await expect(panel).toBeHidden();
await page.locator('[data-test-id="context-drawer-toggle"]').click();
await toggle.click();
await expect(panel).toBeVisible();
// Narrower than the viewport so the backdrop stays tappable, and
@@ -126,15 +135,13 @@ test.describe('portrait phone 375x812', () => {
expect(panelBox.width).toBeGreaterThanOrEqual(300);
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
const footer = panel.locator('.settings-panel-footer');
await expect(footer.locator('.settings-back-button')).toBeVisible();
// Before #1326 the section list kept its full content height and
// painted over the footer whenever the panel was shorter than its
// sections; now the list scrolls and ends above the footer.
// No footer Back any more; the list scrolls and ends inside the
// panel instead of painting past it (#1326).
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
const listBox = await boxOf(panel.locator('.settings-sections-list'));
const footerBox = await boxOf(footer);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
panelBox.y + panelBox.height + 1
);
// Tapping the backdrop (right of the drawer) closes it.
await page
@@ -163,7 +170,18 @@ test.describe('xtream portal routes on a phone', () => {
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
page,
}) => {
// The import navigated here from the dashboard, so the header offers
// the history Back on wide screens...
const back = page.locator('[data-test-id="workspace-header-back"]');
await expect(back).toBeVisible();
await page.setViewportSize(PHONE);
// ...and on a phone it yields to the drawer toggle, the list's only
// way into its categories.
await expect(back).toBeHidden();
await expect(
page.locator('[data-test-id="context-drawer-toggle"]')
).toBeVisible();
// Hidden by default — the content owns the full pane. This is the
// successor to the #1326 stacked layout, which left the content
+30 -2
View File
@@ -7,7 +7,12 @@ async function openSettings(page: Page) {
// The bare settings URL redirects to the default section page.
await page.waitForURL(/\/workspace\/settings\/general$/);
await expect(page.locator('.settings-container')).toBeVisible();
await expect(page.locator('.settings-back-button')).toBeVisible();
await expect(settingsBack(page)).toBeVisible();
}
/** Settings' Back is the workspace header's leading button. */
function settingsBack(page: Page) {
return page.locator('[data-test-id="workspace-header-back"]');
}
/** Settings render one section page at a time — open it via the rail. */
@@ -36,7 +41,30 @@ test.describe('Settings', () => {
test('@settings @web Check settings page', async ({ page }) => {
await openSettings(page);
await page.locator('.settings-back-button').click();
// The context panel no longer carries a Back of its own.
await expect(
page.locator('app-workspace-settings-context-panel button')
).toHaveCount(0);
await settingsBack(page).click();
await page.waitForURL(/\/workspace\/dashboard$/);
});
test('@settings @web settings opening the session lead to the dashboard', async ({
page,
}) => {
// The only entry of a new tab's history, as in Electron after a
// deep link: browser Back has nowhere to go.
const firstEntryPage = await page.context().newPage();
await firstEntryPage.goto('/workspace/settings/general');
// A cold start in a new tab passes the startup splash first.
await expect(firstEntryPage.locator('.settings-container')).toBeVisible(
{ timeout: 15_000 }
);
await settingsBack(firstEntryPage).click();
await firstEntryPage.waitForURL(/\/workspace\/dashboard$/);
// The dashboard replaced the settings entry: no Back leads to it.
await expect(settingsBack(firstEntryPage)).toHaveCount(0);
});
test('@settings @web Change video player', async ({ page }) => {
@@ -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
@@ -0,0 +1,132 @@
import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
addXtreamPortal,
interceptXtreamRequests,
MOCK_SERVER,
} from './xtream-series-playback.fixture';
/**
* Pages reached from a detail or the header search draw no Back arrow of
* their own: they register the workspace header's leading Back. It goes back
* in history when the page was reached in the app; a page that opened the
* session (a deep link, reload or restored view) leads to its parent
* instead, replacing its own history entry.
* Contract: docs/architecture/workspace-shell.md, "Header Back".
*/
const headerBack = (page: Page) =>
page.locator(
'app-workspace-shell-header [data-test-id="workspace-header-back"]'
);
/** The one Back button on the page, wherever it lives. */
const anyBack = (page: Page) =>
page.getByRole('button', { name: 'Back', exact: true });
async function expectOnlyHeaderBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(anyBack(page)).toHaveCount(1);
// None of these pages handles Escape.
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
/** The portal's own URL, from the VOD list that adding it opens. */
function portalUrlOf(page: Page): string {
return page.url().replace(/\/vod.*$/, '');
}
/**
* Opens `url` as the only entry of a new tab's history, where browser Back
* has nowhere to go, as in Electron after a deep link or restored view. A
* `page.goto` in the same tab would leave the previous document behind,
* often at the very URL of the parent.
*/
async function openAsFirstEntry(page: Page, url: string): Promise<Page> {
const firstEntryPage = await page.context().newPage();
await interceptXtreamRequests(firstEntryPage);
await firstEntryPage.goto(url);
return firstEntryPage;
}
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await interceptXtreamRequests(page);
await addXtreamPortal(page);
});
test('@web @xtream the in-portal search page reached in the app goes back in history', async ({
page,
}) => {
await page
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard$/);
// The rail link's tooltip would otherwise sit over the header's leading
// button for as long as the pointer rests on the link.
await page.mouse.move(640, 400);
// Enter on the dashboard opens the active portal's search page.
const search = page.locator(
'app-workspace-shell-header .search-field input[type="search"]'
);
await search.fill('Movie');
await search.press('Enter');
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/search\?q=Movie$/);
await expect(page.locator('app-search-layout')).toBeVisible();
await expectOnlyHeaderBack(page);
await headerBack(page).click();
await page.waitForURL(/\/workspace\/dashboard$/);
});
for (const { name, path, selector, parent } of [
{
name: 'movie Discover',
path: 'discover?type=movie&genre=18&genreLabel=Drama',
selector: 'app-discover-view',
parent: 'vod',
},
{
name: 'series Discover',
path: 'discover?type=tv&genre=18&genreLabel=Drama',
selector: 'app-discover-view',
parent: 'series',
},
// The portal root: its route config redirects to the default section.
{
name: 'actor',
path: 'actor/287',
selector: 'app-actor-view',
parent: 'vod',
},
{
name: 'search',
path: 'search?q=Movie',
selector: 'app-search-layout',
parent: 'vod',
},
]) {
test(`@web @xtream the ${name} page opening the session leads to its parent`, async ({
page,
}) => {
const portalUrl = portalUrlOf(page);
const firstEntryPage = await openAsFirstEntry(
page,
`${portalUrl}/${path}`
);
// A cold start in a new tab passes the startup splash first.
await expect(firstEntryPage.locator(selector)).toBeAttached({
timeout: 15_000,
});
await expectOnlyHeaderBack(firstEntryPage);
await headerBack(firstEntryPage).click();
await firstEntryPage.waitForURL(`${portalUrl}/${parent}`);
// The parent replaced the page's entry, so nothing precedes it and
// the list shows no Back (the history fallback would otherwise).
await expect(headerBack(firstEntryPage)).toHaveCount(0);
});
}
+56 -2
View File
@@ -120,7 +120,13 @@
"serviceWorker": false,
"optimization": false,
"extractLicenses": false,
"sourceMap": true
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/store-devtools.providers.ts",
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
}
]
},
"electron-performance": {
"baseHref": "./",
@@ -143,12 +149,60 @@
}
]
},
"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
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/store-devtools.providers.ts",
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
}
]
},
"electron-e2e-zoneless": {
"baseHref": "./",
"serviceWorker": false,
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/store-devtools.providers.ts",
"with": "apps/web/src/environments/store-devtools.providers.dev.ts"
},
{
"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,
+5 -10
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';
@@ -21,7 +16,6 @@ import { provideServiceWorker } from '@angular/service-worker';
import { provideEffects } from '@ngrx/effects';
import { provideRouterStore, routerReducer } from '@ngrx/router-store';
import { provideStore } from '@ngrx/store';
import { provideStoreDevtools } from '@ngrx/store-devtools';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { PlaylistEffects, playlistReducer } from '@iptvnator/m3u-state';
@@ -38,7 +32,8 @@ import {
DataService,
} from '@iptvnator/services';
import { dbConfig } from '@iptvnator/shared/interfaces';
import { AppConfig } from '../environments/environment';
import { changeDetectionProviders } from '../environments/change-detection.providers';
import { storeDevtoolsProviders } from '../environments/store-devtools.providers';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { ExternalPlaybackService } from './services/external-playback.service';
@@ -110,7 +105,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
@@ -126,7 +121,7 @@ export const appConfig: ApplicationConfig = {
}),
provideEffects([PlaylistEffects]),
provideRouterStore(),
...(AppConfig.production ? [] : [provideStoreDevtools({ maxAge: 25 })]),
...storeDevtoolsProviders,
provideServiceWorker('ngsw-worker.js', {
enabled: shouldEnableServiceWorker(),
registrationStrategy: 'registerWhenStable:30000',
@@ -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,
@@ -0,0 +1,89 @@
import { TestBed } from '@angular/core/testing';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
const SUPPORTED: EmbeddedMpvSupport = { supported: true, platform: 'linux' };
/** A slow login shell: mpv was looked up before its PATH arrived. */
const INCONCLUSIVE: EmbeddedMpvSupport = {
supported: false,
platform: 'linux',
reason: 'mpv executable missing',
inconclusive: true,
};
describe('SettingsEmbeddedMpvFacade', () => {
const originalElectron = window.electron;
let getEmbeddedMpvSupport: jest.Mock;
let facade: SettingsEmbeddedMpvFacade;
beforeEach(() => {
jest.useFakeTimers();
getEmbeddedMpvSupport = jest.fn();
window.electron = {
platform: 'linux',
getEmbeddedMpvSupport,
} as unknown as typeof window.electron;
TestBed.configureTestingModule({
providers: [
SettingsEmbeddedMpvFacade,
{
provide: RuntimeCapabilitiesService,
useValue: { isElectron: true },
},
],
});
facade = TestBed.inject(SettingsEmbeddedMpvFacade);
});
afterEach(() => {
TestBed.resetTestingModule();
window.electron = originalElectron;
jest.useRealTimers();
});
it('offers Embedded MPV once an inconclusive answer turns into supported', async () => {
getEmbeddedMpvSupport
.mockResolvedValueOnce(INCONCLUSIVE)
.mockResolvedValue(SUPPORTED);
await facade.load();
expect(facade.supported()).toBe(false);
// The page stays open; nobody calls load() again.
await jest.advanceTimersByTimeAsync(EMBEDDED_MPV_SUPPORT_RECHECK_MS);
expect(facade.supported()).toBe(true);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('stops asking once the settings page is closed', async () => {
getEmbeddedMpvSupport.mockResolvedValue(INCONCLUSIVE);
await facade.load();
TestBed.resetTestingModule();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
it('reports a failed probe as unsupported', async () => {
getEmbeddedMpvSupport.mockRejectedValue(new Error('addon load failed'));
await facade.load();
expect(facade.support()).toEqual({
supported: false,
platform: 'linux',
reason: 'addon load failed',
});
});
});
@@ -1,6 +1,15 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import {
computed,
DestroyRef,
inject,
Injectable,
signal,
} from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
import {
EmbeddedMpvSupport,
watchEmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
/**
* Probes the desktop backend for embedded MPV support so the playback
@@ -9,6 +18,7 @@ import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
@Injectable()
export class SettingsEmbeddedMpvFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
private stopSupportWatch: (() => void) | undefined;
readonly support = signal<EmbeddedMpvSupport | null>(null);
@@ -24,6 +34,14 @@ export class SettingsEmbeddedMpvFacade {
() => this.support()?.engine === 'frame-copy'
);
constructor() {
inject(DestroyRef).onDestroy(() => this.stopSupportWatch?.());
}
/**
* Resolves with the first answer. An inconclusive one keeps being asked
* for while the page is open, so the option appears without reopening it.
*/
async load(): Promise<void> {
if (!this.runtime.isElectron) {
this.support.set({
@@ -43,15 +61,27 @@ export class SettingsEmbeddedMpvFacade {
return;
}
try {
this.support.set(await window.electron.getEmbeddedMpvSupport());
} catch (error) {
this.support.set({
supported: false,
platform: window.electron.platform,
reason: error instanceof Error ? error.message : String(error),
});
}
this.stopSupportWatch?.();
await new Promise<void>((answered) => {
this.stopSupportWatch = watchEmbeddedMpvSupport(
() => window.electron.getEmbeddedMpvSupport(),
(support) => {
this.support.set(support);
answered();
},
(error) => {
this.support.set({
supported: false,
platform: window.electron.platform,
reason:
error instanceof Error
? error.message
: String(error),
});
answered();
}
);
});
}
/**
@@ -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 {
@@ -66,7 +66,9 @@ export class SettingsSearchFacade {
});
constructor() {
void this.settingsSearch.ensureEmbeddedMpvSupportLoaded();
inject(DestroyRef).onDestroy(
this.settingsSearch.followEmbeddedMpvSupport()
);
effect(() => {
if (!this.isSearching()) {
@@ -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
@@ -95,6 +95,21 @@ describe('SettingsComponent search', () => {
expect(query('app-settings-general-section')).not.toBeNull();
});
it('follows Embedded MPV support only while the page is open', () => {
const stopFollowing = jest.fn();
const follow = jest
.spyOn(settingsSearch, 'followEmbeddedMpvSupport')
.mockReturnValue(stopFollowing);
const page = TestBed.createComponent(SettingsComponent);
expect(follow).toHaveBeenCalledTimes(1);
expect(stopFollowing).not.toHaveBeenCalled();
// Closing the page ends it: nothing shows these rows any more.
page.destroy();
expect(stopFollowing).toHaveBeenCalledTimes(1);
});
it('shows an empty state when nothing matches', () => {
setSettingsSearchQuery('zzzz-no-such-setting');
fixture.detectChanges();
@@ -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(),
];
@@ -0,0 +1,8 @@
import { EnvironmentProviders } from '@angular/core';
import { provideStoreDevtools } from '@ngrx/store-devtools';
// Swapped in for store-devtools.providers.ts by the development,
// electron-e2e and electron-e2e-zoneless build configurations only.
export const storeDevtoolsProviders: EnvironmentProviders[] = [
provideStoreDevtools({ maxAge: 25 }),
];
@@ -0,0 +1,7 @@
import { EnvironmentProviders } from '@angular/core';
// Every build ships without the NgRx store devtools. The development and
// electron-e2e configurations replace this file with
// store-devtools.providers.dev.ts, so @ngrx/store-devtools never enters the
// production, PWA or performance bundles.
export const storeDevtoolsProviders: EnvironmentProviders[] = [];
+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);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff. Show more