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,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',