mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/parental-control-feature-31dde2
# Conflicts: # apps/electron-backend/src/main.ts
This commit is contained in:
commit
a030e1af2f
63 files changed
+4917
-262
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
The desktop app now opens its window before it prepares the portal, program
|
||||
guide, download, player and update machinery, and does that preparation while
|
||||
the window is already loading, so the first screen appears sooner.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
The desktop app now keeps a compiled copy of its startup code next to its
|
||||
user data, so every launch after the first skips part of the JavaScript
|
||||
compilation and reaches the window a little sooner. Set
|
||||
`IPTVNATOR_DISABLE_COMPILE_CACHE=1` to turn this off.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
---
|
||||
|
||||
Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -37,11 +37,13 @@ permissions:
|
||||
|
||||
jobs:
|
||||
electron-e2e-tests:
|
||||
name: Electron E2E on ${{ matrix.os }}
|
||||
name: Electron E2E on ${{ matrix.os }} (${{ matrix.shard }}/${{ matrix.shard-total }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
# macOS's sequential Electron suite exceeded 45m while still passing
|
||||
# tests; retain the full suite with room for setup and retries.
|
||||
timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 45 }}
|
||||
# The sequential Electron suite is split into Playwright shards (one
|
||||
# runner each, split by spec file). Measured on 2026-09-26, a third of
|
||||
# the suite is 6-12 minutes of test time on top of 3-7 minutes of
|
||||
# setup, so 30 minutes leaves room for retries on every OS.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1'
|
||||
NX_SKIP_NX_CACHE: true
|
||||
@@ -49,6 +51,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
shard: [1, 2, 3]
|
||||
shard-total: [3]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -68,7 +72,10 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: pnpm nx build electron-backend
|
||||
|
||||
# Process-lifecycle checks are independent of the shard split;
|
||||
# run them once per OS.
|
||||
- name: Verify Electron process cleanup
|
||||
if: matrix.shard == 1
|
||||
run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
@@ -76,29 +83,81 @@ jobs:
|
||||
|
||||
- name: Run Electron E2E Tests (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e
|
||||
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }}
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run Electron E2E Tests (Windows/Mac)
|
||||
if: runner.os != 'Linux'
|
||||
run: pnpm nx run electron-backend-e2e:e2e
|
||||
run: pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }}
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Summarize Electron E2E semantic coverage
|
||||
if: always()
|
||||
run: pnpm run coverage:e2e:summary -- --project=electron-backend-e2e
|
||||
|
||||
# Each shard's results.json carries config.shard; the summary job
|
||||
# below merges the shards of one OS into a single semantic summary.
|
||||
- name: Upload Electron Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report-electron-${{ matrix.os }}
|
||||
name: playwright-report-electron-${{ matrix.os }}-${{ matrix.shard }}
|
||||
path: |
|
||||
dist/playwright-report/electron-backend-e2e/
|
||||
dist/test-results/electron-backend-e2e/
|
||||
coverage/e2e/
|
||||
retention-days: 7
|
||||
|
||||
electron-e2e-summary:
|
||||
name: Electron E2E summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: electron-e2e-tests
|
||||
# Summarize failed shards too, but not a cancelled (superseded) run.
|
||||
if: ${{ !cancelled() }}
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
# One download per OS keeps each OS's shards in their own directory
|
||||
# (the artifact paths inside the shards are identical).
|
||||
- name: Download shard reports (Ubuntu)
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: playwright-report-electron-ubuntu-latest-*
|
||||
path: dist/e2e-shards/ubuntu-latest
|
||||
|
||||
- name: Download shard reports (macOS)
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: playwright-report-electron-macos-latest-*
|
||||
path: dist/e2e-shards/macos-latest
|
||||
|
||||
- name: Download shard reports (Windows)
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: playwright-report-electron-windows-latest-*
|
||||
path: dist/e2e-shards/windows-latest
|
||||
|
||||
# The script fails when a shard's results.json is missing or
|
||||
# duplicated, so a partial run is never summarized as complete.
|
||||
- name: Summarize Electron E2E semantic coverage per OS
|
||||
run: |
|
||||
status=0
|
||||
for os in ubuntu-latest macos-latest windows-latest; do
|
||||
echo "## Electron E2E on $os" >> "$GITHUB_STEP_SUMMARY"
|
||||
node tools/coverage/e2e-semantic-summary.mjs --project=electron-backend-e2e --input="dist/e2e-shards/$os" --output-dir="coverage/e2e/$os" || status=1
|
||||
done
|
||||
exit "$status"
|
||||
|
||||
- name: Upload Electron semantic summaries
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-semantic-summary-electron
|
||||
path: coverage/e2e/
|
||||
retention-days: 7
|
||||
|
||||
web-e2e-tests:
|
||||
|
||||
+6
-2
@@ -11,7 +11,7 @@ this marker — see `.changes/README.md`.
|
||||
|
||||
<!-- next-release -->
|
||||
|
||||
# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-21)
|
||||
# [0.24.0](https://github.com/4gray/iptvnator/compare/v0.23.0...v0.24.0) (2026-09-24)
|
||||
|
||||
|
||||

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