diff --git a/.changes/electron-deferred-startup-wiring.md b/.changes/electron-deferred-startup-wiring.md new file mode 100644 index 000000000..126d72aad --- /dev/null +++ b/.changes/electron-deferred-startup-wiring.md @@ -0,0 +1,8 @@ +--- +type: perf +area: electron +--- + +The desktop app now opens its window before it prepares the portal, program +guide, download, player and update machinery, and does that preparation while +the window is already loading, so the first screen appears sooner. diff --git a/.changes/electron-startup-compile-cache.md b/.changes/electron-startup-compile-cache.md new file mode 100644 index 000000000..5eae9d738 --- /dev/null +++ b/.changes/electron-startup-compile-cache.md @@ -0,0 +1,9 @@ +--- +type: perf +area: electron +--- + +The desktop app now keeps a compiled copy of its startup code next to its +user data, so every launch after the first skips part of the JavaScript +compilation and reaches the window a little sooner. Set +`IPTVNATOR_DISABLE_COMPILE_CACHE=1` to turn this off. diff --git a/.changes/xtream-category-selection-scroll.md b/.changes/xtream-category-selection-scroll.md new file mode 100644 index 000000000..19c2bed83 --- /dev/null +++ b/.changes/xtream-category-selection-scroll.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically. diff --git a/.claude/skills/release-cut/SKILL.md b/.claude/skills/release-cut/SKILL.md index 9a2b15540..5b3d7a517 100644 --- a/.claude/skills/release-cut/SKILL.md +++ b/.claude/skills/release-cut/SKILL.md @@ -73,11 +73,12 @@ the complete 27-asset set documented in `docs/architecture/release-pipeline.md`. It is read-only, and fails on an already-published release. Still review the authored text and generated commits by eye. -After verification, manually publish the GitHub release. That publication -automatically verifies its Snap assets and uploads them to `edge`. -Installed-Snap smoke and candidate/stable promotion remain manual. Keep the -blog draft during artifact verification; publish it in a follow-up commit and -verify the website deployment. +Manually publish the release; this verifies and uploads Snaps +to `edge`. Installed-Snap smoke and candidate/stable promotion stay manual. +After public-asset verification, publish the draft blog and update +`apps/website/released-version.json` to the published version together. +Follow the release pipeline's offline-download checks and verify deployment; +never use the development/nightly version for this pin. ## Failure Safety diff --git a/.codex/skills/release-cut/SKILL.md b/.codex/skills/release-cut/SKILL.md index 9a2b15540..5b3d7a517 100644 --- a/.codex/skills/release-cut/SKILL.md +++ b/.codex/skills/release-cut/SKILL.md @@ -73,11 +73,12 @@ the complete 27-asset set documented in `docs/architecture/release-pipeline.md`. It is read-only, and fails on an already-published release. Still review the authored text and generated commits by eye. -After verification, manually publish the GitHub release. That publication -automatically verifies its Snap assets and uploads them to `edge`. -Installed-Snap smoke and candidate/stable promotion remain manual. Keep the -blog draft during artifact verification; publish it in a follow-up commit and -verify the website deployment. +Manually publish the release; this verifies and uploads Snaps +to `edge`. Installed-Snap smoke and candidate/stable promotion stay manual. +After public-asset verification, publish the draft blog and update +`apps/website/released-version.json` to the published version together. +Follow the release pipeline's offline-download checks and verify deployment; +never use the development/nightly version for this pin. ## Failure Safety diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 74ff1e198..dd4031464 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -37,11 +37,13 @@ permissions: jobs: electron-e2e-tests: - name: Electron E2E on ${{ matrix.os }} + name: Electron E2E on ${{ matrix.os }} (${{ matrix.shard }}/${{ matrix.shard-total }}) runs-on: ${{ matrix.os }} - # macOS's sequential Electron suite exceeded 45m while still passing - # tests; retain the full suite with room for setup and retries. - timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 45 }} + # The sequential Electron suite is split into Playwright shards (one + # runner each, split by spec file). Measured on 2026-09-26, a third of + # the suite is 6-12 minutes of test time on top of 3-7 minutes of + # setup, so 30 minutes leaves room for retries on every OS. + timeout-minutes: 30 env: IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' NX_SKIP_NX_CACHE: true @@ -49,6 +51,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + shard: [1, 2, 3] + shard-total: [3] steps: - uses: actions/checkout@v7 @@ -68,7 +72,10 @@ jobs: - name: Build Backend run: pnpm nx build electron-backend + # Process-lifecycle checks are independent of the shard split; + # run them once per OS. - name: Verify Electron process cleanup + if: matrix.shard == 1 run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts - name: Install Playwright Browsers @@ -76,29 +83,81 @@ jobs: - name: Run Electron E2E Tests (Linux) if: runner.os == 'Linux' - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }} env: CI: true - name: Run Electron E2E Tests (Windows/Mac) if: runner.os != 'Linux' - run: pnpm nx run electron-backend-e2e:e2e + run: pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }} env: CI: true - - name: Summarize Electron E2E semantic coverage - if: always() - run: pnpm run coverage:e2e:summary -- --project=electron-backend-e2e - + # Each shard's results.json carries config.shard; the summary job + # below merges the shards of one OS into a single semantic summary. - name: Upload Electron Test Results if: always() uses: actions/upload-artifact@v7 with: - name: playwright-report-electron-${{ matrix.os }} + name: playwright-report-electron-${{ matrix.os }}-${{ matrix.shard }} path: | dist/playwright-report/electron-backend-e2e/ dist/test-results/electron-backend-e2e/ - coverage/e2e/ + retention-days: 7 + + electron-e2e-summary: + name: Electron E2E summary + runs-on: ubuntu-latest + needs: electron-e2e-tests + # Summarize failed shards too, but not a cancelled (superseded) run. + if: ${{ !cancelled() }} + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + + # One download per OS keeps each OS's shards in their own directory + # (the artifact paths inside the shards are identical). + - name: Download shard reports (Ubuntu) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-ubuntu-latest-* + path: dist/e2e-shards/ubuntu-latest + + - name: Download shard reports (macOS) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-macos-latest-* + path: dist/e2e-shards/macos-latest + + - name: Download shard reports (Windows) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-windows-latest-* + path: dist/e2e-shards/windows-latest + + # The script fails when a shard's results.json is missing or + # duplicated, so a partial run is never summarized as complete. + - name: Summarize Electron E2E semantic coverage per OS + run: | + status=0 + for os in ubuntu-latest macos-latest windows-latest; do + echo "## Electron E2E on $os" >> "$GITHUB_STEP_SUMMARY" + node tools/coverage/e2e-semantic-summary.mjs --project=electron-backend-e2e --input="dist/e2e-shards/$os" --output-dir="coverage/e2e/$os" || status=1 + done + exit "$status" + + - name: Upload Electron semantic summaries + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-semantic-summary-electron + path: coverage/e2e/ retention-days: 7 web-e2e-tests: diff --git a/CHANGELOG.md b/CHANGELOG.md index ad49538cf..3e0e17385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ this marker — see `.changes/README.md`. -# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-21) +# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-24) ![IPTVnator 0.24.0 — programme guide, fullscreen browsing and stream info](https://raw.githubusercontent.com/4gray/iptvnator/v0.24.0/apps/website/public/blog/v0-24/announce.png) @@ -40,7 +40,11 @@ Before updating, back up your playlists and important data. Desktop startup appl ## Thanks -Thank you to [@Bpl5966](https://github.com/Bpl5966) for MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)). A warm thank-you to everyone who reported issues, tested builds, helped in the [Telegram community](https://t.me/iptvnator), or supported development. 💙 +Thank you to [@Bpl5966](https://github.com/Bpl5966) for MPV reconnects and extra options ([#1515](https://github.com/4gray/iptvnator/pull/1515)), [@mark-jardine](https://github.com/mark-jardine) for the EPG time offset ([#1489](https://github.com/4gray/iptvnator/pull/1489)), and [@larsemig](https://github.com/larsemig) for stream information ([#1578](https://github.com/4gray/iptvnator/pull/1578)). + +Thank you also to [@thejdubb02](https://github.com/thejdubb02) for the original zoom-persistence, Turkish-search and Xtream EPG-refresh fixes ([#1613](https://github.com/4gray/iptvnator/pull/1613), [#1612](https://github.com/4gray/iptvnator/pull/1612), [#1610](https://github.com/4gray/iptvnator/pull/1610)). This work was incorporated and extended in [#1617](https://github.com/4gray/iptvnator/pull/1617), [#1640](https://github.com/4gray/iptvnator/pull/1640) and [#1647](https://github.com/4gray/iptvnator/pull/1647), with his co-authorship preserved. + +A warm thank-you to everyone who reported issues, tested builds, helped in the [Telegram community](https://t.me/iptvnator), or supported development. 💙 If IPTVnator is useful to you, you can help keep it moving through [GitHub Sponsors](https://github.com/sponsors/4gray) or [Ko-fi](https://ko-fi.com/4gray). diff --git a/README.md b/README.md index b325071ca..fbec01873 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,17 @@ $ pnpm run perf:initial-bytes The contract behind that number is in [docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). +To benchmark the "launch to usable" journey (fresh Electron process on a +seeded profile, exact renderer counters plus wall-clock), run: + +``` +$ pnpm run perf:journeys +``` + +The journeys, their counters and the summary written under +`dist/performance/journeys/` are described in +[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). + ## Disclaimer **IPTVnator doesn't provide any playlists or other digital content.** diff --git a/apps/electron-backend-e2e/playwright.journeys.config.ts b/apps/electron-backend-e2e/playwright.journeys.config.ts new file mode 100644 index 000000000..02f158d43 --- /dev/null +++ b/apps/electron-backend-e2e/playwright.journeys.config.ts @@ -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, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index cdf610087..2c65b3e85 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -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", diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 181bc1079..af7ae8137 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 { + 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 { await page .getByRole('button', { name: 'Refresh playlist', exact: true }) diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 6aa458b81..fa6154625 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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 { - 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 { diff --git a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts new file mode 100644 index 000000000..9fe311cb1 --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts @@ -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 { + const state = await electronApp.evaluate( + (_electron, input) => { + const gate = (globalThis as unknown as Record)[ + 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; +} diff --git a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts new file mode 100644 index 000000000..93eb52f1e --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts @@ -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 = {} +): 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 { + 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 { + 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 { + 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 { + 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 { + const installed = await mainWindow.evaluate( + (key) => + (globalThis as unknown as Record)[key] !== + undefined, + stateKey + ); + if (!installed) { + throw new Error('journey-renderer-probe-not-installed'); + } +} diff --git a/apps/electron-backend-e2e/src/journeys/launch.journey.ts b/apps/electron-backend-e2e/src/journeys/launch.journey.ts new file mode 100644 index 000000000..7a65dccc2 --- /dev/null +++ b/apps/electron-backend-e2e/src/journeys/launch.journey.ts @@ -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 + )}` + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts new file mode 100644 index 000000000..e89dc4c84 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.spec.ts @@ -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 { + 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/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts new file mode 100644 index 000000000..6bdf0aa8b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-main-ipc-capture.ts @@ -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; + 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 { + await electronApp.evaluate(({ ipcMain }, input) => { + const target = globalThis as unknown as Record; + if (target[input.stateKey] !== undefined) { + throw new Error('journey-main-ipc-capture-already-installed'); + } + const state = { + callsAfterSentinel: 0, + callsBeforeSentinel: 0, + callsByMethod: {} as Record, + 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) + : 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 { + const deadline = Date.now() + timeoutMs; + for (;;) { + const state = await electronApp.evaluate( + (_electron, key) => + JSON.parse( + JSON.stringify( + (globalThis as unknown as Record)[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; +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts new file mode 100644 index 000000000..77a461533 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts @@ -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 { + 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/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs new file mode 100644 index 000000000..bac5ee866 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs @@ -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); +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts new file mode 100644 index 000000000..12cef8643 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts @@ -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 }, + target: Record, + 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 { + log.push(`loadFile:${file}`); + return `loaded:${file}`; + } + async loadURL(url: string): Promise { + log.push(`loadURL:${url}`); + return `loaded:${url}`; + } + } + return FakeBrowserWindow; +} + +function settle(): Promise { + 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)[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 = {}; + let clock = 100; + const api = gateModule.installJourneyRendererGate( + FakeBrowserWindow as unknown as { prototype: Record }, + 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 }, + {}, + { 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 { + return `loaded:${file}`; + } + } + const api = gateModule.installJourneyRendererGate( + BrokenBrowserWindow as unknown as { + prototype: Record; + }, + {}, + { 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'); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts new file mode 100644 index 000000000..c5559d871 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts @@ -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 = ` +
IPTVnator
+`; + +function installFakePerformance( + window: JSDOM['window'], + observers: FakeObserver[] +): void { + class FakePerformanceObserver implements FakeObserver { + disconnected = false; + queue: FakeEntry[] = []; + type: string | null = null; + constructor( + private readonly callback: (list: { + getEntries(): FakeEntry[]; + }) => void + ) { + observers.push(this); + } + observe(options: { type: string }): void { + this.type = options.type; + } + takeRecords(): FakeEntry[] { + const queued = this.queue; + this.queue = []; + return queued; + } + disconnect(): void { + this.disconnected = true; + } + emit(entries: FakeEntry[]): void { + this.callback({ getEntries: () => entries }); + } + } + Object.defineProperty(window, 'PerformanceObserver', { + configurable: true, + value: FakePerformanceObserver, + }); + Object.defineProperty(window.performance, 'getEntriesByType', { + configurable: true, + value: (type: string) => + type === 'navigation' + ? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }] + : [], + }); + // jsdom never lays out, so visibility is "connected to the document". + window.HTMLElement.prototype.getClientRects = function getClientRects( + this: HTMLElement + ) { + return (this.isConnected ? [{}] : []) as unknown as DOMRectList; + }; +} + +function createFixture( + overrides: Partial & { + bridge?: boolean; + 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)[ + options.stateKey + ] as JourneyRendererProbeState; + return { + bridgeCalls, + observers, + rawState, + state: () => + JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState, + window, + }; +} + +function settle(ms = 40): Promise { + 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/); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts new file mode 100644 index 000000000..1beef3a41 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts @@ -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; + if (target[options.stateKey] !== undefined) { + return; + } + const epoch = (): number => performance.timeOrigin + performance.now(); + const bridge = target['electron'] as Record | 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 | 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 { + await page.addInitScript(journeyRendererProbeScript, options); +} + +export async function waitForJourneyRendererProbe( + page: Page, + stateKey: string, + timeoutMs: number +): Promise { + await page.waitForFunction( + (key) => + (globalThis as unknown as Record)[key] + ?.final === true, + stateKey, + { polling: 50, timeout: timeoutMs } + ); + const state = await page.evaluate( + (key) => + JSON.parse( + JSON.stringify( + (globalThis as unknown as Record)[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; +} diff --git a/apps/electron-backend-e2e/src/performance/journey-summary.spec.ts b/apps/electron-backend-e2e/src/performance/journey-summary.spec.ts new file mode 100644 index 000000000..d3923da75 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-summary.spec.ts @@ -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, + wallClock: Record, + 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/ 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 }); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-summary.ts b/apps/electron-backend-e2e/src/performance/journey-summary.ts new file mode 100644 index 000000000..a6086792b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-summary.ts @@ -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//summary.json`. + * + * `journeys..counters.` and `journeys..wallClock.` 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>; + readonly evidence: Readonly>; + readonly index: number; + readonly pid: number; + readonly wallClock: Readonly>; + readonly warmup: boolean; +} + +export interface JourneyCounterStability { + readonly stable: boolean; + readonly values: readonly number[]; +} + +export interface JourneySummaryEntry { + readonly counterStability: Readonly< + Record + >; + readonly counters: Readonly>; + readonly iterations: readonly JourneyIterationRecord[]; + readonly unavailable: Readonly>; + readonly wallClock: Readonly>; +} + +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>; + 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>, + 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> +): 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 = {}; + const counterStability: Record = {}; + 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 = {}; + 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 { + await mkdir(dirname(summaryPath), { recursive: true }); + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }); +} diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts new file mode 100644 index 000000000..4820f1c60 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -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 { + 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', + ]); +}); diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts new file mode 100644 index 000000000..14343fc0a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -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 +> = 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, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts index 0210c31d7..0ab7b7c23 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -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' ), }, diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts index 3b1b36fa5..cb7ae28fc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts @@ -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'], diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts index 35985e775..7f1afba19 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts @@ -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 { 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'), diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts index 98d779c2e..7e9fe1747 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts @@ -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 { 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, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 291cd7adf..13189747d 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -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" } diff --git a/apps/electron-backend/src/app/services/compile-cache.spec.ts b/apps/electron-backend/src/app/services/compile-cache.spec.ts new file mode 100644 index 000000000..e446b3764 --- /dev/null +++ b/apps/electron-backend/src/app/services/compile-cache.spec.ts @@ -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> { + 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)[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', + }); + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/compile-cache.ts b/apps/electron-backend/src/app/services/compile-cache.ts new file mode 100644 index 000000000..792d7be76 --- /dev/null +++ b/apps/electron-backend/src/app/services/compile-cache.ts @@ -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)[OUTCOME_KEY] = outcome; +} + +export function readCompileCacheOutcome(): CompileCacheOutcome { + const outcome = (globalThis as Record)[OUTCOME_KEY] as + CompileCacheOutcome | undefined; + return ( + outcome ?? { status: 'unavailable', reason: 'main.entry did not run' } + ); +} diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts new file mode 100644 index 000000000..c1f54988e --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts @@ -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 { + 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); + } + }); +}); diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts new file mode 100644 index 000000000..a3394f951 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts @@ -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 { + readonly load: () => Promise; + 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 { + readonly module: TModule; + readonly result: TResult; +} + +export interface DeferredBootstrapWebContents { + once(event: 'did-start-loading', listener: () => void): unknown; +} + +export interface DeferredBootstrap { + /** 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>; +} + +export function createDeferredBootstrap( + options: DeferredBootstrapOptions +): DeferredBootstrap { + let started: Promise> | null = + null; + let loadedModule: TModule | null = null; + + const trigger = ( + source: DeferredBootstrapTrigger = 'explicit' + ): Promise> => { + 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, + }; +} diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts new file mode 100644 index 000000000..03e41d8f9 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -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; +} + +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 { + 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; +}; diff --git a/apps/electron-backend/src/main.entry.ts b/apps/electron-backend/src/main.entry.ts new file mode 100644 index 000000000..a1a89c4b6 --- /dev/null +++ b/apps/electron-backend/src/main.entry.ts @@ -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'); diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 8ce5e6b03..0961a0f9b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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(); }); }); diff --git a/apps/electron-backend/webpack.config.cjs b/apps/electron-backend/webpack.config.cjs new file mode 100644 index 000000000..0923ee17a --- /dev/null +++ b/apps/electron-backend/webpack.config.cjs @@ -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; +}; diff --git a/apps/web/src/index.html b/apps/web/src/index.html index a70a34f96..508eb3c9b 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -26,10 +26,7 @@