Merge remote-tracking branch 'origin/master' into claude/parental-control-feature-31dde2

# Conflicts:
#	apps/electron-backend/src/main.ts
This commit is contained in:
4gray committed 2026-09-26 23:04:22 +02:00
commit a030e1af2f
63 files changed
+4917 -262

No files matched your search

@@ -0,0 +1,8 @@
---
type: perf
area: electron
---
The desktop app now opens its window before it prepares the portal, program
guide, download, player and update machinery, and does that preparation while
the window is already loading, so the first screen appears sooner.
@@ -0,0 +1,9 @@
---
type: perf
area: electron
---
The desktop app now keeps a compiled copy of its startup code next to its
user data, so every launch after the first skips part of the JavaScript
compilation and reaches the window a little sooner. Set
`IPTVNATOR_DISABLE_COMPILE_CACHE=1` to turn this off.
@@ -0,0 +1,6 @@
---
type: fix
area: xtream
---
Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically.
+6 -5
View File
@@ -73,11 +73,12 @@ the complete 27-asset set documented in `docs/architecture/release-pipeline.md`.
It is read-only, and fails on an already-published release. Still review the
authored text and generated commits by eye.
After verification, manually publish the GitHub release. That publication
automatically verifies its Snap assets and uploads them to `edge`.
Installed-Snap smoke and candidate/stable promotion remain manual. Keep the
blog draft during artifact verification; publish it in a follow-up commit and
verify the website deployment.
Manually publish the release; this verifies and uploads Snaps
to `edge`. Installed-Snap smoke and candidate/stable promotion stay manual.
After public-asset verification, publish the draft blog and update
`apps/website/released-version.json` to the published version together.
Follow the release pipeline's offline-download checks and verify deployment;
never use the development/nightly version for this pin.
## Failure Safety
+6 -5
View File
@@ -73,11 +73,12 @@ the complete 27-asset set documented in `docs/architecture/release-pipeline.md`.
It is read-only, and fails on an already-published release. Still review the
authored text and generated commits by eye.
After verification, manually publish the GitHub release. That publication
automatically verifies its Snap assets and uploads them to `edge`.
Installed-Snap smoke and candidate/stable promotion remain manual. Keep the
blog draft during artifact verification; publish it in a follow-up commit and
verify the website deployment.
Manually publish the release; this verifies and uploads Snaps
to `edge`. Installed-Snap smoke and candidate/stable promotion stay manual.
After public-asset verification, publish the draft blog and update
`apps/website/released-version.json` to the published version together.
Follow the release pipeline's offline-download checks and verify deployment;
never use the development/nightly version for this pin.
## Failure Safety
+71 -12
View File
@@ -37,11 +37,13 @@ permissions:
jobs:
electron-e2e-tests:
name: Electron E2E on ${{ matrix.os }}
name: Electron E2E on ${{ matrix.os }} (${{ matrix.shard }}/${{ matrix.shard-total }})
runs-on: ${{ matrix.os }}
# macOS's sequential Electron suite exceeded 45m while still passing
# tests; retain the full suite with room for setup and retries.
timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 45 }}
# The sequential Electron suite is split into Playwright shards (one
# runner each, split by spec file). Measured on 2026-09-26, a third of
# the suite is 6-12 minutes of test time on top of 3-7 minutes of
# setup, so 30 minutes leaves room for retries on every OS.
timeout-minutes: 30
env:
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1'
NX_SKIP_NX_CACHE: true
@@ -49,6 +51,8 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
shard: [1, 2, 3]
shard-total: [3]
steps:
- uses: actions/checkout@v7
@@ -68,7 +72,10 @@ jobs:
- name: Build Backend
run: pnpm nx build electron-backend
# Process-lifecycle checks are independent of the shard split;
# run them once per OS.
- name: Verify Electron process cleanup
if: matrix.shard == 1
run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts
- name: Install Playwright Browsers
@@ -76,29 +83,81 @@ jobs:
- name: Run Electron E2E Tests (Linux)
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }}
env:
CI: true
- name: Run Electron E2E Tests (Windows/Mac)
if: runner.os != 'Linux'
run: pnpm nx run electron-backend-e2e:e2e
run: pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }}
env:
CI: true
- name: Summarize Electron E2E semantic coverage
if: always()
run: pnpm run coverage:e2e:summary -- --project=electron-backend-e2e
# Each shard's results.json carries config.shard; the summary job
# below merges the shards of one OS into a single semantic summary.
- name: Upload Electron Test Results
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-electron-${{ matrix.os }}
name: playwright-report-electron-${{ matrix.os }}-${{ matrix.shard }}
path: |
dist/playwright-report/electron-backend-e2e/
dist/test-results/electron-backend-e2e/
coverage/e2e/
retention-days: 7
electron-e2e-summary:
name: Electron E2E summary
runs-on: ubuntu-latest
needs: electron-e2e-tests
# Summarize failed shards too, but not a cancelled (superseded) run.
if: ${{ !cancelled() }}
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
# One download per OS keeps each OS's shards in their own directory
# (the artifact paths inside the shards are identical).
- name: Download shard reports (Ubuntu)
uses: actions/download-artifact@v8
with:
pattern: playwright-report-electron-ubuntu-latest-*
path: dist/e2e-shards/ubuntu-latest
- name: Download shard reports (macOS)
uses: actions/download-artifact@v8
with:
pattern: playwright-report-electron-macos-latest-*
path: dist/e2e-shards/macos-latest
- name: Download shard reports (Windows)
uses: actions/download-artifact@v8
with:
pattern: playwright-report-electron-windows-latest-*
path: dist/e2e-shards/windows-latest
# The script fails when a shard's results.json is missing or
# duplicated, so a partial run is never summarized as complete.
- name: Summarize Electron E2E semantic coverage per OS
run: |
status=0
for os in ubuntu-latest macos-latest windows-latest; do
echo "## Electron E2E on $os" >> "$GITHUB_STEP_SUMMARY"
node tools/coverage/e2e-semantic-summary.mjs --project=electron-backend-e2e --input="dist/e2e-shards/$os" --output-dir="coverage/e2e/$os" || status=1
done
exit "$status"
- name: Upload Electron semantic summaries
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-semantic-summary-electron
path: coverage/e2e/
retention-days: 7
web-e2e-tests:
+6 -2
View File
@@ -11,7 +11,7 @@ this marker — see `.changes/README.md`.
<!-- next-release -->
# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-21)
# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-24)
![IPTVnator 0.24.0 — programme guide, fullscreen browsing and stream info](https://raw.githubusercontent.com/4gray/iptvnator/v0.24.0/apps/website/public/blog/v0-24/announce.png)
@@ -40,7 +40,11 @@ Before updating, back up your playlists and important data. Desktop startup appl
## Thanks
Thank you to [@Bpl5966](https://github.com/Bpl5966) for MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)). A warm thank-you to everyone who reported issues, tested builds, helped in the [Telegram community](https://t.me/iptvnator), or supported development. 💙
Thank you to [@Bpl5966](https://github.com/Bpl5966) for MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)).
Thank you also to [@thejdubb02](https://github.com/thejdubb02) for the original zoom-persistence, Turkish-search and Xtream EPG-refresh fixes ([#1613](https://github.com/4gray/iptvnator/pull/1613), [#1612](https://github.com/4gray/iptvnator/pull/1612), [#1610](https://github.com/4gray/iptvnator/pull/1610)). This work was incorporated and extended in [#1617](https://github.com/4gray/iptvnator/pull/1617), [#1640](https://github.com/4gray/iptvnator/pull/1640) and [#1647](https://github.com/4gray/iptvnator/pull/1647), with his co-authorship preserved.
A warm thank-you to everyone who reported issues, tested builds, helped in the [Telegram community](https://t.me/iptvnator), or supported development. 💙
If IPTVnator is useful to you, you can help keep it moving through [GitHub Sponsors](https://github.com/sponsors/4gray) or [Ko-fi](https://ko-fi.com/4gray).
+11
View File
@@ -387,6 +387,17 @@ $ pnpm run perf:initial-bytes
The contract behind that number is in
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
To benchmark the "launch to usable" journey (fresh Electron process on a
seeded profile, exact renderer counters plus wall-clock), run:
```
$ pnpm run perf:journeys
```
The journeys, their counters and the summary written under
`dist/performance/journeys/` are described in
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
## Disclaimer
**IPTVnator doesn't provide any playlists or other digital content.**
@@ -0,0 +1,37 @@
import { workspaceRoot } from '@nx/devkit';
import { defineConfig } from '@playwright/test';
/**
* Performance journeys (docs/architecture/performance-journeys.md). One
* worker, no retries: every journey spawns its own Electron processes and
* writes one summary per run. The Xtream mock serves both the M3U playlist
* and the portal on a dedicated loopback port so a normal E2E server on
* 3211 cannot be reused by accident. Locally a server left behind by an
* earlier run on that port is reused (its fixtures are deterministic); CI
* always starts its own.
*/
const xtreamMockPort =
process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231';
export default defineConfig({
fullyParallel: false,
reporter: [['list']],
retries: 0,
testDir: './src/journeys',
testMatch: '**/*.journey.ts',
timeout: 30 * 60 * 1_000,
use: {
testIdAttribute: 'data-test-id',
},
webServer: {
command: 'pnpm nx run xtream-mock-server:serve',
cwd: workspaceRoot,
env: {
HOST: '127.0.0.1',
PORT: xtreamMockPort,
},
reuseExistingServer: !process.env['CI'],
url: `http://127.0.0.1:${xtreamMockPort}/health`,
},
workers: 1,
});
+10
View File
@@ -49,6 +49,16 @@
"command": "pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts"
}
},
"journeys": {
"dependsOn": ["electron-backend:build-performance"],
"executor": "nx:run-commands",
"cache": false,
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.journeys.config.ts"
}
},
"packaged-frame-copy-smoke": {
"dependsOn": ["test-packaged-frame-copy-fixtures"],
"executor": "nx:run-commands",
@@ -1,4 +1,6 @@
import { Locator, Page } from '@playwright/test';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import { ok as assert } from 'node:assert';
import {
addXtreamPortal,
closeElectronApp,
@@ -14,8 +16,96 @@ import {
waitForSourceRowIdle,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { applyTheme } from './theme-contrast';
test.describe('Electron Xtream Category Management', () => {
test('keeps the selected category in view with 800 categories, 600 hidden and A-Z sorting', async ({
dataDir,
request,
}) => {
test.slow();
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
username: 'category-scroll',
password: 'category-scroll',
});
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForXtreamWorkspaceReady(app.mainWindow);
const panel = app.mainWindow.locator('app-workspace-context-panel');
const rows = panel.locator('.category-item');
await expect(rows).toHaveCount(800);
const dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.getByRole('button', { name: 'Deselect All', exact: true })
.click();
await dialog.locator('input[type="search"]').fill('Visible');
await dialog
.getByRole('button', { name: 'Select Filtered', exact: true })
.click();
await expect(dialog.locator('.selection-info')).toHaveText(
'Total selected: 200 / 800'
);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await expect(dialog).toBeHidden();
await expect(rows).toHaveCount(200);
await panel
.getByRole('button', { name: 'Sort categories', exact: true })
.click();
await app.mainWindow
.getByRole('menuitem', { name: 'Name A-Z' })
.click();
const categories = await app.mainWindow.evaluate(async () => {
const playlistId = location.pathname.match(
/\/workspace\/xtreams\/([^/]+)/
)?.[1];
if (!playlistId)
throw new Error('Xtream playlist route is missing');
const api = (
window as unknown as { electron: ElectronBridgeApi }
).electron;
return api.dbGetCategories(playlistId, 'live');
});
categories.sort((left, right) =>
left.name.localeCompare(right.name)
);
await expect(rows.locator('.nav-item-label')).toHaveText(
categories.map((category) => category.name)
);
// Exercise collisions above and below the clicked row. Reading the
// imported IDs avoids relying on SQLite allocation/import order.
for (const [theme, direction] of [
['dark', -1],
['light', 1],
] as const) {
await applyTheme(app.mainWindow, theme);
const category = categories.find((candidate, index) => {
const wrongIndex = categories.findIndex(
(other) => other.xtream_id === candidate.id
);
return (
wrongIndex >= 0 && (wrongIndex - index) * direction > 15
);
});
assert(
category,
`Missing fixture collision in direction ${direction}`
);
const row = rows.filter({ hasText: category.name });
await row.click();
await expect(row).toHaveAttribute('aria-current', 'true');
await expectCategoryCentered(row);
}
} finally {
await closeElectronApp(app);
}
});
for (const section of ['Live TV', 'Movies', 'Series']) {
test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({
dataDir,
@@ -407,6 +497,44 @@ async function openManageCategoriesDialog(page: Page) {
return dialog;
}
/** Wait for the real smooth scroll to finish with the selected row centered. */
async function expectCategoryCentered(row: Locator): Promise<void> {
let previousTop = -1;
let stableSamples = 0;
await expect
.poll(
async () => {
const position = await row.evaluate((element) => {
const container = element.closest(
'app-workspace-context-category-view'
) as HTMLElement;
const bounds = container.getBoundingClientRect();
const rowBounds = element.getBoundingClientRect();
const target =
container.scrollTop +
rowBounds.top -
bounds.top -
container.clientHeight / 2 +
rowBounds.height / 2;
const clamped = Math.min(
container.scrollHeight - container.clientHeight,
Math.max(0, target)
);
return {
top: container.scrollTop,
centered: Math.abs(container.scrollTop - clamped) < 2,
};
});
stableSamples =
position.top === previousTop ? stableSamples + 1 : 0;
previousTop = position.top;
return position.centered && stableSamples >= 3;
},
{ intervals: [100] }
)
.toBe(true);
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
@@ -30,6 +30,7 @@ import {
import {
captureElectronProcess,
closeElectronApplicationAndConfirmExit,
type ElectronExitConfirmationOptions,
prepareElectronApplication,
} from './electron-process-lifecycle';
@@ -200,7 +201,7 @@ export { expect };
* helper that spawns the app itself has to use the same list. `appArgs` land
* after the entry point, which is where the OS puts an opened file's path.
*/
function buildElectronLaunchArgs(
export function buildElectronLaunchArgs(
extraArgs: readonly string[] = [],
appArgs: readonly string[] = [],
entryPoint = electronMainPath
@@ -577,10 +578,18 @@ export async function closeElectronApp(
export async function closeElectronAppAndConfirmExit(
app: LaunchedElectronApp
): Promise<void> {
await closeElectronApplicationAndConfirmExit(app.electronApp, {
await closeElectronApplicationAndConfirmExit(
app.electronApp,
electronAppExitConfirmationOptions()
);
}
/** The close/exit timeouts the shared fixture applies to every launch. */
export function electronAppExitConfirmationOptions(): ElectronExitConfirmationOptions {
return {
closeTimeoutMs: electronAppCloseTimeoutMs,
exitTimeoutMs: electronAppKillWaitMs,
});
};
}
function assertPackagedRendererBuildIsElectronSafe(): void {
@@ -0,0 +1,77 @@
import type { ElectronApplication } from '@playwright/test';
/**
* Test-side client for the main-process gate in
* `../performance/journey-renderer-gate.cjs`.
*/
export const JOURNEY_RENDERER_GATE_KEY = '__iptvnatorJourneyGate';
export interface JourneyRendererGateState {
readonly blankLoadedEpochMs: number | null;
readonly errors: readonly string[];
readonly gatedEpochMs: number | null;
readonly gatedMethod: string | null;
readonly passThroughLoads: number;
readonly releasedEpochMs: number | null;
readonly timedOut: boolean;
}
export async function readJourneyRendererGate(
electronApp: ElectronApplication,
gateKey: string,
action: 'read' | 'release'
): Promise<JourneyRendererGateState> {
const state = await electronApp.evaluate(
(_electron, input) => {
const gate = (globalThis as unknown as Record<string, unknown>)[
input.gateKey
] as { release(): unknown; state: unknown } | undefined;
if (!gate) {
return null;
}
const result =
input.action === 'release' ? gate.release() : gate.state;
return JSON.parse(JSON.stringify(result)) as unknown;
},
{ action, gateKey }
);
if (state === null) {
throw new Error('journey-renderer-gate-not-installed');
}
return state as JourneyRendererGateState;
}
/**
* The gate proves the ordering the counters rely on: the real document was
* loaded once, only after the test released it, and the renderer probe ran
* after the release (so it was registered before that document existed).
*/
export function assertJourneyRendererGate(
gate: JourneyRendererGateState,
rendererProbeInstalledEpochMs: number
): JourneyRendererGateState {
if (gate.timedOut) {
throw new Error('journey-renderer-gate-timed-out');
}
if (gate.errors.length > 0) {
throw new Error(
`journey-renderer-gate-errors: ${gate.errors.join(', ')}`
);
}
if (
gate.gatedEpochMs === null ||
gate.blankLoadedEpochMs === null ||
gate.releasedEpochMs === null
) {
throw new Error('journey-renderer-gate-incomplete');
}
if (gate.passThroughLoads !== 0) {
throw new Error(
`journey-renderer-gate-extra-loads-${gate.passThroughLoads}`
);
}
if (rendererProbeInstalledEpochMs < gate.releasedEpochMs) {
throw new Error('journey-renderer-gate-probe-before-release');
}
return gate;
}
@@ -0,0 +1,229 @@
import { cp, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { _electron as electron, type Page } from '@playwright/test';
import { captureElectronProcess } from '../electron-process-lifecycle';
import {
addXtreamPortal,
buildElectronLaunchArgs,
buildElectronLaunchEnvironment,
closeElectronAppAndConfirmExit,
electronAppExitConfirmationOptions,
importM3uPlaylistFromUrl,
launchElectronApp,
waitForM3uCatalog,
waitForXtreamCatalog,
type LaunchElectronAppOptions,
} from '../electron-test-fixtures';
import { closeElectronApplicationAndConfirmExit } from '../electron-process-lifecycle';
import {
installJourneyMainIpcCapture,
JOURNEY_MAIN_IPC_STATE_KEY,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
readJourneyMainIpcCapture,
} from '../performance/journey-main-ipc-capture';
import {
createLaunchJourneyProbeOptions,
installJourneyRendererProbe,
waitForJourneyRendererProbe,
} from '../performance/journey-renderer-probe';
import {
assertJourneyRendererGate,
JOURNEY_RENDERER_GATE_KEY,
readJourneyRendererGate,
} from './journey-renderer-gate-client';
import type { LaunchJourneyMeasurement } from '../performance/launch-journey-record';
/**
* Process lifecycle for J1 "Launch to usable": one seeded profile template,
* then a fresh Electron process on a fresh copy of that template per
* iteration. Mirrors `xtream-benchmark-app-startup.ts` without the import
* scaffolding that journey does not need.
*/
export const LAUNCH_JOURNEY_XTREAM_MOCK_PORT =
process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231';
export const LAUNCH_JOURNEY_MOCK_ORIGIN = `http://127.0.0.1:${LAUNCH_JOURNEY_XTREAM_MOCK_PORT}`;
/** Main-process hook loaded with `-r`; see journey-renderer-gate.cjs. */
export const JOURNEY_RENDERER_GATE_PATH = resolve(
__dirname,
'../performance/journey-renderer-gate.cjs'
);
function launchOptions(
env: Record<string, string> = {}
): LaunchElectronAppOptions {
return {
env: { IPTVNATOR_TRACE_RENDERER_CONSOLE: '0', ...env },
environmentInheritance: 'runtime-only',
omitEnvKeys: [
'IPTVNATOR_XTREAM_MOCK_CONTROL',
'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN',
],
};
}
function removeDirectory(directory: string): Promise<void> {
return rm(directory, {
force: true,
maxRetries: 20,
recursive: true,
retryDelay: 250,
});
}
/**
* Seeds one M3U source and one Xtream portal through the app's own dialogs
* and returns the data directory to copy for every measured launch.
*/
export async function seedLaunchJourneyProfile(
mockOrigin: string
): Promise<string> {
const templateDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-journey-launch-seed-')
);
try {
const app = await launchElectronApp(templateDirectory, launchOptions());
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
`${mockOrigin}/playlist.m3u`
);
await waitForM3uCatalog(app.mainWindow);
await addXtreamPortal(app.mainWindow, { serverUrl: mockOrigin });
await waitForXtreamCatalog(app.mainWindow);
} finally {
await closeElectronAppAndConfirmExit(app);
}
return templateDirectory;
} catch (failure) {
await removeDirectory(templateDirectory);
throw failure;
}
}
export function removeLaunchJourneyProfile(directory: string): Promise<void> {
return removeDirectory(directory);
}
/**
* Spawns a fresh Electron process on a copy of the seeded profile. The gate
* hook parks the first renderer load on `about:blank`, which gives Playwright
* a page to attach the renderer probe to; the main-process IPC capture is
* installed next, and only then is the real load released. Both captures are
* therefore in place before the renderer runs any script, and the probe,
* capture and gate records still prove it.
*/
export async function measureLaunchJourney(
templateDirectory: string,
timeoutMs: number
): Promise<LaunchJourneyMeasurement> {
const dataDirectory = await mkdtemp(
join(tmpdir(), 'iptvnator-journey-launch-')
);
try {
await cp(templateDirectory, dataDirectory, { recursive: true });
const env = buildElectronLaunchEnvironment(
dataDirectory,
launchOptions({ IPTVNATOR_TRACE_IPC: '1' })
);
const args = buildElectronLaunchArgs([
'-r',
JOURNEY_RENDERER_GATE_PATH,
]);
const spawnEpochMs = Date.now();
const electronApp = await electron.launch({ args, env });
captureElectronProcess(electronApp);
try {
const probeOptions = createLaunchJourneyProbeOptions();
// The gate parks the window on about:blank, so this resolves
// before the real document exists.
const mainWindow = await electronApp.firstWindow();
if (mainWindow.url() !== 'about:blank') {
throw new Error(
`journey-renderer-gate-missing: first document is ${mainWindow.url()}`
);
}
await installJourneyRendererProbe(mainWindow, probeOptions);
await installJourneyMainIpcCapture(electronApp, {
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
sentinelId: probeOptions.sentinelId,
sentinelMethod: probeOptions.sentinelMethod,
stateKey: JOURNEY_MAIN_IPC_STATE_KEY,
});
await readJourneyRendererGate(
electronApp,
JOURNEY_RENDERER_GATE_KEY,
'release'
);
// The page object is still on about:blank; wait for the real
// document to commit before touching its execution context.
await mainWindow.waitForURL((url) => url.href !== 'about:blank', {
timeout: timeoutMs,
waitUntil: 'commit',
});
await assertJourneyRendererProbeInstalled(
mainWindow,
probeOptions.stateKey
);
const renderer = await waitForJourneyRendererProbe(
mainWindow,
probeOptions.stateKey,
timeoutMs
);
// Re-read after the probe finished: a reload or recovery
// navigation during startup shows up as a pass-through load
// only in the live state, and such an iteration is invalid.
const gate = assertJourneyRendererGate(
await readJourneyRendererGate(
electronApp,
JOURNEY_RENDERER_GATE_KEY,
'read'
),
renderer.installed.epochMs
);
const ipc = await readJourneyMainIpcCapture(
electronApp,
JOURNEY_MAIN_IPC_STATE_KEY,
10_000
);
if (ipc.installedEpochMs > renderer.installed.epochMs) {
throw new Error('journey-main-ipc-capture-installed-late');
}
const electronVersion = await electronApp.evaluate(
() => process.versions.electron
);
return {
electronVersion,
gate,
ipc,
pid: electronApp.process().pid ?? -1,
renderer,
spawnEpochMs,
};
} finally {
await closeElectronApplicationAndConfirmExit(
electronApp,
electronAppExitConfirmationOptions()
);
}
} finally {
await removeDirectory(dataDirectory);
}
}
async function assertJourneyRendererProbeInstalled(
mainWindow: Page,
stateKey: string
): Promise<void> {
const installed = await mainWindow.evaluate(
(key) =>
(globalThis as unknown as Record<string, unknown>)[key] !==
undefined,
stateKey
);
if (!installed) {
throw new Error('journey-renderer-probe-not-installed');
}
}
@@ -0,0 +1,121 @@
import { relative } from 'node:path';
import { test } from '@playwright/test';
import {
electronMainPath,
packagedRendererIndexPath,
workspaceRoot,
} from '../electron-test-fixtures';
import {
JOURNEY_SUMMARY_SCHEMA_VERSION,
resolveJourneySummaryPath,
summarizeJourneyIterations,
writeJourneySummary,
type JourneyIterationRecord,
type JourneySummary,
} from '../performance/journey-summary';
import {
LAUNCH_JOURNEY_ID,
LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS,
toLaunchIterationRecord,
} from '../performance/launch-journey-record';
import {
LAUNCH_JOURNEY_MOCK_ORIGIN,
measureLaunchJourney,
removeLaunchJourneyProfile,
seedLaunchJourneyProfile,
} from './launch-journey-app';
/**
* J1 "Launch to usable": Electron process spawn until the first playlist or
* portal card is visible on /workspace with the inline splash removed.
* Contract: docs/architecture/performance-journeys.md.
*/
const WARMUP_ITERATIONS = 1;
const MEASURED_ITERATIONS = readPositiveInteger(
'IPTVNATOR_JOURNEY_MEASURED_ITERATIONS',
5
);
const ITERATION_TIMEOUT_MS = 120_000;
function readPositiveInteger(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw === '') {
return fallback;
}
const value = Number(raw);
if (!Number.isSafeInteger(value) || value < 1) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
test.describe.configure({ mode: 'serial' });
test('J1 launch to usable', async () => {
const templateDirectory = await seedLaunchJourneyProfile(
LAUNCH_JOURNEY_MOCK_ORIGIN
);
const iterations: JourneyIterationRecord[] = [];
let electronVersion = 'unknown';
try {
const total = WARMUP_ITERATIONS + MEASURED_ITERATIONS;
for (let index = 0; index < total; index += 1) {
const warmup = index < WARMUP_ITERATIONS;
const measurement = await measureLaunchJourney(
templateDirectory,
ITERATION_TIMEOUT_MS
);
electronVersion = measurement.electronVersion;
const record = toLaunchIterationRecord(index, warmup, measurement);
iterations.push(record);
console.log(
`[journey:launch] iteration ${index}${warmup ? ' (warm-up)' : ''} pid=${record.pid} ${JSON.stringify(
{ ...record.counters, ...record.wallClock }
)}`
);
}
} finally {
await removeLaunchJourneyProfile(templateDirectory);
}
const entry = summarizeJourneyIterations(
iterations,
LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS
);
const summary: JourneySummary = {
generatedAt: new Date().toISOString(),
harness: {
arch: process.arch,
ci: Boolean(process.env['CI']),
electron: electronVersion,
electronMain: relative(workspaceRoot, electronMainPath),
measuredIterations: MEASURED_ITERATIONS,
node: process.version,
platform: process.platform,
rendererIndex: relative(workspaceRoot, packagedRendererIndexPath),
warmupIterations: WARMUP_ITERATIONS,
},
journeys: { [LAUNCH_JOURNEY_ID]: entry },
schemaVersion: JOURNEY_SUMMARY_SCHEMA_VERSION,
};
const summaryPath = resolveJourneySummaryPath(workspaceRoot);
await writeJourneySummary(summaryPath, summary);
await test.info().attach('journey-summary', {
contentType: 'application/json',
path: summaryPath,
});
console.log(
`[journey:launch] summary ${relative(workspaceRoot, summaryPath)}\n${JSON.stringify(
{
counters: entry.counters,
counterStability: entry.counterStability,
unavailable: entry.unavailable,
wallClock: entry.wallClock,
},
null,
2
)}`
);
});
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import test from 'node:test';
import {
assertJourneyMainIpcCapture,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
type JourneyMainIpcCaptureState,
} from './journey-main-ipc-capture';
import { JOURNEY_IPC_SENTINEL_METHOD } from './journey-renderer-probe';
const electronBackendSource = resolve(
__dirname,
'../../../electron-backend/src/app'
);
function validCapture(
overrides: Partial<JourneyMainIpcCaptureState> = {}
): JourneyMainIpcCaptureState {
return {
callsAfterSentinel: 2,
callsBeforeSentinel: 7,
callsByMethod: { dbGetAppPlaylists: 1, getSettings: 6 },
installedEpochMs: 1,
malformedEvents: 0,
processStartEpochMs: 0,
senderIds: [1],
sentinel: { occurrences: 1, receivedEpochMs: 2 },
...overrides,
};
}
test('the trace channel literal matches the main-process constant', () => {
const source = readFileSync(
resolve(electronBackendSource, 'services/debug-trace.ts'),
'utf8'
);
assert.match(
source,
new RegExp(
`DEBUG_TRACE_EVENT_CHANNEL = '${JOURNEY_RENDERER_API_TRACE_CHANNEL}'`
)
);
});
test('the preload traces every bridge invocation on that channel when IPC tracing is on', () => {
const preload = readFileSync(
resolve(electronBackendSource, 'api/main.preload.ts'),
'utf8'
);
assert.match(
preload,
/ipcRenderer\.send\(DEBUG_TRACE_EVENT_CHANNEL, payload\)/
);
assert.match(preload, /name\.startsWith\('on'\)/);
assert.match(preload, /name\.startsWith\('remove'\)/);
assert.match(
preload,
new RegExp(`${JOURNEY_IPC_SENTINEL_METHOD}: \\(playlistId: string`)
);
const debugTrace = readFileSync(
resolve(electronBackendSource, 'services/debug-trace.ts'),
'utf8'
);
assert.match(debugTrace, /readFlag\('IPTVNATOR_TRACE_IPC'\)/);
});
test('accepts a capture with exactly one sentinel from one renderer', () => {
assert.equal(
assertJourneyMainIpcCapture(validCapture()).callsBeforeSentinel,
7
);
});
test('rejects captures that cannot bound the counter exactly', () => {
assert.throws(() => assertJourneyMainIpcCapture(null), /missing/);
assert.throws(
() =>
assertJourneyMainIpcCapture(
validCapture({
sentinel: { occurrences: 0, receivedEpochMs: null },
})
),
/sentinel-count-0/
);
assert.throws(
() =>
assertJourneyMainIpcCapture(
validCapture({
sentinel: { occurrences: 2, receivedEpochMs: 2 },
})
),
/sentinel-count-2/
);
assert.throws(
() => assertJourneyMainIpcCapture(validCapture({ senderIds: [1, 2] })),
/senders-2/
);
assert.throws(
() => assertJourneyMainIpcCapture(validCapture({ malformedEvents: 1 })),
/malformed/
);
});
@@ -0,0 +1,155 @@
import type { ElectronApplication } from '@playwright/test';
/**
* Main-process side of the journey IPC counter.
*
* With `IPTVNATOR_TRACE_IPC=1` the preload wraps every bridge method (except
* `on*` / `remove*` listener registrations) and sends one trace event per
* invocation on the renderer-API trace channel before forwarding the call
* (see `apps/electron-backend/src/app/api/main.preload.ts`). This capture
* subscribes to that channel from the test side and counts `start` events
* until the renderer probe's sentinel call arrives. Renderer-to-main IPC is
* delivered in order, so every call started before the sentinel is counted
* and nothing after it is.
*/
/** Literal of `DEBUG_TRACE_EVENT_CHANNEL` in `services/debug-trace.ts`. */
export const JOURNEY_RENDERER_API_TRACE_CHANNEL = 'IPTVNATOR_DEBUG_TRACE_EVENT';
export const JOURNEY_MAIN_IPC_STATE_KEY = '__iptvnatorJourneyMainIpcCapture';
export interface JourneyMainIpcCaptureOptions {
readonly channel: string;
readonly sentinelId: string;
readonly sentinelMethod: string;
readonly stateKey: string;
}
export interface JourneyMainIpcCaptureState {
readonly callsAfterSentinel: number;
readonly callsBeforeSentinel: number;
readonly callsByMethod: Record<string, number>;
readonly installedEpochMs: number;
readonly malformedEvents: number;
readonly processStartEpochMs: number;
readonly senderIds: number[];
readonly sentinel: {
readonly occurrences: number;
readonly receivedEpochMs: number | null;
};
}
export async function installJourneyMainIpcCapture(
electronApp: ElectronApplication,
options: JourneyMainIpcCaptureOptions
): Promise<void> {
await electronApp.evaluate(({ ipcMain }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
if (target[input.stateKey] !== undefined) {
throw new Error('journey-main-ipc-capture-already-installed');
}
const state = {
callsAfterSentinel: 0,
callsBeforeSentinel: 0,
callsByMethod: {} as Record<string, number>,
installedEpochMs: Date.now(),
malformedEvents: 0,
processStartEpochMs: Date.now() - process.uptime() * 1000,
senderIds: [] as number[],
sentinel: {
occurrences: 0,
receivedEpochMs: null as number | null,
},
};
target[input.stateKey] = state;
ipcMain.on(input.channel, (event, payload: unknown) => {
const record =
typeof payload === 'object' && payload !== null
? (payload as Record<string, unknown>)
: null;
if (!record || typeof record['method'] !== 'string') {
state.malformedEvents += 1;
return;
}
if (record['phase'] !== 'start') {
return;
}
const senderId = event.sender.id;
if (!state.senderIds.includes(senderId)) {
state.senderIds.push(senderId);
}
const method = record['method'];
let isSentinel = false;
if (method === input.sentinelMethod) {
try {
isSentinel = JSON.stringify(
record['args'] ?? null
).includes(input.sentinelId);
} catch {
isSentinel = false;
}
}
if (isSentinel) {
state.sentinel.occurrences += 1;
state.sentinel.receivedEpochMs ??= Date.now();
return;
}
if (state.sentinel.receivedEpochMs !== null) {
state.callsAfterSentinel += 1;
return;
}
state.callsBeforeSentinel += 1;
state.callsByMethod[method] =
(state.callsByMethod[method] ?? 0) + 1;
});
}, options);
}
export async function readJourneyMainIpcCapture(
electronApp: ElectronApplication,
stateKey: string,
timeoutMs: number
): Promise<JourneyMainIpcCaptureState> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const state = await electronApp.evaluate(
(_electron, key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as unknown,
stateKey
);
const capture = state as JourneyMainIpcCaptureState | null;
if (capture?.sentinel.receivedEpochMs !== null) {
return assertJourneyMainIpcCapture(capture);
}
if (Date.now() >= deadline) {
throw new Error('journey-main-ipc-capture-sentinel-timeout');
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
}
export function assertJourneyMainIpcCapture(
value: unknown
): JourneyMainIpcCaptureState {
const state = value as JourneyMainIpcCaptureState | null | undefined;
if (!state || typeof state.callsBeforeSentinel !== 'number') {
throw new Error('journey-main-ipc-capture-missing');
}
if (state.sentinel.occurrences !== 1) {
throw new Error(
`journey-main-ipc-capture-sentinel-count-${state.sentinel.occurrences}`
);
}
if (state.senderIds.length !== 1) {
throw new Error(
`journey-main-ipc-capture-senders-${state.senderIds.length}`
);
}
if (state.malformedEvents > 0) {
throw new Error('journey-main-ipc-capture-malformed-events');
}
return state;
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assertJourneyRendererGate,
JOURNEY_RENDERER_GATE_KEY,
type JourneyRendererGateState,
} from '../journeys/journey-renderer-gate-client';
function gate(
overrides: Partial<JourneyRendererGateState> = {}
): JourneyRendererGateState {
return {
blankLoadedEpochMs: 1_050,
errors: [],
gatedEpochMs: 1_020,
gatedMethod: 'loadFile',
passThroughLoads: 0,
releasedEpochMs: 1_150,
timedOut: false,
...overrides,
};
}
test('the client and the hook agree on the global key', () => {
assert.equal(JOURNEY_RENDERER_GATE_KEY, '__iptvnatorJourneyGate');
});
test('accepts a gate that held the load until the test released it', () => {
assert.equal(
assertJourneyRendererGate(gate(), 1_200).releasedEpochMs,
1_150
);
});
test('rejects gates that cannot prove the probe preceded the document', () => {
assert.throws(
() => assertJourneyRendererGate(gate({ timedOut: true }), 1_200),
/timed-out/
);
assert.throws(
() =>
assertJourneyRendererGate(
gate({ errors: ['blank-failed'] }),
1_200
),
/errors: blank-failed/
);
assert.throws(
() => assertJourneyRendererGate(gate({ releasedEpochMs: null }), 1_200),
/incomplete/
);
assert.throws(
() => assertJourneyRendererGate(gate({ passThroughLoads: 1 }), 1_200),
/extra-loads-1/
);
assert.throws(
() => assertJourneyRendererGate(gate(), 1_100),
/probe-before-release/
);
});
@@ -0,0 +1,96 @@
'use strict';
/**
* Main-process gate for the performance journeys, loaded into Electron with
* `-r` from the test side (the same mechanism Playwright uses for its own
* loader). It is not part of the application build.
*
* Problem: Playwright resolves `electron.launch()` while the app is already
* creating its window, and an init script registered afterwards races the
* renderer's first document. Electron reports no page to Playwright until a
* navigation commits, so the gate makes the first `loadFile`/`loadURL`
* navigate to `about:blank` first. That gives Playwright a page object the
* test can attach `addInitScript` to; the real load proceeds only after the
* test calls `globalThis.__iptvnatorJourneyGate.release()`. A safety timeout
* releases the gate on its own and records that it did, so a broken test
* cannot hang the app; the journey treats a timed-out gate as invalid.
*/
const GATE_KEY = '__iptvnatorJourneyGate';
const DEFAULT_TIMEOUT_MS = 15000;
function installJourneyRendererGate(BrowserWindow, target, options = {}) {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const now = options.now ?? (() => Date.now());
const state = {
blankLoadedEpochMs: null,
errors: [],
gatedEpochMs: null,
gatedMethod: null,
passThroughLoads: 0,
releasedEpochMs: null,
timedOut: false,
};
let releaseGate = null;
const gate = new Promise((resolve) => {
releaseGate = resolve;
});
const timer = setTimeout(() => {
if (state.releasedEpochMs === null) {
state.timedOut = true;
state.releasedEpochMs = now();
releaseGate();
}
}, timeoutMs);
const api = {
release() {
if (state.releasedEpochMs === null) {
state.releasedEpochMs = now();
clearTimeout(timer);
releaseGate();
}
return state;
},
state,
};
Object.defineProperty(target, GATE_KEY, {
configurable: false,
enumerable: false,
value: api,
writable: false,
});
for (const method of ['loadFile', 'loadURL']) {
const original = BrowserWindow.prototype[method];
if (typeof original !== 'function') continue;
BrowserWindow.prototype[method] = async function gatedLoad(...args) {
if (state.gatedEpochMs !== null) {
state.passThroughLoads += 1;
return original.apply(this, args);
}
state.gatedEpochMs = now();
state.gatedMethod = method;
try {
await this.webContents.loadURL('about:blank');
state.blankLoadedEpochMs = now();
} catch (error) {
state.errors.push(
error instanceof Error ? error.message : String(error)
);
}
await gate;
return original.apply(this, args);
};
}
return api;
}
module.exports = { GATE_KEY, installJourneyRendererGate };
if (
process.versions &&
process.versions.electron &&
!process.env['IPTVNATOR_JOURNEY_GATE_MANUAL']
) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { BrowserWindow } = require('electron');
installJourneyRendererGate(BrowserWindow, globalThis);
}
@@ -0,0 +1,141 @@
import assert from 'node:assert/strict';
import test from 'node:test';
interface GateState {
blankLoadedEpochMs: number | null;
errors: string[];
gatedEpochMs: number | null;
gatedMethod: string | null;
passThroughLoads: number;
releasedEpochMs: number | null;
timedOut: boolean;
}
interface GateApi {
release(): GateState;
state: GateState;
}
interface GateModule {
GATE_KEY: string;
installJourneyRendererGate(
browserWindow: { prototype: Record<string, unknown> },
target: Record<string, unknown>,
options?: { now?: () => number; timeoutMs?: number }
): GateApi;
}
// The e2e project compiles to CommonJS, so the hook is loaded with require.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const gateModule = require('./journey-renderer-gate.cjs') as GateModule;
function createFakeBrowserWindow(log: string[]) {
class FakeBrowserWindow {
webContents = {
loadURL: async (url: string) => {
log.push(`webContents.loadURL:${url}`);
},
};
async loadFile(file: string): Promise<string> {
log.push(`loadFile:${file}`);
return `loaded:${file}`;
}
async loadURL(url: string): Promise<string> {
log.push(`loadURL:${url}`);
return `loaded:${url}`;
}
}
return FakeBrowserWindow;
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 5));
}
test('the module does not touch Electron when loaded outside it', () => {
assert.equal(typeof gateModule.installJourneyRendererGate, 'function');
assert.equal(gateModule.GATE_KEY, '__iptvnatorJourneyGate');
assert.equal(
(globalThis as Record<string, unknown>)[gateModule.GATE_KEY],
undefined
);
});
test('holds the first load behind about:blank until released, then passes later loads through', async () => {
const log: string[] = [];
const FakeBrowserWindow = createFakeBrowserWindow(log);
const target: Record<string, unknown> = {};
let clock = 100;
const api = gateModule.installJourneyRendererGate(
FakeBrowserWindow as unknown as { prototype: Record<string, unknown> },
target,
{ now: () => clock++, timeoutMs: 60_000 }
);
assert.equal(target[gateModule.GATE_KEY], api);
const window = new FakeBrowserWindow();
const load = window.loadFile('index.html');
await settle();
assert.deepEqual(log, ['webContents.loadURL:about:blank']);
assert.equal(api.state.gatedMethod, 'loadFile');
assert.equal(api.state.gatedEpochMs, 100);
assert.equal(api.state.blankLoadedEpochMs, 101);
assert.equal(api.state.releasedEpochMs, null);
api.release();
assert.equal(await load, 'loaded:index.html');
assert.deepEqual(log, [
'webContents.loadURL:about:blank',
'loadFile:index.html',
]);
assert.equal(api.state.releasedEpochMs, 102);
assert.equal(api.state.timedOut, false);
assert.equal(
await window.loadURL('http://localhost/'),
'loaded:http://localhost/'
);
assert.equal(api.state.passThroughLoads, 1);
assert.equal(api.release().releasedEpochMs, 102);
});
test('releases itself after the timeout and records it', async () => {
const log: string[] = [];
const FakeBrowserWindow = createFakeBrowserWindow(log);
const api = gateModule.installJourneyRendererGate(
FakeBrowserWindow as unknown as { prototype: Record<string, unknown> },
{},
{ timeoutMs: 10 }
);
const window = new FakeBrowserWindow();
assert.equal(
await window.loadURL('http://localhost/'),
'loaded:http://localhost/'
);
assert.equal(api.state.timedOut, true);
assert.equal(typeof api.state.releasedEpochMs, 'number');
});
test('records a failed about:blank navigation and still loads after release', async () => {
class BrokenBrowserWindow {
webContents = {
loadURL: async () => {
throw new Error('blank-failed');
},
};
async loadFile(file: string): Promise<string> {
return `loaded:${file}`;
}
}
const api = gateModule.installJourneyRendererGate(
BrokenBrowserWindow as unknown as {
prototype: Record<string, unknown>;
},
{},
{ timeoutMs: 60_000 }
);
const load = new BrokenBrowserWindow().loadFile('index.html');
await settle();
assert.deepEqual(api.state.errors, ['blank-failed']);
api.release();
assert.equal(await load, 'loaded:index.html');
});
@@ -0,0 +1,409 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import {
assertJourneyRendererProbeState,
createLaunchJourneyProbeOptions,
JOURNEY_IPC_SENTINEL_ID,
JOURNEY_IPC_SENTINEL_METHOD,
JOURNEY_PROBE_STATE_KEY,
journeyRendererProbeScript,
type JourneyRendererProbeOptions,
type JourneyRendererProbeState,
} from './journey-renderer-probe';
interface FakeEntry {
duration?: number;
entryType: string;
hadRecentInput?: boolean;
startTime: number;
value?: number;
}
interface FakeObserver {
disconnected: boolean;
emit(entries: FakeEntry[]): void;
queue: FakeEntry[];
type: string | null;
}
interface Fixture {
readonly bridgeCalls: unknown[];
readonly observers: FakeObserver[];
/** The live state object inside the jsdom realm. */
readonly rawState: () => JourneyRendererProbeState;
/** A JSON clone, so assertions compare values across realms. */
readonly state: () => JourneyRendererProbeState;
readonly window: JSDOM['window'];
}
const PAGE = `<!doctype html><html><head></head><body class="mat-app-background">
<div id="initial-splash" role="status"><span>IPTVnator</span></div>
<app-root></app-root></body></html>`;
function installFakePerformance(
window: JSDOM['window'],
observers: FakeObserver[]
): void {
class FakePerformanceObserver implements FakeObserver {
disconnected = false;
queue: FakeEntry[] = [];
type: string | null = null;
constructor(
private readonly callback: (list: {
getEntries(): FakeEntry[];
}) => void
) {
observers.push(this);
}
observe(options: { type: string }): void {
this.type = options.type;
}
takeRecords(): FakeEntry[] {
const queued = this.queue;
this.queue = [];
return queued;
}
disconnect(): void {
this.disconnected = true;
}
emit(entries: FakeEntry[]): void {
this.callback({ getEntries: () => entries });
}
}
Object.defineProperty(window, 'PerformanceObserver', {
configurable: true,
value: FakePerformanceObserver,
});
Object.defineProperty(window.performance, 'getEntriesByType', {
configurable: true,
value: (type: string) =>
type === 'navigation'
? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }]
: [],
});
// jsdom never lays out, so visibility is "connected to the document".
window.HTMLElement.prototype.getClientRects = function getClientRects(
this: HTMLElement
) {
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
};
}
function createFixture(
overrides: Partial<JourneyRendererProbeOptions> & {
bridge?: boolean;
url?: string;
} = {}
): Fixture {
const {
bridge = true,
url = 'file:///dist/apps/web/workspace/dashboard',
...optionOverrides
} = overrides;
const dom = new JSDOM(PAGE, {
pretendToBeVisual: true,
runScripts: 'outside-only',
url,
});
const { window } = dom;
const observers: FakeObserver[] = [];
const bridgeCalls: unknown[] = [];
installFakePerformance(window, observers);
// tsx (esbuild keepNames) rewrites named inner functions as
// `__name(fn, 'name')` when it transpiles the probe for this test runner.
// Playwright's Babel transform, which serializes the probe for the real
// browser, does not, so the shim is a test-runner concern only.
Object.defineProperty(window, '__name', {
configurable: true,
value: (target: unknown) => target,
});
if (bridge) {
Object.defineProperty(window, 'electron', {
configurable: true,
value: Object.freeze({
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
bridgeCalls.push(id);
return Promise.resolve(null);
},
onSomething: () => undefined,
}),
});
}
const options = {
...createLaunchJourneyProbeOptions(),
...optionOverrides,
};
window.eval(
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})`
);
const rawState = (): JourneyRendererProbeState =>
(window as unknown as Record<string, JourneyRendererProbeState>)[
options.stateKey
] as JourneyRendererProbeState;
return {
bridgeCalls,
observers,
rawState,
state: () =>
JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState,
window,
};
}
function settle(ms = 40): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function renderFirstCard(fixture: Fixture): void {
const { document } = fixture.window;
document.getElementById('initial-splash')?.remove();
const rail = document.createElement('section');
rail.setAttribute('data-test-id', 'dashboard-recent-sources-rail');
document.querySelector('app-root')?.append(rail);
const card = document.createElement('div');
card.setAttribute('data-test-id', 'dashboard-recent-sources-rail-card');
rail.append(card);
}
test('records install facts before any renderer script ran', () => {
const fixture = createFixture();
const state = fixture.state();
assert.equal(state.schemaVersion, 1);
assert.equal(state.journey, 'launch');
assert.equal(state.installed.readyState, 'loading');
assert.equal(state.installed.scriptCount, 0);
assert.equal(state.installed.bridgePresent, true);
assert.equal(state.capabilities.observedTarget, 'documentElement');
assert.equal(state.capabilities.layoutShift, true);
assert.equal(state.capabilities.longTask, true);
assert.deepEqual(state.invalidReasons, []);
assert.equal(state.terminal, null);
assert.deepEqual(
fixture.observers.map((observer) => observer.type),
['layout-shift', 'longtask']
);
});
test('installs once per document', () => {
const fixture = createFixture();
const first = fixture.rawState();
fixture.window.eval(
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(createLaunchJourneyProbeOptions())})`
);
assert.equal(fixture.rawState(), first);
});
test('counts mutation records until the first card is visible after the splash is gone', async () => {
const fixture = createFixture();
const { document } = fixture.window;
const appRoot = document.querySelector('app-root') as HTMLElement;
appRoot.append(document.createElement('div'));
appRoot.append(document.createElement('div'));
appRoot.setAttribute('data-ready', '1');
await settle();
assert.equal(fixture.state().terminal, null);
renderFirstCard(fixture);
await settle();
const state = fixture.state();
assert.ok(state.terminal, 'terminal must be recorded');
assert.equal(
state.terminal.cardTestId,
'dashboard-recent-sources-rail-card'
);
assert.equal(state.terminal.cardTag, 'div');
assert.match(state.terminal.pathname, /\/workspace\/dashboard$/);
// 2 appends + 1 attribute + splash removal + rail append + card append
assert.equal(state.counters.domMutations, 6);
assert.equal(state.final, true);
assert.equal(typeof state.firstCardPaintEpochMs, 'number');
assert.deepEqual(fixture.bridgeCalls, [JOURNEY_IPC_SENTINEL_ID]);
assert.equal(state.sentinel.status, 'sent');
assert.equal(
state.navigation?.loadEventEndEpochMs,
fixture.window.performance.timeOrigin + 120
);
assert.equal(
state.capabilities.changeDetectionTicks,
'unavailable-ng-global-not-published'
);
assert.ok(fixture.observers.every((observer) => observer.disconnected));
appRoot.append(document.createElement('div'));
await settle();
assert.equal(fixture.state().counters.domMutations, 6);
assert.doesNotThrow(() => assertJourneyRendererProbeState(fixture.state()));
});
test('sums layout shifts without recent input and counts long tasks over 50 ms up to the post-paint cutoff', async () => {
const fixture = createFixture();
const [layoutShift, longTask] = fixture.observers as [
FakeObserver,
FakeObserver,
];
const now = () => fixture.window.performance.now();
layoutShift.emit([
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 0.25,
},
{
entryType: 'layout-shift',
hadRecentInput: true,
startTime: now(),
value: 5,
},
]);
longTask.emit([
{ duration: 80, entryType: 'longtask', startTime: now() },
{ duration: 50, entryType: 'longtask', startTime: now() },
]);
// Entries still queued when the terminal frame closes the observers.
layoutShift.queue.push(
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 0.5,
},
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now() + 60_000,
value: 9,
}
);
longTask.queue.push(
{ duration: 120, entryType: 'longtask', startTime: now() },
{ duration: 300, entryType: 'longtask', startTime: now() + 60_000 }
);
renderFirstCard(fixture);
await new Promise((resolve) => queueMicrotask(() => resolve(undefined)));
// Delivered after the terminal batch, before the post-paint cutoff.
assert.ok(fixture.rawState().terminal, 'terminal must be set');
assert.equal(fixture.rawState().final, false);
layoutShift.emit([
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 0.125,
},
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now() + 60_000,
value: 7,
},
]);
longTask.emit([
{ duration: 64, entryType: 'longtask', startTime: now() },
{ duration: 500, entryType: 'longtask', startTime: now() + 60_000 },
]);
await settle();
const state = fixture.state();
assert.equal(state.final, true);
assert.ok(
(state.firstCardPaintEpochMs ?? 0) >
(state.terminal?.epochMs ?? Number.POSITIVE_INFINITY),
'the cutoff is sampled after the terminal batch'
);
assert.equal(state.counters.layoutShiftScore, 0.875);
assert.equal(state.counters.longTasks, 3);
assert.deepEqual(state.longTaskDurationsMs, [80, 64, 120]);
layoutShift.emit([
{
entryType: 'layout-shift',
hadRecentInput: false,
startTime: now(),
value: 1,
},
]);
longTask.emit([{ duration: 99, entryType: 'longtask', startTime: now() }]);
assert.equal(fixture.state().counters.layoutShiftScore, 0.875);
assert.equal(fixture.state().counters.longTasks, 3);
});
test('does not end while the splash is present, off the workspace route, or before a card is visible', async () => {
const withSplash = createFixture();
const rail = withSplash.window.document.createElement('div');
rail.setAttribute('data-test-id', 'dashboard-recent-sources-rail-card');
withSplash.window.document.querySelector('app-root')?.append(rail);
await settle();
assert.equal(withSplash.state().terminal, null);
assert.deepEqual(withSplash.bridgeCalls, []);
const offRoute = createFixture({ url: 'file:///dist/apps/web/index.html' });
renderFirstCard(offRoute);
await settle();
assert.equal(offRoute.state().terminal, null);
const noCard = createFixture();
noCard.window.document.getElementById('initial-splash')?.remove();
await settle();
assert.equal(noCard.state().terminal, null);
assert.throws(
() => assertJourneyRendererProbeState(noCard.state()),
/incomplete/
);
});
test('reports a missing bridge instead of guessing the IPC boundary', async () => {
const fixture = createFixture({ bridge: false });
assert.equal(fixture.state().installed.bridgePresent, false);
renderFirstCard(fixture);
await settle();
assert.equal(fixture.state().sentinel.status, 'bridge-missing');
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/sentinel-bridge-missing/
);
});
test('rejects a probe that was installed after the document started', async () => {
const fixture = createFixture();
const state = fixture.rawState() as { invalidReasons: string[] };
state.invalidReasons.push('probe-installed-after-document-start');
renderFirstCard(fixture);
await settle();
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/probe-installed-after-document-start/
);
});
test('rejects a probe whose performance observers were unavailable instead of reporting zeros', async () => {
const fixture = createFixture();
const state = fixture.rawState() as {
capabilities: { layoutShift: boolean; longTask: boolean };
};
state.capabilities.longTask = false;
renderFirstCard(fixture);
await settle();
assert.equal(fixture.state().counters.longTasks, 0);
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/observer-unavailable: longTask/
);
state.capabilities.layoutShift = false;
assert.throws(
() => assertJourneyRendererProbeState(fixture.state()),
/observer-unavailable: layoutShift, longTask/
);
});
test('launch options target the workspace source cards and the shared sentinel', () => {
const options = createLaunchJourneyProbeOptions();
assert.equal(options.stateKey, JOURNEY_PROBE_STATE_KEY);
assert.equal(options.sentinelMethod, 'dbGetAppPlaylist');
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
});
@@ -0,0 +1,378 @@
import type { Page } from '@playwright/test';
/**
* Renderer-side probe for the performance journeys (J1 "Launch to usable").
*
* The probe is injected from the test side through `addInitScript` while the
* journey gate (`journey-renderer-gate.cjs`) parks the window on
* `about:blank`, so it runs before any renderer script and never touches
* production code. It
* counts DOM mutations, layout shifts and long tasks until the journey's
* terminal condition and then emits one JSON blob under
* `window.__iptvnatorJourneyProbe`.
*
* IPC invocations are not counted here: the bridge object exposed by
* `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe
* fires one sentinel bridge call at the terminal moment; the main-process
* capture (`journey-main-ipc-capture.ts`) counts the preload's renderer-API
* trace events received before that sentinel. Renderer-to-main IPC is
* ordered, so the count is exact regardless of clock skew.
*/
export const JOURNEY_PROBE_STATE_KEY = '__iptvnatorJourneyProbe';
export const JOURNEY_PROBE_SCHEMA_VERSION = 1;
export const JOURNEY_IPC_SENTINEL_ID = '__iptvnator-journey-sentinel__';
/** Bridge method used for the sentinel: a read-only lookup by id. */
export const JOURNEY_IPC_SENTINEL_METHOD = 'dbGetAppPlaylist';
export interface JourneyRendererProbeOptions {
/** Selector for the element whose visibility ends the journey. */
readonly cardSelector: string;
readonly journey: string;
/** Pathname fragment the terminal route must contain. */
readonly routeFragment: string;
readonly sentinelId: string;
readonly sentinelMethod: string;
/** Element id of the inline splash that must be gone at the end. */
readonly splashId: string;
readonly stateKey: string;
}
export interface JourneyRendererProbeCounters {
domMutations: number;
layoutShiftScore: number;
longTasks: number;
}
export interface JourneyRendererProbeState {
readonly capabilities: {
changeDetectionTicks: string;
layoutShift: boolean;
longTask: boolean;
observedTarget: 'document' | 'documentElement';
};
readonly counters: JourneyRendererProbeCounters;
final: boolean;
firstCardPaintEpochMs: number | null;
readonly installed: {
readonly bridgePresent: boolean;
readonly documentElementPresent: boolean;
readonly epochMs: number;
readonly readyState: string;
readonly scriptCount: number;
};
readonly invalidReasons: string[];
readonly journey: string;
readonly longTaskDurationsMs: number[];
navigation: {
readonly domContentLoadedEpochMs: number;
readonly loadEventEndEpochMs: number;
} | null;
readonly schemaVersion: number;
sentinel: {
readonly epochMs: number | null;
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
};
terminal: {
readonly cardTag: string;
readonly cardTestId: string | null;
readonly epochMs: number;
readonly pathname: string;
} | null;
}
/**
* Page-side script. It must stay self-contained: Playwright serializes it
* with `toString()`, so it may only use its argument and browser globals.
*/
export function journeyRendererProbeScript(
options: JourneyRendererProbeOptions
): 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: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'pending',
layoutShift: false,
longTask: false,
observedTarget: document.documentElement
? 'documentElement'
: 'document',
},
counters: { domMutations: 0, layoutShiftScore: 0, longTasks: 0 },
final: false,
firstCardPaintEpochMs: null,
installed: {
bridgePresent: typeof bridge === 'object' && bridge !== null,
documentElementPresent: document.documentElement !== null,
epochMs: epoch(),
readyState: document.readyState,
scriptCount: document.scripts.length,
},
invalidReasons: [],
journey: options.journey,
longTaskDurationsMs: [],
navigation: null,
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
terminal: null,
};
target[options.stateKey] = state;
if (
state.installed.scriptCount > 0 ||
state.installed.readyState !== 'loading'
) {
state.invalidReasons.push('probe-installed-after-document-start');
}
const acceptLayoutShift = (
entries: readonly PerformanceEntry[],
untilEpochMs: number
): void => {
for (const entry of entries) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
value?: number;
};
if (
shift.hadRecentInput === true ||
typeof shift.value !== 'number' ||
performance.timeOrigin + shift.startTime > untilEpochMs
) {
continue;
}
state.counters.layoutShiftScore += shift.value;
}
};
const acceptLongTasks = (
entries: readonly PerformanceEntry[],
untilEpochMs: number
): void => {
for (const entry of entries) {
if (
entry.duration <= 50 ||
performance.timeOrigin + entry.startTime > untilEpochMs
) {
continue;
}
state.counters.longTasks += 1;
state.longTaskDurationsMs.push(entry.duration);
}
};
// Entries delivered between the terminal batch and the post-paint
// cutoff wait here so the cutoff applies to them as well.
const pendingLayoutShifts: PerformanceEntry[] = [];
const pendingLongTasks: PerformanceEntry[] = [];
const observe = (
type: string,
accept: (entries: readonly PerformanceEntry[], until: number) => void,
pending: PerformanceEntry[]
): PerformanceObserver | null => {
try {
const observer = new PerformanceObserver((list) => {
if (state.final) return;
if (state.terminal !== null) {
pending.push(...list.getEntries());
return;
}
accept(list.getEntries(), Number.POSITIVE_INFINITY);
});
observer.observe({ type, buffered: true });
return observer;
} catch {
return null;
}
};
const layoutShiftObserver = observe(
'layout-shift',
acceptLayoutShift,
pendingLayoutShifts
);
const longTaskObserver = observe(
'longtask',
acceptLongTasks,
pendingLongTasks
);
state.capabilities.layoutShift = layoutShiftObserver !== null;
state.capabilities.longTask = longTaskObserver !== null;
const finalize = (untilEpochMs: number): void => {
if (layoutShiftObserver) {
acceptLayoutShift(
[...pendingLayoutShifts, ...layoutShiftObserver.takeRecords()],
untilEpochMs
);
layoutShiftObserver.disconnect();
}
if (longTaskObserver) {
acceptLongTasks(
[...pendingLongTasks, ...longTaskObserver.takeRecords()],
untilEpochMs
);
longTaskObserver.disconnect();
}
state.firstCardPaintEpochMs = untilEpochMs;
state.final = true;
};
const sendSentinel = (): void => {
const method = bridge?.[options.sentinelMethod];
if (typeof method !== 'function') {
state.sentinel = { epochMs: null, status: 'bridge-missing' };
return;
}
try {
const result: unknown = method.call(bridge, options.sentinelId);
state.sentinel = { epochMs: epoch(), status: 'sent' };
void Promise.resolve(result).catch(() => undefined);
} catch {
state.sentinel = { epochMs: null, status: 'failed' };
}
};
const isVisible = (element: Element | null): element is HTMLElement =>
element instanceof HTMLElement && element.getClientRects().length > 0;
const readNavigation = (): JourneyRendererProbeState['navigation'] => {
if (typeof performance.getEntriesByType !== 'function') {
return null;
}
const entry = performance.getEntriesByType('navigation')[0] as
PerformanceNavigationTiming | undefined;
if (!entry || entry.loadEventEnd <= 0) {
return null;
}
return {
domContentLoadedEpochMs:
performance.timeOrigin + entry.domContentLoadedEventEnd,
loadEventEndEpochMs: performance.timeOrigin + entry.loadEventEnd,
};
};
const mutationObserver = new MutationObserver((records) => {
if (state.terminal !== null) return;
state.counters.domMutations += records.length;
if (
!location.pathname.includes(options.routeFragment) ||
document.getElementById(options.splashId) !== null
) {
return;
}
const card = document.querySelector(options.cardSelector);
if (!isVisible(card)) return;
state.terminal = {
cardTag: card.tagName.toLowerCase(),
cardTestId: card.getAttribute('data-test-id'),
epochMs: epoch(),
pathname: location.pathname,
};
mutationObserver.disconnect();
sendSentinel();
state.navigation = readNavigation();
if (state.navigation === null) {
state.invalidReasons.push('load-event-not-finished-at-first-card');
}
const ng = target['ng'] as Record<string, unknown> | undefined;
state.capabilities.changeDetectionTicks =
typeof ng?.['ɵsetProfiler'] === 'function'
? 'hook-present-not-counted'
: 'unavailable-ng-global-not-published';
// A rAF callback runs before that frame's style, layout and paint,
// so the cutoff is sampled in a timer queued from it: by then the
// frame that paints the card has been committed, and the render
// task's own long task and layout shift fall inside the cutoff.
requestAnimationFrame(() => {
setTimeout(() => finalize(epoch()), 0);
});
});
mutationObserver.observe(document.documentElement ?? document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
}
export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
journey: 'launch',
routeFragment: '/workspace',
sentinelId: JOURNEY_IPC_SENTINEL_ID,
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
splashId: 'initial-splash',
stateKey: JOURNEY_PROBE_STATE_KEY,
};
}
/**
* Registers the probe on a page that is still parked on `about:blank` by the
* journey gate, so it is guaranteed to run at the start of the next document.
*/
export async function installJourneyRendererProbe(
page: Page,
options: JourneyRendererProbeOptions
): Promise<void> {
await page.addInitScript(journeyRendererProbeScript, options);
}
export async function waitForJourneyRendererProbe(
page: Page,
stateKey: string,
timeoutMs: number
): Promise<JourneyRendererProbeState> {
await page.waitForFunction(
(key) =>
(globalThis as unknown as Record<string, { final?: boolean }>)[key]
?.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 assertJourneyRendererProbeState(state);
}
export function assertJourneyRendererProbeState(
value: unknown
): JourneyRendererProbeState {
const state = value as JourneyRendererProbeState | null;
if (
!state ||
state.schemaVersion !== JOURNEY_PROBE_SCHEMA_VERSION ||
state.final !== true ||
state.terminal === null
) {
throw new Error('journey-renderer-probe-incomplete');
}
if (state.invalidReasons.length > 0) {
throw new Error(
`journey-renderer-probe-invalid: ${state.invalidReasons.join(', ')}`
);
}
if (state.sentinel.status !== 'sent') {
throw new Error(
`journey-renderer-probe-sentinel-${state.sentinel.status}`
);
}
// A zero from an observer that never ran is not a measurement; a build
// without these entry types must fail the iteration, never lower a
// baseline.
const missing = (['layoutShift', 'longTask'] as const).filter(
(capability) => !state.capabilities[capability]
);
if (missing.length > 0) {
throw new Error(
`journey-renderer-probe-observer-unavailable: ${missing.join(', ')}`
);
}
return state;
}
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
formatJourneyOutputTimestamp,
JOURNEY_SUMMARY_SCHEMA_VERSION,
percentile,
resolveJourneySummaryPath,
summarizeJourneyIterations,
writeJourneySummary,
type JourneyIterationRecord,
type JourneySummary,
} from './journey-summary';
function iteration(
index: number,
counters: Record<string, number>,
wallClock: Record<string, number>,
warmup = false
): JourneyIterationRecord {
return {
counters,
evidence: {},
index,
pid: 100 + index,
wallClock,
warmup,
};
}
test('percentile interpolates linearly like the shared statistics helper', () => {
assert.equal(percentile([10], 90), 10);
assert.equal(percentile([30, 10, 20], 50), 20);
assert.equal(percentile([10, 20, 30, 40, 50], 90), 46);
assert.equal(percentile([10, 20, 30, 40, 50], 0), 10);
assert.throws(() => percentile([], 50), /percentile-input/);
assert.throws(() => percentile([1], 101), /percentile-input/);
});
test('summarizes exact counters and P50/P90 wall-clock from measured iterations only', () => {
const entry = summarizeJourneyIterations(
[
iteration(
0,
{ 'renderer.x': 99 },
{ spawnToFirstCardMs: 9_000 },
true
),
iteration(1, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_000 }),
iteration(2, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_200 }),
iteration(3, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_100 }),
iteration(4, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_300 }),
iteration(5, { 'renderer.x': 12 }, { spawnToFirstCardMs: 1_400 }),
],
{ 'renderer.y': 'not measurable' }
);
assert.deepEqual(entry.counters, { 'renderer.x': 12 });
assert.deepEqual(entry.counterStability, {
'renderer.x': { stable: true, values: [12, 12, 12, 12, 12] },
});
assert.deepEqual(entry.wallClock, {
'spawnToFirstCardMs.p50': 1_200,
'spawnToFirstCardMs.p90': 1_360,
});
assert.deepEqual(entry.unavailable, { 'renderer.y': 'not measurable' });
assert.equal(entry.iterations.length, 6);
});
test('reports the maximum and flags instability when measured counters disagree', () => {
const entry = summarizeJourneyIterations(
[
iteration(0, { a: 3, b: 0.5 }, { w: 1 }),
iteration(1, { a: 5, b: 0.5 }, { w: 2 }),
iteration(2, { a: 4, b: 0.5 }, { w: 3 }),
],
{}
);
assert.deepEqual(entry.counters, { a: 5, b: 0.5 });
assert.deepEqual(entry.counterStability['a'], {
stable: false,
values: [3, 5, 4],
});
assert.deepEqual(entry.counterStability['b'], {
stable: true,
values: [0.5, 0.5, 0.5],
});
assert.deepEqual(entry.wallClock, { 'w.p50': 2, 'w.p90': 2.8 });
});
test('rejects runs that cannot produce an exact summary', () => {
assert.throws(
() =>
summarizeJourneyIterations(
[iteration(0, { a: 1 }, { w: 1 }, true)],
{}
),
/no-measured-iterations/
);
assert.throws(
() =>
summarizeJourneyIterations(
[
iteration(0, { a: 1 }, { w: 1 }),
iteration(1, { b: 1 }, { w: 1 }),
],
{}
),
/counter-set-mismatch-iteration-1/
);
assert.throws(
() =>
summarizeJourneyIterations(
[
iteration(0, { a: 1 }, { w: 1 }),
iteration(1, { a: 1 }, { v: 1 }),
],
{}
),
/wall-clock-set-mismatch-iteration-1/
);
assert.throws(
() =>
summarizeJourneyIterations(
[iteration(0, { a: Number.NaN }, { w: 1 })],
{}
),
/counter-not-finite-a/
);
const duplicate = { ...iteration(1, { a: 1 }, { w: 1 }), pid: 100 };
assert.throws(
() =>
summarizeJourneyIterations(
[iteration(0, { a: 1 }, { w: 1 }), duplicate],
{}
),
/duplicate-pid/
);
});
test('writes the summary below dist/performance/journeys/<timestamp> and never overwrites', async () => {
const date = new Date('2026-09-26T10:49:12.345Z');
assert.equal(formatJourneyOutputTimestamp(date), '20260926T104912Z');
assert.throws(
() => formatJourneyOutputTimestamp(new Date('nope')),
/invalid-date/
);
const root = await mkdtemp(join(tmpdir(), 'iptvnator-journey-summary-'));
try {
const summaryPath = resolveJourneySummaryPath(root, date);
assert.equal(
summaryPath,
join(
root,
'dist',
'performance',
'journeys',
'20260926T104912Z',
'summary.json'
)
);
const summary: JourneySummary = {
generatedAt: date.toISOString(),
harness: {
arch: 'arm64',
ci: false,
electron: '43.0.0',
electronMain: 'dist/apps/electron-backend/main.js',
measuredIterations: 1,
node: 'v22',
platform: 'darwin',
rendererIndex: 'dist/apps/web/index.html',
warmupIterations: 0,
},
journeys: {
launch: summarizeJourneyIterations(
[iteration(0, { a: 1 }, { w: 1 })],
{}
),
},
schemaVersion: JOURNEY_SUMMARY_SCHEMA_VERSION,
};
await writeJourneySummary(summaryPath, summary);
const written = JSON.parse(
await readFile(summaryPath, 'utf8')
) as JourneySummary;
assert.equal(written.journeys['launch']?.counters['a'], 1);
assert.equal(written.journeys['launch']?.wallClock['w.p50'], 1);
await assert.rejects(
writeJourneySummary(summaryPath, summary),
/EEXIST/
);
} finally {
await rm(root, { force: true, recursive: true });
}
});
@@ -0,0 +1,204 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
/**
* Journey summary schema written to
* `dist/performance/journeys/<timestamp>/summary.json`.
*
* `journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are
* plain numbers so the ratchet checker in `tools/performance/` can compare
* them with the committed baselines. Everything else is evidence.
*/
export const JOURNEY_SUMMARY_SCHEMA_VERSION = 1;
export interface JourneyIterationRecord {
readonly counters: Readonly<Record<string, number>>;
readonly evidence: Readonly<Record<string, unknown>>;
readonly index: number;
readonly pid: number;
readonly wallClock: Readonly<Record<string, number>>;
readonly warmup: boolean;
}
export interface JourneyCounterStability {
readonly stable: boolean;
readonly values: readonly number[];
}
export interface JourneySummaryEntry {
readonly counterStability: Readonly<
Record<string, JourneyCounterStability>
>;
readonly counters: Readonly<Record<string, number>>;
readonly iterations: readonly JourneyIterationRecord[];
readonly unavailable: Readonly<Record<string, string>>;
readonly wallClock: Readonly<Record<string, number>>;
}
export interface JourneySummaryHarness {
readonly arch: string;
readonly ci: boolean;
readonly electron: string;
readonly electronMain: string;
readonly measuredIterations: number;
readonly node: string;
readonly platform: string;
readonly rendererIndex: string;
readonly warmupIterations: number;
}
export interface JourneySummary {
readonly generatedAt: string;
readonly harness: JourneySummaryHarness;
readonly journeys: Readonly<Record<string, JourneySummaryEntry>>;
readonly schemaVersion: number;
}
/** Linear-interpolation percentile, the method `performance-statistics.ts` uses. */
export function percentile(values: readonly number[], rank: number): number {
if (
values.length === 0 ||
!Number.isFinite(rank) ||
rank < 0 ||
rank > 100
) {
throw new Error('journey-summary-percentile-input');
}
const sorted = [...values].sort((left, right) => left - right);
if (sorted.length === 1) {
return sorted[0] ?? 0;
}
const position = ((sorted.length - 1) * rank) / 100;
const lowerIndex = Math.floor(position);
const upperIndex = Math.ceil(position);
const lower = sorted[lowerIndex] ?? 0;
const upper = sorted[upperIndex] ?? lower;
return lower + (upper - lower) * (position - lowerIndex);
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
function assertSameKeys(
expected: readonly string[],
actual: Readonly<Record<string, number>>,
kind: string,
index: number
): void {
const keys = Object.keys(actual).sort();
if (
keys.length !== expected.length ||
keys.some((key, position) => key !== expected[position])
) {
throw new Error(
`journey-summary-${kind}-set-mismatch-iteration-${index}`
);
}
for (const key of keys) {
if (!Number.isFinite(actual[key])) {
throw new Error(`journey-summary-${kind}-not-finite-${key}`);
}
}
}
/**
* Counters are exact: the summary carries the value shared by every measured
* iteration. When iterations disagree the maximum is reported (a ratchet
* must never read a value lower than what a run produced) and the
* disagreement is recorded in `counterStability` so the counter is not
* promoted to a guardrail until it is deterministic.
*/
export function summarizeJourneyIterations(
iterations: readonly JourneyIterationRecord[],
unavailable: Readonly<Record<string, string>>
): JourneySummaryEntry {
const measured = iterations.filter((iteration) => !iteration.warmup);
if (measured.length === 0) {
throw new Error('journey-summary-no-measured-iterations');
}
const pids = new Set(iterations.map((iteration) => iteration.pid));
if (pids.size !== iterations.length) {
throw new Error('journey-summary-duplicate-pid');
}
const first = measured[0] as JourneyIterationRecord;
const counterNames = Object.keys(first.counters).sort();
const wallClockNames = Object.keys(first.wallClock).sort();
for (const iteration of measured) {
assertSameKeys(
counterNames,
iteration.counters,
'counter',
iteration.index
);
assertSameKeys(
wallClockNames,
iteration.wallClock,
'wall-clock',
iteration.index
);
}
const counters: Record<string, number> = {};
const counterStability: Record<string, JourneyCounterStability> = {};
for (const name of counterNames) {
const values = measured.map(
(iteration) => iteration.counters[name] as number
);
counters[name] = Math.max(...values);
counterStability[name] = Object.freeze({
stable: values.every((value) => value === values[0]),
values: Object.freeze(values),
});
}
const wallClock: Record<string, number> = {};
for (const name of wallClockNames) {
const values = measured.map(
(iteration) => iteration.wallClock[name] as number
);
wallClock[`${name}.p50`] = roundTenth(percentile(values, 50));
wallClock[`${name}.p90`] = roundTenth(percentile(values, 90));
}
return Object.freeze({
counterStability: Object.freeze(counterStability),
counters: Object.freeze(counters),
iterations: Object.freeze([...iterations]),
unavailable: Object.freeze({ ...unavailable }),
wallClock: Object.freeze(wallClock),
});
}
/** `YYYYMMDDTHHMMSSZ`, the timestamp form the other benchmarks use. */
export function formatJourneyOutputTimestamp(date: Date): string {
if (Number.isNaN(date.getTime())) {
throw new Error('journey-summary-invalid-date');
}
return date
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z');
}
export function resolveJourneySummaryPath(
repositoryRoot: string,
date: Date = new Date()
): string {
return join(
repositoryRoot,
'dist',
'performance',
'journeys',
formatJourneyOutputTimestamp(date),
'summary.json'
);
}
export async function writeJourneySummary(
summaryPath: string,
summary: JourneySummary
): Promise<void> {
await mkdir(dirname(summaryPath), { recursive: true });
await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, {
encoding: 'utf8',
flag: 'wx',
});
}
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyRendererProbeState } from './journey-renderer-probe';
import {
LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS,
toLaunchIterationRecord,
type LaunchJourneyMeasurement,
} from './launch-journey-record';
function measurement(
overrides: Partial<LaunchJourneyMeasurement> = {}
): LaunchJourneyMeasurement {
const renderer: JourneyRendererProbeState = {
capabilities: {
changeDetectionTicks: 'unavailable-ng-global-not-published',
layoutShift: true,
longTask: true,
observedTarget: 'document',
},
counters: {
domMutations: 480,
layoutShiftScore: 0.123456789,
longTasks: 2,
},
final: true,
firstCardPaintEpochMs: 2_650,
installed: {
bridgePresent: true,
documentElementPresent: false,
epochMs: 1_200,
readyState: 'loading',
scriptCount: 0,
},
invalidReasons: [],
journey: 'launch',
longTaskDurationsMs: [71.26, 120.04],
navigation: {
domContentLoadedEpochMs: 1_300,
loadEventEndEpochMs: 1_400.26,
},
schemaVersion: 1,
sentinel: { epochMs: 2_601, status: 'sent' },
terminal: {
cardTag: 'div',
cardTestId: 'dashboard-recent-sources-rail-card',
epochMs: 2_600.04,
pathname: '/dist/apps/web/workspace/dashboard',
},
};
const ipc: JourneyMainIpcCaptureState = {
callsAfterSentinel: 3,
callsBeforeSentinel: 14,
callsByMethod: { dbGetAppPlaylists: 1, getSettings: 13 },
installedEpochMs: 1_100,
malformedEvents: 0,
processStartEpochMs: 900,
senderIds: [1],
sentinel: { occurrences: 1, receivedEpochMs: 2_602 },
};
return {
electronVersion: '43.3.0',
gate: {
blankLoadedEpochMs: 1_050,
errors: [],
gatedEpochMs: 1_020,
gatedMethod: 'loadFile',
passThroughLoads: 0,
releasedEpochMs: 1_150,
timedOut: false,
},
ipc,
pid: 4242,
renderer,
spawnEpochMs: 1_000,
...overrides,
};
}
test('maps the probe and IPC capture to exact counters and spawn-relative wall-clock', () => {
const record = toLaunchIterationRecord(2, false, measurement());
assert.equal(record.index, 2);
assert.equal(record.warmup, false);
assert.equal(record.pid, 4242);
assert.deepEqual(record.counters, {
'renderer.domMutationsToFirstCard': 480,
'renderer.ipcCallsToFirstCard': 14,
'renderer.layoutShiftScore': 0.123,
'renderer.longTasks': 2,
});
assert.deepEqual(record.wallClock, {
spawnToDidFinishLoadMs: 400.3,
spawnToFirstCardMs: 1_600,
});
assert.deepEqual(record.evidence['ipcCallsByMethod'], {
dbGetAppPlaylists: 1,
getSettings: 13,
});
assert.deepEqual(record.evidence['longTaskDurationsMs'], [71.3, 120]);
assert.equal(record.evidence['ipcCallsAfterFirstCard'], 3);
assert.deepEqual(record.evidence['epochs'], {
firstCard: 2_600.04,
firstCardPaint: 2_650,
loadEventEnd: 1_400.26,
mainIpcCaptureInstalled: 1_100,
mainProcessStart: 900,
rendererGateBlankLoaded: 1_050,
rendererGateReleased: 1_150,
rendererProbeInstalled: 1_200,
spawn: 1_000,
});
});
test('rejects measurements whose clocks or probes are inconsistent', () => {
const base = measurement();
assert.throws(
() =>
toLaunchIterationRecord(0, false, {
...base,
renderer: { ...base.renderer, navigation: null },
}),
/incomplete-probe/
);
assert.throws(
() =>
toLaunchIterationRecord(0, false, { ...base, spawnEpochMs: 2_700 }),
/clock-order/
);
assert.throws(
() =>
toLaunchIterationRecord(0, false, {
...base,
renderer: {
...base.renderer,
capabilities: {
...base.renderer.capabilities,
changeDetectionTicks: 'hook-present-not-counted',
},
},
}),
/cd-hook-hook-present-not-counted/
);
});
test('names the counters the harness cannot measure yet', () => {
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS).sort(), [
'main.sqlStatementsBeforeReadyToShow',
'renderer.cdTicksToFirstCard',
]);
});
@@ -0,0 +1,120 @@
import type { JourneyRendererGateState } from '../journeys/journey-renderer-gate-client';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyRendererProbeState } from './journey-renderer-probe';
import type { JourneyIterationRecord } from './journey-summary';
/**
* Maps one measured launch (renderer probe + main IPC capture) to the
* journey summary's iteration record for J1 "Launch to usable".
*/
export const LAUNCH_JOURNEY_ID = 'launch';
export const LAUNCH_JOURNEY_COUNTER = {
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
LONG_TASKS: 'renderer.longTasks',
} as const;
export const LAUNCH_JOURNEY_WALL_CLOCK = {
SPAWN_TO_DID_FINISH_LOAD: 'spawnToDidFinishLoadMs',
SPAWN_TO_FIRST_CARD: 'spawnToFirstCardMs',
} as const;
/**
* Counters the plan lists for J1 that this harness cannot measure without
* production changes. They are reported instead of faked.
*/
export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
Record<string, string>
> = Object.freeze({
'main.sqlStatementsBeforeReadyToShow':
'SQL statements are only visible as worker stdout trace lines, which are forwarded asynchronously; plan item A2 adds a countable channel.',
'renderer.cdTicksToFirstCard':
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
});
export interface LaunchJourneyMeasurement {
readonly electronVersion: string;
readonly gate: JourneyRendererGateState;
readonly ipc: JourneyMainIpcCaptureState;
readonly pid: number;
readonly renderer: JourneyRendererProbeState;
readonly spawnEpochMs: number;
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
export function toLaunchIterationRecord(
index: number,
warmup: boolean,
measurement: LaunchJourneyMeasurement
): JourneyIterationRecord {
const { ipc, renderer, spawnEpochMs } = measurement;
if (renderer.terminal === null || renderer.navigation === null) {
throw new Error('launch-journey-record-incomplete-probe');
}
const spawnToFirstCardMs = renderer.terminal.epochMs - spawnEpochMs;
const spawnToDidFinishLoadMs =
renderer.navigation.loadEventEndEpochMs - spawnEpochMs;
if (
spawnToDidFinishLoadMs <= 0 ||
spawnToFirstCardMs <= spawnToDidFinishLoadMs
) {
throw new Error('launch-journey-record-clock-order');
}
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
) {
throw new Error(
`launch-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}`
);
}
return Object.freeze({
counters: Object.freeze({
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
[LAUNCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]:
Math.round(renderer.counters.layoutShiftScore * 1_000) / 1_000,
[LAUNCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
}),
evidence: Object.freeze({
capabilities: renderer.capabilities,
electronVersion: measurement.electronVersion,
epochs: Object.freeze({
firstCard: renderer.terminal.epochMs,
firstCardPaint: renderer.firstCardPaintEpochMs,
loadEventEnd: renderer.navigation.loadEventEndEpochMs,
mainIpcCaptureInstalled: ipc.installedEpochMs,
mainProcessStart: ipc.processStartEpochMs,
rendererGateBlankLoaded: measurement.gate.blankLoadedEpochMs,
rendererGateReleased: measurement.gate.releasedEpochMs,
rendererProbeInstalled: renderer.installed.epochMs,
spawn: spawnEpochMs,
}),
firstCard: Object.freeze({
cardTag: renderer.terminal.cardTag,
cardTestId: renderer.terminal.cardTestId,
pathname: renderer.terminal.pathname,
}),
ipcCallsAfterFirstCard: ipc.callsAfterSentinel,
ipcCallsByMethod: ipc.callsByMethod,
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
observedTarget: renderer.capabilities.observedTarget,
}),
index,
pid: measurement.pid,
wallClock: Object.freeze({
[LAUNCH_JOURNEY_WALL_CLOCK.SPAWN_TO_DID_FINISH_LOAD]: roundTenth(
spawnToDidFinishLoadMs
),
[LAUNCH_JOURNEY_WALL_CLOCK.SPAWN_TO_FIRST_CARD]:
roundTenth(spawnToFirstCardMs),
}),
warmup,
});
}
@@ -62,13 +62,33 @@ export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = {
'database-worker-source-map'
),
},
main: {
deferredEvents: {
javascript: buildFile(
'dist/apps/electron-backend/deferred-events.js',
'deferred-events-javascript'
),
sourceMap: buildFile(
'dist/apps/electron-backend/deferred-events.js.map',
'deferred-events-source-map'
),
},
launcher: {
javascript: buildFile(
'dist/apps/electron-backend/main.js',
'main-javascript'
'launcher-javascript'
),
sourceMap: buildFile(
'dist/apps/electron-backend/main.js.map',
'launcher-source-map'
),
},
main: {
javascript: buildFile(
'dist/apps/electron-backend/main.app.js',
'main-javascript'
),
sourceMap: buildFile(
'dist/apps/electron-backend/main.app.js.map',
'main-source-map'
),
},
@@ -11,6 +11,8 @@ const RENDERER_FILE =
const BUILD_KEYS = ['electron', 'renderer'] as const;
const ELECTRON_KEYS = [
'databaseWorker',
'deferredEvents',
'launcher',
'main',
'playlistRefreshWorker',
'preload',
@@ -26,7 +28,9 @@ const RENDERER_KEYS = [
] as const;
const ELECTRON_PATHS = {
databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js',
main: 'dist/apps/electron-backend/main.js',
deferredEvents: 'dist/apps/electron-backend/deferred-events.js',
launcher: 'dist/apps/electron-backend/main.js',
main: 'dist/apps/electron-backend/main.app.js',
playlistRefreshWorker:
'dist/apps/electron-backend/workers/playlist-refresh.worker.js',
preload: 'dist/apps/electron-backend/main.preload.js',
@@ -44,6 +48,11 @@ export function parseXtreamBenchmarkBuildIdentity(
electronInput['databaseWorker'],
ELECTRON_PATHS.databaseWorker
),
deferredEvents: pair(
electronInput['deferredEvents'],
ELECTRON_PATHS.deferredEvents
),
launcher: pair(electronInput['launcher'], ELECTRON_PATHS.launcher),
main: pair(electronInput['main'], ELECTRON_PATHS.main),
playlistRefreshWorker: pair(
electronInput['playlistRefreshWorker'],
@@ -38,9 +38,19 @@ describe('Xtream benchmark build identity', () => {
assert.deepEqual(identity.electron.main.javascript, {
bytes: 4,
path: 'dist/apps/electron-backend/main.js',
path: 'dist/apps/electron-backend/main.app.js',
sha256: sha256('main'),
});
assert.deepEqual(identity.electron.launcher.javascript, {
bytes: 8,
path: 'dist/apps/electron-backend/main.js',
sha256: sha256('launcher'),
});
assert.deepEqual(identity.electron.deferredEvents.javascript, {
bytes: 8,
path: 'dist/apps/electron-backend/deferred-events.js',
sha256: sha256('deferred'),
});
assert.deepEqual(identity.electron.preload.sourceMap, {
bytes: 11,
path: 'dist/apps/electron-backend/main.preload.js.map',
@@ -112,7 +122,7 @@ describe('Xtream benchmark build identity', () => {
const symlinkRoot = await buildFixture();
const mainPath = join(
symlinkRoot,
'dist/apps/electron-backend/main.js'
'dist/apps/electron-backend/main.app.js'
);
const external = join(symlinkRoot, 'external-main.js');
await writeFile(external, 'main');
@@ -219,8 +229,12 @@ async function buildFixture(): Promise<string> {
mkdir(join(renderer, 'assets'), { recursive: true }),
]);
await Promise.all([
writeFile(join(backend, 'main.js'), 'main'),
writeFile(join(backend, 'main.js.map'), 'main-map'),
writeFile(join(backend, 'deferred-events.js'), 'deferred'),
writeFile(join(backend, 'deferred-events.js.map'), 'deferred-map'),
writeFile(join(backend, 'main.js'), 'launcher'),
writeFile(join(backend, 'main.js.map'), 'launcher-map'),
writeFile(join(backend, 'main.app.js'), 'main'),
writeFile(join(backend, 'main.app.js.map'), 'main-map'),
writeFile(join(backend, 'main.preload.js'), 'preload'),
writeFile(join(backend, 'main.preload.js.map'), 'preload-map'),
writeFile(join(workers, 'database.worker.js'), 'database'),
@@ -18,6 +18,8 @@ export interface XtreamBuildPairIdentity {
export interface XtreamBenchmarkBuildIdentity {
readonly electron: {
readonly databaseWorker: XtreamBuildPairIdentity;
readonly deferredEvents: XtreamBuildPairIdentity;
readonly launcher: XtreamBuildPairIdentity;
readonly main: XtreamBuildPairIdentity;
readonly playlistRefreshWorker: XtreamBuildPairIdentity;
readonly preload: XtreamBuildPairIdentity;
@@ -35,7 +37,13 @@ const BACKEND_ROOT = 'dist/apps/electron-backend';
const RENDERER_ROOT = 'dist/apps/web';
const ELECTRON_PATHS = {
databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`,
main: `${BACKEND_ROOT}/main.js`,
// main.app.js loads this chunk once the window starts loading; most IPC
// handlers and the database wiring live there.
deferredEvents: `${BACKEND_ROOT}/deferred-events.js`,
// main.js only enables the compile cache and requires main.app.js, but
// it decides startup behavior, so both belong to the identity.
launcher: `${BACKEND_ROOT}/main.js`,
main: `${BACKEND_ROOT}/main.app.js`,
playlistRefreshWorker: `${BACKEND_ROOT}/workers/playlist-refresh.worker.js`,
preload: `${BACKEND_ROOT}/main.preload.js`,
} as const;
@@ -45,17 +53,28 @@ export async function captureXtreamBuildIdentity(
): Promise<XtreamBenchmarkBuildIdentity> {
try {
if (!isAbsolute(workspaceRoot)) invalid();
const [databaseWorker, main, playlistRefreshWorker, preload, renderer] =
await Promise.all([
readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker),
readPair(workspaceRoot, ELECTRON_PATHS.main),
readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker),
readPair(workspaceRoot, ELECTRON_PATHS.preload),
readRenderer(workspaceRoot),
]);
const [
databaseWorker,
deferredEvents,
launcher,
main,
playlistRefreshWorker,
preload,
renderer,
] = await Promise.all([
readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker),
readPair(workspaceRoot, ELECTRON_PATHS.deferredEvents),
readPair(workspaceRoot, ELECTRON_PATHS.launcher),
readPair(workspaceRoot, ELECTRON_PATHS.main),
readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker),
readPair(workspaceRoot, ELECTRON_PATHS.preload),
readRenderer(workspaceRoot),
]);
return Object.freeze({
electron: Object.freeze({
databaseWorker,
deferredEvents,
launcher,
main,
playlistRefreshWorker,
preload,
+30 -2
View File
@@ -51,8 +51,15 @@
],
"options": {
"outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts",
"main": "apps/electron-backend/src/main.entry.ts",
"additionalEntryPoints": [
{
"entryName": "main.app",
"entryPath": "apps/electron-backend/src/main.ts"
}
],
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"webpackConfig": "apps/electron-backend/webpack.config.cjs",
"assets": [
"apps/electron-backend/src/assets",
{
@@ -143,8 +150,15 @@
],
"options": {
"outputPath": "dist/apps/electron-backend",
"main": "apps/electron-backend/src/main.ts",
"main": "apps/electron-backend/src/main.entry.ts",
"additionalEntryPoints": [
{
"entryName": "main.app",
"entryPath": "apps/electron-backend/src/main.ts"
}
],
"tsConfig": "apps/electron-backend/tsconfig.app.json",
"webpackConfig": "apps/electron-backend/webpack.config.cjs",
"assets": [
"apps/electron-backend/src/assets",
{
@@ -197,6 +211,13 @@
"options": {
"name": "electron-backend",
"frontendProject": "web",
"files": [
{
"from": "electron-backend",
"to": "electron-backend",
"filter": ["main.app.js", "deferred-events.js"]
}
],
"sourcePath": "dist/apps",
"outputPath": "dist/packages",
"prepackageOnly": true
@@ -211,6 +232,13 @@
"options": {
"name": "electron-backend",
"frontendProject": "web",
"files": [
{
"from": "electron-backend",
"to": "electron-backend",
"filter": ["main.app.js", "deferred-events.js"]
}
],
"sourcePath": "dist/apps",
"outputPath": "dist/executables"
}
@@ -0,0 +1,255 @@
import { join } from 'node:path';
import {
COMPILE_CACHE_DIR_ENV,
COMPILE_CACHE_DISABLE_ENV,
enableStartupCompileCache,
isCompileCacheDisabled,
publishCompileCacheOutcome,
readCompileCacheOutcome,
resolveCompileCacheDirectory,
type CompileCacheEnableResult,
type CompileCacheModule,
} from './compile-cache';
const OUTCOME_KEY = Symbol.for('iptvnator.compileCacheOutcome');
const userData = join('/profiles', 'iptvnator');
const defaultDirectory = join(userData, 'v8-compile-cache');
function createModule(
result: CompileCacheEnableResult = {
status: 1,
directory: defaultDirectory,
}
): jest.Mocked<Required<CompileCacheModule>> {
return { enableCompileCache: jest.fn().mockReturnValue(result) };
}
function enable(
env: NodeJS.ProcessEnv,
module: CompileCacheModule = createModule(),
userDataPath: () => string = () => userData
) {
return enableStartupCompileCache({ env, module, userDataPath });
}
describe('startup compile cache guard', () => {
afterEach(() => {
delete (globalThis as Record<symbol, unknown>)[OUTCOME_KEY];
});
describe('cache directory', () => {
it('lives under userData/v8-compile-cache by default', () => {
expect(resolveCompileCacheDirectory({}, () => userData)).toBe(
defaultDirectory
);
});
it('prefers IPTVNATOR_COMPILE_CACHE_DIR and never asks for userData then', () => {
const userDataPath = jest.fn(() => userData);
const directory = resolveCompileCacheDirectory(
{ [COMPILE_CACHE_DIR_ENV]: ' /tmp/iptvnator-cache ' },
userDataPath
);
expect(directory).toBe('/tmp/iptvnator-cache');
expect(userDataPath).not.toHaveBeenCalled();
});
it('ignores a blank IPTVNATOR_COMPILE_CACHE_DIR', () => {
expect(
resolveCompileCacheDirectory(
{ [COMPILE_CACHE_DIR_ENV]: ' ' },
() => userData
)
).toBe(defaultDirectory);
});
it('stays inside the E2E data directory, mirroring the userData override', () => {
const directory = resolveCompileCacheDirectory(
{ IPTVNATOR_E2E_DATA_DIR: '/tmp/e2e-run' },
() => userData
);
expect(directory).toBe(
join('/tmp/e2e-run', 'user-data', 'v8-compile-cache')
);
});
});
describe('kill switch', () => {
it.each(['1', 'true', 'yes', 'ON', ' on '])(
'honours IPTVNATOR_DISABLE_COMPILE_CACHE=%j without touching node:module',
(value) => {
const module = createModule();
const userDataPath = jest.fn(() => userData);
const outcome = enableStartupCompileCache({
env: { [COMPILE_CACHE_DISABLE_ENV]: value },
module,
userDataPath,
});
expect(outcome).toEqual({
status: 'disabled',
reason: COMPILE_CACHE_DISABLE_ENV,
});
expect(module.enableCompileCache).not.toHaveBeenCalled();
expect(userDataPath).not.toHaveBeenCalled();
}
);
it.each(['', '0', 'false', 'off'])(
'keeps the cache on for IPTVNATOR_DISABLE_COMPILE_CACHE=%j',
(value) => {
expect(
isCompileCacheDisabled({
[COMPILE_CACHE_DISABLE_ENV]: value,
})
).toBe(false);
expect(
enable({ [COMPILE_CACHE_DISABLE_ENV]: value }).status
).toBe('enabled');
}
);
it('defaults to enabled when the variable is absent', () => {
expect(isCompileCacheDisabled({})).toBe(false);
});
});
describe('enabling', () => {
it('enables the cache in the resolved directory', () => {
const module = createModule();
const outcome = enable({}, module);
expect(module.enableCompileCache).toHaveBeenCalledWith(
defaultDirectory
);
expect(outcome).toEqual({
status: 'enabled',
directory: defaultDirectory,
});
});
it('reports the directory Node settled on when it differs', () => {
const module = createModule({
status: 1,
directory: '/resolved/elsewhere',
});
expect(enable({}, module)).toEqual({
status: 'enabled',
directory: '/resolved/elsewhere',
});
});
it('maps ALREADY_ENABLED without treating it as a failure', () => {
expect(enable({}, createModule({ status: 2 }))).toEqual({
status: 'already-enabled',
directory: defaultDirectory,
});
});
it('maps Node’s own DISABLED status and keeps its message', () => {
expect(
enable(
{},
createModule({
status: 3,
message: 'NODE_DISABLE_COMPILE_CACHE is set',
})
)
).toEqual({
status: 'disabled',
reason: 'NODE_DISABLE_COMPILE_CACHE is set',
});
});
it('maps FAILED with the message Node gives', () => {
expect(
enable(
{},
createModule({
status: 0,
message: 'cannot create directory',
})
)
).toEqual({
status: 'failed',
directory: defaultDirectory,
reason: 'cannot create directory',
});
});
});
describe('failure tolerance', () => {
it('reports unavailable when Node has no enableCompileCache', () => {
expect(enable({}, {})).toEqual({
status: 'unavailable',
reason: 'module.enableCompileCache is missing',
});
});
it('swallows an exception thrown by enableCompileCache', () => {
const module: CompileCacheModule = {
enableCompileCache: () => {
throw new Error('EACCES: permission denied');
},
};
expect(enable({}, module)).toEqual({
status: 'failed',
directory: defaultDirectory,
reason: 'EACCES: permission denied',
});
});
it('swallows a failing userData lookup', () => {
const module = createModule();
const outcome = enable({}, module, () => {
throw new Error('app is not ready');
});
expect(outcome).toEqual({
status: 'failed',
reason: 'app is not ready',
});
expect(module.enableCompileCache).not.toHaveBeenCalled();
});
it('stringifies non-Error throwables', () => {
const module: CompileCacheModule = {
enableCompileCache: () => {
throw 'boom';
},
};
expect(enable({}, module).reason).toBe('boom');
});
});
describe('outcome hand-over to the bundle', () => {
it('reads back what the entry published', () => {
publishCompileCacheOutcome({
status: 'enabled',
directory: defaultDirectory,
});
expect(readCompileCacheOutcome()).toEqual({
status: 'enabled',
directory: defaultDirectory,
});
});
it('explains a missing outcome instead of tracing undefined', () => {
expect(readCompileCacheOutcome()).toEqual({
status: 'unavailable',
reason: 'main.entry did not run',
});
});
});
});
@@ -0,0 +1,148 @@
/**
* V8 compile cache for the Electron main process.
*
* `dist/apps/electron-backend/main.js` is built from `main.entry.ts`. It
* enables Node's on-disk compile cache and only then requires the real
* application bundle, `main.app.js` (built from `main.ts`), so the bundle and
* the packages it pulls in are compiled from cached bytecode on every launch
* after the first. V8 produces a code cache for the script it is compiling,
* which is why the call cannot live inside the bundle it is meant to cache.
*
* This module holds the decision logic so the entry stays a few lines and the
* guards are unit-testable: an environment kill switch, an explicit cache
* directory, and failure tolerance (a broken cache must never block startup).
*/
import { join } from 'node:path';
export const COMPILE_CACHE_DISABLE_ENV = 'IPTVNATOR_DISABLE_COMPILE_CACHE';
export const COMPILE_CACHE_DIR_ENV = 'IPTVNATOR_COMPILE_CACHE_DIR';
export const COMPILE_CACHE_DIR_NAME = 'v8-compile-cache';
const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
const OUTCOME_KEY = Symbol.for('iptvnator.compileCacheOutcome');
/** `module.constants.compileCacheStatus` of Node 22.1+, by value. */
const NODE_STATUS = {
FAILED: 0,
ENABLED: 1,
ALREADY_ENABLED: 2,
DISABLED: 3,
} as const;
export type CompileCacheStatus =
'enabled' | 'already-enabled' | 'disabled' | 'unavailable' | 'failed';
export interface CompileCacheOutcome {
readonly status: CompileCacheStatus;
readonly directory?: string;
readonly reason?: string;
}
export interface CompileCacheEnableResult {
readonly status: number;
readonly directory?: string;
readonly message?: string;
}
/** The slice of `node:module` the entry relies on; injectable for tests. */
export interface CompileCacheModule {
enableCompileCache?: (directory?: string) => CompileCacheEnableResult;
}
export interface EnableCompileCacheOptions {
readonly env?: NodeJS.ProcessEnv;
readonly module: CompileCacheModule;
/** Electron's `userData`; only consulted without an explicit directory. */
readonly userDataPath: () => string;
}
export function isCompileCacheDisabled(
env: NodeJS.ProcessEnv = process.env
): boolean {
const value = env[COMPILE_CACHE_DISABLE_ENV]?.trim().toLowerCase();
return value ? TRUE_VALUES.has(value) : false;
}
export function resolveCompileCacheDirectory(
env: NodeJS.ProcessEnv,
userDataPath: () => string
): string {
const explicit = env[COMPILE_CACHE_DIR_ENV]?.trim();
if (explicit) {
return explicit;
}
// Mirrors getElectronUserDataPath() in @iptvnator/shared/database, which
// profile bootstrap applies later. The entry cannot import that library
// without loading the database stack ahead of the cache.
const e2eDataDir = env.IPTVNATOR_E2E_DATA_DIR?.trim();
const userData = e2eDataDir
? join(e2eDataDir, 'user-data')
: userDataPath();
return join(userData, COMPILE_CACHE_DIR_NAME);
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function enableStartupCompileCache(
options: EnableCompileCacheOptions
): CompileCacheOutcome {
const env = options.env ?? process.env;
if (isCompileCacheDisabled(env)) {
return { status: 'disabled', reason: COMPILE_CACHE_DISABLE_ENV };
}
const enable = options.module.enableCompileCache;
if (typeof enable !== 'function') {
return {
status: 'unavailable',
reason: 'module.enableCompileCache is missing',
};
}
let directory: string | undefined;
try {
directory = resolveCompileCacheDirectory(env, options.userDataPath);
const result = enable.call(options.module, directory);
switch (result.status) {
case NODE_STATUS.ENABLED:
return {
status: 'enabled',
directory: result.directory ?? directory,
};
case NODE_STATUS.ALREADY_ENABLED:
return {
status: 'already-enabled',
directory: result.directory ?? directory,
};
case NODE_STATUS.DISABLED:
return {
status: 'disabled',
reason: result.message ?? 'NODE_DISABLE_COMPILE_CACHE',
};
default:
return {
status: 'failed',
directory,
reason: result.message ?? `status ${result.status}`,
};
}
} catch (error) {
return { status: 'failed', directory, reason: describeError(error) };
}
}
/** Hands the entry's outcome to the bundle, which owns the startup trace. */
export function publishCompileCacheOutcome(outcome: CompileCacheOutcome): void {
(globalThis as Record<symbol, unknown>)[OUTCOME_KEY] = outcome;
}
export function readCompileCacheOutcome(): CompileCacheOutcome {
const outcome = (globalThis as Record<symbol, unknown>)[OUTCOME_KEY] as
CompileCacheOutcome | undefined;
return (
outcome ?? { status: 'unavailable', reason: 'main.entry did not run' }
);
}
@@ -0,0 +1,145 @@
import { EventEmitter } from 'node:events';
import { createDeferredBootstrap } from './deferred-bootstrap';
interface FakeModule {
readonly name: string;
}
const fakeModule: FakeModule = { name: 'deferred' };
/** Mirrors webpack's node chunk loading: a synchronous require behind a resolved promise. */
const loadResolved = () => Promise.resolve(fakeModule);
function macrotask(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
describe('deferred main-process bootstrap', () => {
it('registers handlers before the next macrotask once the window starts loading', async () => {
const order: string[] = [];
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: (module) => {
order.push(`run:${module.name}`);
return 'registered';
},
});
bootstrap.armOn(webContents);
// An IPC message that the renderer sends right after it starts
// loading arrives as a macrotask; it must queue behind registration.
setImmediate(() => order.push('renderer-ipc'));
webContents.emit('did-start-loading');
await macrotask();
expect(order).toEqual(['run:deferred', 'renderer-ipc']);
expect(bootstrap.module).toBe(fakeModule);
});
it('runs the deferred work exactly once across both triggers', async () => {
const run = jest.fn(() => 'once');
const load = jest.fn(loadResolved);
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({ load, run });
bootstrap.armOn(webContents);
webContents.emit('did-start-loading');
webContents.emit('did-start-loading');
const explicit = bootstrap.trigger();
const outcome = await explicit;
expect(load).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledTimes(1);
expect(outcome).toEqual({ module: fakeModule, result: 'once' });
await expect(bootstrap.trigger()).resolves.toBe(outcome);
});
it('falls back to the explicit trigger when no window is available', async () => {
const sources: string[] = [];
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: () => undefined,
onTrigger: (source) => sources.push(source),
});
bootstrap.armOn(null);
bootstrap.armOn(undefined);
await bootstrap.trigger();
expect(sources).toEqual(['explicit']);
});
it('reports the trigger source and the duration', async () => {
const onTrigger = jest.fn();
const onDone = jest.fn();
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: () => undefined,
onTrigger,
onDone,
});
bootstrap.armOn(webContents);
webContents.emit('did-start-loading');
await bootstrap.trigger();
expect(onTrigger).toHaveBeenCalledTimes(1);
expect(onTrigger).toHaveBeenCalledWith('did-start-loading');
expect(onDone).toHaveBeenCalledTimes(1);
expect(onDone.mock.calls[0][0]).toBeGreaterThanOrEqual(0);
});
it('surfaces a failed load to every awaiting caller without running handlers', async () => {
const run = jest.fn();
const onError = jest.fn();
const bootstrap = createDeferredBootstrap({
load: () => Promise.reject(new Error('chunk missing')),
run,
onError,
});
const first = bootstrap.trigger();
const second = bootstrap.trigger();
await expect(first).rejects.toThrow('chunk missing');
await expect(second).rejects.toThrow('chunk missing');
expect(run).not.toHaveBeenCalled();
expect(bootstrap.module).toBeNull();
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(expect.any(Error));
});
it('reports a failure fired by the window event instead of leaving it unhandled', async () => {
const unhandled = jest.fn();
process.on('unhandledRejection', unhandled);
try {
const onError = jest.fn();
const webContents = new EventEmitter();
const bootstrap = createDeferredBootstrap({
load: loadResolved,
run: () => {
throw new Error('handler registration failed');
},
onError,
});
bootstrap.armOn(webContents);
webContents.emit('did-start-loading');
await macrotask();
await macrotask();
expect(onError).toHaveBeenCalledTimes(1);
expect(unhandled).not.toHaveBeenCalled();
// A later awaiting caller still sees the failure.
await expect(bootstrap.trigger()).rejects.toThrow(
'handler registration failed'
);
expect(onError).toHaveBeenCalledTimes(1);
} finally {
process.off('unhandledRejection', unhandled);
}
});
});
@@ -0,0 +1,88 @@
/**
* Runs a deferred piece of main-process startup exactly once, triggered by
* the main window's `did-start-loading` event or, as a fallback, explicitly.
*
* Ordering guarantee relied on by main.ts: `load()` is a webpack dynamic
* import of a sibling chunk, which on the Electron main target is a
* synchronous `require` wrapped in an already-resolved promise, and `run()`
* registers IPC handlers synchronously. Both therefore finish within the
* microtask checkpoint of the task that fired the trigger. A renderer IPC
* message is delivered as a separate macrotask, so no `invoke` can arrive
* between the renderer starting to load and the handlers existing.
*/
export interface DeferredBootstrapOptions<TModule, TResult> {
readonly load: () => Promise<TModule>;
readonly run: (module: TModule) => TResult;
readonly onTrigger?: (source: DeferredBootstrapTrigger) => void;
readonly onDone?: (durationMs: number) => void;
/**
* Called once when the load or the registration fails. The event
* listener has no caller to report to, so without this a failure would
* only surface as an unhandled rejection; the promise returned by
* `trigger()` still rejects for callers that await it.
*/
readonly onError?: (error: unknown) => void;
}
export type DeferredBootstrapTrigger = 'did-start-loading' | 'explicit';
export interface DeferredBootstrapOutcome<TModule, TResult> {
readonly module: TModule;
readonly result: TResult;
}
export interface DeferredBootstrapWebContents {
once(event: 'did-start-loading', listener: () => void): unknown;
}
export interface DeferredBootstrap<TModule, TResult> {
/** The loaded module, or null until the trigger has fired. */
readonly module: TModule | null;
/** Arms the `did-start-loading` trigger; a missing webContents is a no-op. */
armOn(webContents: DeferredBootstrapWebContents | null | undefined): void;
/** Starts load + run if not started yet; always returns the same promise. */
trigger(
source?: DeferredBootstrapTrigger
): Promise<DeferredBootstrapOutcome<TModule, TResult>>;
}
export function createDeferredBootstrap<TModule, TResult>(
options: DeferredBootstrapOptions<TModule, TResult>
): DeferredBootstrap<TModule, TResult> {
let started: Promise<DeferredBootstrapOutcome<TModule, TResult>> | null =
null;
let loadedModule: TModule | null = null;
const trigger = (
source: DeferredBootstrapTrigger = 'explicit'
): Promise<DeferredBootstrapOutcome<TModule, TResult>> => {
if (started) {
return started;
}
options.onTrigger?.(source);
const startedAt = performance.now();
started = options.load().then((module) => {
loadedModule = module;
const result = options.run(module);
options.onDone?.(performance.now() - startedAt);
return { module, result };
});
started.catch((error: unknown) => options.onError?.(error));
return started;
};
return {
get module() {
return loadedModule;
},
armOn(webContents) {
webContents?.once('did-start-loading', () => {
// Rejections are reported through onError and re-surface to
// whoever awaits trigger(); nothing to handle here.
trigger('did-start-loading').catch(() => undefined);
});
},
trigger,
};
}
@@ -0,0 +1,180 @@
/**
* Main-process work that only has to exist once the renderer has started
* loading: portal, EPG, download, player, probe, remote-control and update
* IPC, the database, and the recovery passes that follow the first load.
*
* main.ts loads this module through a dynamic import inside the main
* window's `did-start-loading` listener (see deferred-bootstrap.ts), so the
* heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3,
* electron-updater, fix-path) are evaluated while the renderer parses and
* runs its own bundle instead of before the window can load at all.
*
* Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no
* renderer `invoke` finds a missing handler depends on it.
*/
import { app } from 'electron';
import { autoUpdater } from 'electron-updater';
import { registerM3uSourceProbe } from '../events/m3u-source-probe';
import { registerSourceProbeCancellation } from '../events/source-probe-control';
import App from '../app';
import { initDatabase } from '../database/connection';
import DatabaseEvents from '../events/database.events';
import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from '../events/database/downloads.events';
import { setRecordingsMainWindow } from '../events/database/recording-broadcast';
import { reconcileStaleRecordings } from '../events/database/recording-recovery';
import ElectronEvents from '../events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from '../events/embedded-mpv.events';
import EpgEvents from '../events/epg.events';
import AppUpdateEvents from '../events/app-update.events';
import { shutdownMpvSession } from '../events/mpv-session.service';
import PlayerEvents from '../events/player.events';
import { shutdownVlcSession } from '../events/vlc-session.service';
import PlaylistEvents from '../events/playlist.events';
import ParentalLockEvents from '../events/parental-lock.events';
import RemoteControlEvents from '../events/remote-control.events';
import SettingsEvents from '../events/settings.events';
import SharedEvents from '../events/shared.events';
import StalkerEvents from '../events/stalker.events';
import XtreamEvents from '../events/xtream.events';
import { registerStreamProbeHandlers } from '../events/stream-probe';
import { registerConnectivityGuardHandlers } from '../events/connectivity-guard.events';
import { isStartupTraceEnabled, trace } from '../services/debug-trace';
import { AppUpdateService } from '../services/app-update.service';
import {
onAppUpdateChannelChange,
readStoredAppUpdateChannel,
} from '../services/app-update-channel';
import { databaseWorkerClient } from '../services/database-worker-client';
import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service';
export interface DeferredEventsContext {
readonly appVersion: string;
readonly windowCloseGuard: ReturnType<typeof bootstrapWindowCloseGuard>;
}
export interface DeferredEventsHandles {
readonly appUpdateService: AppUpdateService;
}
export function bootstrapDeferredEvents(
context: DeferredEventsContext
): DeferredEventsHandles {
const { windowCloseGuard } = context;
const appUpdateService = new AppUpdateService({
app,
appVersion: context.appVersion,
channel: readStoredAppUpdateChannel(),
getMainWindow: () => App.mainWindow,
updater: () => autoUpdater,
// quitAndInstall() closes the windows before 'before-quit' fires
// (macOS), so without this an armed close guard would intercept
// the install's window close and strand the update.
prepareQuit: () => windowCloseGuard.allowNextClose(),
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
});
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
onAppUpdateChannelChange((channel) => appUpdateService.setChannel(channel));
ElectronEvents.bootstrapElectronEvents();
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
PlaylistEvents.bootstrapPlaylistEvents();
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
ParentalLockEvents.bootstrapParentalLockEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
registerM3uSourceProbe();
registerSourceProbeCancellation();
registerConnectivityGuardHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Keep the downloads broadcaster bound to the live window. macOS can
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
App.onMainWindowCreated(setRecordingsMainWindow);
return { appUpdateService };
}
/**
* Database initialization and recovery, after the first renderer load is
* underway so Linux Electron E2E can observe a BrowserWindow even when
* SQLite startup or download recovery is slow. IPC handlers call
* getDatabase() lazily and share the same initialization promise.
*/
export async function finishStartupAfterFirstLoad(): Promise<void> {
await initDatabase();
if (isStartupTraceEnabled()) {
trace('startup', 'init-database:done');
}
await resetStaleDownloads();
if (isStartupTraceEnabled()) {
trace('startup', 'reset-stale-downloads:done');
}
await reconcileStaleRecordings();
if (isStartupTraceEnabled()) {
trace('startup', 'reconcile-stale-recordings:done');
}
}
let fixPathScheduled = false;
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name.
*
* Runs after window creation + IPC handler registration so the 50-300 ms
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
* subsequent calls are no-ops. fix-path itself is imported here, on demand,
* so its module evaluation stays off the launch path as well.
*/
export function scheduleDeferredFixPath(): void {
if (fixPathScheduled || process.platform === 'win32') {
return;
}
fixPathScheduled = true;
setImmediate(() => {
import('fix-path')
.then(({ default: fixPath }) => {
fixPath();
if (isStartupTraceEnabled()) {
trace('startup', 'fix-path:done');
}
})
.catch((error) => {
console.warn('fix-path failed:', error);
});
});
}
/** Tears down sessions and the DB worker; safe when nothing was started. */
export function shutdownDeferredServices(): void {
shutdownEmbeddedMpv();
shutdownMpvSession();
shutdownVlcSession();
void databaseWorkerClient.shutdown();
}
/** The module shape main.ts receives from its dynamic import. */
export type DeferredEventsModule = {
readonly bootstrapDeferredEvents: typeof bootstrapDeferredEvents;
readonly finishStartupAfterFirstLoad: typeof finishStartupAfterFirstLoad;
readonly scheduleDeferredFixPath: typeof scheduleDeferredFixPath;
readonly shutdownDeferredServices: typeof shutdownDeferredServices;
};
+35
View File
@@ -0,0 +1,35 @@
/**
* Process entry of the Electron main process. It becomes
* `dist/apps/electron-backend/main.js`: the file Electron, `nx serve`, the E2E
* fixtures and the packaged app launch.
*
* It enables the V8 compile cache and only then requires the application
* bundle, `main.app.js` (built from `main.ts`). V8 writes a code cache for
* the script being compiled, so a call inside the bundle would leave the
* bundle itself uncached; only this small file pays the uncached compile.
* Keep it free of imports beyond `electron`, Node built-ins and the guard
* helper: anything imported here is compiled before the cache is on.
*/
import { app } from 'electron';
import * as nodeModule from 'node:module';
import {
enableStartupCompileCache,
publishCompileCacheOutcome,
type CompileCacheModule,
} from './app/services/compile-cache';
declare const __non_webpack_require__: NodeJS.Require;
publishCompileCacheOutcome(
enableStartupCompileCache({
module: nodeModule as CompileCacheModule,
// Read before main.ts calls app.setName(), deliberately: renaming
// here would move every path derived from the app name, including
// the settings store's, for existing Linux profiles. On macOS and
// Windows the directory is the same either way.
userDataPath: () => app.getPath('userData'),
})
);
// Resolved next to this file at run time; webpack must not inline it.
__non_webpack_require__('./main.app.js');
+78 -132
View File
@@ -1,51 +1,24 @@
import { registerM3uSourceProbe } from './app/events/m3u-source-probe';
import { registerSourceProbeCancellation } from './app/events/source-probe-control';
// Select persistence before eager imports (notably electron-conf) cache userData.
import './app/services/electron-profile-bootstrap';
import { app, BrowserWindow } from 'electron';
import { autoUpdater } from 'electron-updater';
import fixPath from 'fix-path';
import App from './app/app';
import { initDatabase } from './app/database/connection';
import DatabaseEvents from './app/events/database.events';
import {
resetStaleDownloads,
setMainWindow as setDownloadsMainWindow,
} from './app/events/database/downloads.events';
import { setRecordingsMainWindow } from './app/events/database/recording-broadcast';
import { reconcileStaleRecordings } from './app/events/database/recording-recovery';
import ElectronEvents from './app/events/electron.events';
import EmbeddedMpvEvents, {
shutdownEmbeddedMpv,
} from './app/events/embedded-mpv.events';
import EpgEvents from './app/events/epg.events';
import AppUpdateEvents from './app/events/app-update.events';
import { shutdownMpvSession } from './app/events/mpv-session.service';
import PlayerEvents from './app/events/player.events';
import { shutdownVlcSession } from './app/events/vlc-session.service';
import PlaylistEvents from './app/events/playlist.events';
import PlaylistOpenEvents from './app/events/playlist-open.events';
import RemoteControlEvents from './app/events/remote-control.events';
import ParentalLockEvents from './app/events/parental-lock.events';
import SettingsEvents from './app/events/settings.events';
import SharedEvents from './app/events/shared.events';
import SquirrelEvents from './app/events/squirrel.events';
import StalkerEvents from './app/events/stalker.events';
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
import { readCompileCacheOutcome } from './app/services/compile-cache';
import { applyElectronNetworkDefaults } from './app/util/network-defaults';
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
import { AppUpdateService } from './app/services/app-update.service';
import {
onAppUpdateChannelChange,
readStoredAppUpdateChannel,
} from './app/services/app-update-channel';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service';
import { registerStreamProbeHandlers } from './app/events/stream-probe';
import { registerConnectivityGuardHandlers } from './app/events/connectivity-guard.events';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
import {
createDeferredBootstrap,
type DeferredBootstrap,
} from './app/startup/deferred-bootstrap';
import type {
DeferredEventsHandles,
DeferredEventsModule,
} from './app/startup/deferred-events';
import {
isFrameCopyRuntimeUsable,
shouldPromotePersistedFrameCopyOptIn,
@@ -62,6 +35,10 @@ import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service';
app.setName('iptvnator');
if (isStartupTraceEnabled()) {
trace('startup', 'compile-cache', readCompileCacheOutcome());
}
// Before the first portal, playlist or update request leaves this process.
applyElectronNetworkDefaults((line) => {
if (isStartupTraceEnabled()) {
@@ -100,33 +77,11 @@ if (
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
}
let fixPathScheduled = false;
/**
* Update process.env.PATH from the user's interactive login shell so that
* spawned external players (MPV/VLC) can be resolved by binary name.
*
* Runs after window creation + IPC handler registration so the 50-300 ms
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
* subsequent calls are no-ops.
*/
function scheduleDeferredFixPath(): void {
if (fixPathScheduled || process.platform === 'win32') {
return;
}
fixPathScheduled = true;
setImmediate(() => {
try {
fixPath();
if (isStartupTraceEnabled()) {
trace('startup', 'fix-path:done');
}
} catch (error) {
console.warn('fix-path failed:', error);
}
});
}
/** Set once bootstrapAppEvents() arms the deferred group; read at quit. */
let deferredEvents: DeferredBootstrap<
DeferredEventsModule,
DeferredEventsHandles
> | null = null;
export default class Main {
static initialize() {
@@ -143,6 +98,13 @@ export default class Main {
App.main(app, BrowserWindow);
}
/**
* Everything the renderer may call before its first paint registers
* here, synchronously, before the window loads. The rest lives in
* app/startup/deferred-events.ts and is loaded inside the window's
* `did-start-loading` listener (see deferred-bootstrap.ts for why that
* still guarantees the handlers exist before any renderer invoke).
*/
static async bootstrapAppEvents() {
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:start');
@@ -151,76 +113,62 @@ export default class Main {
const windowCloseGuard = bootstrapWindowCloseGuard((listener) =>
App.onMainWindowCreated(listener)
);
const appUpdateService = new AppUpdateService({
app,
appVersion: environment.version,
channel: readStoredAppUpdateChannel(),
getMainWindow: () => App.mainWindow,
updater: () => autoUpdater,
// quitAndInstall() closes the windows before 'before-quit' fires
// (macOS), so without this an armed close guard would intercept
// the install's window close and strand the update.
prepareQuit: () => windowCloseGuard.allowNextClose(),
cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(),
});
AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService);
onAppUpdateChannelChange((channel) =>
appUpdateService.setChannel(channel)
);
registerStaticHeaderShims();
ElectronEvents.bootstrapElectronEvents();
WindowEvents.bootstrapWindowEvents();
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
PlaylistEvents.bootstrapPlaylistEvents();
PlaylistOpenEvents.bootstrapPlaylistOpenEvents();
SharedEvents.bootstrapSharedEvents();
PlayerEvents.bootstrapPlayerEvents();
SettingsEvents.bootstrapSettingsEvents();
ParentalLockEvents.bootstrapParentalLockEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
registerM3uSourceProbe();
registerSourceProbeCancellation();
registerConnectivityGuardHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Keep the downloads broadcaster bound to the live window. macOS can
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
App.onMainWindowCreated(setRecordingsMainWindow);
const deferred = createDeferredBootstrap<
DeferredEventsModule,
DeferredEventsHandles
>({
load: () =>
import(
/* webpackChunkName: "deferred-events" */ './app/startup/deferred-events.js'
),
run: (module) =>
module.bootstrapDeferredEvents({
appVersion: environment.version,
windowCloseGuard,
}),
onTrigger: (source) => {
if (isStartupTraceEnabled()) {
trace('startup', 'deferred-events:start', { source });
}
},
onDone: (durationMs) => {
if (isStartupTraceEnabled()) {
trace('startup', 'deferred-events:done', { durationMs });
}
},
// The window is open by now; without this a missing chunk would
// only show up as an unhandled rejection with no context.
onError: (error) => {
console.error(
'Deferred main-process startup failed; portal, EPG, database and download handlers are unavailable:',
error
);
if (isStartupTraceEnabled()) {
trace('startup', 'deferred-events:failed', error);
}
},
});
deferredEvents = deferred;
deferred.armOn(App.mainWindow?.webContents);
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
// before the main process has installed handlers.
await App.loadMainWindow();
void appUpdateService.checkForUpdatesOnStartup();
// Load the renderer only after the pre-paint handlers are registered.
// The deferred group registers as soon as the navigation starts; the
// fallback below covers a load that never gets that far. Its errors
// surface through the awaited trigger(), so they are swallowed here.
const loadingMainWindow = App.loadMainWindow();
void loadingMainWindow
.catch(() => undefined)
.then(() => deferred.trigger())
.catch(() => undefined);
await loadingMainWindow;
const { module, result } = await deferred.trigger();
void result.appUpdateService.checkForUpdatesOnStartup();
// Initialize the database after the first renderer load is underway so
// Linux Electron E2E can observe a BrowserWindow even when SQLite
// startup or download recovery is slow. IPC handlers call getDatabase()
// lazily and share the same initialization promise.
await initDatabase();
if (isStartupTraceEnabled()) {
trace('startup', 'init-database:done');
}
await resetStaleDownloads();
if (isStartupTraceEnabled()) {
trace('startup', 'reset-stale-downloads:done');
}
await reconcileStaleRecordings();
if (isStartupTraceEnabled()) {
trace('startup', 'reconcile-stale-recordings:done');
}
await module.finishStartupAfterFirstLoad();
if (isStartupTraceEnabled()) {
trace('startup', 'bootstrap-events:done');
@@ -233,7 +181,7 @@ export default class Main {
// takes to complete; the spawn would still find MPV/VLC at any of
// the well-known paths checked by getDefault*Path before falling
// back to bare-name PATH lookup.
scheduleDeferredFixPath();
module.scheduleDeferredFixPath();
}
}
@@ -304,9 +252,7 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
// playback and database work destroyed. 'will-quit' only fires once
// every window close was allowed through.
app.on('will-quit', () => {
shutdownEmbeddedMpv();
shutdownMpvSession();
shutdownVlcSession();
void databaseWorkerClient.shutdown();
// Nothing to tear down when the deferred group never loaded.
deferredEvents?.module?.shutdownDeferredServices();
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* nx-electron build hook (project.json `webpackConfig`).
*
* The backend compiles with TypeScript's NodeNext resolution, which spells a
* relative dynamic import with a `.js` extension (main.ts loads
* `./app/startup/deferred-events.js`). webpack must map that back onto the
* `.ts` source, which is what `resolve.extensionAlias` does.
*/
module.exports = (config) => {
// Async chunks keep their webpackChunkName instead of a numeric id, so
// packaging and the layout check can list them by name.
config.output = { ...config.output, chunkFilename: '[name].js' };
config.resolve = {
...config.resolve,
extensionAlias: {
...config.resolve?.extensionAlias,
'.js': ['.ts', '.js'],
},
};
return config;
};
+1 -4
View File
@@ -26,10 +26,7 @@
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico" />
<script src="assets/app-config.js" defer></script>
<style>
/* Inline splash — paints immediately while the JS bundle and the
styles.css (~300KB) are still downloading and Angular is
bootstrapping. Removed from the DOM by main.ts after
bootstrapApplication() resolves. No assets, zero extra HTTP. */
/* Asset-free splash while bundles load; main.ts removes it after bootstrap. */
#initial-splash {
position: fixed;
inset: 0;
+9 -6
View File
@@ -74,11 +74,12 @@ resolves it at build time:
options whose file is missing are dropped, sizes and the publish date come
from the API. `deploy-website.yml` passes `GITHUB_TOKEN` to the build so
the call is authenticated.
2. Fallback: the root `package.json` version with the asset naming pattern
from `electron-builder.json`. This is deterministic but cannot prove the
files exist yet (a version bump lands on `master` before the release is
published), so a warning is printed. Set `WEBSITE_SKIP_RELEASE_FETCH=1` to
force it for offline or reproducible builds.
2. Fallback: the published version pinned in `released-version.json` with
the asset naming pattern from `electron-builder.json`. Advance this pin
only after that GitHub release is public and its assets are verified, in
the follow-up commit that publishes the article. It must stay independent
of the root `package.json` development/nightly version. Set
`WEBSITE_SKIP_RELEASE_FETCH=1` to force it for offline or reproducible builds.
Both paths produce the same page structure. Adding an artifact means adding a
`DownloadOption` (matcher + fallback name) in `downloads.ts`; the pages and the
@@ -88,7 +89,9 @@ resolved version.
`pnpm nx test website` builds the site and runs
`tools/testing/website-download-pages.test.mjs`, which checks titles,
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
without depending on a specific version.
without depending on a specific version. Resolver regression tests use the real
Astro build constants and cover offline builds, rate limits and timeouts,
ensuring each platform keeps links to the pinned published release.
Two of the suites drive the built site in a real browser:
`tools/testing/website-screenshot-showcase.test.mjs` (the home page channel
+3 -10
View File
@@ -4,16 +4,9 @@ import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import mdx from '@astrojs/mdx';
/**
* Repository version for the download pages' offline fallback
* (`src/lib/downloads.ts`). Read from the workspace root through the file
* system and injected as a build-time constant: importing the root
* `package.json` from inside the project trips the Nx module-boundaries rule
* ("external resources cannot be imported using a relative path"), and a
* value fixed at build time is what the fallback needs anyway.
*/
/** Published version for offline downloads; independent of the nightly base. */
const { version } = JSON.parse(
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
readFileSync(new URL('./released-version.json', import.meta.url), 'utf8'),
);
// Tailwind (v3) is applied via apps/website/postcss.config.mjs — the
@@ -26,7 +19,7 @@ export default defineConfig({
integrations: [sitemap(), mdx()],
vite: {
define: {
__IPTVNATOR_VERSION__: JSON.stringify(version),
__IPTVNATOR_RELEASED_VERSION__: JSON.stringify(version),
},
},
});
+3
View File
@@ -0,0 +1,3 @@
{
"version": "0.24.0"
}
@@ -2,12 +2,12 @@
title: v0.24 - Release Notes
description: A rebuilt programme guide, channels and episodes within reach in fullscreen, and live stream information — plus more dependable sources, playback and upgrades.
featured: true
pubDate: 2026-09-21
pubDate: 2026-09-24
author: 4gray
heroImage: /iptvnator/blog/v0-24/announce.png
tags:
- release
draft: true
draft: false
---
import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';
@@ -24,7 +24,7 @@ There is plenty around those three changes: local XMLTV files, catch-up download
<ReleaseMeta
version="v0.24.0"
releaseDate="September 21, 2026"
releaseDate="September 24, 2026"
channels={['Desktop', 'PWA']}
/>
@@ -106,7 +106,7 @@ The player overlay now shows stream information: resolution, playback and source
- **AppManager discovery.** AppImages include the repository metadata AppManager needs to find and download complete updates. ([#1559](https://github.com/4gray/iptvnator/pull/1559))
- **Review inactive sources together.** Desktop users can select sources to remove, retain individual entries and see the result for each deletion. ([#1596](https://github.com/4gray/iptvnator/pull/1596))
- **Check source availability.** Desktop playlist lists show background availability checks for Stalker portals and M3U links alongside Xtream accounts. ([#1592](https://github.com/4gray/iptvnator/pull/1592))
- **Try the nightly channel.** Settings → About offers builds from master. Stable remains the default; returning from nightly waits for a newer stable release. ([#1608](https://github.com/4gray/iptvnator/pull/1608))
- **Try the nightly channel.** Settings → About offers builds from master, also available in the [nightly repository](https://github.com/4gray/iptvnator-nightly/releases). Stable remains the default; returning from nightly waits for a newer stable release. ([#1608](https://github.com/4gray/iptvnator/pull/1608))
## Other changes
@@ -235,7 +235,11 @@ Please back up your playlists and important data before updating. Desktop startu
## Thanks
Thank you to [@Bpl5966](https://github.com/Bpl5966) for embedded MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)). Welcome to Bpl5966 and mark-jardine, whose first contributions are included in this release.
Thank you to [@Bpl5966](https://github.com/Bpl5966) for embedded MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)).
Thank you also to [@thejdubb02](https://github.com/thejdubb02) for the original zoom-persistence, Turkish-search and Xtream EPG-refresh fixes ([#1613](https://github.com/4gray/iptvnator/pull/1613), [#1612](https://github.com/4gray/iptvnator/pull/1612), [#1610](https://github.com/4gray/iptvnator/pull/1610)). This work was incorporated and extended in [#1617](https://github.com/4gray/iptvnator/pull/1617), [#1640](https://github.com/4gray/iptvnator/pull/1640) and [#1647](https://github.com/4gray/iptvnator/pull/1647), with his co-authorship preserved.
Welcome to Bpl5966, mark-jardine and thejdubb02, whose first contributions are included in this release.
A warm thank-you to everyone who reported issues, tested builds and helped others in the [Telegram community](https://t.me/iptvnator), and to the sponsors and donors who make time for this project possible. 💙 If IPTVnator is useful to you, you can support its development through [GitHub Sponsors](https://github.com/sponsors/4gray) or [Ko-fi](https://ko-fi.com/4gray).
+2 -6
View File
@@ -1,8 +1,4 @@
/// <reference types="astro/client" />
/**
* Repository version from the workspace root `package.json`, injected at
* build time by `astro.config.mjs` through Vite `define`. Used by the download
* pages' offline fallback (`src/lib/downloads.ts`).
*/
declare const __IPTVNATOR_VERSION__: string;
/** Published release from released-version.json, injected by astro.config.mjs. */
declare const __IPTVNATOR_RELEASED_VERSION__: string;
+9 -9
View File
@@ -7,13 +7,13 @@ import type { DownloadPlatform } from './platforms';
* *published* release so that every direct link points at an asset that
* exists, and so file sizes and the publish date can be shown. When the API
* is unreachable (offline build, rate limit, `WEBSITE_SKIP_RELEASE_FETCH=1`)
* the pages fall back to the repository version from the root `package.json`
* — injected as `__IPTVNATOR_VERSION__` by `astro.config.mjs`, since a
* relative import of the root manifest violates the Nx module boundaries —
* and the known asset naming pattern from `electron-builder.json`.
* the pages fall back to the published version in `released-version.json`,
* injected as `__IPTVNATOR_RELEASED_VERSION__` by `astro.config.mjs`, and the
* known asset naming pattern from `electron-builder.json`. The development
* version in the root package manifest must never select download assets.
*/
const FALLBACK_VERSION = __IPTVNATOR_VERSION__;
const FALLBACK_VERSION = __IPTVNATOR_RELEASED_VERSION__;
export const GITHUB_REPO = '4gray/iptvnator';
export const REPO_URL = `https://github.com/${GITHUB_REPO}`;
@@ -38,8 +38,8 @@ export interface ResolvedRelease {
url: string;
publishedAt: Date | null;
assets: ReleaseAsset[];
/** Where the data came from; the fallback cannot promise that the files exist yet. */
source: 'github-api' | 'package-json';
/** Whether the API or the pinned published version supplied the release. */
source: 'github-api' | 'published-version';
}
export interface DownloadOption {
@@ -321,10 +321,10 @@ function fallbackRelease(): ResolvedRelease {
url: `${RELEASES_URL}/tag/${tag}`,
publishedAt: null,
assets,
source: 'package-json',
source: 'published-version',
};
}
function warnFallback(reason: string): void {
console.warn(`[website] Latest release lookup failed (${reason}); using package.json version ${FALLBACK_VERSION}.`);
console.warn(`[website] Latest release lookup failed (${reason}); using pinned published version ${FALLBACK_VERSION}.`);
}
@@ -84,6 +84,15 @@ function generatePortalData(username: string, password: string): PortalData {
};
}
let liveCategories = generateCategories('live', categoryCount.live);
if (scenario.categoryFixture === 'scroll') {
liveCategories = liveCategories.map((category, index) => ({
...category,
// Coprime step permutes names independently of provider/SQLite IDs.
category_name: `${index % 4 === 0 ? 'Visible' : 'Hidden'} ${String(
(index * 137) % categoryCount.live
).padStart(3, '0')}`,
}));
}
const vodCategories = generateCategories('vod', categoryCount.vod);
const seriesCategories = generateCategories('series', categoryCount.series);
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
@@ -13,6 +13,8 @@ export interface ScenarioConfig {
expiryDate: string;
/** Optional deterministic EPG fixture profile for scenario-specific tests. */
epgFixture?: 'timezone-focus';
/** Large, deliberately reordered categories for sidebar scroll coverage. */
categoryFixture?: 'scroll';
/**
* Optional `server_info` clock override. `timezone` is reported
* verbatim (real panels sometimes send spellings such as `UTC+3` that
@@ -54,6 +56,18 @@ export interface ScenarioConfig {
* Unknown credential pairs use a hash of "username:password" as seed.
*/
export const SCENARIOS: Record<string, ScenarioConfig> = {
'category-scroll:category-scroll': {
name: 'category-scroll',
description: '800 live categories, including 200 marked Visible',
seed: 800,
categoryCount: { live: 800, vod: 0, series: 0 },
itemsPerCategory: 1,
seasonsPerSeries: 1,
episodesPerSeason: 1,
accountStatus: 'Active',
expiryDate: '2099-12-31',
categoryFixture: 'scroll',
},
'live-fallback:live-fallback': {
name: 'live-format-fallback',
description: 'Local HLS failures and playable TS',
+5
View File
@@ -87,6 +87,11 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0
- **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database
- **Display order**: The sidebar defaults to server order. Users can switch the
category panel to `A-Z` or `Z-A` from the sort menu next to category search.
- **Selection scrolling**: The panel centers the rendered selected row after
selection changes. Electron selects by local SQLite category ID; the row's
`data-category-id` can contain its provider ID. Those IDs are not
interchangeable when locating the scroll target, including after filtering
hidden categories or sorting.
- **All-hidden recovery**: Once the selected Xtream type is loaded, the manage
categories button remains available even if every visible category has been
hidden. The sidebar category list is filtered, but the dialog reads all
+188 -15
View File
@@ -4,8 +4,10 @@ IPTVnator measures performance through a small set of everyday user journeys.
Each journey has deterministic counters that are asserted exactly, and
wall-clock timings that are recorded as evidence. Counters are ratcheted in CI:
a committed baseline may only be lowered, and only with the measured output as
evidence. This document is the contract for that loop; `tools/performance/`
holds the scripts.
evidence. This document is the contract for that loop. The journey harness lives in
`apps/electron-backend-e2e/src/journeys` and
`apps/electron-backend-e2e/src/performance/journey-*.ts`; the ratchet scripts
live in `tools/performance/`.
## Journeys
@@ -16,9 +18,159 @@ holds the scripts.
| J3 `playback` | click on a channel | HTML5 `playing` event |
| J4 `search` | six-character query typed into global search | results list settled |
Only the J1 counter `renderer.initialBytes` is instrumented today. The other
journeys and counters follow the plan in `.plans/` and are added one thread at
a time; each thread names its journey and counter in the PR description.
J1 is instrumented today: `renderer.initialBytes` from the built output, and
the runtime counters of the launch benchmark below. J2 to J4 follow the plan
in `.plans/` and are added one thread at a time; each thread names its journey
and counter in the PR description.
## Running the journeys
```bash
pnpm run perf:journeys
```
The script runs the Nx target `electron-backend-e2e:journeys`, which builds the
`electron-performance` configuration of the Electron app and the renderer
first, starts the Xtream mock server on the dedicated loopback port
`127.0.0.1:3231` (override with `IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT`), and runs
`playwright.journeys.config.ts` with one worker. Each run writes one file:
```
dist/performance/journeys/<YYYYMMDDTHHMMSSZ>/summary.json
```
The file is never overwritten; a second run in the same second fails instead.
`IPTVNATOR_JOURNEY_MEASURED_ITERATIONS` lowers the five measured iterations
for a quick local check; the warm-up iteration always runs. Numbers from a
laptop are previews: the Linux CI runner is the canonical measurer for
baselines, as it is for `renderer.initialBytes`.
## J1 `launch`: launch to usable
The profile holds one M3U source and one Xtream portal, both served by the
Xtream mock (`/playlist.m3u` and `player_api.php` on the same origin). The
profile is seeded once per run through the app's own "Add playlist" dialogs,
then every iteration copies that seeded data directory into a fresh temporary
directory and spawns a fresh Electron process on it. One warm-up iteration is
recorded but excluded from the summary; five measured iterations follow. The
app lands on `/workspace/dashboard`, so the first card is a card of the
"Recent sources" rail; an `app-playlist-item` row on `/workspace/sources`
also ends the journey for profiles that disable the dashboard.
The journey ends at the first `MutationObserver` batch in which all of the
following hold: the location is below `/workspace`, `#initial-splash` is no
longer in the DOM, and a source card has a non-empty client rect. Counters are
frozen at that microtask checkpoint, so bridge calls and mutations issued
later in the same task are included and everything after it is not.
Three test-side pieces are injected; production code is not changed:
- `journey-renderer-gate.cjs` is loaded into the main process with `-r`, the
mechanism Playwright uses for its own loader. Playwright resolves
`electron.launch()` while the app is already creating its window, and
Electron reports no page until a navigation commits, so an init script
registered afterwards would race the first document. The gate makes the
first `loadFile` navigate to `about:blank` and holds the real load until
the test releases it. A 15 s safety timeout releases it on its own and the
iteration is then invalid.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
`about:blank` page, so it runs at the start of the real document. It
records that it ran while the document was still `loading` with zero
scripts and emits one JSON blob under `window.__iptvnatorJourneyProbe`.
- `journey-main-ipc-capture.ts` subscribes to the preload's renderer-API trace
channel (`IPTVNATOR_DEBUG_TRACE_EVENT`, enabled with
`IPTVNATOR_TRACE_IPC=1`) through `electronApp.evaluate`, also before the
release. The record refuses an iteration whose gate timed out, saw a second
load, or released before the probe was in place.
### Counters
| Counter | Source |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsToFirstCard` | `start` trace events the preload emits for every bridge invocation (listener registrations `on*`/`remove*` excluded, as in `wrapElectronApi`). The renderer probe fires one sentinel `dbGetAppPlaylist('__iptvnator-journey-sentinel__')` at the terminal moment; renderer-to-main IPC is ordered, so events before the sentinel are the exact count. |
| `renderer.domMutationsToFirstCard` | `MutationRecord`s (not callback batches) from a `MutationObserver` on the document element with `childList`, `attributes`, `characterData` and `subtree`. When the init script runs before `<html>` exists the observer watches `document`, which the blob reports in `capabilities.observedTarget`. |
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
Counters are exact: the summary carries the value shared by every measured
iteration. When iterations disagree, the summary reports the maximum and marks
the counter `stable: false` under `counterStability`; such a counter is not
promoted to a guardrail until it is deterministic.
Two counters from the plan are listed under `unavailable` with the reason
instead of being faked:
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
scripts, which sets `ngDevMode` to false, so Angular does not publish
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
terminal moment and the record refuses a build where the hook exists but was
not counted.
- `main.sqlStatementsBeforeReadyToShow`: SQL statements are only visible as
worker-thread trace lines on stdout, which Node forwards asynchronously, so
they cannot be ordered against `ready-to-show`. Plan item A2 adds a channel
that can be counted.
### Wall-clock
| Entry | Derivation |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spawnToDidFinishLoadMs.p50/.p90` | `performance.timeOrigin + loadEventEnd` of the navigation entry (the main frame's `load`, which is what `did-finish-load` reports) minus the test-side timestamp taken just before `electron.launch`. |
| `spawnToFirstCardMs.p50/.p90` | Terminal epoch of the renderer probe minus the same spawn timestamp. |
Percentiles use linear interpolation over the five measured iterations. The
spawn timestamp includes Playwright's own launch overhead and the gate's
`about:blank` detour: Playwright holds `app.whenReady()` until its CDP session
is attached, and the real document loads only after the probes are in place,
so absolute values are larger than a bare launch. They are comparable between runs of the same
harness, which is what the ratchet needs. The main process start
(`Date.now() - process.uptime()`) is recorded per iteration under
`evidence.epochs` for cross-checks.
### Summary schema
```json
{
"schemaVersion": 1,
"generatedAt": "2026-09-26T11:02:14.318Z",
"harness": {
"platform": "darwin",
"electron": "43.3.0",
"measuredIterations": 5,
"warmupIterations": 1
},
"journeys": {
"launch": {
"counters": { "renderer.ipcCallsToFirstCard": 12 },
"counterStability": {
"renderer.ipcCallsToFirstCard": {
"stable": true,
"values": [12, 12, 12, 12, 12]
}
},
"wallClock": {
"spawnToFirstCardMs.p50": 1234.5,
"spawnToFirstCardMs.p90": 1300.1
},
"unavailable": { "renderer.cdTicksToFirstCard": "reason" },
"iterations": [
{
"index": 0,
"warmup": true,
"pid": 1,
"counters": {},
"wallClock": {},
"evidence": {}
}
]
}
}
}
```
`journeys.<id>.counters.<name>` and `journeys.<id>.wallClock.<name>` are plain
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
`tools/performance/journey-baselines.json`. A J1 baseline is added once the
numbers are stable on the CI runner; until then the summary is evidence only.
## `renderer.initialBytes`
@@ -65,17 +217,17 @@ counter:
```json
{
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2739510,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1693,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 2739510,
"unit": "bytes",
"updatedAt": "2026-09-26",
"evidencePr": 1693,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
}
}
}
}
```
@@ -144,3 +296,24 @@ trade-off, say so in the PR and let the maintainer decide.
the test file in `tools/performance/project.json`.
4. Validate the counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
## Adding a journey
1. Add `apps/electron-backend-e2e/src/journeys/<journey>.journey.ts`. Seed the
profile through the app's dialogs, spawn a fresh process per iteration
with `measureLaunchJourney` as the model, and drive the journey's start
action with Playwright.
2. Give the journey its own probe options (`cardSelector`, `routeFragment`,
terminal condition) or extend `journey-renderer-probe.ts` when the end
condition is not "an element became visible". Keep the probe
self-contained: Playwright serializes it with `toString()`.
3. Map the measurement to a `JourneyIterationRecord` in a
`<journey>-journey-record.ts` under `src/performance/`; name counters
`renderer.*` or `main.*`, and list counters you cannot measure under
`unavailable` with the reason.
4. Add the journey under `journeys.<id>` in the summary through
`summarizeJourneyIterations`; the schema needs no change.
5. Cover the probe with jsdom fixtures and the record and summary code with
`node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`).
6. Validate a counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
+7 -2
View File
@@ -436,8 +436,13 @@ Publishing the GitHub release is manual. That publication automatically
verifies its Snap assets and uploads them to `edge`; installed-Snap smoke and
candidate/stable promotion remain manual (see
`tools/packaging/validate-snap-release-boundary.mjs`). Keep the blog post a
draft during artifact verification, then publish it in a follow-up commit and
verify the website deployment.
draft during artifact verification. After the release is public and its assets
are verified, publish the blog and advance
`apps/website/released-version.json` to that published version in the same
follow-up commit. Run `WEBSITE_SKIP_RELEASE_FETCH=1 pnpm nx test website --skip-nx-cache`,
compare the generated download links with the public release assets, and
verify the website deployment. The fallback pin must never follow the
development/nightly version in the root `package.json`.
If a Store upload fails after publication, run `publish-snap.yaml` from
`master` with its `tag` input set to the existing public stable tag, for example
+15 -1
View File
@@ -135,6 +135,16 @@ After an E2E run, generate the semantic summary with:
pnpm run coverage:e2e:summary
```
CI runs the Electron suite as three Playwright shards per OS
(`--shard=<n>/3`, split by spec file because the suite is sequential). Each
shard uploads `playwright-report-electron-<os>-<n>`; the follow-up
`Electron E2E summary` job downloads the shards of each OS into their own
directory and runs the summary per OS with `--input=<directory>` and
`--output-dir=coverage/e2e/<os>`. A directory input merges every
`results.json` beneath it and fails when a shard is missing or duplicated, so
the summary never reports a partial run as complete. Tests for that merge live
in `tools/coverage/e2e-shard-reports.test.mjs` (`pnpm run coverage:tools:test`).
For local investigation only, Chromium browser V8 coverage can be explored with:
```bash
@@ -160,6 +170,7 @@ pnpm nx build web
pnpm run perf:initial-bytes # breakdown only
pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline
pnpm nx test performance-tools
pnpm run perf:journeys # J1 launch benchmark, writes dist/performance/journeys/<timestamp>/summary.json
```
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
@@ -168,7 +179,10 @@ bytes on the initial path (the J1 counter `renderer.initialBytes`).
`tools/performance/journey-baselines.json`; baselines only move down. CI runs
the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that
target `master` and for `master` pushes (dispatch it with
`gh workflow run ci.yml --ref <branch>` for a stacked branch). The contract, what counts and how to add a counter are in the
`gh workflow run ci.yml --ref <branch>` for a stacked branch). `perf:journeys` builds the `electron-performance` configuration and runs the
J1 launch benchmark against the Xtream mock; its probe specs run with
`pnpm nx run electron-backend-e2e:test-performance-harness`. The contract, what
counts and how to add a counter or a journey are in the
[performance journeys](performance-journeys.md) document.
## Logging
+22 -2
View File
@@ -32,6 +32,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`
- Settings, portal request/response, and trace payloads must use
`@iptvnator/shared/logging` or the redacting portal logger before reaching
@@ -93,8 +94,27 @@ classifying a zero rendered-frame signal as an infrastructure flake.
## Main-process ownership
The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database,
registers events and creates the main window. The preload is
The process entry is `apps/electron-backend/src/main.entry.ts` (built to
`dist/apps/electron-backend/main.js`): it enables the V8 compile cache under
`userData/v8-compile-cache` and then requires the application bundle,
`main.app.js`, built from `apps/electron-backend/src/main.ts`. Before the window
loads, `main.ts` registers only what the renderer can call before its first paint
(window state, the close guard, playlist-open requests, request-header shims);
everything else, including the database, portal, EPG, download, player,
remote-control and update IPC, lives in
`apps/electron-backend/src/app/startup/deferred-events.ts`, built as the
`deferred-events.js` chunk and loaded inside the window's `did-start-loading`
listener. That import and its registrations finish within the same task, so no
renderer `invoke` can find a missing handler (`app/startup/deferred-bootstrap.ts`
holds the scheduler and its test); the startup trace reports it as
`deferred-events:start` and `deferred-events:done`. The cache is
disposable; `IPTVNATOR_DISABLE_COMPILE_CACHE=1` turns it off and
`IPTVNATOR_COMPILE_CACHE_DIR` relocates it (E2E runs keep it inside
`IPTVNATOR_E2E_DATA_DIR`). nx-electron packages the backend through an
allowlist, so `apps/electron-backend/project.json` lists `main.app.js` and
`deferred-events.js` under the `files` option of the `package` and `make`
targets, and `verify:package-layout` fails when any of the entry files is missing
from `app.asar`. The preload is
`apps/electron-backend/src/app/api/main.preload.ts`, with handlers under
`apps/electron-backend/src/app/events/`. The window follows the saved startup mode
(normal/maximized/fullscreen); `--fullscreen` overrides a single launch. Use
+1 -1
View File
@@ -14,7 +14,7 @@ are not prerequisites for reading repository contracts.
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters and the CI ratchet; `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
@@ -60,7 +60,7 @@ export class WorkspaceContextCategoryViewComponent {
}
}
private readonly hostEl = inject(ElementRef<HTMLElement>);
private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef);
readonly categoryClicked = output<WorkspaceCategoryViewItem>();
/** Right-click on a row; the host decides whether a menu applies. */
@@ -78,12 +78,11 @@ export class WorkspaceContextCategoryViewComponent {
queueMicrotask(() => {
const container = this.hostEl.nativeElement;
const candidates = Array.from(
container.querySelectorAll('[data-category-id]')
) as HTMLElement[];
const selected = candidates.find(
(el) =>
el.dataset['categoryId'] === String(selectedCategory)
// Follow the rendered selection: Electron selects by SQLite
// ID, while data-category-id can contain a provider ID that
// coincides with a different row's SQLite ID.
const selected = container.querySelector<HTMLElement>(
'.category-item[aria-current="true"]'
);
if (!selected) {
return;
@@ -62,7 +62,9 @@ function getCategoryLabels(
describe('WorkspaceContextPanelComponent', () => {
let fixture: ComponentFixture<WorkspaceContextPanelComponent>;
const xtreamCategories = signal([
const xtreamCategories = signal<
Array<{ id: number; name: string; xtream_id?: number }>
>([
{ id: 1, name: 'News' },
{ id: 2, name: 'Sports' },
]);
@@ -323,6 +325,59 @@ describe('WorkspaceContextPanelComponent', () => {
);
});
it.each([
{ id: 7, name: 'Alpha', top: 520 },
{ id: 8, name: 'Zulu', top: 2420 },
])(
'scrolls to the selected local category $name when provider IDs collide after sorting',
async ({ id, name, top }) => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
// SQLite IDs drive selection; provider IDs can belong to another row.
xtreamCategories.set([
{ id: 8, xtream_id: 7, name: 'Zulu' },
{ id: 50, xtream_id: 500, name: 'Middle' },
{ id: 7, xtream_id: 8, name: 'Alpha' },
]);
fixture.componentInstance.setCategorySortMode('name-asc');
fixture.detectChanges();
const container = fixture.nativeElement.querySelector(
'app-workspace-context-category-view'
) as HTMLElement;
Object.defineProperties(container, {
clientHeight: { value: 400 },
scrollHeight: { value: 3000 },
});
container.scrollTop = 500;
container.getBoundingClientRect = () =>
new DOMRect(0, 100, 200, 400);
container.scrollTo = jest.fn();
const rows = Array.from(
container.querySelectorAll<HTMLButtonElement>('.category-item')
);
rows.forEach((row, index) => {
row.getBoundingClientRect = () =>
new DOMRect(0, [300, 800, 2200][index], 200, 40);
});
const selected = rows.find((row) =>
row.textContent?.includes(name)
);
if (!selected) throw new Error(`Missing category ${name}`);
selected.click();
expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(id);
xtreamSelectedCategoryId.set(id);
fixture.detectChanges();
await fixture.whenStable();
expect(selected.getAttribute('aria-current')).toBe('true');
expect(container.scrollTo).toHaveBeenCalledWith({
behavior: 'smooth',
top,
});
}
);
it('uses translated category sort labels and distinct mode icons', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedTypeContentState.set('ready');
+5 -2
View File
@@ -1,6 +1,6 @@
{
"name": "iptvnator",
"version": "0.24.0",
"version": "0.25.0",
"engines": {
"node": "^22.22.3 || ^24.15.0"
},
@@ -44,7 +44,7 @@
"styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs",
"styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs",
"styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check",
"coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs",
"coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs",
"coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs",
"coverage:merge": "node tools/coverage/merge-coverage.mjs",
"coverage:health": "node tools/coverage/coverage-health.mjs",
@@ -73,6 +73,7 @@
"i18n:check": "node tools/i18n/check-drift.mjs",
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
"perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
"perf:journeys": "nx run electron-backend-e2e:journeys",
"perf:ratchet:check": "node tools/performance/check-journey-ratchet.mjs --summary dist/performance/journey-summary.json",
"perf:tools:test": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs",
"agents:validate": "node tools/skills/validate-agent-guidance.mjs",
@@ -197,6 +198,7 @@
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/jest": "^30.0.0",
"@types/jsdom": "21.1.7",
"@types/mocha": "9.0.0",
"@types/node": "20.19.9",
"@types/proxy-from-env": "1.0.4",
@@ -230,6 +232,7 @@
"jest-environment-node": "^30.5.1",
"jest-preset-angular": "17.0.0",
"jest-util": "^30.5.1",
"jsdom": "26.1.0",
"jsonc-eslint-parser": "^2.1.0",
"material-design-icons-iconfont": "6.7.0",
"mrmime": "2.0.1",
+6
View File
@@ -338,6 +338,9 @@ importers:
'@types/jest':
specifier: ^30.0.0
version: 30.0.0
'@types/jsdom':
specifier: 21.1.7
version: 21.1.7
'@types/mocha':
specifier: 9.0.0
version: 9.0.0
@@ -437,6 +440,9 @@ importers:
jest-util:
specifier: ^30.5.1
version: 30.5.1
jsdom:
specifier: 26.1.0
version: 26.1.0
jsonc-eslint-parser:
specifier: ^2.1.0
version: 2.4.2
+91 -13
View File
@@ -11,6 +11,13 @@ import {
import path from 'node:path';
import process from 'node:process';
import {
describeShardReports,
findPlaywrightJsonReports,
loadPlaywrightReports,
verifyShardReports,
} from './e2e-shard-reports.mjs';
const workspaceRoot = process.cwd();
const args = process.argv.slice(2);
const projectArg = valueFor('--project');
@@ -18,7 +25,12 @@ const inputArg = valueFor('--input');
const policy = JSON.parse(
readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8')
);
const outputDir = path.join(workspaceRoot, policy.reporting.e2eSummaryDir);
const outputDirArg = valueFor('--output-dir');
const outputDir = path.resolve(
workspaceRoot,
outputDirArg ?? policy.reporting.e2eSummaryDir
);
const outputDirLabel = outputDirArg ?? policy.reporting.e2eSummaryDir;
function valueFor(flag) {
const prefixed = args.find((arg) => arg.startsWith(`${flag}=`));
@@ -57,8 +69,19 @@ function tagsFromTitle(title) {
);
}
function collectFromPlaywrightJson(filePath, projectName) {
const report = JSON.parse(readFileSync(filePath, 'utf8'));
function fail(message) {
console.error(`e2e-semantic-summary: ${message}`);
if (process.env.GITHUB_STEP_SUMMARY) {
writeFileSync(
process.env.GITHUB_STEP_SUMMARY,
`\n> **E2E semantic summary not written:** ${message}\n`,
{ flag: 'a' }
);
}
process.exit(1);
}
function collectFromPlaywrightJson(report, projectName) {
const tests = [];
function walkSuite(suite, inheritedFile) {
@@ -137,16 +160,58 @@ function defaultInputFor(projectName) {
return path.join(workspaceRoot, 'dist/test-results', projectName, 'results.json');
}
function collectTests(projectName) {
/**
* `--output-dir` overrides the policy's summary directory so several runs
* (one per OS in CI) can be summarized side by side in one job.
*
* `--input` may name one Playwright JSON report or a directory that holds the
* `results.json` of every shard (as downloaded from the per-shard CI
* artifacts). An explicit input that does not exist, a directory without any
* report, or an incomplete or duplicated shard set aborts instead of writing
* a partial summary. Only the implicit default falls back to scanning the
* spec sources.
*/
function resolveReportPaths(projectName) {
const inputPath = inputArg
? path.resolve(workspaceRoot, inputArg)
: defaultInputFor(projectName);
if (existsSync(inputPath)) {
return collectFromPlaywrightJson(inputPath, projectName);
if (!existsSync(inputPath)) {
if (inputArg) {
fail(`Playwright JSON report input does not exist: ${inputPath}`);
}
return [];
}
if (!statSync(inputPath).isDirectory()) {
return [inputPath];
}
const found = findPlaywrightJsonReports(inputPath);
if (found.length === 0) {
fail(`no Playwright JSON reports (results.json) found under ${inputPath}`);
}
return found;
}
function collectTests(projectName) {
const reportPaths = resolveReportPaths(projectName);
if (reportPaths.length === 0) {
return { tests: collectFromSource(projectName), reports: [] };
}
return collectFromSource(projectName);
const reports = loadPlaywrightReports(reportPaths);
const verification = verifyShardReports(reports);
if (!verification.ok) {
fail(
`${projectName} reports do not form one complete run: ${verification.problems.join('; ')}`
);
}
return {
tests: reports.flatMap((entry) =>
collectFromPlaywrightJson(entry.report, projectName)
),
reports,
};
}
function statusCounts(tests) {
@@ -174,7 +239,7 @@ function journeyMatches(journey, tests) {
);
}
function markdownFor(projectName, tests) {
function markdownFor(projectName, tests, reportsLabel) {
const counts = statusCounts(tests);
const countsText = Object.entries(counts)
.map(([status, count]) => `${status}: ${count}`)
@@ -197,6 +262,8 @@ function markdownFor(projectName, tests) {
Source: ${tests.some((test) => test.status === 'not-run') ? 'spec source scan' : 'Playwright JSON report'}
Reports: ${reportsLabel}
Total tracked tests: ${tests.length}
Statuses: ${countsText || 'none'}
@@ -216,12 +283,23 @@ ${journeys || '| _none_ | _n/a_ | 0 | missing |'}
}
const projects = projectArg ? [projectArg] : ['web-e2e', 'electron-backend-e2e'];
const allTests = projects.flatMap((projectName) => collectTests(projectName));
const collected = projects.map((projectName) => ({
projectName,
...collectTests(projectName),
}));
const allTests = collected.flatMap((entry) => entry.tests);
const reportsLabel = collected
.map((entry) =>
projectArg
? describeShardReports(entry.reports)
: `${entry.projectName}: ${describeShardReports(entry.reports)}`
)
.join('; ');
mkdirSync(outputDir, { recursive: true });
if (projectArg) {
const content = markdownFor(projectArg, allTests);
const content = markdownFor(projectArg, allTests, reportsLabel);
writeFileSync(path.join(outputDir, `${projectArg}-semantic-summary.md`), content);
writeFileSync(
path.join(outputDir, `${projectArg}-semantic-summary.json`),
@@ -230,9 +308,9 @@ if (projectArg) {
if (process.env.GITHUB_STEP_SUMMARY) {
writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' });
}
console.log(`Wrote ${policy.reporting.e2eSummaryDir}/${projectArg}-semantic-summary.md`);
console.log(`Wrote ${outputDirLabel}/${projectArg}-semantic-summary.md`);
} else {
const content = markdownFor(undefined, allTests);
const content = markdownFor(undefined, allTests, reportsLabel);
writeFileSync(path.join(outputDir, 'semantic-summary.md'), content);
writeFileSync(
path.join(outputDir, 'semantic-summary.json'),
@@ -241,5 +319,5 @@ if (projectArg) {
if (process.env.GITHUB_STEP_SUMMARY) {
writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' });
}
console.log(`Wrote ${policy.reporting.e2eSummaryDir}/semantic-summary.md`);
console.log(`Wrote ${outputDirLabel}/semantic-summary.md`);
}
+148
View File
@@ -0,0 +1,148 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
/**
* Helpers for reading one or more Playwright JSON reports (`results.json`).
*
* The Electron E2E workflow runs the suite as several Playwright shards, one
* per runner. Each shard writes its own `results.json` that carries
* `config.shard = { current, total }` and only that shard's tests. A semantic
* summary is only honest when every shard is present exactly once, so callers
* verify the set before merging.
*/
export const playwrightReportFileName = 'results.json';
/** Recursively lists every `results.json` below `rootDir`, sorted by path. */
export function findPlaywrightJsonReports(rootDir) {
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
return [];
}
const found = [];
for (const entry of readdirSync(rootDir)) {
const fullPath = path.join(rootDir, entry);
if (statSync(fullPath).isDirectory()) {
found.push(...findPlaywrightJsonReports(fullPath));
} else if (entry === playwrightReportFileName) {
found.push(fullPath);
}
}
return found.sort();
}
/**
* Parses each report and extracts its shard descriptor: `null` when the run
* was not sharded (`config.shard` absent or `null`). A descriptor that is
* present but malformed is kept as `malformedShard` so verification rejects
* it instead of mistaking a partial run for a complete unsharded one.
*/
export function loadPlaywrightReports(reportPaths) {
return reportPaths.map((reportPath) => {
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
const shard = report.config?.shard ?? null;
const wellFormed =
shard !== null &&
Number.isInteger(shard.current) &&
Number.isInteger(shard.total);
return {
path: reportPath,
report,
shard: wellFormed ? { current: shard.current, total: shard.total } : null,
malformedShard: shard !== null && !wellFormed,
};
});
}
/**
* Checks that the loaded reports form exactly one complete run: either a
* single unsharded report, or every shard `1..total` exactly once.
*/
export function verifyShardReports(reports) {
const problems = [];
if (reports.length === 0) {
problems.push('no Playwright JSON reports were provided');
return { ok: false, problems };
}
const malformed = reports.filter((entry) => entry.malformedShard);
if (malformed.length > 0) {
problems.push(`malformed config.shard descriptor: ${describePaths(malformed)}`);
}
const unsharded = reports.filter(
(entry) => entry.shard === null && !entry.malformedShard
);
const sharded = reports.filter((entry) => entry.shard !== null);
if (unsharded.length > 0 && sharded.length > 0) {
problems.push(
`mixed sharded and unsharded reports: ${describePaths(unsharded)} carry no shard descriptor`
);
}
if (unsharded.length > 1) {
problems.push(
`${unsharded.length} unsharded reports would count every test more than once: ${describePaths(unsharded)}`
);
}
if (sharded.length > 0) {
const totals = new Set(sharded.map((entry) => entry.shard.total));
if (totals.size > 1) {
problems.push(
`shard totals disagree: ${Array.from(totals).sort().join(', ')}`
);
} else {
const [total] = totals;
const seen = new Map();
for (const entry of sharded) {
const list = seen.get(entry.shard.current) ?? [];
list.push(entry);
seen.set(entry.shard.current, list);
}
const missing = [];
for (let index = 1; index <= total; index += 1) {
if (!seen.has(index)) {
missing.push(`${index}/${total}`);
}
}
if (missing.length > 0) {
problems.push(`missing shards: ${missing.join(', ')}`);
}
for (const [current, entries] of seen) {
if (entries.length > 1) {
problems.push(
`shard ${current}/${total} appears ${entries.length} times: ${describePaths(entries)}`
);
}
if (current < 1 || current > total) {
problems.push(`shard ${current}/${total} is outside 1..${total}`);
}
}
}
}
return { ok: problems.length === 0, problems };
}
/** Human-readable label for the summary, e.g. `3/3 shards (1/3, 2/3, 3/3)`. */
export function describeShardReports(reports) {
if (reports.length === 0) {
return 'none';
}
const sharded = reports.filter((entry) => entry.shard !== null);
if (sharded.length === 0) {
return reports.length === 1
? '1 report (unsharded)'
: `${reports.length} reports (unsharded)`;
}
const total = Math.max(...sharded.map((entry) => entry.shard.total));
const labels = sharded
.map((entry) => entry.shard)
.sort((left, right) => left.current - right.current)
.map((shard) => `${shard.current}/${shard.total}`);
return `${sharded.length}/${total} shards (${labels.join(', ')})`;
}
function describePaths(entries) {
return entries.map((entry) => entry.path).join(', ');
}
+407
View File
@@ -0,0 +1,407 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, it } from 'node:test';
import {
describeShardReports,
findPlaywrightJsonReports,
loadPlaywrightReports,
verifyShardReports,
} from './e2e-shard-reports.mjs';
const toolsDir = path.dirname(fileURLToPath(import.meta.url));
const repositoryRoot = path.resolve(toolsDir, '../..');
const summaryScript = path.join(toolsDir, 'e2e-semantic-summary.mjs');
const temporaryRoots = [];
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function makeTemporaryDir(prefix) {
const root = mkdtempSync(path.join(tmpdir(), prefix));
temporaryRoots.push(root);
return root;
}
function playwrightReport({ shard, file, titles, status = 'passed' }) {
return {
config: { shard },
suites: [
{
file,
specs: titles.map((title) => ({
title,
tags: [],
tests: [{ results: [{ status }] }],
})),
},
],
errors: [],
stats: {},
};
}
function writeReport(root, relativeDir, report) {
const directory = path.join(root, relativeDir);
mkdirSync(directory, { recursive: true });
const reportPath = path.join(directory, 'results.json');
writeFileSync(reportPath, JSON.stringify(report));
return reportPath;
}
function entry(shard, reportPath = `report-${shard?.current ?? 'single'}.json`) {
return { path: reportPath, shard, report: {} };
}
describe('findPlaywrightJsonReports', () => {
it('lists every nested results.json in sorted order', () => {
const root = makeTemporaryDir('iptvnator-shard-find-');
const second = writeReport(root, 'b-shard-2/dist/test-results/x', {});
const first = writeReport(root, 'a-shard-1/dist/test-results/x', {});
writeFileSync(path.join(root, 'a-shard-1', 'other.json'), '{}');
assert.deepEqual(findPlaywrightJsonReports(root), [first, second]);
});
it('returns nothing for a missing directory or a file path', () => {
const root = makeTemporaryDir('iptvnator-shard-find-');
const reportPath = writeReport(root, 'single', {});
assert.deepEqual(findPlaywrightJsonReports(path.join(root, 'nope')), []);
assert.deepEqual(findPlaywrightJsonReports(reportPath), []);
});
});
describe('loadPlaywrightReports', () => {
it('extracts a shard descriptor and flags malformed ones', () => {
const root = makeTemporaryDir('iptvnator-shard-load-');
const sharded = writeReport(root, 'sharded', {
config: { shard: { current: 2, total: 3 } },
});
const unsharded = writeReport(root, 'unsharded', { config: { shard: null } });
const malformed = writeReport(root, 'malformed', {
config: { shard: { current: '2', total: 3 } },
});
const loaded = loadPlaywrightReports([sharded, unsharded, malformed]);
assert.deepEqual(loaded[0].shard, { current: 2, total: 3 });
assert.equal(loaded[0].malformedShard, false);
assert.equal(loaded[1].shard, null);
assert.equal(loaded[1].malformedShard, false);
assert.equal(loaded[2].shard, null);
assert.equal(loaded[2].malformedShard, true);
});
});
describe('verifyShardReports', () => {
it('accepts a single unsharded report', () => {
assert.deepEqual(verifyShardReports([entry(null)]), { ok: true, problems: [] });
});
it('accepts a complete shard set in any order', () => {
const reports = [
entry({ current: 3, total: 3 }),
entry({ current: 1, total: 3 }),
entry({ current: 2, total: 3 }),
];
assert.deepEqual(verifyShardReports(reports), { ok: true, problems: [] });
});
it('rejects a malformed shard descriptor even as the only report', () => {
const result = verifyShardReports([
{ ...entry(null, 'broken.json'), malformedShard: true },
]);
assert.equal(result.ok, false);
assert.deepEqual(result.problems, [
'malformed config.shard descriptor: broken.json',
]);
});
it('rejects an empty set', () => {
const result = verifyShardReports([]);
assert.equal(result.ok, false);
assert.match(result.problems.join('\n'), /no Playwright JSON reports/);
});
it('names missing shards', () => {
const result = verifyShardReports([
entry({ current: 1, total: 3 }),
entry({ current: 3, total: 3 }),
]);
assert.equal(result.ok, false);
assert.deepEqual(result.problems, ['missing shards: 2/3']);
});
it('rejects duplicate shards even when the set looks complete', () => {
const result = verifyShardReports([
entry({ current: 1, total: 2 }, 'a.json'),
entry({ current: 1, total: 2 }, 'b.json'),
entry({ current: 2, total: 2 }, 'c.json'),
]);
assert.equal(result.ok, false);
assert.deepEqual(result.problems, ['shard 1/2 appears 2 times: a.json, b.json']);
});
it('rejects disagreeing shard totals', () => {
const result = verifyShardReports([
entry({ current: 1, total: 2 }),
entry({ current: 2, total: 3 }),
]);
assert.equal(result.ok, false);
assert.deepEqual(result.problems, ['shard totals disagree: 2, 3']);
});
it('rejects mixed sharded and unsharded reports and repeated unsharded runs', () => {
const mixed = verifyShardReports([
entry({ current: 1, total: 1 }),
entry(null, 'plain.json'),
]);
const repeated = verifyShardReports([entry(null, 'a.json'), entry(null, 'b.json')]);
assert.equal(mixed.ok, false);
assert.match(mixed.problems.join('\n'), /mixed sharded and unsharded.*plain\.json/);
assert.equal(repeated.ok, false);
assert.match(repeated.problems.join('\n'), /2 unsharded reports.*a\.json, b\.json/);
});
});
describe('describeShardReports', () => {
it('labels empty, unsharded and sharded sets', () => {
assert.equal(describeShardReports([]), 'none');
assert.equal(describeShardReports([entry(null)]), '1 report (unsharded)');
assert.equal(
describeShardReports([
entry({ current: 2, total: 3 }),
entry({ current: 1, total: 3 }),
]),
'2/3 shards (1/3, 2/3)'
);
});
});
describe('e2e-semantic-summary CLI', () => {
function makeWorkspace() {
const root = makeTemporaryDir('iptvnator-e2e-summary-');
mkdirSync(path.join(root, 'tools/coverage'), { recursive: true });
copyFileSync(
path.join(repositoryRoot, 'tools/coverage/coverage-policy.json'),
path.join(root, 'tools/coverage/coverage-policy.json')
);
return root;
}
function runSummary(root, args) {
const stepSummary = path.join(root, 'step-summary.md');
const result = spawnSync(
process.execPath,
[summaryScript, '--project=electron-backend-e2e', ...args],
{ cwd: root, encoding: 'utf8', env: { ...process.env, GITHUB_STEP_SUMMARY: stepSummary } }
);
return {
...result,
stepSummary: existsSync(stepSummary) ? readFileSync(stepSummary, 'utf8') : '',
summaryPath: path.join(root, 'coverage/e2e/electron-backend-e2e-semantic-summary.md'),
jsonPath: path.join(root, 'coverage/e2e/electron-backend-e2e-semantic-summary.json'),
};
}
it('merges a complete shard directory into one summary', () => {
const root = makeWorkspace();
const shards = path.join(root, 'shards');
writeReport(
shards,
'electron-ubuntu-1/dist/test-results/electron-backend-e2e',
playwrightReport({
shard: { current: 1, total: 3 },
file: 'src/smoke.e2e.ts',
titles: ['boots @critical @electron'],
})
);
writeReport(
shards,
'electron-ubuntu-2/dist/test-results/electron-backend-e2e',
playwrightReport({
shard: { current: 2, total: 3 },
file: 'src/search.e2e.ts',
titles: ['finds @search', 'sorts @search'],
status: 'failed',
})
);
writeReport(
shards,
'electron-ubuntu-3/dist/test-results/electron-backend-e2e',
playwrightReport({
shard: { current: 3, total: 3 },
file: 'src/xtream.e2e.ts',
titles: ['browses @xtream'],
})
);
const result = runSummary(root, ['--input=shards']);
assert.equal(result.status, 0, result.stderr);
const markdown = readFileSync(result.summaryPath, 'utf8');
assert.match(markdown, /Source: Playwright JSON report/);
assert.match(markdown, /Reports: 3\/3 shards \(1\/3, 2\/3, 3\/3\)/);
assert.match(markdown, /Total tracked tests: 4/);
assert.match(markdown, /\| @search \| 2 \|/);
assert.match(markdown, /\| Workspace search across providers \| @search \| 2 \| failing \|/);
assert.match(markdown, /\| Electron app starts and renders workspace \| @critical \| 1 \| covered \|/);
const tests = JSON.parse(readFileSync(result.jsonPath, 'utf8'));
assert.deepEqual(
tests.map((test) => test.file).sort(),
['src/search.e2e.ts', 'src/search.e2e.ts', 'src/smoke.e2e.ts', 'src/xtream.e2e.ts']
);
assert.match(result.stepSummary, /Reports: 3\/3 shards/);
});
it('refuses to summarize an incomplete shard set', () => {
const root = makeWorkspace();
const shards = path.join(root, 'shards');
writeReport(
shards,
'shard-1',
playwrightReport({
shard: { current: 1, total: 3 },
file: 'src/smoke.e2e.ts',
titles: ['boots @critical'],
})
);
writeReport(
shards,
'shard-3',
playwrightReport({
shard: { current: 3, total: 3 },
file: 'src/xtream.e2e.ts',
titles: ['browses @xtream'],
})
);
const result = runSummary(root, ['--input=shards']);
assert.equal(result.status, 1);
assert.match(result.stderr, /missing shards: 2\/3/);
assert.match(result.stepSummary, /not written.*missing shards: 2\/3/);
assert.equal(existsSync(result.summaryPath), false);
});
it('refuses an explicit input path that does not exist', () => {
const root = makeWorkspace();
const result = runSummary(root, ['--input=dist/e2e-shards']);
assert.equal(result.status, 1);
assert.match(result.stderr, /input does not exist/);
assert.match(result.stepSummary, /not written.*input does not exist/);
assert.equal(existsSync(result.summaryPath), false);
});
it('refuses a single report whose shard descriptor is malformed', () => {
const root = makeWorkspace();
writeReport(root, 'shards/one', {
config: { shard: { current: 'x', total: 3 } },
suites: [],
});
const result = runSummary(root, ['--input=shards']);
assert.equal(result.status, 1);
assert.match(result.stderr, /malformed config\.shard descriptor/);
assert.equal(existsSync(result.summaryPath), false);
});
it('refuses a report directory without any results.json', () => {
const root = makeWorkspace();
mkdirSync(path.join(root, 'shards/empty'), { recursive: true });
const result = runSummary(root, ['--input=shards']);
assert.equal(result.status, 1);
assert.match(result.stderr, /no Playwright JSON reports/);
assert.equal(existsSync(result.summaryPath), false);
});
it('still accepts a single report file and labels it unsharded', () => {
const root = makeWorkspace();
const reportPath = writeReport(
root,
'dist/test-results/electron-backend-e2e',
playwrightReport({
shard: null,
file: 'src/smoke.e2e.ts',
titles: ['boots @critical'],
})
);
const explicit = runSummary(root, [`--input=${reportPath}`]);
assert.equal(explicit.status, 0, explicit.stderr);
assert.match(readFileSync(explicit.summaryPath, 'utf8'), /Reports: 1 report \(unsharded\)/);
const implicit = runSummary(root, []);
assert.equal(implicit.status, 0, implicit.stderr);
assert.match(readFileSync(implicit.summaryPath, 'utf8'), /Total tracked tests: 1/);
});
it('writes into --output-dir instead of the policy directory', () => {
const root = makeWorkspace();
writeReport(
root,
'dist/test-results/electron-backend-e2e',
playwrightReport({
shard: null,
file: 'src/smoke.e2e.ts',
titles: ['boots @critical'],
})
);
const result = runSummary(root, ['--output-dir=coverage/e2e/macos-latest']);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(result.summaryPath), false);
const summaryPath = path.join(
root,
'coverage/e2e/macos-latest/electron-backend-e2e-semantic-summary.md'
);
assert.match(readFileSync(summaryPath, 'utf8'), /Total tracked tests: 1/);
assert.match(result.stdout, /Wrote coverage\/e2e\/macos-latest\//);
});
it('falls back to the spec source scan when no report exists', () => {
const root = makeWorkspace();
mkdirSync(path.join(root, 'apps/electron-backend-e2e/src'), { recursive: true });
writeFileSync(
path.join(root, 'apps/electron-backend-e2e/src/smoke.e2e.ts'),
"test('boots @critical', async () => {});\n"
);
const result = runSummary(root, []);
assert.equal(result.status, 0, result.stderr);
const markdown = readFileSync(result.summaryPath, 'utf8');
assert.match(markdown, /Source: spec source scan/);
assert.match(markdown, /Reports: none/);
assert.match(markdown, /Statuses: not-run: 1/);
});
});
@@ -472,6 +472,54 @@ function verifyPackagedPackageMetadata(resourceDir, errors) {
}
}
/**
* The main-process entry is split: `main.js` enables the V8 compile cache and
* requires `main.app.js`, the application bundle, which loads the
* `deferred-events.js` chunk once the window starts loading. nx-electron packages the
* backend through an allowlist, so a missing bundle only surfaces as an
* uncaught "Cannot find module" at launch; fail the layout check instead.
*/
const REQUIRED_MAIN_PROCESS_ENTRIES = [
'/electron-backend/main.js',
'/electron-backend/main.app.js',
'/electron-backend/deferred-events.js',
'/electron-backend/main.preload.js',
];
function verifyPackagedMainProcessEntries(resourceDir, errors) {
const asarPath = path.join(resourceDir, 'app.asar');
if (!fileExists(asarPath)) {
return;
}
let entries;
try {
// @electron/asar lists entries with the host separator on Windows.
entries = new Set(
listPackage(asarPath).map((entry) => {
const normalized = entry.replace(/\\/g, '/');
return normalized.startsWith('/')
? normalized
: `/${normalized}`;
})
);
} catch (error) {
errors.push(
`Unable to list main-process entries in ${asarPath}: ${error.message}`
);
return;
}
const missing = REQUIRED_MAIN_PROCESS_ENTRIES.filter(
(entry) => !entries.has(entry)
);
if (missing.length > 0) {
errors.push(
`Packaged app.asar is missing main-process entry files in ${asarPath}: ${missing.join(', ')}`
);
}
}
function verifyLinuxLauncher(resourceDir, targetNames, errors) {
let launcherLayout;
try {
@@ -711,6 +759,7 @@ function verifyResourceDir(resourceDir) {
let linuxTargetNames;
verifyPackagedPackageMetadata(resourceDir, errors);
verifyPackagedMainProcessEntries(resourceDir, errors);
verifyPackagedDependencyClosure(resourceDir, errors);
verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors);
+40 -1
View File
@@ -1,15 +1,19 @@
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import ts from 'typescript';
import astroConfig from '../../apps/website/astro.config.mjs';
/**
* Structural checks for the per-OS download pages in the built website.
* The build resolves the latest release from the GitHub API and falls back to
* package.json, so assertions accept any semver version but insist on direct
* the pinned published version, so assertions accept any semver but insist on direct
* asset links, canonical URLs, structured data and internal linking.
*/
const distRoot = new URL('../../dist/apps/website/', import.meta.url);
const { version: publishedVersion } = JSON.parse(await readFile(new URL('../../apps/website/released-version.json', import.meta.url), 'utf8'));
const SITE = 'https://4gray.github.io/iptvnator';
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
@@ -152,3 +156,38 @@ test('sitemap lists the download pages', async () => {
assert.match(sitemap, new RegExp(`<loc>${SITE}/${path}</loc>`));
}
});
// Use the real Astro defines so a development-version bump cannot silently
// change the offline download URLs. The API is the only mocked boundary.
for (const failure of ['offline', 'rate-limit', 'timeout']) {
test(`release lookup ${failure}: downloads stay on the published release`, async () => {
const source = await readFile(new URL('../../apps/website/src/lib/downloads.ts', import.meta.url), 'utf8');
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText;
const exports = {};
let fetchCalls = 0;
const warnings = [];
vm.runInNewContext(code, {
exports,
...Object.fromEntries(Object.entries(astroConfig.vite.define).map(([name, value]) => [name, JSON.parse(value)])),
process: { env: { WEBSITE_SKIP_RELEASE_FETCH: failure === 'offline' ? '1' : '0' } },
AbortSignal,
console: { warn(message) { warnings.push(message); } },
fetch: async () => {
fetchCalls++;
if (failure === 'timeout') throw new Error('The operation timed out');
return { ok: false, status: 403 };
},
});
const release = await exports.getLatestRelease();
assert.equal(release.version, publishedVersion, 'The published release is independent of the upcoming development version');
assert.equal(fetchCalls, failure === 'offline' ? 0 : 1);
assert.equal(warnings.length, failure === 'offline' ? 0 : 1);
for (const platform of ['windows', 'macos', 'linux']) {
const downloads = exports.resolveDownloads(release, platform);
assert.ok(downloads.length > 0);
for (const download of downloads) {
assert.ok(download.url.includes(`/releases/download/v${publishedVersion}/iptvnator-${publishedVersion}-`), download.url);
}
}
});
}