mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
Merge master, dedupe the kept copy, and retire superseded switches
Master had moved ten commits ahead. Two conflicts, both resolved without losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into `events/stream-probe.ts` stays extracted (master added performance capture nearby but never touched that handler), and the mock-server scenario table takes the union of master's `performance` row and this branch's `multisrc` pair. Two findings fixed alongside it. Pinning the copy the route is already on makes discovery return that very row, so prepending the current source listed one stream twice — a phantom copy in the grouping and a chip that counted it. And handing the "playing" badge back to the route row left an in-flight switch valid, so a slow resolution could arrive afterwards and replace the playback the user had just started with Play, Resume or Restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
9f0a8b4f19
385 files changed
+58306
-2264
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: electron-backend
|
||||
---
|
||||
|
||||
Cancelling a large Xtream import now stops database work even while it is
|
||||
running at full speed, instead of letting the import finish before processing
|
||||
the cancel request.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: epg
|
||||
---
|
||||
|
||||
A database error while looking up manual EPG channel mappings no longer breaks
|
||||
the request that triggered it. Searching for a channel in the "Map EPG channel"
|
||||
dialog, or saving and removing a mapping, now degrades gracefully instead of
|
||||
failing outright.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: epg
|
||||
---
|
||||
|
||||
A database error while reading or saving a manual EPG channel mapping no longer
|
||||
surfaces as a failed request. Looking up, saving, deleting, and searching
|
||||
mappings now fall back quietly, so a transient storage hiccup can no longer take
|
||||
down the EPG panel or the "Map EPG channel" dialog.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: settings
|
||||
issues: [1156, 102]
|
||||
---
|
||||
|
||||
Launching IPTVnator a second time now brings the running window to the front
|
||||
instead of starting a rival copy that could not save anything — a common reason
|
||||
settings appeared to reset on restart. If saving or loading settings does fail,
|
||||
the app now says so instead of silently showing defaults.
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
# commit checked out above (the modern default); JavaScript is
|
||||
# interpreted, so no build step is needed before analysis.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,4 +53,4 @@ jobs:
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
@@ -56,4 +56,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -17,6 +17,7 @@ This file provides guidance to coding agents working in this repository.
|
||||
- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.
|
||||
- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
|
||||
- ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- <why> */`; the generator skips those files, so a justified exemption never lands in the baseline.
|
||||
- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps/<project>/**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find <project> -name '*.ts' | wc -l`.
|
||||
- Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs.
|
||||
|
||||
## Documentation After Changes
|
||||
@@ -69,6 +70,7 @@ This file provides guidance to coding agents working in this repository.
|
||||
- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
|
||||
- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black.
|
||||
- If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field.
|
||||
- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost.
|
||||
|
||||
### Trace / Debug Startup
|
||||
|
||||
@@ -85,8 +87,8 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus 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_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
|
||||
|
||||
- Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
|
||||
@@ -126,6 +126,7 @@ nx run electron-backend:make
|
||||
- When the task is Electron automation/debugging, use the `electron` skill
|
||||
- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
|
||||
- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`.
|
||||
- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost.
|
||||
|
||||
For startup tracing or white-screen debugging:
|
||||
|
||||
@@ -141,8 +142,8 @@ Useful narrower flags:
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus 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_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
|
||||
|
||||
Settings, portal request/response, and trace payloads must use
|
||||
`@iptvnator/shared/logging` or the redacting portal logger before reaching
|
||||
@@ -227,6 +228,15 @@ process) instead carries its own file-wide
|
||||
`/* eslint-disable max-lines -- <why> */`; the generator skips those files, so
|
||||
a justified exemption never lands in the baseline.
|
||||
|
||||
Project `lint` targets that shell out to eslint must quote the glob, e.g.
|
||||
`eslint "apps/<project>/**/*.ts"`. An unquoted `**` is expanded by the POSIX
|
||||
shell on Linux and macOS (which has no `globstar`, so it matches only a
|
||||
shallow subset of files) while Windows passes the literal pattern to ESLint,
|
||||
which expands it recursively — the two hosts then lint different file sets.
|
||||
The target still reports success either way, so a broken glob hides missing
|
||||
coverage instead of failing. After changing such a target, compare the linted
|
||||
file count against `find <project> -name '*.ts' | wc -l`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure (Nx Workspace)
|
||||
@@ -581,6 +591,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
|
||||
|
||||
- Bootstraps Electron app and initializes database
|
||||
- Registers event handlers for IPC communication
|
||||
- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging.
|
||||
|
||||
**Database**:
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { workspaceRoot } from '@nx/devkit';
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
const controlToken =
|
||||
process.env['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ??
|
||||
randomBytes(32).toString('base64url');
|
||||
process.env['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] = controlToken;
|
||||
|
||||
export default defineConfig({
|
||||
fullyParallel: false,
|
||||
reporter: [['list']],
|
||||
retries: 0,
|
||||
testDir: './src',
|
||||
testMatch: 'xtream.performance.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',
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL: '1',
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: controlToken,
|
||||
PORT: '3221',
|
||||
},
|
||||
reuseExistingServer: false,
|
||||
url: 'http://127.0.0.1:3221/health',
|
||||
},
|
||||
workers: 1,
|
||||
});
|
||||
@@ -29,6 +29,26 @@
|
||||
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts"
|
||||
}
|
||||
},
|
||||
"benchmark-m3u-import": {
|
||||
"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.performance.config.ts src/m3u-import.performance.ts"
|
||||
}
|
||||
},
|
||||
"benchmark-xtream": {
|
||||
"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.xtream-performance.config.ts src/xtream.performance.ts"
|
||||
}
|
||||
},
|
||||
"packaged-frame-copy-smoke": {
|
||||
"dependsOn": ["test-packaged-frame-copy-fixtures"],
|
||||
"executor": "nx:run-commands",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Page,
|
||||
test as base,
|
||||
} from '@playwright/test';
|
||||
import { spawn } from 'child_process';
|
||||
import { createServer, Server } from 'http';
|
||||
import {
|
||||
accessSync,
|
||||
@@ -76,7 +77,9 @@ type ElectronFixtures = {
|
||||
|
||||
export type LaunchElectronAppOptions = {
|
||||
args?: readonly string[];
|
||||
environmentInheritance?: 'all' | 'runtime-only';
|
||||
env?: Record<string, string | undefined>;
|
||||
omitEnvKeys?: readonly string[];
|
||||
};
|
||||
|
||||
export type PortalDebugEvent = {
|
||||
@@ -119,6 +122,38 @@ export type LaunchedElectronApp = {
|
||||
mainWindow: Page;
|
||||
};
|
||||
|
||||
export type CompetingElectronInstanceResult = {
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
};
|
||||
|
||||
const competingInstanceStderrLimit = 4000;
|
||||
|
||||
/**
|
||||
* Removes a run's data directory, tolerating handles the OS has not released
|
||||
* yet.
|
||||
*
|
||||
* Electron's single-instance lock keeps `SingletonLock`/`SingletonSocket`
|
||||
* (`lockfile` on Windows) open for the whole process lifetime, and Windows
|
||||
* releases those handles asynchronously as the process dies — so a removal
|
||||
* issued right after `closeElectronApp` can still hit EBUSY. This is throwaway
|
||||
* temp state, so a stubborn directory is a warning, never a test failure.
|
||||
*/
|
||||
function removeDataDir(dataDir: string): void {
|
||||
try {
|
||||
rmSync(dataDir, {
|
||||
force: true,
|
||||
maxRetries: 20,
|
||||
recursive: true,
|
||||
retryDelay: 250,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`Could not remove E2E data dir ${dataDir}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
dataDir: async ({ browserName }, use) => {
|
||||
void browserName;
|
||||
@@ -126,12 +161,29 @@ export const test = base.extend<ElectronFixtures>({
|
||||
|
||||
await use(dataDir);
|
||||
|
||||
rmSync(dataDir, { force: true, recursive: true });
|
||||
removeDataDir(dataDir);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
/**
|
||||
* Builds the argv every locally launched Electron process must share.
|
||||
*
|
||||
* Headless Linux CI has no usable sandbox or GPU, and an Electron started
|
||||
* without these flags there dies on a signal instead of running — so any
|
||||
* helper that spawns the app itself has to use the same list.
|
||||
*/
|
||||
function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] {
|
||||
const args = [...extraArgs, electronMainPath];
|
||||
|
||||
if (process.platform === 'linux' && process.env['CI']) {
|
||||
args.unshift('--no-sandbox', '--disable-gpu');
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function launchElectronApp(
|
||||
dataDir: string,
|
||||
options: LaunchElectronAppOptions = {}
|
||||
@@ -143,24 +195,11 @@ export async function launchElectronApp(
|
||||
}
|
||||
assertPackagedRendererBuildIsElectronSafe();
|
||||
|
||||
const args = [...(options.args ?? []), electronMainPath];
|
||||
|
||||
if (process.platform === 'linux' && process.env['CI']) {
|
||||
args.unshift('--no-sandbox', '--disable-gpu');
|
||||
}
|
||||
const args = buildElectronLaunchArgs(options.args);
|
||||
|
||||
const electronApp = await electron.launch({
|
||||
args,
|
||||
env: {
|
||||
...process.env,
|
||||
// Electron E2E uses local mock HTTP servers for playlists, portals, and EPG sources.
|
||||
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS:
|
||||
process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1',
|
||||
...options.env,
|
||||
ELECTRON_IS_DEV: '0',
|
||||
IPTVNATOR_E2E_DATA_DIR: dataDir,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
env: buildElectronLaunchEnvironment(dataDir, options),
|
||||
});
|
||||
attachElectronProcessDiagnostics(electronApp);
|
||||
|
||||
@@ -176,6 +215,86 @@ export async function launchElectronApp(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildElectronLaunchEnvironment(
|
||||
dataDir: string,
|
||||
options: LaunchElectronAppOptions = {},
|
||||
inheritedEnvironment: NodeJS.ProcessEnv = process.env
|
||||
): Record<string, string> {
|
||||
if (
|
||||
options.environmentInheritance !== undefined &&
|
||||
options.environmentInheritance !== 'all' &&
|
||||
options.environmentInheritance !== 'runtime-only'
|
||||
) {
|
||||
throw new Error('Electron environment inheritance mode is invalid');
|
||||
}
|
||||
const runtimeOnly = options.environmentInheritance === 'runtime-only';
|
||||
const environment: Record<string, string | undefined> = {
|
||||
...(runtimeOnly
|
||||
? selectElectronRuntimeEnvironment(inheritedEnvironment)
|
||||
: inheritedEnvironment),
|
||||
// Electron E2E uses local mock HTTP servers for playlists, portals, and EPG sources.
|
||||
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: runtimeOnly
|
||||
? '1'
|
||||
: (inheritedEnvironment['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ??
|
||||
'1'),
|
||||
...options.env,
|
||||
ELECTRON_IS_DEV: '0',
|
||||
IPTVNATOR_E2E_DATA_DIR: dataDir,
|
||||
NODE_ENV: 'test',
|
||||
};
|
||||
for (const key of options.omitEnvKeys ?? []) {
|
||||
delete environment[key];
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(environment).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const ELECTRON_RUNTIME_ENVIRONMENT_KEYS = Object.freeze([
|
||||
'APPDATA',
|
||||
'COMSPEC',
|
||||
'DBUS_SESSION_BUS_ADDRESS',
|
||||
'DISPLAY',
|
||||
'HOME',
|
||||
'LANG',
|
||||
'LANGUAGE',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'LOCALAPPDATA',
|
||||
'LOGNAME',
|
||||
'PATH',
|
||||
'PATHEXT',
|
||||
'SYSTEMROOT',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
'TMPDIR',
|
||||
'USER',
|
||||
'USERPROFILE',
|
||||
'WAYLAND_DISPLAY',
|
||||
'WINDIR',
|
||||
'XAUTHORITY',
|
||||
'XDG_RUNTIME_DIR',
|
||||
'XDG_SESSION_TYPE',
|
||||
'__CF_USER_TEXT_ENCODING',
|
||||
] as const);
|
||||
|
||||
function selectElectronRuntimeEnvironment(
|
||||
inheritedEnvironment: NodeJS.ProcessEnv
|
||||
): Record<string, string> {
|
||||
const selected: Record<string, string> = {};
|
||||
const allowed = new Set(
|
||||
ELECTRON_RUNTIME_ENVIRONMENT_KEYS.map((key) => key.toUpperCase())
|
||||
);
|
||||
for (const [key, value] of Object.entries(inheritedEnvironment)) {
|
||||
if (typeof value === 'string' && allowed.has(key.toUpperCase())) {
|
||||
selected[key] = value;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the x64 unpacked Linux executable produced by electron-builder.
|
||||
* An explicit path wins so CI can point at an AppImage/Flatpak extraction
|
||||
@@ -333,6 +452,65 @@ function attachElectronProcessDiagnostics(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a raw second Electron process against an already-running instance's
|
||||
* data directory and reports how it terminated.
|
||||
*
|
||||
* Deliberately not `launchElectronApp`: the expected outcome is that no window
|
||||
* is ever created, because the single-instance guard hands the launch over to
|
||||
* the running app. Two live instances would share a Chromium profile whose
|
||||
* IndexedDB only one of them can lock, which is how settings silently stopped
|
||||
* persisting (issues #102, #1156).
|
||||
*/
|
||||
export async function launchCompetingElectronInstance(
|
||||
dataDir: string,
|
||||
timeoutMs = 30000
|
||||
): Promise<CompetingElectronInstanceResult> {
|
||||
// In a Node context the `electron` package resolves to its binary path.
|
||||
const electronBinaryPath = require('electron') as unknown as string;
|
||||
const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), {
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_IS_DEV: '0',
|
||||
IPTVNATOR_E2E_DATA_DIR: dataDir,
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
|
||||
// Kept for the assertion message: a competing launch that dies for an
|
||||
// unrelated reason (missing sandbox, missing GPU) looks exactly like a
|
||||
// refused one from the outside.
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr = `${stderr}${chunk.toString()}`.slice(
|
||||
-competingInstanceStderrLimit
|
||||
);
|
||||
});
|
||||
|
||||
return new Promise((resolvePromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
resolvePromise({
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
stderr,
|
||||
timedOut: true,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
resolvePromise({
|
||||
exitCode: code,
|
||||
signal,
|
||||
stderr,
|
||||
timedOut: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeElectronApp(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
@@ -1598,9 +1776,7 @@ export async function expectWorkspaceSearchStatus(
|
||||
}
|
||||
|
||||
/** The degraded-search hint chip must be absent (e.g. complete local search). */
|
||||
export async function expectNoWorkspaceSearchStatus(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
export async function expectNoWorkspaceSearchStatus(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .search-chip--status')
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { runM3uImportBenchmark } from './performance/m3u-import.benchmark';
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle.
|
||||
test('profiles initial M3U imports at 10k, 50k, and 100k', async () => {
|
||||
await runM3uImportBenchmark();
|
||||
});
|
||||
@@ -52,11 +52,125 @@ test('correlates the real preload marker to a DB request whose payload omits ope
|
||||
});
|
||||
|
||||
assert.deepEqual(identity, {
|
||||
ipcCallId: 2,
|
||||
operationId: 'refresh-operation-1',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test('retains the initial-import upsert marker when its operationId is intentionally uncorrelated', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
correlationState: 'uncorrelated',
|
||||
ipcCallId: 7,
|
||||
method: 'dbUpsertAppPlaylist',
|
||||
operationId: null,
|
||||
sourceEpochMs: 1_077.25,
|
||||
})
|
||||
);
|
||||
|
||||
const identity = api.matchDatabaseRequest({
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
payload: { _id: 'playlist-1' },
|
||||
requestId: 'initial-import-upsert',
|
||||
type: 'request',
|
||||
});
|
||||
|
||||
assert.deepEqual(identity, {
|
||||
ipcCallId: 7,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-uncorrelated',
|
||||
sourceEpochMs: 1_077.25,
|
||||
});
|
||||
});
|
||||
|
||||
test('captures exact successful preload completion markers for DB return-clone attribution', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
correlationState: 'uncorrelated',
|
||||
ipcCallId: 7,
|
||||
method: 'dbUpsertAppPlaylist',
|
||||
operationId: null,
|
||||
phase: 'success',
|
||||
sourceEpochMs: 140.25,
|
||||
})
|
||||
);
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
correlationState: 'uncorrelated',
|
||||
ipcCallId: 8,
|
||||
operationId: null,
|
||||
phase: 'success',
|
||||
sourceEpochMs: 175.5,
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(api.successMarkerCount(), 2);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), [
|
||||
{
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 140.25,
|
||||
},
|
||||
{
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 175.5,
|
||||
},
|
||||
]);
|
||||
assert.equal(api.successMarkerCount(), 0);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), []);
|
||||
});
|
||||
|
||||
test('fails closed for malformed or invalid successful preload markers and resets completions', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
for (const invalid of [
|
||||
{ invalidReason: 'out-of-order' },
|
||||
{ correlationState: 'invalid' },
|
||||
{ ipcCallId: 0 },
|
||||
{ playlistId: '' },
|
||||
{ sourceEpochMs: Number.NaN },
|
||||
{ method: 'refreshPlaylist' },
|
||||
{ correlationState: 'correlated', operationId: null },
|
||||
]) {
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
phase: 'success',
|
||||
...invalid,
|
||||
})
|
||||
);
|
||||
}
|
||||
assert.equal(api.successMarkerCount(), 0);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), []);
|
||||
|
||||
ipcMain.emit(
|
||||
CHANNEL,
|
||||
{},
|
||||
marker({
|
||||
phase: 'success',
|
||||
})
|
||||
);
|
||||
api.stop();
|
||||
api.start();
|
||||
assert.equal(api.successMarkerCount(), 0);
|
||||
assert.deepEqual(api.takeSuccessMarkers(), []);
|
||||
});
|
||||
|
||||
test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => {
|
||||
const { api, ipcMain } = createCapture();
|
||||
const identity = api.matchDatabaseRequest({
|
||||
@@ -73,8 +187,10 @@ test('resolves a response-retained request identity when the marker arrives late
|
||||
|
||||
ipcMain.emit(CHANNEL, {}, marker());
|
||||
assert.deepEqual(responseOutcome.identity, {
|
||||
ipcCallId: 2,
|
||||
operationId: 'refresh-operation-1',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
|
||||
api.stop();
|
||||
@@ -87,8 +203,10 @@ test('resolves a response-retained request identity when the marker arrives late
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(afterReset, {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-missing',
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,8 +219,10 @@ test('fails closed for missing and ambiguous preload markers', () => {
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(missing, {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-missing',
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
|
||||
const ambiguousCapture = createCapture();
|
||||
@@ -122,8 +242,10 @@ test('fails closed for missing and ambiguous preload markers', () => {
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(ambiguous, {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,19 +313,25 @@ test('quarantines a key after response-before-marker ambiguity', () => {
|
||||
});
|
||||
|
||||
const ambiguousIdentity = {
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
|
||||
sourceEpochMs: null,
|
||||
};
|
||||
assert.deepEqual(requestA, ambiguousIdentity);
|
||||
assert.deepEqual(requestB, ambiguousIdentity);
|
||||
assert.deepEqual(requestC, ambiguousIdentity);
|
||||
assert.deepEqual(otherPlaylist, {
|
||||
ipcCallId: 4,
|
||||
operationId: 'other-playlist-operation',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
assert.deepEqual(otherOperation, {
|
||||
ipcCallId: 5,
|
||||
operationId: 'other-db-operation',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
|
||||
api.stop();
|
||||
@@ -223,7 +351,9 @@ test('quarantines a key after response-before-marker ambiguity', () => {
|
||||
type: 'request',
|
||||
});
|
||||
assert.deepEqual(freshCaptureIdentity, {
|
||||
ipcCallId: 6,
|
||||
operationId: 'fresh-capture-operation',
|
||||
operationIdUnavailableReason: null,
|
||||
sourceEpochMs: 100,
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,26 @@ export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY =
|
||||
'__iptvnatorM3uRefreshDatabaseRequestIdentityCapture';
|
||||
|
||||
export interface DatabaseRequestIdentity {
|
||||
ipcCallId: number | null;
|
||||
operationId: string | null;
|
||||
operationIdUnavailableReason: string | null;
|
||||
sourceEpochMs: number | null;
|
||||
}
|
||||
|
||||
export interface PreloadDatabaseSuccessMarker {
|
||||
ipcCallId: number;
|
||||
operation: 'DB_GET_APP_PLAYLIST' | 'DB_UPSERT_APP_PLAYLIST';
|
||||
operationId: string | null;
|
||||
playlistId: string;
|
||||
sourceEpochMs: number;
|
||||
}
|
||||
|
||||
export interface DatabaseRequestIdentityCaptureApi {
|
||||
matchDatabaseRequest(message: unknown): DatabaseRequestIdentity;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
successMarkerCount(): number;
|
||||
takeSuccessMarkers(): readonly PreloadDatabaseSuccessMarker[];
|
||||
}
|
||||
|
||||
interface DatabaseRequestIdentityElectron {
|
||||
@@ -31,8 +43,10 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
interface PendingMarker {
|
||||
readonly ipcCallId: number;
|
||||
readonly operation: string;
|
||||
readonly operationId: string;
|
||||
readonly operationId: string | null;
|
||||
readonly operationIdUnavailableReason: string | null;
|
||||
readonly playlistId: string;
|
||||
readonly sourceEpochMs: number;
|
||||
}
|
||||
interface PendingRequest {
|
||||
readonly identity: DatabaseRequestIdentity;
|
||||
@@ -58,6 +72,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
let pendingMarkers: PendingMarker[] = [];
|
||||
let pendingRequests: PendingRequest[] = [];
|
||||
let quarantinedKeys: QuarantinedKey[] = [];
|
||||
let successMarkers: PreloadDatabaseSuccessMarker[] = [];
|
||||
|
||||
const readRecord = (value: unknown): JsonRecord | null =>
|
||||
typeof value === 'object' && value !== null
|
||||
@@ -88,11 +103,37 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
candidate.playlistId === playlistId;
|
||||
const markAmbiguous = (requests: PendingRequest[]): void => {
|
||||
for (const request of requests) {
|
||||
request.identity.ipcCallId = null;
|
||||
request.identity.operationId = null;
|
||||
request.identity.operationIdUnavailableReason =
|
||||
'preload-performance-marker-ambiguous';
|
||||
request.identity.sourceEpochMs = null;
|
||||
}
|
||||
};
|
||||
const unavailableIdentity = (reason: string): DatabaseRequestIdentity => ({
|
||||
ipcCallId: null,
|
||||
operationId: null,
|
||||
operationIdUnavailableReason: reason,
|
||||
sourceEpochMs: null,
|
||||
});
|
||||
const identityFromMarker = (
|
||||
marker: PendingMarker
|
||||
): DatabaseRequestIdentity => ({
|
||||
ipcCallId: marker.ipcCallId,
|
||||
operationId: marker.operationId,
|
||||
operationIdUnavailableReason: marker.operationIdUnavailableReason,
|
||||
sourceEpochMs: marker.sourceEpochMs,
|
||||
});
|
||||
const applyMarker = (
|
||||
identity: DatabaseRequestIdentity,
|
||||
marker: PendingMarker
|
||||
): void => {
|
||||
identity.ipcCallId = marker.ipcCallId;
|
||||
identity.operationId = marker.operationId;
|
||||
identity.operationIdUnavailableReason =
|
||||
marker.operationIdUnavailableReason;
|
||||
identity.sourceEpochMs = marker.sourceEpochMs;
|
||||
};
|
||||
const isQuarantined = (operation: string, playlistId: string): boolean =>
|
||||
quarantinedKeys.some((key) => matches(key, operation, playlistId));
|
||||
const quarantine = (operation: string, playlistId: string): void => {
|
||||
@@ -116,32 +157,86 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
return;
|
||||
}
|
||||
const marker = readRecord(input);
|
||||
const correlationState = marker?.['correlationState'];
|
||||
const operation =
|
||||
typeof marker?.['method'] === 'string'
|
||||
? markerMethodToOperation[marker['method']]
|
||||
: undefined;
|
||||
const operationId = marker?.['operationId'];
|
||||
const playlistId = marker?.['playlistId'];
|
||||
const ipcCallId = marker?.['ipcCallId'];
|
||||
const sourceEpochMs = marker?.['sourceEpochMs'];
|
||||
const validMarkerIdentity =
|
||||
operation !== undefined &&
|
||||
typeof playlistId === 'string' &&
|
||||
playlistId.length > 0 &&
|
||||
Number.isSafeInteger(ipcCallId) &&
|
||||
Number(ipcCallId) >= 1 &&
|
||||
typeof sourceEpochMs === 'number' &&
|
||||
Number.isFinite(sourceEpochMs) &&
|
||||
sourceEpochMs >= 0;
|
||||
if (
|
||||
marker?.['phase'] === 'success' &&
|
||||
(correlationState === 'correlated' ||
|
||||
correlationState === 'uncorrelated') &&
|
||||
marker['invalidReason'] === null &&
|
||||
validMarkerIdentity &&
|
||||
(correlationState !== 'correlated' ||
|
||||
(typeof operationId === 'string' &&
|
||||
operationId.length > 0))
|
||||
) {
|
||||
successMarkers.push({
|
||||
ipcCallId: Number(ipcCallId),
|
||||
operation: operation as PreloadDatabaseSuccessMarker['operation'],
|
||||
operationId:
|
||||
correlationState === 'correlated'
|
||||
? String(operationId)
|
||||
: null,
|
||||
playlistId,
|
||||
sourceEpochMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!marker ||
|
||||
marker['phase'] !== 'start' ||
|
||||
marker['correlationState'] !== 'correlated' ||
|
||||
(correlationState !== 'correlated' &&
|
||||
correlationState !== 'uncorrelated') ||
|
||||
marker['invalidReason'] !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const operation =
|
||||
typeof marker['method'] === 'string'
|
||||
? markerMethodToOperation[marker['method']]
|
||||
: undefined;
|
||||
const operationId = marker['operationId'];
|
||||
const playlistId = marker['playlistId'];
|
||||
const ipcCallId = marker['ipcCallId'];
|
||||
const correlatedOperationId =
|
||||
correlationState === 'correlated' &&
|
||||
typeof operationId === 'string' &&
|
||||
operationId.length > 0
|
||||
? operationId
|
||||
: null;
|
||||
if (
|
||||
operation === undefined ||
|
||||
typeof operationId !== 'string' ||
|
||||
operationId.length === 0 ||
|
||||
(correlationState === 'correlated' &&
|
||||
correlatedOperationId === null) ||
|
||||
typeof playlistId !== 'string' ||
|
||||
playlistId.length === 0 ||
|
||||
!Number.isSafeInteger(ipcCallId) ||
|
||||
Number(ipcCallId) < 1
|
||||
Number(ipcCallId) < 1 ||
|
||||
typeof sourceEpochMs !== 'number' ||
|
||||
!Number.isFinite(sourceEpochMs) ||
|
||||
sourceEpochMs < 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const pendingMarker: PendingMarker = {
|
||||
ipcCallId: Number(ipcCallId),
|
||||
operation,
|
||||
operationId: correlatedOperationId,
|
||||
operationIdUnavailableReason:
|
||||
correlationState === 'correlated'
|
||||
? null
|
||||
: 'preload-performance-marker-uncorrelated',
|
||||
playlistId,
|
||||
sourceEpochMs,
|
||||
};
|
||||
|
||||
if (isQuarantined(operation, playlistId)) {
|
||||
return;
|
||||
@@ -154,8 +249,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
request.identity.operationId = operationId;
|
||||
request.identity.operationIdUnavailableReason = null;
|
||||
applyMarker(request.identity, pendingMarker);
|
||||
pendingRequests = pendingRequests.filter(
|
||||
(candidate) => candidate !== request
|
||||
);
|
||||
@@ -166,12 +260,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMarkers.push({
|
||||
ipcCallId: Number(ipcCallId),
|
||||
operation,
|
||||
operationId,
|
||||
playlistId,
|
||||
});
|
||||
pendingMarkers.push(pendingMarker);
|
||||
pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId);
|
||||
});
|
||||
|
||||
@@ -189,19 +278,15 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
operation !== 'DB_UPSERT_APP_PLAYLIST') ||
|
||||
playlistId === null
|
||||
) {
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-not-applicable',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-not-applicable'
|
||||
);
|
||||
}
|
||||
|
||||
if (isQuarantined(operation, playlistId)) {
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-ambiguous',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-ambiguous'
|
||||
);
|
||||
}
|
||||
const markerCandidates = pendingMarkers.filter((marker) =>
|
||||
matches(marker, operation, playlistId)
|
||||
@@ -209,34 +294,25 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
if (markerCandidates.length === 1) {
|
||||
const marker = markerCandidates[0];
|
||||
if (!marker) {
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-missing',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-missing'
|
||||
);
|
||||
}
|
||||
pendingMarkers = pendingMarkers.filter(
|
||||
(candidate) => candidate !== marker
|
||||
);
|
||||
return {
|
||||
operationId: marker.operationId,
|
||||
operationIdUnavailableReason: null,
|
||||
};
|
||||
return identityFromMarker(marker);
|
||||
}
|
||||
if (markerCandidates.length > 1) {
|
||||
quarantine(operation, playlistId);
|
||||
return {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-ambiguous',
|
||||
};
|
||||
return unavailableIdentity(
|
||||
'preload-performance-marker-ambiguous'
|
||||
);
|
||||
}
|
||||
|
||||
const identity: DatabaseRequestIdentity = {
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-missing',
|
||||
};
|
||||
const identity = unavailableIdentity(
|
||||
'preload-performance-marker-missing'
|
||||
);
|
||||
pendingRequests.push({
|
||||
identity,
|
||||
operation,
|
||||
@@ -248,6 +324,7 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
pendingMarkers = [];
|
||||
pendingRequests = [];
|
||||
quarantinedKeys = [];
|
||||
successMarkers = [];
|
||||
active = true;
|
||||
},
|
||||
stop: () => {
|
||||
@@ -255,6 +332,13 @@ export function installDatabaseRequestIdentityCaptureInMain(
|
||||
pendingMarkers = [];
|
||||
pendingRequests = [];
|
||||
quarantinedKeys = [];
|
||||
successMarkers = [];
|
||||
},
|
||||
successMarkerCount: () => successMarkers.length,
|
||||
takeSuccessMarkers: () => {
|
||||
const captured = Object.freeze([...successMarkers]);
|
||||
successMarkers = [];
|
||||
return captured;
|
||||
},
|
||||
};
|
||||
target[stateKey] = api;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { createDatabaseWorkerCancelCapture } from './database-worker-cancel-capture';
|
||||
|
||||
function dispatch(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
captureGeneration: 3,
|
||||
ipcCallId: 9,
|
||||
operationId: 'operation-1',
|
||||
postedEpochMs: 110,
|
||||
requestId: 'request-1',
|
||||
sourceEpochMs: 100,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function receipt(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: 'performance-cancel-received',
|
||||
epochMs: 120,
|
||||
operationId: 'operation-1',
|
||||
requestId: 'request-1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('database worker cancel capture', () => {
|
||||
it('restores without module closures for Electron-main injection', () => {
|
||||
const source = createDatabaseWorkerCancelCapture.toString();
|
||||
assert.doesNotMatch(source, /__name/);
|
||||
const factory = new Function(
|
||||
`"use strict"; return (${source});`
|
||||
)() as typeof createDatabaseWorkerCancelCapture;
|
||||
factory().start(1);
|
||||
});
|
||||
|
||||
it('keeps dispatch, worker receipt, and terminal cancellation distinct', () => {
|
||||
const capture = createDatabaseWorkerCancelCapture();
|
||||
capture.start(3);
|
||||
assert.equal(capture.acceptDispatch(dispatch()), true);
|
||||
assert.equal(
|
||||
capture.acceptReceipt({
|
||||
captureGeneration: 3,
|
||||
message: receipt(),
|
||||
receivedEpochMs: 121,
|
||||
recordGeneration: 3,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
capture.acceptTerminal({
|
||||
captureGeneration: 3,
|
||||
operationId: 'operation-1',
|
||||
receivedEpochMs: 130,
|
||||
recordGeneration: 3,
|
||||
requestId: 'request-1',
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
const snapshot = capture.stop();
|
||||
assert.deepEqual(snapshot.invalidReasons, []);
|
||||
assert.deepEqual(
|
||||
snapshot.timeline.map(({ epochMs, type }) => ({ epochMs, type })),
|
||||
[
|
||||
{ epochMs: 110, type: 'db-cancel-dispatched' },
|
||||
{ epochMs: 120, type: 'db-cancel-received' },
|
||||
{ epochMs: 130, type: 'db-cancel-terminal-received' },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects extra fields, non-monotonic epochs, and cross-generation receipts', () => {
|
||||
for (const testCase of [
|
||||
{
|
||||
message: receipt({ credential: 'forbidden' }),
|
||||
receivedEpochMs: 121,
|
||||
recordGeneration: 3,
|
||||
},
|
||||
{
|
||||
message: receipt({ epochMs: 109 }),
|
||||
receivedEpochMs: 121,
|
||||
recordGeneration: 3,
|
||||
},
|
||||
{
|
||||
message: receipt({ epochMs: 122 }),
|
||||
receivedEpochMs: 121,
|
||||
recordGeneration: 3,
|
||||
},
|
||||
{
|
||||
message: receipt(),
|
||||
receivedEpochMs: 121,
|
||||
recordGeneration: 2,
|
||||
},
|
||||
]) {
|
||||
const capture = createDatabaseWorkerCancelCapture();
|
||||
capture.start(3);
|
||||
assert.equal(capture.acceptDispatch(dispatch()), true);
|
||||
assert.equal(
|
||||
capture.acceptReceipt({
|
||||
captureGeneration: 3,
|
||||
...testCase,
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.notEqual(capture.stop().invalidReasons.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates a missing receipt separately from a missing terminal', () => {
|
||||
const missingReceipt = createDatabaseWorkerCancelCapture();
|
||||
missingReceipt.start(3);
|
||||
assert.equal(missingReceipt.acceptDispatch(dispatch()), true);
|
||||
assert.deepEqual(missingReceipt.stop().invalidReasons, [
|
||||
'cancel-receipt-missing',
|
||||
'cancel-terminal-missing',
|
||||
]);
|
||||
|
||||
const missingTerminal = createDatabaseWorkerCancelCapture();
|
||||
missingTerminal.start(3);
|
||||
assert.equal(missingTerminal.acceptDispatch(dispatch()), true);
|
||||
assert.equal(
|
||||
missingTerminal.acceptReceipt({
|
||||
captureGeneration: 3,
|
||||
message: receipt(),
|
||||
receivedEpochMs: 121,
|
||||
recordGeneration: 3,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.deepEqual(missingTerminal.stop().invalidReasons, [
|
||||
'cancel-terminal-missing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails neutrally for revoked or throwing proxy input', () => {
|
||||
const capture = createDatabaseWorkerCancelCapture();
|
||||
capture.start(3);
|
||||
const revoked = Proxy.revocable({}, {});
|
||||
revoked.revoke();
|
||||
const throwing = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('property access denied');
|
||||
},
|
||||
ownKeys() {
|
||||
throw new Error('keys denied');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() => capture.acceptDispatch(revoked.proxy));
|
||||
assert.equal(capture.acceptDispatch(revoked.proxy), false);
|
||||
assert.doesNotThrow(() => capture.acceptReceipt(throwing as never));
|
||||
assert.equal(capture.acceptReceipt(throwing as never), false);
|
||||
assert.doesNotThrow(() => capture.acceptTerminal(throwing as never));
|
||||
assert.equal(capture.acceptTerminal(throwing as never), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
export interface DatabaseWorkerCancelTimelineRecord {
|
||||
readonly epochMs: number;
|
||||
readonly ipcCallId: number;
|
||||
readonly operationId: string;
|
||||
readonly requestId: string;
|
||||
readonly sourceEpochMs: number;
|
||||
readonly type:
|
||||
| 'db-cancel-dispatched'
|
||||
| 'db-cancel-received'
|
||||
| 'db-cancel-terminal-received';
|
||||
}
|
||||
|
||||
export interface DatabaseWorkerCancelCaptureSnapshot {
|
||||
readonly invalidReasons: readonly string[];
|
||||
readonly timeline: readonly DatabaseWorkerCancelTimelineRecord[];
|
||||
}
|
||||
|
||||
export interface DatabaseWorkerCancelCapture {
|
||||
acceptDispatch(input: unknown): boolean;
|
||||
acceptReceipt(input: {
|
||||
readonly captureGeneration: number;
|
||||
readonly message: unknown;
|
||||
readonly receivedEpochMs: number;
|
||||
readonly recordGeneration: number | null;
|
||||
}): boolean;
|
||||
acceptTerminal(input: {
|
||||
readonly captureGeneration: number;
|
||||
readonly operationId: string;
|
||||
readonly receivedEpochMs: number;
|
||||
readonly recordGeneration: number | null;
|
||||
readonly requestId: string;
|
||||
}): boolean;
|
||||
start(captureGeneration: number): void;
|
||||
stop(): DatabaseWorkerCancelCaptureSnapshot;
|
||||
}
|
||||
|
||||
export function createDatabaseWorkerCancelCapture(): DatabaseWorkerCancelCapture {
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
interface DispatchState {
|
||||
readonly captureGeneration: number;
|
||||
readonly ipcCallId: number;
|
||||
readonly operationId: string;
|
||||
readonly postedEpochMs: number;
|
||||
readonly requestId: string;
|
||||
readonly sourceEpochMs: number;
|
||||
receiptEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
}
|
||||
|
||||
const dispatchKeys = new Set([
|
||||
'captureGeneration',
|
||||
'ipcCallId',
|
||||
'operationId',
|
||||
'postedEpochMs',
|
||||
'requestId',
|
||||
'sourceEpochMs',
|
||||
]);
|
||||
const receiptKeys = new Set([
|
||||
'type',
|
||||
'epochMs',
|
||||
'operationId',
|
||||
'requestId',
|
||||
]);
|
||||
const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
let active = false;
|
||||
let generation = 0;
|
||||
let invalidReasons: string[] = [];
|
||||
let timeline: DatabaseWorkerCancelTimelineRecord[] = [];
|
||||
const dispatches = new Map<string, DispatchState>();
|
||||
const helpers = {
|
||||
invalidate(reason: string): false {
|
||||
invalidReasons.push(reason);
|
||||
return false;
|
||||
},
|
||||
isEpoch(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
);
|
||||
},
|
||||
isGeneration(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) > 0;
|
||||
},
|
||||
isIdentifier(value: unknown): value is string {
|
||||
return typeof value === 'string' && identifierPattern.test(value);
|
||||
},
|
||||
isRecord(value: unknown): value is JsonRecord {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
},
|
||||
ownsOnly(value: JsonRecord, keys: ReadonlySet<string>): boolean {
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
return (
|
||||
ownKeys.length === keys.size &&
|
||||
ownKeys.every((key) => typeof key === 'string' && keys.has(key))
|
||||
);
|
||||
},
|
||||
timelineRecord(
|
||||
dispatch: DispatchState,
|
||||
epochMs: number,
|
||||
type: DatabaseWorkerCancelTimelineRecord['type']
|
||||
): DatabaseWorkerCancelTimelineRecord {
|
||||
return {
|
||||
epochMs,
|
||||
ipcCallId: dispatch.ipcCallId,
|
||||
operationId: dispatch.operationId,
|
||||
requestId: dispatch.requestId,
|
||||
sourceEpochMs: dispatch.sourceEpochMs,
|
||||
type,
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
acceptDispatch(input): boolean {
|
||||
try {
|
||||
if (
|
||||
!active ||
|
||||
!helpers.isRecord(input) ||
|
||||
!helpers.ownsOnly(input, dispatchKeys) ||
|
||||
input['captureGeneration'] !== generation ||
|
||||
!helpers.isGeneration(input['captureGeneration']) ||
|
||||
!Number.isSafeInteger(input['ipcCallId']) ||
|
||||
Number(input['ipcCallId']) <= 0 ||
|
||||
!helpers.isIdentifier(input['operationId']) ||
|
||||
!helpers.isIdentifier(input['requestId']) ||
|
||||
!helpers.isEpoch(input['postedEpochMs']) ||
|
||||
!helpers.isEpoch(input['sourceEpochMs']) ||
|
||||
Number(input['sourceEpochMs']) >
|
||||
Number(input['postedEpochMs'])
|
||||
) {
|
||||
return helpers.invalidate('cancel-dispatch-invalid');
|
||||
}
|
||||
const requestId = input['requestId'];
|
||||
if (dispatches.has(requestId)) {
|
||||
return helpers.invalidate('cancel-dispatch-duplicate');
|
||||
}
|
||||
const dispatch = {
|
||||
captureGeneration: generation,
|
||||
ipcCallId: Number(input['ipcCallId']),
|
||||
operationId: input['operationId'],
|
||||
postedEpochMs: Number(input['postedEpochMs']),
|
||||
receiptEpochMs: null,
|
||||
requestId,
|
||||
sourceEpochMs: Number(input['sourceEpochMs']),
|
||||
terminalEpochMs: null,
|
||||
};
|
||||
dispatches.set(requestId, dispatch);
|
||||
timeline.push(
|
||||
helpers.timelineRecord(
|
||||
dispatch,
|
||||
dispatch.postedEpochMs,
|
||||
'db-cancel-dispatched'
|
||||
)
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return helpers.invalidate('cancel-dispatch-invalid');
|
||||
}
|
||||
},
|
||||
acceptReceipt(input): boolean {
|
||||
try {
|
||||
if (
|
||||
!active ||
|
||||
input.captureGeneration !== generation ||
|
||||
input.recordGeneration !== generation
|
||||
) {
|
||||
return helpers.invalidate(
|
||||
'cancel-receipt-generation-mismatch'
|
||||
);
|
||||
}
|
||||
const message = input.message;
|
||||
if (
|
||||
!helpers.isRecord(message) ||
|
||||
!helpers.ownsOnly(message, receiptKeys) ||
|
||||
message['type'] !== 'performance-cancel-received' ||
|
||||
!helpers.isIdentifier(message['operationId']) ||
|
||||
!helpers.isIdentifier(message['requestId']) ||
|
||||
!helpers.isEpoch(message['epochMs']) ||
|
||||
!helpers.isEpoch(input.receivedEpochMs)
|
||||
) {
|
||||
return helpers.invalidate('cancel-receipt-invalid-schema');
|
||||
}
|
||||
const dispatch = dispatches.get(message['requestId']);
|
||||
if (!dispatch) {
|
||||
return helpers.invalidate(
|
||||
'cancel-receipt-dispatch-missing'
|
||||
);
|
||||
}
|
||||
if (dispatch.operationId !== message['operationId']) {
|
||||
return helpers.invalidate(
|
||||
'cancel-receipt-correlation-mismatch'
|
||||
);
|
||||
}
|
||||
if (
|
||||
Number(message['epochMs']) < dispatch.postedEpochMs ||
|
||||
Number(message['epochMs']) > input.receivedEpochMs
|
||||
) {
|
||||
return helpers.invalidate('cancel-receipt-epoch-invalid');
|
||||
}
|
||||
if (dispatch.receiptEpochMs !== null) {
|
||||
return helpers.invalidate('cancel-receipt-duplicate');
|
||||
}
|
||||
dispatch.receiptEpochMs = Number(message['epochMs']);
|
||||
timeline.push(
|
||||
helpers.timelineRecord(
|
||||
dispatch,
|
||||
dispatch.receiptEpochMs,
|
||||
'db-cancel-received'
|
||||
)
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return helpers.invalidate('cancel-receipt-invalid-schema');
|
||||
}
|
||||
},
|
||||
acceptTerminal(input): boolean {
|
||||
try {
|
||||
if (
|
||||
!active ||
|
||||
input.captureGeneration !== generation ||
|
||||
input.recordGeneration !== generation
|
||||
) {
|
||||
return helpers.invalidate(
|
||||
'cancel-terminal-generation-mismatch'
|
||||
);
|
||||
}
|
||||
if (
|
||||
!helpers.isIdentifier(input.operationId) ||
|
||||
!helpers.isIdentifier(input.requestId) ||
|
||||
!helpers.isEpoch(input.receivedEpochMs)
|
||||
) {
|
||||
return helpers.invalidate('cancel-terminal-invalid');
|
||||
}
|
||||
const dispatch = dispatches.get(input.requestId);
|
||||
if (!dispatch) {
|
||||
return helpers.invalidate(
|
||||
'cancel-terminal-dispatch-missing'
|
||||
);
|
||||
}
|
||||
if (dispatch.operationId !== input.operationId) {
|
||||
return helpers.invalidate(
|
||||
'cancel-terminal-correlation-mismatch'
|
||||
);
|
||||
}
|
||||
if (
|
||||
dispatch.receiptEpochMs === null ||
|
||||
input.receivedEpochMs < dispatch.receiptEpochMs
|
||||
) {
|
||||
return helpers.invalidate('cancel-terminal-before-receipt');
|
||||
}
|
||||
if (dispatch.terminalEpochMs !== null) {
|
||||
return helpers.invalidate('cancel-terminal-duplicate');
|
||||
}
|
||||
dispatch.terminalEpochMs = input.receivedEpochMs;
|
||||
timeline.push(
|
||||
helpers.timelineRecord(
|
||||
dispatch,
|
||||
dispatch.terminalEpochMs,
|
||||
'db-cancel-terminal-received'
|
||||
)
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return helpers.invalidate('cancel-terminal-invalid');
|
||||
}
|
||||
},
|
||||
start(captureGeneration): void {
|
||||
if (!helpers.isGeneration(captureGeneration)) {
|
||||
throw new Error('database-worker-cancel-generation-invalid');
|
||||
}
|
||||
active = true;
|
||||
generation = captureGeneration;
|
||||
invalidReasons = [];
|
||||
timeline = [];
|
||||
dispatches.clear();
|
||||
},
|
||||
stop(): DatabaseWorkerCancelCaptureSnapshot {
|
||||
for (const dispatch of dispatches.values()) {
|
||||
if (dispatch.receiptEpochMs === null) {
|
||||
invalidReasons.push('cancel-receipt-missing');
|
||||
}
|
||||
if (dispatch.terminalEpochMs === null) {
|
||||
invalidReasons.push('cancel-terminal-missing');
|
||||
}
|
||||
}
|
||||
active = false;
|
||||
return Object.freeze({
|
||||
invalidReasons: Object.freeze([...invalidReasons]),
|
||||
timeline: Object.freeze([...timeline]),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert.ok(start >= 0, `missing start marker: ${startMarker}`);
|
||||
assert.ok(end > start, `missing end marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('counts only finite nonnegative worker heap and external-memory samples per capture generation', () => {
|
||||
const workerRecord = section(
|
||||
'interface WorkerRecord',
|
||||
'interface TimelineRecord'
|
||||
);
|
||||
const createRecord = section(
|
||||
'const record: WorkerRecord = {',
|
||||
'nextWorkerOrdinal += 1'
|
||||
);
|
||||
const sampleWorker = section(
|
||||
'const sampleWorker =',
|
||||
'const resetWorkerForCapture'
|
||||
);
|
||||
const resetWorker = section(
|
||||
'const resetWorkerForCapture',
|
||||
'const startWorker'
|
||||
);
|
||||
|
||||
assert.match(workerRecord, /externalMemorySampleCount: number/);
|
||||
assert.match(workerRecord, /heapUsedSampleCount: number/);
|
||||
assert.match(createRecord, /externalMemorySampleCount: 0/);
|
||||
assert.match(createRecord, /heapUsedSampleCount: 0/);
|
||||
assert.match(resetWorker, /record\.externalMemorySampleCount = 0/);
|
||||
assert.match(resetWorker, /record\.heapUsedSampleCount = 0/);
|
||||
|
||||
assert.match(sampleWorker, /const heapUsedSample = stats\.used_heap_size/);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/Number\.isFinite\(heapUsedSample\)[\s\S]*heapUsedSample >= 0/
|
||||
);
|
||||
assert.match(sampleWorker, /record\.heapUsedSampleCount \+= 1/);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/const externalMemorySample = stats\.external_memory/
|
||||
);
|
||||
assert.match(
|
||||
sampleWorker,
|
||||
/Number\.isFinite\(externalMemorySample\)[\s\S]*externalMemorySample >= 0/
|
||||
);
|
||||
assert.match(sampleWorker, /record\.externalMemorySampleCount \+= 1/);
|
||||
assert.doesNotMatch(sampleWorker, /used_heap_size \?\? 0/);
|
||||
assert.doesNotMatch(sampleWorker, /external_memory \?\? 0/);
|
||||
});
|
||||
|
||||
test('raw worker metrics distinguish missing samples from measured zero-byte peaks', () => {
|
||||
const transport = section(
|
||||
'const workers = currentWorkerRecords',
|
||||
'const transport: MainCaptureGenerationTransport'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
transport,
|
||||
/externalMemorySampleCount:\s*record\.externalMemorySampleCount/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/heapUsedSampleCount:\s*record\.heapUsedSampleCount/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakExternalBytes:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*record\.externalPeak[\s\S]*:\s*null/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakHeapUsedBytes:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*record\.heapPeak[\s\S]*:\s*null/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakExternalUnavailableReason:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-external-memory-samples-missing'/
|
||||
);
|
||||
assert.match(
|
||||
transport,
|
||||
/peakHeapUsedUnavailableReason:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-heap-used-samples-missing'/
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
transport,
|
||||
/peakExternalBytes: record\.externalPeak[,}]/
|
||||
);
|
||||
assert.doesNotMatch(transport, /peakHeapUsedBytes: record\.heapPeak[,}]/);
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assessDatabaseWorkerPeakMemoryValidity,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON,
|
||||
} from './database-worker-peak-memory-validity';
|
||||
|
||||
describe('database worker peak-memory validity', () => {
|
||||
it('requires successful heap and external-memory samples in every measured run', () => {
|
||||
const validity = assessDatabaseWorkerPeakMemoryValidity([
|
||||
iteration('warmup', 'warmup', worker(0, 0)),
|
||||
iteration('run-01', 'measured', worker(3, 4)),
|
||||
iteration('diagnostic', 'diagnostic', worker(0, 0)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(validity, {
|
||||
invalidMeasuredRuns: [],
|
||||
measuredRunCount: 1,
|
||||
validForComparison: true,
|
||||
validMeasuredRunCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for absent samples instead of accepting initialized zero peaks', () => {
|
||||
const missingCounts = assessDatabaseWorkerPeakMemoryValidity([
|
||||
iteration('run-01', 'measured', {
|
||||
kind: 'database.worker',
|
||||
peakExternalBytes: 0,
|
||||
peakHeapUsedBytes: 0,
|
||||
}),
|
||||
iteration('run-02', 'measured', worker(0, 1)),
|
||||
iteration('run-03', 'measured', worker(1, 0)),
|
||||
]);
|
||||
|
||||
assert.deepEqual(missingCounts.invalidMeasuredRuns, [
|
||||
{
|
||||
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING,
|
||||
runId: 'run-01',
|
||||
},
|
||||
{
|
||||
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING,
|
||||
runId: 'run-02',
|
||||
},
|
||||
{
|
||||
reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING,
|
||||
runId: 'run-03',
|
||||
},
|
||||
]);
|
||||
assert.equal(missingCounts.validForComparison, false);
|
||||
assert.equal(missingCounts.validMeasuredRunCount, 0);
|
||||
});
|
||||
|
||||
it('reports stable worker-cardinality and malformed-peak reasons', () => {
|
||||
const validity = assessDatabaseWorkerPeakMemoryValidity([
|
||||
{ kind: 'measured', main: { workers: [] }, runId: 'missing' },
|
||||
{
|
||||
kind: 'measured',
|
||||
main: { workers: [worker(1, 1), worker(1, 1)] },
|
||||
runId: 'multiple',
|
||||
},
|
||||
iteration('heap-invalid', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakHeapUsedBytes: Number.NaN,
|
||||
}),
|
||||
iteration('external-invalid', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakExternalBytes: -1,
|
||||
}),
|
||||
iteration('heap-reason-incoherent', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakHeapUsedUnavailableReason:
|
||||
'worker-heap-used-samples-missing',
|
||||
}),
|
||||
iteration('external-reason-incoherent', 'measured', {
|
||||
...worker(1, 1),
|
||||
peakExternalUnavailableReason:
|
||||
'worker-external-memory-samples-missing',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
validity.invalidMeasuredRuns.map(({ reason }) => reason),
|
||||
[
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID,
|
||||
DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID,
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function iteration(
|
||||
runId: string,
|
||||
kind: string,
|
||||
databaseWorker: Record<string, unknown>
|
||||
) {
|
||||
return {
|
||||
kind,
|
||||
main: { workers: [databaseWorker] },
|
||||
runId,
|
||||
};
|
||||
}
|
||||
|
||||
function worker(
|
||||
heapUsedSampleCount: number,
|
||||
externalMemorySampleCount: number
|
||||
) {
|
||||
return {
|
||||
externalMemorySampleCount,
|
||||
heapUsedSampleCount,
|
||||
kind: 'database.worker',
|
||||
peakExternalBytes: 600,
|
||||
peakExternalUnavailableReason: null,
|
||||
peakHeapUsedBytes: 1_500,
|
||||
peakHeapUsedUnavailableReason: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export const DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON = {
|
||||
EXTERNAL_MEMORY_PEAK_INVALID:
|
||||
'database-worker-external-memory-peak-invalid',
|
||||
EXTERNAL_MEMORY_SAMPLES_MISSING:
|
||||
'database-worker-external-memory-samples-missing',
|
||||
HEAP_PEAK_INVALID: 'database-worker-heap-peak-invalid',
|
||||
HEAP_SAMPLES_MISSING: 'database-worker-heap-samples-missing',
|
||||
MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers',
|
||||
DATABASE_WORKER_MISSING: 'database-worker-missing',
|
||||
} as const;
|
||||
|
||||
export type DatabaseWorkerPeakMemoryInvalidReason =
|
||||
(typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON)[keyof typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON];
|
||||
|
||||
export interface DatabaseWorkerPeakMemoryValidity {
|
||||
readonly invalidMeasuredRuns: readonly {
|
||||
readonly reason: DatabaseWorkerPeakMemoryInvalidReason;
|
||||
readonly runId: string;
|
||||
}[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
interface PeakMemoryWorker {
|
||||
readonly externalMemorySampleCount?: unknown;
|
||||
readonly heapUsedSampleCount?: unknown;
|
||||
readonly kind: string;
|
||||
readonly peakExternalBytes: unknown;
|
||||
readonly peakExternalUnavailableReason?: unknown;
|
||||
readonly peakHeapUsedBytes: unknown;
|
||||
readonly peakHeapUsedUnavailableReason?: unknown;
|
||||
}
|
||||
|
||||
interface PeakMemoryIteration {
|
||||
readonly kind: string;
|
||||
readonly main: {
|
||||
readonly workers: readonly PeakMemoryWorker[];
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export function assessDatabaseWorkerPeakMemoryValidity(
|
||||
iterations: readonly PeakMemoryIteration[]
|
||||
): DatabaseWorkerPeakMemoryValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === 'measured'
|
||||
);
|
||||
const invalidMeasuredRuns: {
|
||||
reason: DatabaseWorkerPeakMemoryInvalidReason;
|
||||
runId: string;
|
||||
}[] = [];
|
||||
|
||||
for (const iteration of measured) {
|
||||
const workers = iteration.main.workers.filter(
|
||||
(worker) => worker.kind === 'database.worker'
|
||||
);
|
||||
const reason = assessWorkers(workers);
|
||||
if (reason !== null) {
|
||||
invalidMeasuredRuns.push({ reason, runId: iteration.runId });
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
|
||||
measuredRunCount: measured.length,
|
||||
validForComparison:
|
||||
measured.length > 0 && invalidMeasuredRuns.length === 0,
|
||||
validMeasuredRunCount: measured.length - invalidMeasuredRuns.length,
|
||||
});
|
||||
}
|
||||
|
||||
function assessWorkers(
|
||||
workers: readonly PeakMemoryWorker[]
|
||||
): DatabaseWorkerPeakMemoryInvalidReason | null {
|
||||
if (workers.length === 0) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING;
|
||||
}
|
||||
if (workers.length !== 1) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS;
|
||||
}
|
||||
|
||||
const worker = workers[0];
|
||||
if (!worker) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING;
|
||||
}
|
||||
if (!isPositiveSampleCount(worker.heapUsedSampleCount)) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING;
|
||||
}
|
||||
if (
|
||||
!isPeak(worker.peakHeapUsedBytes) ||
|
||||
worker.peakHeapUsedUnavailableReason !== null
|
||||
) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID;
|
||||
}
|
||||
if (!isPositiveSampleCount(worker.externalMemorySampleCount)) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING;
|
||||
}
|
||||
if (
|
||||
!isPeak(worker.peakExternalBytes) ||
|
||||
worker.peakExternalUnavailableReason !== null
|
||||
) {
|
||||
return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPositiveSampleCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
function isPeak(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
+5
-4
@@ -370,21 +370,22 @@ test('worker capture failures use an artifact-neutral timeline label', () => {
|
||||
assert.doesNotMatch(source, /worker-profile-error:\$\{stage\}/);
|
||||
});
|
||||
|
||||
test('capture generation resets artifact paths and always emits current DB records', () => {
|
||||
test('capture generation resets artifact paths while arming and always emits current DB records', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
const start = source.indexOf('const startCapture = async (');
|
||||
const diagnostic = source.indexOf('if (state.diagnostic)', start);
|
||||
const api = source.indexOf('const api =', start);
|
||||
const profileReset = source.indexOf('state.mainProfilePath = null', start);
|
||||
const snapshotReset = source.indexOf(
|
||||
'state.mainSnapshotPath = null',
|
||||
start
|
||||
);
|
||||
|
||||
assert.ok(profileReset > start && profileReset < diagnostic);
|
||||
assert.ok(snapshotReset > start && snapshotReset < diagnostic);
|
||||
assert.ok(profileReset > start && profileReset < api);
|
||||
assert.ok(snapshotReset > start && snapshotReset < api);
|
||||
assert.doesNotMatch(source.slice(start, api), /'Profiler\.start'/);
|
||||
assert.match(
|
||||
source,
|
||||
/record\.captureGeneration === state\.captureGeneration[\s\S]*record\.kind === 'database\.worker'/
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assessDatabaseWorkerRequestMetricsValidity,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON,
|
||||
} from './database-worker-request-metrics-validity';
|
||||
import { createCompleteTestMainCapture } from './m3u-import-report.test-helpers';
|
||||
import type {
|
||||
WorkerCaptureMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
|
||||
describe('database worker request-metrics validity', () => {
|
||||
it('requires exactly two complete request samples in every measured run', () => {
|
||||
const worker = completeWorker();
|
||||
const validity = assessDatabaseWorkerRequestMetricsValidity([
|
||||
iteration('warmup', 'warmup', [worker]),
|
||||
iteration('run-01', 'measured', [worker]),
|
||||
iteration('run-02', 'measured', [worker]),
|
||||
iteration('diagnostic', 'diagnostic', [worker]),
|
||||
]);
|
||||
|
||||
assert.deepEqual(validity, {
|
||||
expectedRequestCount: 4,
|
||||
invalidRequests: [],
|
||||
measuredRunCount: 2,
|
||||
validForComparison: true,
|
||||
validMeasuredRunCount: 2,
|
||||
validRequestCount: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails each measured run with missing ELD, ELU, CPU, or raw capture metrics', () => {
|
||||
const validity = assessDatabaseWorkerRequestMetricsValidity([
|
||||
invalidRequestIteration('run-eld', {
|
||||
eventLoopDelay: null,
|
||||
eventLoopDelayUnavailableReason:
|
||||
'event-loop-delay-capture-unavailable',
|
||||
histogramFlushedEpochMs: null,
|
||||
}),
|
||||
invalidRequestIteration('run-elu', {
|
||||
eventLoopUtilization: null,
|
||||
eventLoopUtilizationUnavailableReason:
|
||||
'event-loop-utilization-unavailable',
|
||||
}),
|
||||
invalidRequestIteration('run-cpu', {
|
||||
threadCpuSystemMicros: null,
|
||||
threadCpuUnavailableReason: 'thread-cpu-usage-unavailable',
|
||||
threadCpuUserMicros: null,
|
||||
}),
|
||||
invalidRequestIteration('run-capture', {
|
||||
performanceCaptureUnavailableReason:
|
||||
'worker-performance-capture-invalid',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(validity.expectedRequestCount, 8);
|
||||
assert.equal(validity.validRequestCount, 4);
|
||||
assert.equal(validity.validMeasuredRunCount, 0);
|
||||
assert.equal(validity.validForComparison, false);
|
||||
assert.deepEqual(
|
||||
validity.invalidRequests.map(({ reason }) => reason),
|
||||
[
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for missing, multiple, and wrong request sets', () => {
|
||||
const worker = completeWorker();
|
||||
const validity = assessDatabaseWorkerRequestMetricsValidity([
|
||||
iteration('missing', 'measured', []),
|
||||
iteration('multiple', 'measured', [worker, worker]),
|
||||
iteration('wrong-requests', 'measured', [
|
||||
{
|
||||
...worker,
|
||||
requests: worker.requests.slice(0, 1),
|
||||
},
|
||||
]),
|
||||
]);
|
||||
|
||||
assert.equal(validity.expectedRequestCount, 6);
|
||||
assert.equal(validity.validRequestCount, 0);
|
||||
assert.equal(validity.validForComparison, false);
|
||||
assert.deepEqual(
|
||||
validity.invalidRequests.map(({ reason }) => reason),
|
||||
[
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
|
||||
DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID,
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function completeWorker(): WorkerCaptureMetrics {
|
||||
const worker = createCompleteTestMainCapture().workers[0];
|
||||
assert.ok(worker);
|
||||
return worker;
|
||||
}
|
||||
|
||||
function invalidRequestIteration(
|
||||
runId: string,
|
||||
updates: Partial<WorkerRequestPerformanceMetrics>
|
||||
) {
|
||||
const worker = completeWorker();
|
||||
const get = worker.requests[1];
|
||||
assert.ok(get);
|
||||
return iteration(runId, 'measured', [
|
||||
{
|
||||
...worker,
|
||||
requests: [worker.requests[0], { ...get, ...updates }],
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function iteration(
|
||||
runId: string,
|
||||
kind: string,
|
||||
workers: readonly WorkerCaptureMetrics[]
|
||||
) {
|
||||
return { kind, main: { workers }, runId };
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import type {
|
||||
WorkerCaptureMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
PERFORMANCE_ITERATION_KIND,
|
||||
PERFORMANCE_WORKER_KIND,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
|
||||
export const DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON = {
|
||||
DATABASE_WORKER_MISSING: 'database-worker-missing',
|
||||
EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid',
|
||||
EVENT_LOOP_UTILIZATION_INVALID: 'event-loop-utilization-invalid',
|
||||
MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers',
|
||||
REQUEST_SET_INVALID: 'initial-import-request-set-invalid',
|
||||
THREAD_CPU_INVALID: 'thread-cpu-invalid',
|
||||
WORKER_CAPTURE_INVALID: 'worker-performance-capture-invalid',
|
||||
} as const;
|
||||
|
||||
export type DatabaseWorkerRequestMetricsInvalidReason =
|
||||
(typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON)[keyof typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON];
|
||||
|
||||
export interface DatabaseWorkerRequestMetricsValidity {
|
||||
readonly expectedRequestCount: number;
|
||||
readonly invalidRequests: readonly {
|
||||
readonly operation: string | null;
|
||||
readonly reason: DatabaseWorkerRequestMetricsInvalidReason;
|
||||
readonly runId: string;
|
||||
}[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
readonly validRequestCount: number;
|
||||
}
|
||||
|
||||
interface RequestMetricsIteration {
|
||||
readonly kind: string;
|
||||
readonly main: {
|
||||
readonly workers: readonly WorkerCaptureMetrics[];
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export function assessDatabaseWorkerRequestMetricsValidity(
|
||||
iterations: readonly RequestMetricsIteration[]
|
||||
): DatabaseWorkerRequestMetricsValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) =>
|
||||
iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
);
|
||||
const invalidRequests: {
|
||||
operation: string | null;
|
||||
reason: DatabaseWorkerRequestMetricsInvalidReason;
|
||||
runId: string;
|
||||
}[] = [];
|
||||
let validMeasuredRunCount = 0;
|
||||
let validRequestCount = 0;
|
||||
|
||||
for (const iteration of measured) {
|
||||
const workers = iteration.main.workers.filter(
|
||||
(worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE
|
||||
);
|
||||
if (workers.length === 0) {
|
||||
invalidRequests.push({
|
||||
operation: null,
|
||||
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const worker = workers.length === 1 ? workers[0] : null;
|
||||
if (!worker) {
|
||||
invalidRequests.push({
|
||||
operation: null,
|
||||
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const requests = requireInitialImportRequestPair(worker);
|
||||
if (requests === null) {
|
||||
invalidRequests.push({
|
||||
operation: null,
|
||||
reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let runIsValid = true;
|
||||
for (const request of requests) {
|
||||
const reason = assessRequest(request);
|
||||
if (reason === null) {
|
||||
validRequestCount += 1;
|
||||
} else {
|
||||
runIsValid = false;
|
||||
invalidRequests.push({
|
||||
operation: request.operation,
|
||||
reason,
|
||||
runId: iteration.runId,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (runIsValid) {
|
||||
validMeasuredRunCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedRequestCount = measured.length * 2;
|
||||
return Object.freeze({
|
||||
expectedRequestCount,
|
||||
invalidRequests: Object.freeze(invalidRequests),
|
||||
measuredRunCount: measured.length,
|
||||
validForComparison:
|
||||
measured.length > 0 &&
|
||||
validMeasuredRunCount === measured.length &&
|
||||
validRequestCount === expectedRequestCount,
|
||||
validMeasuredRunCount,
|
||||
validRequestCount,
|
||||
});
|
||||
}
|
||||
|
||||
function requireInitialImportRequestPair(
|
||||
worker: WorkerCaptureMetrics
|
||||
): readonly [
|
||||
WorkerRequestPerformanceMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
] | null {
|
||||
const upserts = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success
|
||||
);
|
||||
const gets = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_GET_APP_PLAYLIST' && request.success
|
||||
);
|
||||
return worker.requests.length === 2 &&
|
||||
upserts.length === 1 &&
|
||||
upserts[0] &&
|
||||
gets.length === 1 &&
|
||||
gets[0]
|
||||
? [upserts[0], gets[0]]
|
||||
: null;
|
||||
}
|
||||
|
||||
function assessRequest(
|
||||
request: WorkerRequestPerformanceMetrics
|
||||
): DatabaseWorkerRequestMetricsInvalidReason | null {
|
||||
if (
|
||||
request.performanceCaptureUnavailableReason !== null ||
|
||||
request.invalidReason !== null
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID;
|
||||
}
|
||||
if (
|
||||
request.eventLoopDelayUnavailableReason !== null ||
|
||||
request.histogramFlushedEpochMs === null ||
|
||||
!isEventLoopDelay(request.eventLoopDelay)
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID;
|
||||
}
|
||||
if (
|
||||
request.eventLoopUtilizationUnavailableReason !== null ||
|
||||
!isUtilization(request.eventLoopUtilization)
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID;
|
||||
}
|
||||
if (
|
||||
request.threadCpuUnavailableReason !== null ||
|
||||
!isFiniteNonNegative(request.threadCpuSystemMicros) ||
|
||||
!isFiniteNonNegative(request.threadCpuUserMicros)
|
||||
) {
|
||||
return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEventLoopDelay(
|
||||
value: WorkerRequestPerformanceMetrics['eventLoopDelay']
|
||||
): value is NonNullable<WorkerRequestPerformanceMetrics['eventLoopDelay']> {
|
||||
return (
|
||||
value !== null &&
|
||||
isFiniteNonNegative(value.maxMs) &&
|
||||
isFiniteNonNegative(value.p95Ms) &&
|
||||
isFiniteNonNegative(value.p99Ms) &&
|
||||
value.p95Ms <= value.p99Ms &&
|
||||
value.p99Ms <= value.maxMs
|
||||
);
|
||||
}
|
||||
|
||||
function isUtilization(value: number | null): value is number {
|
||||
return isFiniteNonNegative(value) && value <= 1;
|
||||
}
|
||||
|
||||
function isFiniteNonNegative(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildElectronLaunchEnvironment,
|
||||
ELECTRON_RUNTIME_ENVIRONMENT_KEYS,
|
||||
} from '../electron-test-fixtures';
|
||||
import { assertXtreamBenchmarkEnvironmentIsolated } from './xtream-benchmark-iteration-support';
|
||||
|
||||
describe('Electron launch environment isolation', () => {
|
||||
it('inherits only portable runtime keys for a profiled benchmark child', () => {
|
||||
const environment = buildElectronLaunchEnvironment(
|
||||
'/tmp/iptvnator-performance',
|
||||
{
|
||||
environmentInheritance: 'runtime-only',
|
||||
env: {
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
AWS_SECRET_ACCESS_KEY: 'aws-secret',
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
GITHUB_TOKEN: 'github-secret',
|
||||
HTTPS_PROXY: 'https://user:password@proxy.invalid',
|
||||
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '0',
|
||||
IPTVNATOR_REAL_PROVIDER_URL: 'https://provider.invalid',
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL: '1',
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token',
|
||||
NODE_OPTIONS: '--require credential-dumper.js',
|
||||
PATH: '/usr/bin',
|
||||
REAL_PROVIDER_PASSWORD: 'provider-password',
|
||||
REAL_PROVIDER_URL: 'https://provider.invalid',
|
||||
TMPDIR: '/tmp/runtime',
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(environment['IPTVNATOR_PERF_CAPTURE'], '1');
|
||||
assert.equal(environment['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'], '1');
|
||||
assert.equal(environment['PATH'], '/usr/bin');
|
||||
assert.equal(environment['TMPDIR'], '/tmp/runtime');
|
||||
for (const key of [
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'ELECTRON_RUN_AS_NODE',
|
||||
'GITHUB_TOKEN',
|
||||
'HTTPS_PROXY',
|
||||
'IPTVNATOR_REAL_PROVIDER_URL',
|
||||
'IPTVNATOR_XTREAM_MOCK_CONTROL',
|
||||
'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN',
|
||||
'NODE_OPTIONS',
|
||||
'REAL_PROVIDER_PASSWORD',
|
||||
'REAL_PROVIDER_URL',
|
||||
]) {
|
||||
assert.equal(environment[key], undefined, key);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops undefined overrides instead of serializing them into the child', () => {
|
||||
const environment = buildElectronLaunchEnvironment(
|
||||
'/tmp/iptvnator-performance',
|
||||
{ env: { OPTIONAL_DEBUG_FLAG: undefined } },
|
||||
{ OPTIONAL_DEBUG_FLAG: 'inherited' }
|
||||
);
|
||||
|
||||
assert.equal(environment['OPTIONAL_DEBUG_FLAG'], undefined);
|
||||
});
|
||||
|
||||
it('checks the effective Electron main environment, not only launch input', async () => {
|
||||
const allowed = [
|
||||
...ELECTRON_RUNTIME_ENVIRONMENT_KEYS,
|
||||
'ELECTRON_IS_DEV',
|
||||
'IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS',
|
||||
'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS',
|
||||
'IPTVNATOR_E2E_DATA_DIR',
|
||||
'IPTVNATOR_PERF_CAPTURE',
|
||||
'IPTVNATOR_PERF_WORKER_PROFILING',
|
||||
'IPTVNATOR_TRACE_RENDERER_CONSOLE',
|
||||
'NODE_ENV',
|
||||
];
|
||||
|
||||
await assert.doesNotReject(
|
||||
assertXtreamBenchmarkEnvironmentIsolated(fakeApp(allowed))
|
||||
);
|
||||
for (const leaked of [
|
||||
'GITHUB_TOKEN',
|
||||
'HTTPS_PROXY',
|
||||
'NODE_OPTIONS',
|
||||
'REAL_PROVIDER_URL',
|
||||
]) {
|
||||
await assert.rejects(
|
||||
assertXtreamBenchmarkEnvironmentIsolated(
|
||||
fakeApp([...allowed, leaked])
|
||||
),
|
||||
/xtream-benchmark-environment-reached-electron/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function fakeApp(keys: readonly string[]) {
|
||||
return {
|
||||
electronApp: {
|
||||
evaluate: async () => [...keys],
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isCpuProfile(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const startTime = value['startTime'];
|
||||
const endTime = value['endTime'];
|
||||
const nodes = value['nodes'];
|
||||
const samples = value['samples'];
|
||||
const timeDeltas = value['timeDeltas'];
|
||||
if (
|
||||
!isNonNegativeSafeInteger(startTime) ||
|
||||
!isNonNegativeSafeInteger(endTime) ||
|
||||
endTime <= startTime ||
|
||||
!Array.isArray(nodes) ||
|
||||
nodes.length === 0 ||
|
||||
!Array.isArray(samples) ||
|
||||
samples.length === 0 ||
|
||||
!Array.isArray(timeDeltas) ||
|
||||
timeDeltas.length !== samples.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeIds = new Set<number>();
|
||||
for (const node of nodes) {
|
||||
if (!isCpuProfileNode(node) || nodeIds.has(node['id'])) {
|
||||
return false;
|
||||
}
|
||||
nodeIds.add(node['id']);
|
||||
}
|
||||
if (
|
||||
nodes.some((node) =>
|
||||
(node as Record<string, unknown>)['children'] instanceof Array
|
||||
? (
|
||||
(node as Record<string, unknown>)[
|
||||
'children'
|
||||
] as unknown[]
|
||||
).some(
|
||||
(childId) =>
|
||||
!isNonNegativeSafeInteger(childId) ||
|
||||
!nodeIds.has(childId)
|
||||
)
|
||||
: false
|
||||
) ||
|
||||
samples.some(
|
||||
(sampleId) =>
|
||||
!isNonNegativeSafeInteger(sampleId) ||
|
||||
!nodeIds.has(sampleId)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const duration = endTime - startTime;
|
||||
let elapsed = 0;
|
||||
for (const delta of timeDeltas) {
|
||||
if (!Number.isSafeInteger(delta)) {
|
||||
return false;
|
||||
}
|
||||
elapsed += delta as number;
|
||||
if (
|
||||
!Number.isSafeInteger(elapsed) ||
|
||||
elapsed < 0 ||
|
||||
elapsed > duration
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return elapsed > 0;
|
||||
}
|
||||
|
||||
export function readCpuProfileDurationMicros(value: unknown): number | null {
|
||||
if (!isCpuProfile(value)) {
|
||||
return null;
|
||||
}
|
||||
const profile = value as Record<string, number>;
|
||||
return profile['endTime'] - profile['startTime'];
|
||||
}
|
||||
|
||||
export function isHeapSnapshot(value: unknown): boolean {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!isRecord(value['snapshot']) ||
|
||||
!isRecord(value['snapshot']['meta'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const meta = value['snapshot']['meta'];
|
||||
const nodeFields = meta['node_fields'];
|
||||
const nodeTypes = meta['node_types'];
|
||||
const edgeFields = meta['edge_fields'];
|
||||
const edgeTypes = meta['edge_types'];
|
||||
const nodes = value['nodes'];
|
||||
const edges = value['edges'];
|
||||
const strings = value['strings'];
|
||||
return (
|
||||
isNonEmptyStringArray(nodeFields) &&
|
||||
isHeapTypeTable(nodeTypes, nodeFields.length) &&
|
||||
isNonEmptyStringArray(edgeFields) &&
|
||||
isHeapTypeTable(edgeTypes, edgeFields.length) &&
|
||||
Array.isArray(nodes) &&
|
||||
nodes.length > 0 &&
|
||||
nodes.length % nodeFields.length === 0 &&
|
||||
Array.isArray(edges) &&
|
||||
edges.length > 0 &&
|
||||
edges.length % edgeFields.length === 0 &&
|
||||
Array.isArray(strings) &&
|
||||
strings.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isRendererTrace(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const events = value['traceEvents'];
|
||||
return (
|
||||
Array.isArray(events) &&
|
||||
events.length > 0 &&
|
||||
events.every(
|
||||
(event) =>
|
||||
isRecord(event) &&
|
||||
isNonEmptyString(event['name']) &&
|
||||
isNonEmptyString(event['ph']) &&
|
||||
isNonNegativeFiniteNumber(event['ts'])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isHeapTypeTable(value: unknown, fieldCount: number): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === fieldCount &&
|
||||
value.every(
|
||||
(entry) =>
|
||||
isNonEmptyString(entry) || isNonEmptyStringArray(entry)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isCpuProfileNode(
|
||||
value: unknown
|
||||
): value is Record<string, unknown> & { readonly id: number } {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!isNonNegativeSafeInteger(value['id']) ||
|
||||
!isRecord(value['callFrame'])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const callFrame = value['callFrame'];
|
||||
const scriptId = callFrame['scriptId'];
|
||||
const children = value['children'];
|
||||
const hitCount = value['hitCount'];
|
||||
return (
|
||||
typeof callFrame['functionName'] === 'string' &&
|
||||
(typeof scriptId === 'string' ||
|
||||
isNonNegativeSafeInteger(scriptId)) &&
|
||||
typeof callFrame['url'] === 'string' &&
|
||||
Number.isSafeInteger(callFrame['lineNumber']) &&
|
||||
Number.isSafeInteger(callFrame['columnNumber']) &&
|
||||
(children === undefined ||
|
||||
(Array.isArray(children) &&
|
||||
children.every(isNonNegativeSafeInteger))) &&
|
||||
(hitCount === undefined || isNonNegativeSafeInteger(hitCount))
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is readonly string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every(isNonEmptyString)
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
);
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
type InitialImportDiagnosticCapture,
|
||||
validateInitialImportDiagnosticArtifacts,
|
||||
} from './initial-import-diagnostic-artifacts';
|
||||
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
|
||||
const HEAP_META = {
|
||||
edge_fields: ['type', 'name_or_index', 'to_node'],
|
||||
edge_types: [['context'], 'string_or_number', 'node'],
|
||||
node_fields: [
|
||||
'type',
|
||||
'name',
|
||||
'id',
|
||||
'self_size',
|
||||
'edge_count',
|
||||
'detachedness',
|
||||
],
|
||||
node_types: [
|
||||
['hidden'],
|
||||
'string',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
],
|
||||
};
|
||||
const HEAP_SNAPSHOT = {
|
||||
edges: [0, 0, 0],
|
||||
nodes: [0, 0, 1, 0, 1, 0],
|
||||
snapshot: { meta: HEAP_META },
|
||||
strings: ['', 'root'],
|
||||
};
|
||||
const TRACE = {
|
||||
traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }],
|
||||
};
|
||||
|
||||
function capture(directory: string): InitialImportDiagnosticCapture {
|
||||
return {
|
||||
main: {
|
||||
cpuProfilePath: join(directory, 'main.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
ordinal: 4,
|
||||
profilePath: join(
|
||||
directory,
|
||||
'database.worker-4.cpuprofile'
|
||||
),
|
||||
snapshotPath: join(
|
||||
directory,
|
||||
'database.worker-4.heapsnapshot'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: {
|
||||
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
|
||||
tracePath: join(directory, 'renderer.trace.json'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeArtifacts(directory: string): Promise<void> {
|
||||
const artifacts: Readonly<Record<string, unknown>> = {
|
||||
'database.worker-4.cpuprofile': CPU_PROFILE,
|
||||
'database.worker-4.heapsnapshot': HEAP_SNAPSHOT,
|
||||
'main.cpuprofile': CPU_PROFILE,
|
||||
'main.heapsnapshot': HEAP_SNAPSHOT,
|
||||
'renderer.cpuprofile': CPU_PROFILE,
|
||||
'renderer.heapsnapshot': HEAP_SNAPSHOT,
|
||||
'renderer.trace.json': TRACE,
|
||||
};
|
||||
await Promise.all(
|
||||
Object.entries(artifacts).map(([filename, value]) =>
|
||||
writeFile(join(directory, filename), JSON.stringify(value))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function withArtifacts(
|
||||
run: (directory: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-content-')
|
||||
);
|
||||
try {
|
||||
await writeArtifacts(directory);
|
||||
await run(directory);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('accepts bounded signed CPU time deltas emitted by Chromium', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
await writeFile(
|
||||
join(directory, 'renderer.cpuprofile'),
|
||||
JSON.stringify({
|
||||
...CPU_PROFILE,
|
||||
endTime: 5_000,
|
||||
samples: [1, 1, 1],
|
||||
timeDeltas: [1_000, -100, 1_000],
|
||||
})
|
||||
);
|
||||
await assert.doesNotReject(() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects CPU profiles without useful bounded samples and stack references', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
const invalidProfiles = [
|
||||
{ ...CPU_PROFILE, samples: [], timeDeltas: [] },
|
||||
{ ...CPU_PROFILE, timeDeltas: undefined },
|
||||
{ ...CPU_PROFILE, timeDeltas: [] },
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
endTime: CPU_PROFILE.startTime,
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
samples: [2],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
timeDeltas: [-1],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
timeDeltas: [2_001],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
nodes: [
|
||||
...CPU_PROFILE.nodes,
|
||||
{ ...CPU_PROFILE.nodes[0], id: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
...CPU_PROFILE,
|
||||
nodes: [{ id: 1 }],
|
||||
},
|
||||
];
|
||||
for (const profile of invalidProfiles) {
|
||||
await writeFile(
|
||||
join(directory, 'main.cpuprofile'),
|
||||
JSON.stringify(profile)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
),
|
||||
/initial-import-diagnostic-artifact-invalid:main-cpu-profile/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects empty or malformed heap tables and invalid field strides', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
const invalidSnapshots = [
|
||||
{ snapshot: {}, nodes: [], edges: [], strings: [] },
|
||||
{ ...HEAP_SNAPSHOT, nodes: [] },
|
||||
{ ...HEAP_SNAPSHOT, edges: [] },
|
||||
{ ...HEAP_SNAPSHOT, strings: [] },
|
||||
{
|
||||
...HEAP_SNAPSHOT,
|
||||
snapshot: {
|
||||
meta: { ...HEAP_META, node_types: undefined },
|
||||
},
|
||||
},
|
||||
{ ...HEAP_SNAPSHOT, nodes: [...HEAP_SNAPSHOT.nodes, 0] },
|
||||
{ ...HEAP_SNAPSHOT, edges: [...HEAP_SNAPSHOT.edges, 0] },
|
||||
];
|
||||
for (const snapshot of invalidSnapshots) {
|
||||
await writeFile(
|
||||
join(directory, 'main.heapsnapshot'),
|
||||
JSON.stringify(snapshot)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
),
|
||||
/initial-import-diagnostic-artifact-invalid:main-heap-snapshot/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects empty traces and malformed trace events', async () => {
|
||||
await withArtifacts(async (directory) => {
|
||||
const invalidTraces = [
|
||||
{ traceEvents: [] },
|
||||
{ traceEvents: [null] },
|
||||
{ traceEvents: [{ name: '', ph: 'X', ts: 1 }] },
|
||||
{ traceEvents: [{ name: 'initial-import', ph: '', ts: 1 }] },
|
||||
{
|
||||
traceEvents: [
|
||||
{ name: 'initial-import', ph: 'X', ts: 'invalid' },
|
||||
],
|
||||
},
|
||||
];
|
||||
for (const trace of invalidTraces) {
|
||||
await writeFile(
|
||||
join(directory, 'renderer.trace.json'),
|
||||
JSON.stringify(trace)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
validateInitialImportDiagnosticArtifacts(
|
||||
capture(directory),
|
||||
directory
|
||||
),
|
||||
/initial-import-diagnostic-artifact-invalid:renderer-trace/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
+394
@@ -0,0 +1,394 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
|
||||
|
||||
interface DiagnosticCapture {
|
||||
readonly main: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly workers: readonly {
|
||||
readonly kind: string;
|
||||
readonly ordinal: number;
|
||||
readonly profilePath: string | null;
|
||||
readonly snapshotPath: string | null;
|
||||
}[];
|
||||
};
|
||||
readonly renderer: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly tracePath: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface DiagnosticArtifactsModule {
|
||||
validateInitialImportDiagnosticArtifacts?: (
|
||||
capture: DiagnosticCapture,
|
||||
iterationDirectory: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const diagnosticArtifactsModulePromise = import(
|
||||
new URL('./initial-import-diagnostic-artifacts.ts', import.meta.url).href
|
||||
)
|
||||
.then((module) => module as DiagnosticArtifactsModule)
|
||||
.catch(() => null);
|
||||
|
||||
const HEAP_SNAPSHOT = {
|
||||
edges: [0, 0, 0],
|
||||
nodes: [0, 0, 1, 0, 1, 0],
|
||||
snapshot: {
|
||||
meta: {
|
||||
edge_fields: ['type', 'name_or_index', 'to_node'],
|
||||
edge_types: [['context'], 'string_or_number', 'node'],
|
||||
node_fields: [
|
||||
'type',
|
||||
'name',
|
||||
'id',
|
||||
'self_size',
|
||||
'edge_count',
|
||||
'detachedness',
|
||||
],
|
||||
node_types: [
|
||||
['hidden'],
|
||||
'string',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
],
|
||||
},
|
||||
},
|
||||
strings: ['', 'root'],
|
||||
};
|
||||
const RENDERER_TRACE = {
|
||||
traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }],
|
||||
};
|
||||
|
||||
function capture(directory: string, databaseOrdinal = 4): DiagnosticCapture {
|
||||
return {
|
||||
main: {
|
||||
cpuProfilePath: join(directory, 'main.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
ordinal: databaseOrdinal,
|
||||
profilePath: join(
|
||||
directory,
|
||||
`database.worker-${databaseOrdinal}.cpuprofile`
|
||||
),
|
||||
snapshotPath: join(
|
||||
directory,
|
||||
`database.worker-${databaseOrdinal}.heapsnapshot`
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: {
|
||||
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
|
||||
tracePath: join(directory, 'renderer.trace.json'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeValidArtifacts(
|
||||
directory: string,
|
||||
databaseOrdinal = 4
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(directory, 'main.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'renderer.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, `database.worker-${databaseOrdinal}.cpuprofile`),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'main.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'renderer.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
),
|
||||
writeFile(
|
||||
join(directory, 'renderer.trace.json'),
|
||||
JSON.stringify(RENDERER_TRACE)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
test('accepts the complete parseable initial-import diagnostic artifact set', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module, 'initial-import diagnostic validator must exist');
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-diagnostic-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await assert.doesNotReject(() =>
|
||||
validate?.(capture(directory), directory)
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed for malformed or missing required process artifacts', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-invalid-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await writeFile(join(directory, 'renderer.trace.json'), '{');
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-invalid:renderer-trace/
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(directory, 'renderer.trace.json'),
|
||||
JSON.stringify(RENDERER_TRACE)
|
||||
);
|
||||
const missingMainProfile: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
cpuProfilePath: null,
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => validate?.(missingMainProfile, directory),
|
||||
/initial-import-diagnostic-artifact-missing:main-cpu-profile/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects escaped paths and symlinks instead of reading outside the iteration directory', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-contained-')
|
||||
);
|
||||
const outsideDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-outside-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
const escapedCapture: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
cpuProfilePath: join(outsideDirectory, 'main.cpuprofile'),
|
||||
},
|
||||
};
|
||||
await writeFile(
|
||||
join(outsideDirectory, 'main.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(escapedCapture, directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:main-cpu-profile/
|
||||
);
|
||||
|
||||
const rendererProfilePath = join(directory, 'renderer.cpuprofile');
|
||||
await unlink(rendererProfilePath);
|
||||
await symlink(
|
||||
join(outsideDirectory, 'main.cpuprofile'),
|
||||
rendererProfilePath
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:renderer-cpu-profile/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
await rm(outsideDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires exactly one database profile and rejects every playlist-worker profile', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-workers-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await writeFile(
|
||||
join(directory, 'database.worker-9.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-database-profile-cardinality-invalid/
|
||||
);
|
||||
|
||||
await unlink(join(directory, 'database.worker-9.cpuprofile'));
|
||||
await writeFile(
|
||||
join(directory, 'playlist-refresh.worker-2.cpuprofile'),
|
||||
JSON.stringify(CPU_PROFILE)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-playlist-profile-unexpected/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requires a structurally valid database worker heap snapshot', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
const missingSnapshot: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
workers: [
|
||||
{
|
||||
...capture(directory).main.workers[0],
|
||||
snapshotPath: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => validate?.(missingSnapshot, directory),
|
||||
/initial-import-diagnostic-artifact-missing:database-heap-snapshot/
|
||||
);
|
||||
|
||||
await writeFile(
|
||||
join(directory, 'database.worker-4.heapsnapshot'),
|
||||
JSON.stringify({ snapshot: {}, nodes: [], edges: [] })
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-invalid:database-heap-snapshot/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects escaped and symlinked database worker heap snapshots', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-contained-')
|
||||
);
|
||||
const outsideDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-outside-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
const outsideSnapshotPath = join(
|
||||
outsideDirectory,
|
||||
'database.worker-4.heapsnapshot'
|
||||
);
|
||||
await writeFile(outsideSnapshotPath, JSON.stringify(HEAP_SNAPSHOT));
|
||||
const escapedSnapshot: DiagnosticCapture = {
|
||||
...capture(directory),
|
||||
main: {
|
||||
...capture(directory).main,
|
||||
workers: [
|
||||
{
|
||||
...capture(directory).main.workers[0],
|
||||
snapshotPath: outsideSnapshotPath,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => validate?.(escapedSnapshot, directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/
|
||||
);
|
||||
|
||||
const snapshotPath = join(directory, 'database.worker-4.heapsnapshot');
|
||||
await unlink(snapshotPath);
|
||||
await symlink(outsideSnapshotPath, snapshotPath);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
await rm(outsideDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects extra database snapshots and every playlist-worker snapshot', async () => {
|
||||
const module = await diagnosticArtifactsModulePromise;
|
||||
assert.ok(module);
|
||||
const validate = module.validateInitialImportDiagnosticArtifacts;
|
||||
assert.equal(typeof validate, 'function');
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-count-')
|
||||
);
|
||||
|
||||
try {
|
||||
await writeValidArtifacts(directory);
|
||||
await writeFile(
|
||||
join(directory, 'database.worker-9.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-database-snapshot-cardinality-invalid/
|
||||
);
|
||||
|
||||
await unlink(join(directory, 'database.worker-9.heapsnapshot'));
|
||||
await writeFile(
|
||||
join(directory, 'playlist-refresh.worker-2.heapsnapshot'),
|
||||
JSON.stringify(HEAP_SNAPSHOT)
|
||||
);
|
||||
await assert.rejects(
|
||||
() => validate?.(capture(directory), directory),
|
||||
/initial-import-diagnostic-playlist-snapshot-unexpected/
|
||||
);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { lstat, readFile, readdir, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
import {
|
||||
isCpuProfile,
|
||||
isHeapSnapshot,
|
||||
isRendererTrace,
|
||||
} from './initial-import-diagnostic-artifact-content-validation';
|
||||
|
||||
interface DiagnosticWorkerArtifact {
|
||||
readonly kind: string;
|
||||
readonly ordinal: number;
|
||||
readonly profilePath: string | null;
|
||||
readonly snapshotPath: string | null;
|
||||
}
|
||||
|
||||
export interface InitialImportDiagnosticCapture {
|
||||
readonly main: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly workers: readonly DiagnosticWorkerArtifact[];
|
||||
};
|
||||
readonly renderer: {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly tracePath: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
type ArtifactLabel =
|
||||
| 'database-cpu-profile'
|
||||
| 'database-heap-snapshot'
|
||||
| 'main-cpu-profile'
|
||||
| 'main-heap-snapshot'
|
||||
| 'renderer-cpu-profile'
|
||||
| 'renderer-heap-snapshot'
|
||||
| 'renderer-trace';
|
||||
|
||||
interface ArtifactDescription {
|
||||
readonly filename: string;
|
||||
readonly label: ArtifactLabel;
|
||||
readonly path: string | null;
|
||||
readonly validate: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
export async function validateInitialImportDiagnosticArtifacts(
|
||||
capture: InitialImportDiagnosticCapture,
|
||||
iterationDirectory: string
|
||||
): Promise<void> {
|
||||
const directory = resolve(iterationDirectory);
|
||||
let realDirectory: string;
|
||||
try {
|
||||
realDirectory = await realpath(directory);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-artifact-path-invalid:iteration-directory'
|
||||
);
|
||||
}
|
||||
|
||||
const databaseWorker = requireExactDatabaseWorker(capture.main.workers);
|
||||
await assertWorkerArtifactCardinality(directory);
|
||||
|
||||
const artifacts: readonly ArtifactDescription[] = [
|
||||
{
|
||||
filename: 'main.cpuprofile',
|
||||
label: 'main-cpu-profile',
|
||||
path: capture.main.cpuProfilePath,
|
||||
validate: isCpuProfile,
|
||||
},
|
||||
{
|
||||
filename: 'renderer.cpuprofile',
|
||||
label: 'renderer-cpu-profile',
|
||||
path: capture.renderer.cpuProfilePath,
|
||||
validate: isCpuProfile,
|
||||
},
|
||||
{
|
||||
filename: 'renderer.trace.json',
|
||||
label: 'renderer-trace',
|
||||
path: capture.renderer.tracePath,
|
||||
validate: isRendererTrace,
|
||||
},
|
||||
{
|
||||
filename: 'main.heapsnapshot',
|
||||
label: 'main-heap-snapshot',
|
||||
path: capture.main.heapSnapshotPath,
|
||||
validate: isHeapSnapshot,
|
||||
},
|
||||
{
|
||||
filename: 'renderer.heapsnapshot',
|
||||
label: 'renderer-heap-snapshot',
|
||||
path: capture.renderer.heapSnapshotPath,
|
||||
validate: isHeapSnapshot,
|
||||
},
|
||||
{
|
||||
filename: `database.worker-${databaseWorker.ordinal}.cpuprofile`,
|
||||
label: 'database-cpu-profile',
|
||||
path: databaseWorker.profilePath,
|
||||
validate: isCpuProfile,
|
||||
},
|
||||
{
|
||||
filename: `database.worker-${databaseWorker.ordinal}.heapsnapshot`,
|
||||
label: 'database-heap-snapshot',
|
||||
path: databaseWorker.snapshotPath,
|
||||
validate: isHeapSnapshot,
|
||||
},
|
||||
];
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
await validateJsonArtifact(directory, realDirectory, artifact);
|
||||
}
|
||||
}
|
||||
|
||||
function requireExactDatabaseWorker(
|
||||
workers: readonly DiagnosticWorkerArtifact[]
|
||||
): DiagnosticWorkerArtifact {
|
||||
if (
|
||||
workers.some(
|
||||
(worker) =>
|
||||
worker.kind === 'playlist-refresh.worker' &&
|
||||
worker.profilePath !== null
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-profile-unexpected'
|
||||
);
|
||||
}
|
||||
if (
|
||||
workers.some(
|
||||
(worker) =>
|
||||
worker.kind === 'playlist-refresh.worker' &&
|
||||
worker.snapshotPath !== null
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-snapshot-unexpected'
|
||||
);
|
||||
}
|
||||
const databaseWorkers = workers.filter(
|
||||
(worker) => worker.kind === 'database.worker'
|
||||
);
|
||||
const worker = databaseWorkers[0];
|
||||
if (
|
||||
databaseWorkers.length !== 1 ||
|
||||
!worker ||
|
||||
!Number.isSafeInteger(worker.ordinal) ||
|
||||
worker.ordinal < 0
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-database-profile-cardinality-invalid'
|
||||
);
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
async function assertWorkerArtifactCardinality(
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
let entries: readonly string[];
|
||||
try {
|
||||
entries = await readdir(directory);
|
||||
} catch {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-artifact-path-invalid:iteration-directory'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.some((entry) =>
|
||||
/^playlist-refresh\.worker-\d+\.cpuprofile$/u.test(entry)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-profile-unexpected'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.filter((entry) =>
|
||||
/^database\.worker-\d+\.cpuprofile$/u.test(entry)
|
||||
).length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-database-profile-cardinality-invalid'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.some((entry) =>
|
||||
/^playlist-refresh\.worker-\d+\.heapsnapshot$/u.test(entry)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-playlist-snapshot-unexpected'
|
||||
);
|
||||
}
|
||||
if (
|
||||
entries.filter((entry) =>
|
||||
/^database\.worker-\d+\.heapsnapshot$/u.test(entry)
|
||||
).length !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
'initial-import-diagnostic-database-snapshot-cardinality-invalid'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateJsonArtifact(
|
||||
directory: string,
|
||||
realDirectory: string,
|
||||
artifact: ArtifactDescription
|
||||
): Promise<void> {
|
||||
if (artifact.path === null) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-missing:${artifact.label}`
|
||||
);
|
||||
}
|
||||
const expectedPath = join(directory, artifact.filename);
|
||||
if (resolve(artifact.path) !== expectedPath) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-path-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
const stats = await lstat(expectedPath);
|
||||
if (!stats.isFile() || stats.isSymbolicLink()) {
|
||||
throw new Error('unsafe-artifact-type');
|
||||
}
|
||||
const realArtifactPath = await realpath(expectedPath);
|
||||
if (!isContained(realDirectory, realArtifactPath)) {
|
||||
throw new Error('escaped-artifact');
|
||||
}
|
||||
value = JSON.parse(await readFile(realArtifactPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message === 'unsafe-artifact-type' ||
|
||||
error.message === 'escaped-artifact')
|
||||
) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-path-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact.validate(value)) {
|
||||
throw new Error(
|
||||
`initial-import-diagnostic-artifact-invalid:${artifact.label}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isContained(directory: string, artifactPath: string): boolean {
|
||||
const relativePath = relative(directory, artifactPath);
|
||||
return (
|
||||
relativePath.length > 0 &&
|
||||
!isAbsolute(relativePath) &&
|
||||
relativePath !== '..' &&
|
||||
!relativePath.startsWith(`..${sep}`)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CPU_PROFILE = Object.freeze({
|
||||
endTime: 3_000,
|
||||
nodes: Object.freeze([
|
||||
Object.freeze({
|
||||
callFrame: Object.freeze({
|
||||
columnNumber: -1,
|
||||
functionName: '(root)',
|
||||
lineNumber: -1,
|
||||
scriptId: '0',
|
||||
url: '',
|
||||
}),
|
||||
hitCount: 1,
|
||||
id: 1,
|
||||
}),
|
||||
]),
|
||||
samples: Object.freeze([1]),
|
||||
startTime: 1_000,
|
||||
timeDeltas: Object.freeze([1_000]),
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
assertSyntheticM3uImportUrl,
|
||||
createM3uImportIterationDefinitions,
|
||||
listM3uImportScenarios,
|
||||
M3U_IMPORT_SCENARIO_ID,
|
||||
} from './m3u-import-benchmark-contract';
|
||||
|
||||
describe('formal M3U import benchmark contract', () => {
|
||||
it('defines the exact 10k, 50k, and 100k synthetic scenarios', () => {
|
||||
assert.deepEqual(listM3uImportScenarios(), [
|
||||
{
|
||||
channelCount: 10_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K,
|
||||
},
|
||||
{
|
||||
channelCount: 50_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K,
|
||||
},
|
||||
{
|
||||
channelCount: 100_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('schedules one warm-up, five measured runs, and one diagnostic per size', () => {
|
||||
const definitions = createM3uImportIterationDefinitions(false);
|
||||
|
||||
assert.equal(definitions.length, 21);
|
||||
for (const scenario of listM3uImportScenarios()) {
|
||||
const scenarioRuns = definitions.filter(
|
||||
(definition) => definition.scenarioId === scenario.id
|
||||
);
|
||||
assert.deepEqual(
|
||||
scenarioRuns.map(({ kind, runId }) => ({ kind, runId })),
|
||||
[
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
runId: 'warmup-01',
|
||||
},
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
runId: `run-${String(index + 1).padStart(2, '0')}`,
|
||||
})),
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
runId: 'diagnostic',
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.ok(
|
||||
scenarioRuns.every(
|
||||
(definition) =>
|
||||
definition.channelCount === scenario.channelCount
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps fixture sizes unchanged while smoke mode reduces measured runs to one', () => {
|
||||
const definitions = createM3uImportIterationDefinitions(true);
|
||||
|
||||
assert.equal(definitions.length, 9);
|
||||
for (const scenario of listM3uImportScenarios()) {
|
||||
assert.deepEqual(
|
||||
definitions
|
||||
.filter(
|
||||
(definition) => definition.scenarioId === scenario.id
|
||||
)
|
||||
.map(({ kind, runId }) => ({ kind, runId })),
|
||||
[
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
runId: 'warmup-01',
|
||||
},
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
runId: 'run-01',
|
||||
},
|
||||
{
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
runId: 'diagnostic',
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts only the exact credential-free loopback fixture URL', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertSyntheticM3uImportUrl(
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u'
|
||||
)
|
||||
);
|
||||
|
||||
for (const unsafe of [
|
||||
'https://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
'http://localhost:43210/synthetic-performance.m3u',
|
||||
'http://127.0.0.1/synthetic-performance.m3u',
|
||||
'http://user:pass@127.0.0.1:43210/synthetic-performance.m3u',
|
||||
'http://127.0.0.1:43210/other.m3u',
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u?token=secret',
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u#fragment',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertSyntheticM3uImportUrl(unsafe),
|
||||
/exact synthetic HTTP loopback URL/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
PERFORMANCE_ITERATION_KIND,
|
||||
type PerformanceIterationKind,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { SYNTHETIC_M3U_RESOURCE_PATH } from './synthetic-m3u-server';
|
||||
import type { SyntheticM3uChannelCount } from './synthetic-m3u';
|
||||
|
||||
export const M3U_IMPORT_SCENARIO_ID = {
|
||||
IMPORT_10K: 'm3u-import-10k',
|
||||
IMPORT_50K: 'm3u-import-50k',
|
||||
IMPORT_100K: 'm3u-import-100k',
|
||||
} as const;
|
||||
|
||||
export type M3uImportScenarioId =
|
||||
(typeof M3U_IMPORT_SCENARIO_ID)[keyof typeof M3U_IMPORT_SCENARIO_ID];
|
||||
|
||||
export interface M3uImportScenario {
|
||||
readonly channelCount: SyntheticM3uChannelCount;
|
||||
readonly id: M3uImportScenarioId;
|
||||
}
|
||||
|
||||
export interface M3uImportIterationDefinition {
|
||||
readonly channelCount: SyntheticM3uChannelCount;
|
||||
readonly kind: PerformanceIterationKind;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: M3uImportScenarioId;
|
||||
}
|
||||
|
||||
const SCENARIOS: readonly M3uImportScenario[] = Object.freeze([
|
||||
Object.freeze({
|
||||
channelCount: 10_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K,
|
||||
}),
|
||||
Object.freeze({
|
||||
channelCount: 50_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K,
|
||||
}),
|
||||
Object.freeze({
|
||||
channelCount: 100_000,
|
||||
id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K,
|
||||
}),
|
||||
]);
|
||||
|
||||
export function listM3uImportScenarios(): readonly M3uImportScenario[] {
|
||||
return SCENARIOS;
|
||||
}
|
||||
|
||||
export function createM3uImportIterationDefinitions(
|
||||
smoke: boolean
|
||||
): readonly M3uImportIterationDefinition[] {
|
||||
const measuredRuns = smoke ? 1 : 5;
|
||||
const definitions: M3uImportIterationDefinition[] = [];
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
definitions.push(
|
||||
iteration(scenario, PERFORMANCE_ITERATION_KIND.WARMUP, 'warmup-01')
|
||||
);
|
||||
for (let index = 1; index <= measuredRuns; index += 1) {
|
||||
definitions.push(
|
||||
iteration(
|
||||
scenario,
|
||||
PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
`run-${String(index).padStart(2, '0')}`
|
||||
)
|
||||
);
|
||||
}
|
||||
definitions.push(
|
||||
iteration(
|
||||
scenario,
|
||||
PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
'diagnostic'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze(definitions);
|
||||
}
|
||||
|
||||
export function assertSyntheticM3uImportUrl(value: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw invalidSyntheticUrl();
|
||||
}
|
||||
|
||||
if (
|
||||
url.protocol !== 'http:' ||
|
||||
url.hostname !== '127.0.0.1' ||
|
||||
url.port.length === 0 ||
|
||||
url.username.length > 0 ||
|
||||
url.password.length > 0 ||
|
||||
url.pathname !== SYNTHETIC_M3U_RESOURCE_PATH ||
|
||||
url.search.length > 0 ||
|
||||
url.hash.length > 0
|
||||
) {
|
||||
throw invalidSyntheticUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function iteration(
|
||||
scenario: M3uImportScenario,
|
||||
kind: PerformanceIterationKind,
|
||||
runId: string
|
||||
): M3uImportIterationDefinition {
|
||||
return Object.freeze({
|
||||
channelCount: scenario.channelCount,
|
||||
kind,
|
||||
runId,
|
||||
scenarioId: scenario.id,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidSyntheticUrl(): Error {
|
||||
return new Error(
|
||||
'M3U performance fixture must use the exact synthetic HTTP loopback URL'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assertM3uImportOutputPathHasNoSymlinks,
|
||||
assertM3uImportSourceState,
|
||||
M3U_IMPORT_RENDERER_CDP_PORT,
|
||||
resolveM3uImportRendererCdpPort,
|
||||
resolveM3uImportOutputLayout,
|
||||
} from './m3u-import-benchmark-support';
|
||||
import { assertM3uImportRequestDelta } from './m3u-import.benchmark';
|
||||
|
||||
describe('initial M3U import benchmark lifecycle', () => {
|
||||
it('keeps requested output and variant strictly below dist/performance', () => {
|
||||
const workspaceRoot = resolve('/workspace/iptvnator');
|
||||
const outputRoot = resolve(
|
||||
workspaceRoot,
|
||||
'dist/performance/20260727T040000Z-m3u-import'
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveM3uImportOutputLayout(workspaceRoot, outputRoot, 'baseline'),
|
||||
{
|
||||
outputRoot,
|
||||
variantDirectory: resolve(outputRoot, 'baseline'),
|
||||
}
|
||||
);
|
||||
|
||||
for (const requestedRoot of [
|
||||
'dist/performance/run',
|
||||
resolve(workspaceRoot, 'dist/performance'),
|
||||
resolve(workspaceRoot, 'dist/performance-sibling/run'),
|
||||
resolve(workspaceRoot, '../outside'),
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveM3uImportOutputLayout(
|
||||
workspaceRoot,
|
||||
requestedRoot,
|
||||
'baseline'
|
||||
),
|
||||
/absolute path below dist\/performance/
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveM3uImportOutputLayout(
|
||||
workspaceRoot,
|
||||
outputRoot,
|
||||
'../escape'
|
||||
),
|
||||
/variant is invalid/i
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a clean source tree for formal runs but permits dirty smoke evidence', () => {
|
||||
const dirtyState = {
|
||||
commit: 'a'.repeat(40),
|
||||
diffSha256: 'b'.repeat(64),
|
||||
dirty: true,
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => assertM3uImportSourceState(false, dirtyState),
|
||||
/Formal performance runs require a clean Git worktree/
|
||||
);
|
||||
assert.doesNotThrow(() => assertM3uImportSourceState(true, dirtyState));
|
||||
assert.doesNotThrow(() =>
|
||||
assertM3uImportSourceState(false, {
|
||||
...dirtyState,
|
||||
dirty: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an existing output ancestor symlink before artifact creation', async () => {
|
||||
const fixtureRoot = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-m3u-output-path-')
|
||||
);
|
||||
const repositoryRoot = join(fixtureRoot, 'repository');
|
||||
const performanceRoot = join(
|
||||
repositoryRoot,
|
||||
'dist',
|
||||
'performance'
|
||||
);
|
||||
const outside = join(fixtureRoot, 'outside');
|
||||
await mkdir(performanceRoot, { recursive: true });
|
||||
await mkdir(outside);
|
||||
await symlink(outside, join(performanceRoot, 'redirect'));
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
assertM3uImportOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
join(performanceRoot, 'redirect', 'run')
|
||||
),
|
||||
/Performance output path contains a symbolic link/
|
||||
);
|
||||
await assert.doesNotReject(
|
||||
assertM3uImportOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
join(performanceRoot, 'ordinary', 'run')
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await rm(fixtureRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps formal CDP capture on 9222 while allowing an isolated smoke port', () => {
|
||||
assert.equal(
|
||||
resolveM3uImportRendererCdpPort(false, undefined),
|
||||
M3U_IMPORT_RENDERER_CDP_PORT
|
||||
);
|
||||
assert.equal(
|
||||
resolveM3uImportRendererCdpPort(false, '9222'),
|
||||
M3U_IMPORT_RENDERER_CDP_PORT
|
||||
);
|
||||
assert.equal(resolveM3uImportRendererCdpPort(true, '9322'), 9322);
|
||||
|
||||
assert.throws(
|
||||
() => resolveM3uImportRendererCdpPort(false, '9322'),
|
||||
/Formal performance runs require CDP port 9222/
|
||||
);
|
||||
for (const invalid of ['0', '1023', '65536', '9222.5', 'not-a-port']) {
|
||||
assert.throws(
|
||||
() => resolveM3uImportRendererCdpPort(true, invalid),
|
||||
/IPTVNATOR_PERF_CDP_PORT is invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed unless one and only one app GET occurs', () => {
|
||||
assert.doesNotThrow(() => assertM3uImportRequestDelta(3, 4));
|
||||
assert.throws(
|
||||
() => assertM3uImportRequestDelta(3, 3),
|
||||
/exactly one application GET/
|
||||
);
|
||||
assert.throws(
|
||||
() => assertM3uImportRequestDelta(3, 5),
|
||||
/exactly one application GET/
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the exact upsert plus route-reload GET sequence', () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'src/performance/m3u-import.benchmark.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(source, /status\.databaseRequests\s*===\s*2/);
|
||||
assert.match(source, /status\.databaseUpsertsCompleted\s*===\s*1/);
|
||||
assert.match(source, /status\.databaseGetsCompleted\s*===\s*1/);
|
||||
assert.match(source, /status\.databasePending\s*===\s*0/);
|
||||
assert.match(source, /status\.preloadSuccessMarkers\s*===\s*2/);
|
||||
});
|
||||
|
||||
it('always disposes renderer capture before closing Electron on iteration failure', () => {
|
||||
const source = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'src/performance/m3u-import.benchmark.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const runIteration = source.indexOf('async function runIteration');
|
||||
const finallyBlock = source.indexOf('} finally {', runIteration);
|
||||
const rendererDispose = source.indexOf(
|
||||
'await renderer.dispose()',
|
||||
finallyBlock
|
||||
);
|
||||
const appClose = source.indexOf('await closeElectronApp(app)', finallyBlock);
|
||||
|
||||
assert.ok(finallyBlock > runIteration);
|
||||
assert.ok(rendererDispose > finallyBlock);
|
||||
assert.ok(appClose > rendererDispose);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { createServer as createTcpServer } from 'node:net';
|
||||
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
type LaunchedElectronApp,
|
||||
workspaceRoot,
|
||||
} from '../electron-test-fixtures';
|
||||
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
|
||||
import type { RendererWindowIdentity } from './renderer-window-rss-session';
|
||||
|
||||
const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
export const M3U_IMPORT_RENDERER_CDP_PORT = 9222;
|
||||
|
||||
export interface M3uImportGitSourceState {
|
||||
readonly commit: string;
|
||||
readonly diffSha256: string;
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
|
||||
export interface M3uImportOutputLayout {
|
||||
readonly outputRoot: string;
|
||||
readonly variantDirectory: string;
|
||||
}
|
||||
|
||||
export interface M3uImportBenchmarkConfiguration extends M3uImportOutputLayout {
|
||||
readonly electronVersion: string;
|
||||
readonly measuredRuns: number;
|
||||
readonly rendererCdpPort: number;
|
||||
readonly smoke: boolean;
|
||||
readonly sourceState: M3uImportGitSourceState;
|
||||
readonly variant: string;
|
||||
}
|
||||
|
||||
export async function resolveM3uImportBenchmarkConfiguration(): Promise<M3uImportBenchmarkConfiguration> {
|
||||
const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR'];
|
||||
if (!requestedRoot) {
|
||||
throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required');
|
||||
}
|
||||
const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline';
|
||||
const layout = resolveM3uImportOutputLayout(
|
||||
workspaceRoot,
|
||||
requestedRoot,
|
||||
variant
|
||||
);
|
||||
const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1';
|
||||
const rendererCdpPort = resolveM3uImportRendererCdpPort(
|
||||
smoke,
|
||||
process.env['IPTVNATOR_PERF_CDP_PORT']
|
||||
);
|
||||
const sourceState = readGitSourceState(workspaceRoot);
|
||||
assertM3uImportSourceState(smoke, sourceState);
|
||||
const electronPackage = JSON.parse(
|
||||
await readFile(
|
||||
join(workspaceRoot, 'node_modules', 'electron', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as { version?: unknown };
|
||||
if (typeof electronPackage.version !== 'string') {
|
||||
throw new Error('Unable to resolve the Electron runtime version');
|
||||
}
|
||||
await assertM3uImportOutputPathHasNoSymlinks(
|
||||
workspaceRoot,
|
||||
layout.outputRoot
|
||||
);
|
||||
await assertMissing(layout.variantDirectory);
|
||||
await assertPerformanceArtifactCapacity(layout.variantDirectory);
|
||||
await mkdir(layout.variantDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: true,
|
||||
});
|
||||
return Object.freeze({
|
||||
...layout,
|
||||
electronVersion: electronPackage.version,
|
||||
measuredRuns: smoke ? 1 : 5,
|
||||
rendererCdpPort,
|
||||
smoke,
|
||||
sourceState,
|
||||
variant,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveM3uImportOutputLayout(
|
||||
repositoryRoot: string,
|
||||
requestedRoot: string,
|
||||
variant: string
|
||||
): M3uImportOutputLayout {
|
||||
const performanceRoot = resolve(repositoryRoot, 'dist', 'performance');
|
||||
const outputRoot = resolve(requestedRoot);
|
||||
if (
|
||||
!isAbsolute(requestedRoot) ||
|
||||
!isStrictDescendant(performanceRoot, outputRoot)
|
||||
) {
|
||||
throw new Error(
|
||||
'Performance output must be an absolute path below dist/performance'
|
||||
);
|
||||
}
|
||||
if (!OUTPUT_VARIANT_PATTERN.test(variant)) {
|
||||
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
|
||||
}
|
||||
const variantDirectory = resolve(outputRoot, variant);
|
||||
if (!isStrictDescendant(outputRoot, variantDirectory)) {
|
||||
throw new Error('IPTVNATOR_PERF_VARIANT is invalid');
|
||||
}
|
||||
return Object.freeze({ outputRoot, variantDirectory });
|
||||
}
|
||||
|
||||
export async function assertM3uImportOutputPathHasNoSymlinks(
|
||||
repositoryRoot: string,
|
||||
outputRoot: string
|
||||
): Promise<void> {
|
||||
const root = resolve(repositoryRoot);
|
||||
const output = resolve(outputRoot);
|
||||
if (!isStrictDescendant(root, output)) {
|
||||
throw new Error('Performance output path escapes the repository');
|
||||
}
|
||||
const components = relative(root, output).split(sep);
|
||||
let current = root;
|
||||
|
||||
for (const component of ['', ...components]) {
|
||||
current = component === '' ? current : join(current, component);
|
||||
try {
|
||||
const stats = await lstat(current);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Performance output path contains a symbolic link: ${current}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertM3uImportSourceState(
|
||||
smoke: boolean,
|
||||
state: M3uImportGitSourceState
|
||||
): void {
|
||||
if (!smoke && state.dirty) {
|
||||
throw new Error(
|
||||
'Formal performance runs require a clean Git worktree; commit or stash changes first'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveM3uImportRendererCdpPort(
|
||||
smoke: boolean,
|
||||
requestedPort: string | undefined
|
||||
): number {
|
||||
const port =
|
||||
requestedPort === undefined
|
||||
? M3U_IMPORT_RENDERER_CDP_PORT
|
||||
: Number(requestedPort);
|
||||
if (
|
||||
(requestedPort !== undefined && !/^[1-9]\d*$/.test(requestedPort)) ||
|
||||
!Number.isSafeInteger(port) ||
|
||||
port < 1024 ||
|
||||
port > 65_535
|
||||
) {
|
||||
throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid');
|
||||
}
|
||||
if (!smoke && port !== M3U_IMPORT_RENDERER_CDP_PORT) {
|
||||
throw new Error(
|
||||
`Formal performance runs require CDP port ${M3U_IMPORT_RENDERER_CDP_PORT}`
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function resolveRendererWindowIdentity(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<RendererWindowIdentity> {
|
||||
const handle = await app.electronApp.browserWindow(app.mainWindow);
|
||||
try {
|
||||
return await handle.evaluate((browserWindow) => {
|
||||
const exactWindow = browserWindow as unknown as {
|
||||
readonly id: number;
|
||||
readonly webContents: { readonly id: number };
|
||||
};
|
||||
return {
|
||||
browserWindowId: exactWindow.id,
|
||||
webContentsId: exactWindow.webContents.id,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
await handle.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertTcpPortAvailable(port: number): Promise<void> {
|
||||
const server = createTcpServer();
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.once('error', rejectPromise);
|
||||
server.listen(
|
||||
{ exclusive: true, host: '127.0.0.1', port },
|
||||
resolvePromise
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) =>
|
||||
error ? rejectPromise(error) : resolvePromise()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertRendererCdpTarget(
|
||||
page: Page,
|
||||
port: number
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/json/list`
|
||||
);
|
||||
const targets = response.ok
|
||||
? ((await response.json()) as unknown)
|
||||
: null;
|
||||
return (
|
||||
Array.isArray(targets) &&
|
||||
targets.some(
|
||||
(target) =>
|
||||
typeof target === 'object' &&
|
||||
target !== null &&
|
||||
(target as Record<string, unknown>)['type'] ===
|
||||
'page' &&
|
||||
(target as Record<string, unknown>)['url'] ===
|
||||
page.url()
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
export async function writeM3uImportJson(
|
||||
path: string,
|
||||
value: unknown
|
||||
): Promise<void> {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function readGitSourceState(repositoryRoot: string): M3uImportGitSourceState {
|
||||
const git = (args: readonly string[]): string =>
|
||||
execFileSync('git', [...args], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const commit = git(['rev-parse', 'HEAD']).trim();
|
||||
const status = git(['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
const diffSha256 = createHash('sha256')
|
||||
.update(status)
|
||||
.update('\0')
|
||||
.update(git(['diff', '--binary', 'HEAD']))
|
||||
.update('\0')
|
||||
.update(git(['diff', '--binary', '--cached', 'HEAD']))
|
||||
.digest('hex');
|
||||
return Object.freeze({
|
||||
commit,
|
||||
diffSha256,
|
||||
dirty: status.trim().length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(resolve(parent), resolve(candidate));
|
||||
return (
|
||||
pathFromParent.length > 0 &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
async function assertMissing(path: string): Promise<void> {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Performance output already exists: ${path}`);
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type {
|
||||
MainTimelineRecord,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
|
||||
type InitialImportDatabaseOperation =
|
||||
| 'DB_GET_APP_PLAYLIST'
|
||||
| 'DB_UPSERT_APP_PLAYLIST';
|
||||
|
||||
export interface M3uImportIpcAttribution {
|
||||
readonly databaseWorkerToMainCloneProxyMs: number;
|
||||
readonly ipcStructuredCloneProxyMs: number;
|
||||
readonly mainToDatabaseWorkerCloneProxyMs: number;
|
||||
readonly mainToRendererCloneProxyMs: number;
|
||||
readonly rendererToMainCloneProxyMs: number;
|
||||
}
|
||||
|
||||
interface RequestBoundaryAttribution {
|
||||
readonly mainToDatabaseWorkerMs: number;
|
||||
readonly mainToRendererMs: number;
|
||||
readonly rendererToMainMs: number;
|
||||
readonly successEpochMs: number;
|
||||
readonly workerToMainMs: number;
|
||||
}
|
||||
|
||||
export function deriveM3uImportIpcAttribution(input: {
|
||||
readonly get: WorkerRequestPerformanceMetrics;
|
||||
readonly getReadStartedEpochMs: number;
|
||||
readonly importDispatchEndedEpochMs: number;
|
||||
readonly importDispatchStartedEpochMs: number;
|
||||
readonly normalizeEndedEpochMs: number;
|
||||
readonly playlistId: string;
|
||||
readonly publishChannelsStartedEpochMs: number;
|
||||
readonly serializeStartedEpochMs: number;
|
||||
readonly timeline: readonly MainTimelineRecord[];
|
||||
readonly upsert: WorkerRequestPerformanceMetrics;
|
||||
}): M3uImportIpcAttribution {
|
||||
requireOrdered(
|
||||
input.normalizeEndedEpochMs,
|
||||
input.importDispatchStartedEpochMs
|
||||
);
|
||||
requireOrdered(
|
||||
input.importDispatchStartedEpochMs,
|
||||
input.importDispatchEndedEpochMs
|
||||
);
|
||||
|
||||
const upsert = deriveRequestBoundaryAttribution({
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: input.playlistId,
|
||||
request: input.upsert,
|
||||
timeline: input.timeline,
|
||||
workPhaseStartedEpochMs: input.serializeStartedEpochMs,
|
||||
});
|
||||
requireOrdered(input.importDispatchEndedEpochMs, requireSource(input.upsert));
|
||||
|
||||
const get = deriveRequestBoundaryAttribution({
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: input.playlistId,
|
||||
request: input.get,
|
||||
timeline: input.timeline,
|
||||
workPhaseStartedEpochMs: input.getReadStartedEpochMs,
|
||||
});
|
||||
requireOrdered(upsert.successEpochMs, requireSource(input.get));
|
||||
requireOrdered(
|
||||
get.successEpochMs,
|
||||
input.publishChannelsStartedEpochMs
|
||||
);
|
||||
|
||||
const initialMainToRendererMs = difference(
|
||||
input.importDispatchStartedEpochMs,
|
||||
input.normalizeEndedEpochMs
|
||||
);
|
||||
const mainToRendererCloneProxyMs =
|
||||
initialMainToRendererMs +
|
||||
upsert.mainToRendererMs +
|
||||
get.mainToRendererMs;
|
||||
const rendererToMainCloneProxyMs =
|
||||
upsert.rendererToMainMs + get.rendererToMainMs;
|
||||
const mainToDatabaseWorkerCloneProxyMs =
|
||||
upsert.mainToDatabaseWorkerMs + get.mainToDatabaseWorkerMs;
|
||||
const databaseWorkerToMainCloneProxyMs =
|
||||
upsert.workerToMainMs + get.workerToMainMs;
|
||||
|
||||
return Object.freeze({
|
||||
databaseWorkerToMainCloneProxyMs,
|
||||
ipcStructuredCloneProxyMs:
|
||||
mainToRendererCloneProxyMs +
|
||||
rendererToMainCloneProxyMs +
|
||||
mainToDatabaseWorkerCloneProxyMs +
|
||||
databaseWorkerToMainCloneProxyMs,
|
||||
mainToDatabaseWorkerCloneProxyMs,
|
||||
mainToRendererCloneProxyMs,
|
||||
rendererToMainCloneProxyMs,
|
||||
});
|
||||
}
|
||||
|
||||
function deriveRequestBoundaryAttribution(input: {
|
||||
readonly operation: InitialImportDatabaseOperation;
|
||||
readonly playlistId: string;
|
||||
readonly request: WorkerRequestPerformanceMetrics;
|
||||
readonly timeline: readonly MainTimelineRecord[];
|
||||
readonly workPhaseStartedEpochMs: number;
|
||||
}): RequestBoundaryAttribution {
|
||||
requireInitialImportPreloadIdentity(input.request, input.operation);
|
||||
const requestTimeline = requireTimelineRecord(
|
||||
input.timeline,
|
||||
'db-request',
|
||||
input.request,
|
||||
input.operation,
|
||||
input.playlistId
|
||||
);
|
||||
requireTimelineRecord(
|
||||
input.timeline,
|
||||
'db-response',
|
||||
input.request,
|
||||
input.operation,
|
||||
input.playlistId
|
||||
);
|
||||
const successTimeline = requireTimelineRecord(
|
||||
input.timeline,
|
||||
'preload-performance-success',
|
||||
input.request,
|
||||
input.operation,
|
||||
input.playlistId
|
||||
);
|
||||
const sourceEpochMs = requireSource(input.request);
|
||||
const requestReceivedEpochMs = requireTimestamp(
|
||||
input.request.requestReceivedEpochMs
|
||||
);
|
||||
const workStartedEpochMs = requireTimestamp(
|
||||
input.request.workStartedEpochMs
|
||||
);
|
||||
const workEndedEpochMs = requireTimestamp(input.request.workEndedEpochMs);
|
||||
const responsePostedEpochMs = requireTimestamp(
|
||||
input.request.responsePostedEpochMs
|
||||
);
|
||||
const responseEpochMs = requireTimestamp(input.request.responseEpochMs);
|
||||
const successEpochMs = requireTimestamp(successTimeline.sourceEpochMs);
|
||||
|
||||
requireOrdered(sourceEpochMs, requestTimeline.epochMs);
|
||||
requireOrdered(requestTimeline.epochMs, requestReceivedEpochMs);
|
||||
requireOrdered(requestReceivedEpochMs, workStartedEpochMs);
|
||||
requireOrdered(workStartedEpochMs, input.workPhaseStartedEpochMs);
|
||||
requireOrdered(input.workPhaseStartedEpochMs, workEndedEpochMs);
|
||||
requireOrdered(workEndedEpochMs, responsePostedEpochMs);
|
||||
requireOrdered(responsePostedEpochMs, responseEpochMs);
|
||||
requireOrdered(responseEpochMs, successEpochMs);
|
||||
|
||||
return {
|
||||
mainToDatabaseWorkerMs: difference(
|
||||
requestReceivedEpochMs,
|
||||
requestTimeline.epochMs
|
||||
),
|
||||
mainToRendererMs: difference(successEpochMs, responseEpochMs),
|
||||
rendererToMainMs: difference(requestTimeline.epochMs, sourceEpochMs),
|
||||
successEpochMs,
|
||||
workerToMainMs: difference(responseEpochMs, responsePostedEpochMs),
|
||||
};
|
||||
}
|
||||
|
||||
function requireTimelineRecord(
|
||||
timeline: readonly MainTimelineRecord[],
|
||||
type: 'db-request' | 'db-response' | 'preload-performance-success',
|
||||
request: WorkerRequestPerformanceMetrics,
|
||||
operation: InitialImportDatabaseOperation,
|
||||
playlistId: string
|
||||
): MainTimelineRecord {
|
||||
if (!request.requestId || !Number.isSafeInteger(request.ipcCallId)) {
|
||||
throw new Error('database-request-identity-missing');
|
||||
}
|
||||
const matches = timeline.filter((record) => {
|
||||
if (
|
||||
record.type !== type ||
|
||||
record.operation !== operation ||
|
||||
record.playlistId !== playlistId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (type === 'preload-performance-success') {
|
||||
return (
|
||||
record.ipcCallId === request.ipcCallId &&
|
||||
record.sourceEpochMs !== undefined &&
|
||||
record.success === true
|
||||
);
|
||||
}
|
||||
return (
|
||||
record.requestId === request.requestId &&
|
||||
(type !== 'db-response' || record.success === true)
|
||||
);
|
||||
});
|
||||
if (matches.length !== 1 || !matches[0]) {
|
||||
throw new Error(`database-${type}-timeline-correlation`);
|
||||
}
|
||||
const record = matches[0];
|
||||
if (
|
||||
type === 'db-request' &&
|
||||
(record.ipcCallId !== request.ipcCallId ||
|
||||
record.sourceEpochMs !== request.sourceEpochMs)
|
||||
) {
|
||||
throw new Error('database-request-timeline-identity');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function requireInitialImportPreloadIdentity(
|
||||
request: WorkerRequestPerformanceMetrics,
|
||||
operation: InitialImportDatabaseOperation
|
||||
): void {
|
||||
if (
|
||||
request.operation !== operation ||
|
||||
!request.success ||
|
||||
request.performanceCaptureUnavailableReason !== null ||
|
||||
!Number.isSafeInteger(request.ipcCallId) ||
|
||||
Number(request.ipcCallId) < 1 ||
|
||||
typeof request.sourceEpochMs !== 'number' ||
|
||||
!Number.isFinite(request.sourceEpochMs) ||
|
||||
request.sourceEpochMs < 0 ||
|
||||
request.operationId !== null ||
|
||||
request.operationIdUnavailableReason !==
|
||||
'preload-performance-marker-uncorrelated'
|
||||
) {
|
||||
throw new Error('database-request-preload-identity');
|
||||
}
|
||||
}
|
||||
|
||||
function requireSource(request: WorkerRequestPerformanceMetrics): number {
|
||||
return requireTimestamp(request.sourceEpochMs);
|
||||
}
|
||||
|
||||
function requireTimestamp(value: number | null | undefined): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error('database-request-timestamp-missing');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireOrdered(startEpochMs: number, endEpochMs: number): void {
|
||||
if (
|
||||
!Number.isFinite(startEpochMs) ||
|
||||
!Number.isFinite(endEpochMs) ||
|
||||
endEpochMs < startEpochMs
|
||||
) {
|
||||
throw new Error('phase-order');
|
||||
}
|
||||
}
|
||||
|
||||
function difference(endEpochMs: number, startEpochMs: number): number {
|
||||
requireOrdered(startEpochMs, endEpochMs);
|
||||
return endEpochMs - startEpochMs;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
import type { CDPSession } from '@playwright/test';
|
||||
|
||||
interface TraceData {
|
||||
readonly value: readonly unknown[];
|
||||
}
|
||||
|
||||
export interface RendererHeapUsageSample {
|
||||
readonly epochMs: number;
|
||||
readonly usedSize: number;
|
||||
}
|
||||
|
||||
export interface RendererArtifactCapture {
|
||||
beginTraceOutput(): void;
|
||||
readonly cpuProfilePath: string | null;
|
||||
finishTraceOutput(): Promise<void>;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly onTraceComplete: () => void;
|
||||
readonly onTraceData: (data: TraceData) => void;
|
||||
readonly traceComplete: Promise<void>;
|
||||
readonly tracePath: string | null;
|
||||
}
|
||||
|
||||
export function createRendererArtifactCapture(options: {
|
||||
readonly diagnostic: boolean;
|
||||
readonly outputDirectory: string;
|
||||
}): RendererArtifactCapture {
|
||||
const tracePath = options.diagnostic
|
||||
? join(options.outputDirectory, 'renderer.trace.json')
|
||||
: null;
|
||||
const traceOutput = tracePath
|
||||
? createWriteStream(tracePath, { flags: 'wx', mode: 0o600 })
|
||||
: null;
|
||||
let traceOutputOpened = false;
|
||||
let traceOutputFinish: Promise<void> | null = null;
|
||||
let traceFirstEvent = true;
|
||||
let completeTrace: (() => void) | null = null;
|
||||
const traceComplete = new Promise<void>((resolve) => {
|
||||
completeTrace = resolve;
|
||||
});
|
||||
return {
|
||||
beginTraceOutput: () => {
|
||||
if (
|
||||
traceOutput &&
|
||||
!traceOutputOpened &&
|
||||
traceOutputFinish === null
|
||||
) {
|
||||
traceOutput.write('{"traceEvents":[');
|
||||
traceOutputOpened = true;
|
||||
}
|
||||
},
|
||||
cpuProfilePath: options.diagnostic
|
||||
? join(options.outputDirectory, 'renderer.cpuprofile')
|
||||
: null,
|
||||
finishTraceOutput: () => {
|
||||
traceOutputFinish ??= (async () => {
|
||||
if (!traceOutput) {
|
||||
return;
|
||||
}
|
||||
if (!traceOutputOpened) {
|
||||
traceOutput.write('{"traceEvents":[');
|
||||
traceOutputOpened = true;
|
||||
}
|
||||
if (!traceOutput.writableEnded) {
|
||||
traceOutput.write(']}');
|
||||
traceOutput.end();
|
||||
}
|
||||
await finished(traceOutput);
|
||||
})();
|
||||
return traceOutputFinish;
|
||||
},
|
||||
heapSnapshotPath: options.diagnostic
|
||||
? join(options.outputDirectory, 'renderer.heapsnapshot')
|
||||
: null,
|
||||
onTraceComplete: () => {
|
||||
completeTrace?.();
|
||||
completeTrace = null;
|
||||
},
|
||||
onTraceData: (data) => {
|
||||
if (!traceOutputOpened || traceOutput?.writableEnded) {
|
||||
return;
|
||||
}
|
||||
for (const event of data.value) {
|
||||
traceOutput?.write(
|
||||
`${traceFirstEvent ? '' : ','}${JSON.stringify(event)}`
|
||||
);
|
||||
traceFirstEvent = false;
|
||||
}
|
||||
},
|
||||
traceComplete,
|
||||
tracePath,
|
||||
};
|
||||
}
|
||||
|
||||
export async function startRendererDiagnosticCapture(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): Promise<void> {
|
||||
await session.send('Profiler.enable');
|
||||
await session.send('Profiler.start');
|
||||
artifacts.beginTraceOutput();
|
||||
session.on('Tracing.dataCollected', artifacts.onTraceData);
|
||||
session.on('Tracing.tracingComplete', artifacts.onTraceComplete);
|
||||
await session.send('Tracing.start', {
|
||||
categories: [
|
||||
'blink.user_timing',
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-v8.cpu_profiler',
|
||||
'v8',
|
||||
].join(','),
|
||||
options: 'sampling-frequency=1000',
|
||||
transferMode: 'ReportEvents',
|
||||
});
|
||||
}
|
||||
|
||||
export async function stopRendererDiagnosticCapture(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cpuProfile = await session.send('Profiler.stop');
|
||||
await writeFile(
|
||||
requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'),
|
||||
JSON.stringify(cpuProfile['profile']),
|
||||
{ encoding: 'utf8', flag: 'wx', mode: 0o600 }
|
||||
);
|
||||
await session.send('Tracing.end');
|
||||
await artifacts.traceComplete;
|
||||
} finally {
|
||||
disposeRendererDiagnosticListeners(session, artifacts);
|
||||
await artifacts.finishTraceOutput();
|
||||
}
|
||||
}
|
||||
|
||||
export async function disposeRendererDiagnosticCapture(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): Promise<void> {
|
||||
disposeRendererDiagnosticListeners(session, artifacts);
|
||||
await artifacts.finishTraceOutput();
|
||||
}
|
||||
|
||||
export async function takeRendererHeapSnapshot(
|
||||
session: CDPSession,
|
||||
path: string | null
|
||||
): Promise<void> {
|
||||
const output = createWriteStream(
|
||||
requirePath(path, 'renderer heap snapshot'),
|
||||
{ flags: 'wx', mode: 0o600 }
|
||||
);
|
||||
const onChunk = ({ chunk }: { readonly chunk: string }): void => {
|
||||
output.write(chunk);
|
||||
};
|
||||
session.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
||||
try {
|
||||
await session.send('HeapProfiler.takeHeapSnapshot', {
|
||||
reportProgress: false,
|
||||
});
|
||||
} finally {
|
||||
session.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
|
||||
output.end();
|
||||
await finished(output);
|
||||
}
|
||||
}
|
||||
|
||||
export function selectRendererPeakHeapUsedBytes(
|
||||
samples: readonly RendererHeapUsageSample[],
|
||||
measurementStartEpochMs: number,
|
||||
terminalEpochMs: number
|
||||
): number {
|
||||
const inWindow = samples.filter(
|
||||
({ epochMs, usedSize }) =>
|
||||
Number.isFinite(epochMs) &&
|
||||
epochMs >= measurementStartEpochMs &&
|
||||
epochMs <= terminalEpochMs &&
|
||||
Number.isFinite(usedSize) &&
|
||||
usedSize >= 0
|
||||
);
|
||||
if (inWindow.length === 0) {
|
||||
throw new Error('renderer-heap-window-empty');
|
||||
}
|
||||
return Math.max(...inWindow.map(({ usedSize }) => usedSize));
|
||||
}
|
||||
|
||||
export function assertRendererMeasurementWindow(input: {
|
||||
readonly captureCutoffEpochMs: number;
|
||||
readonly measurementStartEpochMs: number;
|
||||
readonly operationStartEpochMs: number;
|
||||
readonly terminalEpochMs: number;
|
||||
}): void {
|
||||
const epochs = Object.values(input);
|
||||
if (
|
||||
!epochs.every((epoch) => Number.isFinite(epoch) && epoch > 0) ||
|
||||
input.measurementStartEpochMs < input.operationStartEpochMs ||
|
||||
input.terminalEpochMs < input.measurementStartEpochMs ||
|
||||
input.captureCutoffEpochMs < input.terminalEpochMs
|
||||
) {
|
||||
throw new Error('renderer-measurement-window-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function disposeRendererDiagnosticListeners(
|
||||
session: CDPSession,
|
||||
artifacts: RendererArtifactCapture
|
||||
): void {
|
||||
session.off('Tracing.dataCollected', artifacts.onTraceData);
|
||||
session.off('Tracing.tracingComplete', artifacts.onTraceComplete);
|
||||
artifacts.onTraceComplete();
|
||||
}
|
||||
|
||||
function requirePath(value: string | null, label: string): string {
|
||||
if (value === null) {
|
||||
throw new Error(`${label} path is unavailable`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based lifecycle test. */
|
||||
import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { startM3uImportRendererCapture } from './m3u-import-renderer-capture';
|
||||
import { M3U_IMPORT_RENDERER_STATE_KEY } from './m3u-import-renderer-probe';
|
||||
|
||||
test('idempotently disposes failed-iteration timers, probe, trace stream, listeners, and CDP session', async () => {
|
||||
const browser = installFakeProbeEnvironment();
|
||||
const outputDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-renderer-capture-dispose-')
|
||||
);
|
||||
const session = new FakeCdpSession();
|
||||
const control = {
|
||||
click: async () => undefined,
|
||||
fill: async () => undefined,
|
||||
first: () => control,
|
||||
getByRole: () => control,
|
||||
isDisabled: async () => false,
|
||||
last: () => control,
|
||||
waitFor: async () => undefined,
|
||||
};
|
||||
const page = {
|
||||
...browser.page,
|
||||
context: () => ({
|
||||
newCDPSession: async () => session,
|
||||
}),
|
||||
getByRole: () => control,
|
||||
locator: () => control,
|
||||
};
|
||||
|
||||
try {
|
||||
const capture = await startM3uImportRendererCapture(
|
||||
page as never,
|
||||
{
|
||||
diagnostic: true,
|
||||
outputDirectory,
|
||||
playlistTitle: 'Synthetic',
|
||||
playlistUrl:
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
}
|
||||
);
|
||||
assert.equal(session.listenerCount('Tracing.dataCollected'), 1);
|
||||
assert.equal(session.listenerCount('Tracing.tracingComplete'), 1);
|
||||
|
||||
await capture.dispose();
|
||||
await capture.dispose();
|
||||
|
||||
assert.equal(session.detachCount, 1);
|
||||
assert.equal(session.listenerCount('Tracing.dataCollected'), 0);
|
||||
assert.equal(session.listenerCount('Tracing.tracingComplete'), 0);
|
||||
assert.equal(browser.readState(), undefined);
|
||||
const heapSamplesAfterDispose = session.heapUsageCalls;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(session.heapUsageCalls, heapSamplesAfterDispose);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(outputDirectory, 'renderer.trace.json'),
|
||||
'utf8'
|
||||
)
|
||||
),
|
||||
{ traceEvents: [] }
|
||||
);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('failed-iteration disposal does not await a wedged renderer or CDP detach', async () => {
|
||||
const browser = installFakeProbeEnvironment();
|
||||
const outputDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-renderer-capture-wedged-')
|
||||
);
|
||||
const session = new FakeCdpSession({ hangDetach: true });
|
||||
let evaluateCalls = 0;
|
||||
const page = createFakePage(browser, session, async (callback, argument) => {
|
||||
evaluateCalls += 1;
|
||||
if (evaluateCalls > 1) {
|
||||
return new Promise<never>(() => undefined);
|
||||
}
|
||||
return browser.page.evaluate(callback, argument);
|
||||
});
|
||||
|
||||
try {
|
||||
const capture = await startM3uImportRendererCapture(page as never, {
|
||||
diagnostic: false,
|
||||
outputDirectory,
|
||||
playlistTitle: 'Synthetic',
|
||||
playlistUrl:
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
});
|
||||
const outcome = await Promise.race([
|
||||
capture.dispose().then(() => 'disposed'),
|
||||
new Promise<'timeout'>((resolve) =>
|
||||
setTimeout(() => resolve('timeout'), 150)
|
||||
),
|
||||
]);
|
||||
|
||||
assert.equal(outcome, 'disposed');
|
||||
assert.equal(session.detachCount, 1);
|
||||
const heapSamplesAfterDispose = session.heapUsageCalls;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(session.heapUsageCalls, heapSamplesAfterDispose);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rolls back partially started capture when Chromium tracing rejects', async () => {
|
||||
const browser = installFakeProbeEnvironment();
|
||||
const outputDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-renderer-capture-startup-')
|
||||
);
|
||||
const session = new FakeCdpSession({ failMethod: 'Tracing.start' });
|
||||
const intervalToken = Object.freeze({});
|
||||
const setIntervalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'setInterval'
|
||||
);
|
||||
const clearIntervalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'clearInterval'
|
||||
);
|
||||
let clearIntervalCalls = 0;
|
||||
Object.defineProperty(globalThis, 'setInterval', {
|
||||
configurable: true,
|
||||
value: () => intervalToken,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearInterval', {
|
||||
configurable: true,
|
||||
value: (token: unknown) => {
|
||||
if (token === intervalToken) {
|
||||
clearIntervalCalls += 1;
|
||||
}
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
startM3uImportRendererCapture(
|
||||
createFakePage(browser, session) as never,
|
||||
{
|
||||
diagnostic: true,
|
||||
outputDirectory,
|
||||
playlistTitle: 'Synthetic',
|
||||
playlistUrl:
|
||||
'http://127.0.0.1:43210/synthetic-performance.m3u',
|
||||
}
|
||||
),
|
||||
/synthetic Tracing\.start failure/
|
||||
);
|
||||
|
||||
assert.equal(clearIntervalCalls, 1);
|
||||
assert.equal(session.detachCount, 1);
|
||||
assert.equal(session.listenerCount('Tracing.dataCollected'), 0);
|
||||
assert.equal(session.listenerCount('Tracing.tracingComplete'), 0);
|
||||
assert.equal(browser.readState(), undefined);
|
||||
assert.deepEqual(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(outputDirectory, 'renderer.trace.json'),
|
||||
'utf8'
|
||||
)
|
||||
),
|
||||
{ traceEvents: [] }
|
||||
);
|
||||
} finally {
|
||||
restoreGlobal('setInterval', setIntervalDescriptor);
|
||||
restoreGlobal('clearInterval', clearIntervalDescriptor);
|
||||
browser.cleanup();
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
class FakeCdpSession extends EventEmitter {
|
||||
detachCount = 0;
|
||||
heapUsageCalls = 0;
|
||||
|
||||
constructor(
|
||||
private readonly options: {
|
||||
readonly failMethod?: string;
|
||||
readonly hangDetach?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async detach(): Promise<void> {
|
||||
this.detachCount += 1;
|
||||
if (this.options.hangDetach) {
|
||||
return new Promise<never>(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async send(method: string): Promise<Record<string, unknown>> {
|
||||
if (method === this.options.failMethod) {
|
||||
throw new Error(`synthetic ${method} failure`);
|
||||
}
|
||||
if (method === 'Runtime.getHeapUsage') {
|
||||
this.heapUsageCalls += 1;
|
||||
return { usedSize: 1_024 };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeProbeEnvironment() {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
let nextFrameId = 1;
|
||||
const replaceGlobal = (key: string, value: unknown): void => {
|
||||
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
class FakePerformanceObserver {
|
||||
static readonly supportedEntryTypes = ['longtask'];
|
||||
|
||||
disconnect(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
observe(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
takeRecords(): PerformanceEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
replaceGlobal('performance', { now: () => 0, timeOrigin: 0 });
|
||||
replaceGlobal('PerformanceObserver', FakePerformanceObserver);
|
||||
replaceGlobal('cancelAnimationFrame', () => undefined);
|
||||
replaceGlobal('requestAnimationFrame', () => nextFrameId++);
|
||||
replaceGlobal('location', { pathname: '/' });
|
||||
replaceGlobal('document', { querySelector: () => null });
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
Reflect.deleteProperty(
|
||||
globalThis,
|
||||
Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY)
|
||||
);
|
||||
Reflect.deleteProperty(
|
||||
globalThis,
|
||||
M3U_IMPORT_RENDERER_STATE_KEY
|
||||
);
|
||||
},
|
||||
page: {
|
||||
evaluate: async (
|
||||
callback: (argument: never) => unknown,
|
||||
argument: never
|
||||
) => callback(argument),
|
||||
},
|
||||
readState: () =>
|
||||
(globalThis as Record<string, unknown>)[
|
||||
M3U_IMPORT_RENDERER_STATE_KEY
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePage(
|
||||
browser: ReturnType<typeof installFakeProbeEnvironment>,
|
||||
session: FakeCdpSession,
|
||||
evaluate = browser.page.evaluate
|
||||
) {
|
||||
const control = {
|
||||
click: async () => undefined,
|
||||
fill: async () => undefined,
|
||||
first: () => control,
|
||||
getByRole: () => control,
|
||||
isDisabled: async () => false,
|
||||
last: () => control,
|
||||
waitFor: async () => undefined,
|
||||
};
|
||||
return {
|
||||
...browser.page,
|
||||
context: () => ({
|
||||
newCDPSession: async () => session,
|
||||
}),
|
||||
evaluate,
|
||||
getByRole: () => control,
|
||||
locator: () => control,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobal(
|
||||
key: 'clearInterval' | 'setInterval',
|
||||
descriptor: PropertyDescriptor | undefined
|
||||
): void {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
|
||||
import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
interface ImportProbe {
|
||||
firstChannelVisibleEpochMs: number | null;
|
||||
frameGapsMs: readonly number[];
|
||||
heartbeatDelaysMs: readonly number[];
|
||||
longTasksMs: readonly number[];
|
||||
operationStartEpochMs: number;
|
||||
performancePhaseEvents: readonly unknown[];
|
||||
playlistId: string | null;
|
||||
routeReadyEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
uiPaintedEpochMs: number | null;
|
||||
}
|
||||
|
||||
interface RendererCaptureModule {
|
||||
advanceM3uImportHeartbeatDeadline?: (
|
||||
deadlineEpochMs: number,
|
||||
nowEpochMs: number
|
||||
) => number;
|
||||
assertCompleteM3uImportRendererProbe?: (probe: ImportProbe) => void;
|
||||
startM3uImportRendererCapture?: unknown;
|
||||
}
|
||||
|
||||
interface RendererProbeModule {
|
||||
M3U_IMPORT_RENDERER_STATE_KEY?: string;
|
||||
installM3uImportRendererProbe?: (page: unknown) => Promise<void>;
|
||||
recordM3uImportHeartbeat?: (
|
||||
page: unknown,
|
||||
deadlineEpochMs: number
|
||||
) => Promise<void>;
|
||||
stopM3uImportRendererProbe?: (page: unknown) => Promise<ImportProbe>;
|
||||
}
|
||||
|
||||
interface FakeProbeState {
|
||||
frameGapsMs: number[];
|
||||
heartbeatDelaysMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
operationStartMonotonicMs: number;
|
||||
terminalEpochMs: number | null;
|
||||
}
|
||||
|
||||
const captureUrl = new URL('./m3u-import-renderer-capture.ts', import.meta.url);
|
||||
const artifactsUrl = new URL(
|
||||
'./m3u-import-renderer-artifacts.ts',
|
||||
import.meta.url
|
||||
);
|
||||
const probeUrl = new URL('./m3u-import-renderer-probe.ts', import.meta.url);
|
||||
const captureModulePromise = import(captureUrl.href)
|
||||
.then((module) => module as RendererCaptureModule)
|
||||
.catch(() => null);
|
||||
const probeModulePromise = import(probeUrl.href)
|
||||
.then((module) => module as unknown as RendererProbeModule)
|
||||
.catch(() => null);
|
||||
|
||||
function installFakeBrowserEnvironment() {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const frames = new Map<number, (timestamp: number) => void>();
|
||||
let nextFrameId = 1;
|
||||
let nowMs = 0;
|
||||
let observerRecords: PerformanceEntry[] | null = null;
|
||||
|
||||
const replaceGlobal = (key: string, value: unknown): void => {
|
||||
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
value,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
class FakePerformanceObserver {
|
||||
static readonly supportedEntryTypes = ['longtask'];
|
||||
|
||||
constructor() {
|
||||
observerRecords = [];
|
||||
}
|
||||
|
||||
disconnect(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
observe(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
takeRecords(): PerformanceEntry[] {
|
||||
return observerRecords?.splice(0) ?? [];
|
||||
}
|
||||
}
|
||||
replaceGlobal('performance', { now: () => nowMs, timeOrigin: 0 });
|
||||
replaceGlobal('PerformanceObserver', FakePerformanceObserver);
|
||||
replaceGlobal('cancelAnimationFrame', (requestId: number) => {
|
||||
frames.delete(requestId);
|
||||
});
|
||||
replaceGlobal(
|
||||
'requestAnimationFrame',
|
||||
(callback: (timestamp: number) => void) => {
|
||||
const requestId = nextFrameId++;
|
||||
frames.set(requestId, callback);
|
||||
return requestId;
|
||||
}
|
||||
);
|
||||
replaceGlobal('location', { pathname: '/' });
|
||||
replaceGlobal('document', { querySelector: () => null });
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(globalThis, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
Reflect.deleteProperty(
|
||||
globalThis,
|
||||
Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY)
|
||||
);
|
||||
},
|
||||
getObserver: () => observerRecords,
|
||||
page: {
|
||||
evaluate: async (
|
||||
callback: (argument: unknown) => unknown,
|
||||
argument: unknown
|
||||
) => callback(argument),
|
||||
},
|
||||
readState: (stateKey: string) =>
|
||||
(globalThis as Record<string, unknown>)[stateKey] as FakeProbeState,
|
||||
runFrame: (timestamp: number) => {
|
||||
nowMs = timestamp;
|
||||
for (const [requestId, callback] of [...frames]) {
|
||||
if (frames.delete(requestId)) {
|
||||
callback(timestamp);
|
||||
}
|
||||
}
|
||||
},
|
||||
setNow: (value: number) => {
|
||||
nowMs = value;
|
||||
},
|
||||
showTerminal: () => {
|
||||
(
|
||||
globalThis as unknown as {
|
||||
location: { pathname: string };
|
||||
}
|
||||
).location.pathname =
|
||||
'/Users/test/iptvnator/dist/apps/web/workspace/playlists/synthetic-id/all';
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: { querySelector: () => ({}) },
|
||||
writable: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readSource(url: URL): string | null {
|
||||
try {
|
||||
return readFileSync(url, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function completeProbe(): ImportProbe {
|
||||
return {
|
||||
firstChannelVisibleEpochMs: 130,
|
||||
frameGapsMs: [16, 48],
|
||||
heartbeatDelaysMs: [0, 12],
|
||||
longTasksMs: [72],
|
||||
operationStartEpochMs: 100,
|
||||
performancePhaseEvents: [],
|
||||
playlistId: 'synthetic-id',
|
||||
routeReadyEpochMs: 120,
|
||||
terminalEpochMs: 150,
|
||||
uiPaintedEpochMs: 150,
|
||||
};
|
||||
}
|
||||
|
||||
test('exports the formal M3U import renderer collector', async () => {
|
||||
const module = await captureModulePromise;
|
||||
|
||||
assert.ok(module, 'M3U import renderer capture module must exist');
|
||||
assert.equal(typeof module.startM3uImportRendererCapture, 'function');
|
||||
assert.equal(
|
||||
typeof module.assertCompleteM3uImportRendererProbe,
|
||||
'function'
|
||||
);
|
||||
});
|
||||
|
||||
test('prepares the URL dialog before starting any measured capture', () => {
|
||||
const source = readSource(captureUrl);
|
||||
assert.ok(source, 'M3U import renderer capture source must exist');
|
||||
|
||||
const dialogPreparation = source.indexOf(
|
||||
'await prepareM3uImportDialog(page, options)'
|
||||
);
|
||||
const cdpSession = source.indexOf('newCDPSession(page)');
|
||||
const initialHeapSample = source.indexOf('await sampleHeap()');
|
||||
|
||||
assert.ok(dialogPreparation >= 0);
|
||||
assert.ok(cdpSession > dialogPreparation);
|
||||
assert.ok(initialHeapSample > cdpSession);
|
||||
});
|
||||
|
||||
test('captures heap samples in every run and raw CPU, trace, and heap artifacts only in diagnostics', () => {
|
||||
const source = readSource(captureUrl);
|
||||
const artifactSource = readSource(artifactsUrl);
|
||||
assert.ok(source);
|
||||
assert.ok(artifactSource);
|
||||
|
||||
assert.match(source, /setInterval\(\(\) => void sampleHeap\(\), 20\)/);
|
||||
assert.match(source, /Runtime\.getHeapUsage/);
|
||||
assert.match(source, /HeapProfiler\.collectGarbage/);
|
||||
assert.match(
|
||||
artifactSource,
|
||||
/cpuProfilePath: options\.diagnostic[\s\S]*renderer\.cpuprofile/
|
||||
);
|
||||
assert.match(artifactSource, /Profiler\.start/);
|
||||
assert.match(artifactSource, /Tracing\.start/);
|
||||
assert.match(artifactSource, /HeapProfiler\.takeHeapSnapshot/);
|
||||
});
|
||||
|
||||
test('clips drained long tasks and heartbeat samples to the operation window', async () => {
|
||||
const module = await probeModulePromise;
|
||||
assert.ok(module);
|
||||
assert.equal(typeof module.installM3uImportRendererProbe, 'function');
|
||||
assert.equal(typeof module.recordM3uImportHeartbeat, 'function');
|
||||
assert.equal(typeof module.stopM3uImportRendererProbe, 'function');
|
||||
assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string');
|
||||
const browser = installFakeBrowserEnvironment();
|
||||
|
||||
try {
|
||||
await module.installM3uImportRendererProbe?.(browser.page);
|
||||
const state = browser.readState(
|
||||
module.M3U_IMPORT_RENDERER_STATE_KEY ?? ''
|
||||
);
|
||||
state.operationStartEpochMs = 100;
|
||||
state.operationStartMonotonicMs = 100;
|
||||
state.terminalEpochMs = 200;
|
||||
const observerRecords = browser.getObserver();
|
||||
assert.ok(observerRecords);
|
||||
observerRecords.push(
|
||||
{ duration: 40, startTime: 80 } as PerformanceEntry,
|
||||
{ duration: 30, startTime: 190 } as PerformanceEntry,
|
||||
{ duration: 30, startTime: 210 } as PerformanceEntry
|
||||
);
|
||||
|
||||
browser.setNow(250);
|
||||
await module.recordM3uImportHeartbeat?.(browser.page, 90);
|
||||
await module.recordM3uImportHeartbeat?.(browser.page, 150);
|
||||
await module.recordM3uImportHeartbeat?.(browser.page, 210);
|
||||
const metrics = await module.stopM3uImportRendererProbe?.(browser.page);
|
||||
|
||||
assert.deepEqual(metrics?.heartbeatDelaysMs, [50]);
|
||||
assert.deepEqual(metrics?.longTasksMs, [20, 10]);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('records the terminal animation-frame gap exactly once', async () => {
|
||||
const module = await probeModulePromise;
|
||||
assert.ok(module?.installM3uImportRendererProbe);
|
||||
assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string');
|
||||
const browser = installFakeBrowserEnvironment();
|
||||
|
||||
try {
|
||||
await module.installM3uImportRendererProbe(browser.page);
|
||||
browser.runFrame(80);
|
||||
const state = browser.readState(
|
||||
module.M3U_IMPORT_RENDERER_STATE_KEY ?? ''
|
||||
);
|
||||
state.operationStartEpochMs = 100;
|
||||
state.operationStartMonotonicMs = 100;
|
||||
browser.showTerminal();
|
||||
browser.runFrame(120);
|
||||
browser.runFrame(140);
|
||||
browser.runFrame(160);
|
||||
|
||||
assert.equal(state.terminalEpochMs, 160);
|
||||
assert.deepEqual(state.frameGapsMs, [20, 20, 20]);
|
||||
await module.stopM3uImportRendererProbe?.(browser.page);
|
||||
} finally {
|
||||
browser.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('advances the external heartbeat on its fixed 50ms deadline grid', async () => {
|
||||
const module = await captureModulePromise;
|
||||
assert.ok(module);
|
||||
const advance = module.advanceM3uImportHeartbeatDeadline;
|
||||
assert.equal(typeof advance, 'function');
|
||||
|
||||
assert.equal(advance?.(1_000, 1_005), 1_050);
|
||||
assert.equal(advance?.(1_000, 1_060), 1_100);
|
||||
assert.equal(advance?.(1_000, 1_200), 1_250);
|
||||
});
|
||||
|
||||
test('installs the production renderer phase hook and returns its raw events', () => {
|
||||
const source = readSource(probeUrl);
|
||||
assert.ok(source);
|
||||
|
||||
assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/);
|
||||
assert.match(source, /Symbol\.for\(hookKey\)/);
|
||||
assert.match(source, /performancePhaseEvents\.push\(event\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/performancePhaseEvents: \[\.\.\.state\.performancePhaseEvents\]/
|
||||
);
|
||||
assert.match(source, /delete target\[hookSymbol\]/);
|
||||
});
|
||||
|
||||
test('timestamps the click immediately before final Add and requires route, first item, and two animation frames', () => {
|
||||
const source = readSource(probeUrl);
|
||||
assert.ok(source);
|
||||
|
||||
const operationStart = source.indexOf(
|
||||
'state.operationStartMonotonicMs = performance.now()'
|
||||
);
|
||||
const click = source.indexOf('button.click()', operationStart);
|
||||
assert.ok(operationStart >= 0);
|
||||
assert.ok(click > operationStart);
|
||||
assert.ok(source.includes('/\\/workspace\\/playlists\\/([^/]+)\\/all$/'));
|
||||
assert.match(source, /\[data-test-id="channel-item"\]/);
|
||||
const firstPaintFrame = source.indexOf(
|
||||
'state.uiPaintFrameRequestId = requestAnimationFrame'
|
||||
);
|
||||
const secondPaintFrame = source.indexOf(
|
||||
'state.uiPaintFrameRequestId = requestAnimationFrame',
|
||||
firstPaintFrame + 1
|
||||
);
|
||||
assert.ok(firstPaintFrame >= 0);
|
||||
assert.ok(secondPaintFrame > firstPaintFrame);
|
||||
for (const phase of [
|
||||
'operationStartEpochMs',
|
||||
'routeReadyEpochMs',
|
||||
'firstChannelVisibleEpochMs',
|
||||
'uiPaintedEpochMs',
|
||||
'terminalEpochMs',
|
||||
]) {
|
||||
assert.match(source, new RegExp(phase));
|
||||
}
|
||||
});
|
||||
|
||||
test('fails closed when any terminal proof or the probe itself is missing', async () => {
|
||||
const module = await captureModulePromise;
|
||||
assert.ok(module);
|
||||
const assertComplete = module.assertCompleteM3uImportRendererProbe;
|
||||
assert.equal(typeof assertComplete, 'function');
|
||||
|
||||
assert.doesNotThrow(() => assertComplete?.(completeProbe()));
|
||||
for (const field of [
|
||||
'playlistId',
|
||||
'routeReadyEpochMs',
|
||||
'firstChannelVisibleEpochMs',
|
||||
'uiPaintedEpochMs',
|
||||
'terminalEpochMs',
|
||||
] as const) {
|
||||
const incomplete = { ...completeProbe(), [field]: null };
|
||||
assert.throws(
|
||||
() => assertComplete?.(incomplete),
|
||||
/m3u-import-renderer-probe-incomplete/
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
assertComplete?.({
|
||||
...completeProbe(),
|
||||
operationStartEpochMs: 0,
|
||||
}),
|
||||
/m3u-import-renderer-probe-incomplete/
|
||||
);
|
||||
});
|
||||
|
||||
test('clears the harness heap sampler even when the renderer probe is missing', () => {
|
||||
const source = readSource(captureUrl);
|
||||
assert.ok(source);
|
||||
const backgroundStop = source.indexOf(
|
||||
'await stopBackgroundWork()',
|
||||
source.indexOf('const stopCapture')
|
||||
);
|
||||
const probeStop = source.indexOf('probe = await stopProbe()', backgroundStop);
|
||||
|
||||
assert.match(source, /clearInterval\(heapSampleTimer\)/);
|
||||
assert.ok(backgroundStop >= 0);
|
||||
assert.ok(probeStop > backgroundStop);
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
|
||||
import type { NumericDistribution } from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
createRendererArtifactCapture,
|
||||
disposeRendererDiagnosticCapture,
|
||||
type RendererArtifactCapture,
|
||||
startRendererDiagnosticCapture,
|
||||
stopRendererDiagnosticCapture,
|
||||
takeRendererHeapSnapshot,
|
||||
} from './m3u-import-renderer-artifacts';
|
||||
import {
|
||||
installM3uImportRendererProbe,
|
||||
type M3uImportRendererProbeMetrics,
|
||||
prepareM3uImportDialog,
|
||||
recordM3uImportHeartbeat,
|
||||
stopM3uImportRendererProbe,
|
||||
triggerM3uImport,
|
||||
waitForM3uImportTerminal,
|
||||
} from './m3u-import-renderer-probe';
|
||||
import { summarizeNumbers } from './performance-statistics';
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 50;
|
||||
|
||||
export interface M3uImportRendererCaptureStartOptions {
|
||||
readonly diagnostic: boolean;
|
||||
readonly outputDirectory: string;
|
||||
readonly playlistTitle: string;
|
||||
readonly playlistUrl: string;
|
||||
}
|
||||
|
||||
export interface M3uImportRendererPhaseTimestamps {
|
||||
readonly firstChannelVisibleEpochMs: number;
|
||||
readonly operationStartEpochMs: number;
|
||||
readonly routeReadyEpochMs: number;
|
||||
readonly terminalEpochMs: number;
|
||||
readonly uiPaintedEpochMs: number;
|
||||
}
|
||||
|
||||
export interface M3uImportRendererCaptureMetrics {
|
||||
readonly cpuProfilePath: string | null;
|
||||
readonly frameGap: NumericDistribution;
|
||||
readonly heapSnapshotPath: string | null;
|
||||
readonly heartbeatDelay: NumericDistribution;
|
||||
readonly longTask: NumericDistribution;
|
||||
readonly peakHeapUsedBytes: number;
|
||||
readonly phaseTimestamps: M3uImportRendererPhaseTimestamps;
|
||||
readonly postGcHeapUsedBytes: number;
|
||||
readonly probe: M3uImportRendererProbeMetrics;
|
||||
readonly tracePath: string | null;
|
||||
}
|
||||
|
||||
export interface RunningM3uImportRendererCapture {
|
||||
readonly session: CDPSession;
|
||||
dispose(): Promise<void>;
|
||||
stop(): Promise<M3uImportRendererCaptureMetrics>;
|
||||
triggerImport(): Promise<number>;
|
||||
waitForTerminal(): Promise<void>;
|
||||
}
|
||||
|
||||
interface HeapUsageResult {
|
||||
readonly usedSize: number;
|
||||
}
|
||||
|
||||
export async function startM3uImportRendererCapture(
|
||||
page: Page,
|
||||
options: M3uImportRendererCaptureStartOptions
|
||||
): Promise<RunningM3uImportRendererCapture> {
|
||||
await prepareM3uImportDialog(page, options);
|
||||
const session = await page.context().newCDPSession(page);
|
||||
let probeInstalled = false;
|
||||
let heapSampleTimer: NodeJS.Timeout | null = null;
|
||||
let artifacts: RendererArtifactCapture | null = null;
|
||||
const heapSamples: number[] = [];
|
||||
let heapSampleError: unknown = null;
|
||||
let heapSamplePromise: Promise<void> | null = null;
|
||||
const sampleHeap = (): Promise<void> => {
|
||||
heapSamplePromise ??= (async () => {
|
||||
try {
|
||||
const sample = (await session.send(
|
||||
'Runtime.getHeapUsage'
|
||||
)) as HeapUsageResult;
|
||||
if (Number.isFinite(sample.usedSize)) {
|
||||
heapSamples.push(sample.usedSize);
|
||||
}
|
||||
} catch (error) {
|
||||
heapSampleError ??= error;
|
||||
} finally {
|
||||
heapSamplePromise = null;
|
||||
}
|
||||
})();
|
||||
return heapSamplePromise;
|
||||
};
|
||||
|
||||
try {
|
||||
await session.send('Runtime.enable');
|
||||
await session.send('Performance.enable');
|
||||
await installM3uImportRendererProbe(page);
|
||||
probeInstalled = true;
|
||||
await sampleHeap();
|
||||
throwCaptureError(heapSampleError, 'Renderer heap sampling failed');
|
||||
heapSampleTimer = setInterval(() => void sampleHeap(), 20);
|
||||
artifacts = createRendererArtifactCapture(options);
|
||||
if (options.diagnostic) {
|
||||
await startRendererDiagnosticCapture(session, artifacts);
|
||||
}
|
||||
} catch (error) {
|
||||
if (heapSampleTimer) {
|
||||
clearInterval(heapSampleTimer);
|
||||
heapSampleTimer = null;
|
||||
}
|
||||
if (artifacts && options.diagnostic) {
|
||||
await disposeRendererDiagnosticCapture(
|
||||
session,
|
||||
artifacts
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (probeInstalled) {
|
||||
continueBestEffort(() => stopM3uImportRendererProbe(page));
|
||||
}
|
||||
continueBestEffort(() => session.detach());
|
||||
throw error;
|
||||
}
|
||||
if (!artifacts || !heapSampleTimer) {
|
||||
throw new Error('M3U import renderer capture initialization failed');
|
||||
}
|
||||
|
||||
let heartbeatDeadlineEpochMs: number | null = null;
|
||||
let heartbeatError: unknown = null;
|
||||
let heartbeatInFlight: Promise<void> | null = null;
|
||||
let heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
let stopped = false;
|
||||
let backgroundStopPromise: Promise<void> | null = null;
|
||||
let probeStopPromise: Promise<M3uImportRendererProbeMetrics> | null = null;
|
||||
let sessionDetachPromise: Promise<void> | null = null;
|
||||
let abortPromise: Promise<void> | null = null;
|
||||
let stopPromise: Promise<M3uImportRendererCaptureMetrics> | null = null;
|
||||
let stopRequested = false;
|
||||
let disposeRequested = false;
|
||||
const stopSchedulingBackgroundWork = (): void => {
|
||||
stopped = true;
|
||||
if (heartbeatTimer) {
|
||||
clearTimeout(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
if (heapSampleTimer) {
|
||||
clearInterval(heapSampleTimer);
|
||||
heapSampleTimer = null;
|
||||
}
|
||||
};
|
||||
const scheduleHeartbeat = (): void => {
|
||||
if (stopped || heartbeatDeadlineEpochMs === null) {
|
||||
return;
|
||||
}
|
||||
const deadline = heartbeatDeadlineEpochMs;
|
||||
heartbeatTimer = setTimeout(
|
||||
() => {
|
||||
heartbeatTimer = null;
|
||||
heartbeatInFlight = recordM3uImportHeartbeat(page, deadline)
|
||||
.catch((error: unknown) => {
|
||||
heartbeatError ??= error;
|
||||
})
|
||||
.finally(() => {
|
||||
heartbeatInFlight = null;
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
heartbeatDeadlineEpochMs =
|
||||
advanceM3uImportHeartbeatDeadline(
|
||||
deadline,
|
||||
Date.now()
|
||||
);
|
||||
scheduleHeartbeat();
|
||||
});
|
||||
},
|
||||
Math.max(0, deadline - Date.now())
|
||||
);
|
||||
};
|
||||
const stopBackgroundWork = (): Promise<void> => {
|
||||
backgroundStopPromise ??= (async () => {
|
||||
stopSchedulingBackgroundWork();
|
||||
await heartbeatInFlight;
|
||||
await heapSamplePromise;
|
||||
await sampleHeap();
|
||||
})();
|
||||
return backgroundStopPromise;
|
||||
};
|
||||
const stopProbe = (): Promise<M3uImportRendererProbeMetrics> => {
|
||||
probeStopPromise ??= stopM3uImportRendererProbe(page);
|
||||
return probeStopPromise;
|
||||
};
|
||||
const detachSession = (): Promise<void> => {
|
||||
sessionDetachPromise ??= session.detach();
|
||||
return sessionDetachPromise;
|
||||
};
|
||||
const abortResources = (): Promise<void> => {
|
||||
abortPromise ??= (async () => {
|
||||
stopSchedulingBackgroundWork();
|
||||
continueBestEffort(() => heartbeatInFlight ?? Promise.resolve());
|
||||
continueBestEffort(() => heapSamplePromise ?? Promise.resolve());
|
||||
continueBestEffort(stopProbe);
|
||||
if (options.diagnostic) {
|
||||
await disposeRendererDiagnosticCapture(
|
||||
session,
|
||||
artifacts
|
||||
).catch(() => undefined);
|
||||
}
|
||||
continueBestEffort(detachSession);
|
||||
})();
|
||||
return abortPromise;
|
||||
};
|
||||
const stopCapture =
|
||||
async (): Promise<M3uImportRendererCaptureMetrics> => {
|
||||
try {
|
||||
await stopBackgroundWork();
|
||||
let probe: M3uImportRendererProbeMetrics | null = null;
|
||||
let probeError: unknown = null;
|
||||
try {
|
||||
probe = await stopProbe();
|
||||
} catch (error) {
|
||||
probeError = error;
|
||||
}
|
||||
|
||||
if (options.diagnostic) {
|
||||
await stopRendererDiagnosticCapture(session, artifacts);
|
||||
}
|
||||
await session.send('HeapProfiler.enable');
|
||||
await session.send('HeapProfiler.collectGarbage');
|
||||
const postGc = (await session.send(
|
||||
'Runtime.getHeapUsage'
|
||||
)) as HeapUsageResult;
|
||||
if (options.diagnostic) {
|
||||
await takeRendererHeapSnapshot(
|
||||
session,
|
||||
artifacts.heapSnapshotPath
|
||||
);
|
||||
}
|
||||
await detachSession();
|
||||
throwCaptureError(
|
||||
heapSampleError,
|
||||
'Renderer heap sampling failed'
|
||||
);
|
||||
throwCaptureError(heartbeatError, 'Renderer heartbeat failed');
|
||||
throwCaptureError(probeError, 'Renderer probe stop failed');
|
||||
assertCompleteM3uImportRendererProbe(probe);
|
||||
return Object.freeze({
|
||||
cpuProfilePath: artifacts.cpuProfilePath,
|
||||
frameGap: summarizeNumbers(probe.frameGapsMs),
|
||||
heapSnapshotPath: artifacts.heapSnapshotPath,
|
||||
heartbeatDelay: summarizeNumbers(
|
||||
probe.heartbeatDelaysMs
|
||||
),
|
||||
longTask: summarizeNumbers(probe.longTasksMs),
|
||||
peakHeapUsedBytes: Math.max(0, ...heapSamples),
|
||||
phaseTimestamps: Object.freeze({
|
||||
firstChannelVisibleEpochMs:
|
||||
probe.firstChannelVisibleEpochMs,
|
||||
operationStartEpochMs: probe.operationStartEpochMs,
|
||||
routeReadyEpochMs: probe.routeReadyEpochMs,
|
||||
terminalEpochMs: probe.terminalEpochMs,
|
||||
uiPaintedEpochMs: probe.uiPaintedEpochMs,
|
||||
}),
|
||||
postGcHeapUsedBytes: postGc.usedSize,
|
||||
probe,
|
||||
tracePath: artifacts.tracePath,
|
||||
});
|
||||
} catch (error) {
|
||||
await abortResources();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
dispose: async () => {
|
||||
disposeRequested = true;
|
||||
if (stopPromise) {
|
||||
continueBestEffort(() => stopPromise as Promise<unknown>);
|
||||
}
|
||||
await abortResources();
|
||||
},
|
||||
session,
|
||||
stop: () => {
|
||||
if (stopRequested || disposeRequested) {
|
||||
return Promise.reject(
|
||||
new Error('M3U import renderer capture already stopped')
|
||||
);
|
||||
}
|
||||
stopRequested = true;
|
||||
stopPromise = stopCapture();
|
||||
return stopPromise;
|
||||
},
|
||||
triggerImport: async () => {
|
||||
const operationStartEpochMs = await triggerM3uImport(page);
|
||||
heartbeatDeadlineEpochMs =
|
||||
operationStartEpochMs + HEARTBEAT_INTERVAL_MS;
|
||||
scheduleHeartbeat();
|
||||
return operationStartEpochMs;
|
||||
},
|
||||
waitForTerminal: () => waitForM3uImportTerminal(page),
|
||||
});
|
||||
}
|
||||
|
||||
export function advanceM3uImportHeartbeatDeadline(
|
||||
deadlineEpochMs: number,
|
||||
nowEpochMs: number
|
||||
): number {
|
||||
const nextDeadline = deadlineEpochMs + HEARTBEAT_INTERVAL_MS;
|
||||
if (nextDeadline > nowEpochMs) {
|
||||
return nextDeadline;
|
||||
}
|
||||
const missedIntervals =
|
||||
Math.floor((nowEpochMs - nextDeadline) / HEARTBEAT_INTERVAL_MS) + 1;
|
||||
return nextDeadline + missedIntervals * HEARTBEAT_INTERVAL_MS;
|
||||
}
|
||||
|
||||
export function assertCompleteM3uImportRendererProbe(
|
||||
probe: M3uImportRendererProbeMetrics | null | undefined
|
||||
): asserts probe is M3uImportRendererProbeMetrics & {
|
||||
readonly firstChannelVisibleEpochMs: number;
|
||||
readonly playlistId: string;
|
||||
readonly routeReadyEpochMs: number;
|
||||
readonly terminalEpochMs: number;
|
||||
readonly uiPaintedEpochMs: number;
|
||||
} {
|
||||
if (
|
||||
!probe ||
|
||||
typeof probe.playlistId !== 'string' ||
|
||||
probe.playlistId.length === 0 ||
|
||||
!isPositiveFinite(probe.operationStartEpochMs) ||
|
||||
!isPositiveFinite(probe.routeReadyEpochMs) ||
|
||||
!isPositiveFinite(probe.firstChannelVisibleEpochMs) ||
|
||||
!isPositiveFinite(probe.uiPaintedEpochMs) ||
|
||||
!isPositiveFinite(probe.terminalEpochMs)
|
||||
) {
|
||||
throw new Error('m3u-import-renderer-probe-incomplete');
|
||||
}
|
||||
if (
|
||||
probe.routeReadyEpochMs < probe.operationStartEpochMs ||
|
||||
probe.firstChannelVisibleEpochMs < probe.routeReadyEpochMs ||
|
||||
probe.uiPaintedEpochMs < probe.firstChannelVisibleEpochMs ||
|
||||
probe.terminalEpochMs < probe.uiPaintedEpochMs
|
||||
) {
|
||||
throw new Error('m3u-import-renderer-probe-incomplete');
|
||||
}
|
||||
}
|
||||
|
||||
function throwCaptureError(error: unknown, label: string): void {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
if (error !== null) {
|
||||
throw new Error(`${label}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function continueBestEffort(work: () => Promise<unknown>): void {
|
||||
try {
|
||||
void work().catch(() => undefined);
|
||||
} catch {
|
||||
// The owning Electron app is closed immediately after abort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function isPositiveFinite(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
type RendererPerformancePhaseHook,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
export const M3U_IMPORT_RENDERER_STATE_KEY =
|
||||
'__iptvnatorM3uImportRendererCapture';
|
||||
|
||||
export interface M3uImportDialogOptions {
|
||||
readonly playlistTitle: string;
|
||||
readonly playlistUrl: string;
|
||||
}
|
||||
|
||||
export interface M3uImportRendererProbeMetrics {
|
||||
readonly firstChannelVisibleEpochMs: number | null;
|
||||
readonly frameGapsMs: readonly number[];
|
||||
readonly heartbeatDelaysMs: readonly number[];
|
||||
readonly longTasksMs: readonly number[];
|
||||
readonly operationStartEpochMs: number;
|
||||
readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[];
|
||||
readonly playlistId: string | null;
|
||||
readonly routeReadyEpochMs: number | null;
|
||||
readonly terminalEpochMs: number | null;
|
||||
readonly uiPaintedEpochMs: number | null;
|
||||
}
|
||||
|
||||
export async function prepareM3uImportDialog(
|
||||
page: Page,
|
||||
options: M3uImportDialogOptions
|
||||
): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).first().click();
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
await dialog
|
||||
.getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' })
|
||||
.fill(options.playlistUrl);
|
||||
await dialog
|
||||
.getByRole('textbox', { name: 'Playlist title' })
|
||||
.fill(options.playlistTitle);
|
||||
const submit = dialog.getByRole('button', { name: 'Add playlist' }).last();
|
||||
await submit.waitFor({ state: 'visible' });
|
||||
if (await submit.isDisabled()) {
|
||||
throw new Error('m3u-import-final-add-disabled');
|
||||
}
|
||||
}
|
||||
|
||||
export async function installM3uImportRendererProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ hookKey, stateKey }) => {
|
||||
interface ProbeState {
|
||||
firstChannelVisibleEpochMs: number | null;
|
||||
frameGapsMs: number[];
|
||||
frameRequestId: number;
|
||||
heartbeatDelaysMs: number[];
|
||||
lastFrameAt: number | null;
|
||||
longTaskObserver: PerformanceObserver | null;
|
||||
longTasksMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
operationStartMonotonicMs: number;
|
||||
performancePhaseEvents: RendererPerformancePhaseEvent[];
|
||||
playlistId: string | null;
|
||||
recordFrameGap(timestamp: number): void;
|
||||
recordLongTask(entry: PerformanceEntry): void;
|
||||
routeReadyEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
uiPaintFrameRequestId: number | null;
|
||||
uiPaintedEpochMs: number | null;
|
||||
}
|
||||
const target = globalThis as unknown as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
const hookSymbol = Symbol.for(hookKey);
|
||||
if (
|
||||
target[stateKey] !== undefined ||
|
||||
target[hookSymbol] !== undefined
|
||||
) {
|
||||
throw new Error('m3u-import-renderer-probe-already-installed');
|
||||
}
|
||||
const epoch = (): number =>
|
||||
performance.timeOrigin + performance.now();
|
||||
const state: ProbeState = {
|
||||
firstChannelVisibleEpochMs: null,
|
||||
frameGapsMs: [],
|
||||
frameRequestId: 0,
|
||||
heartbeatDelaysMs: [],
|
||||
lastFrameAt: null,
|
||||
longTaskObserver: null,
|
||||
longTasksMs: [],
|
||||
operationStartEpochMs: 0,
|
||||
operationStartMonotonicMs: 0,
|
||||
performancePhaseEvents: [],
|
||||
playlistId: null,
|
||||
recordFrameGap: (timestamp) => {
|
||||
const previousFrameAt = state.lastFrameAt;
|
||||
state.lastFrameAt = timestamp;
|
||||
if (
|
||||
previousFrameAt === null ||
|
||||
state.operationStartEpochMs <= 0 ||
|
||||
state.terminalEpochMs !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const measuredFrameStart = Math.max(
|
||||
previousFrameAt,
|
||||
state.operationStartMonotonicMs
|
||||
);
|
||||
if (timestamp > measuredFrameStart) {
|
||||
state.frameGapsMs.push(timestamp - measuredFrameStart);
|
||||
}
|
||||
},
|
||||
recordLongTask: (entry) => {
|
||||
const entryEpochMs =
|
||||
performance.timeOrigin + entry.startTime;
|
||||
const measuredStartEpochMs = Math.max(
|
||||
entryEpochMs,
|
||||
state.operationStartEpochMs
|
||||
);
|
||||
const measuredEndEpochMs = Math.min(
|
||||
entryEpochMs + entry.duration,
|
||||
state.terminalEpochMs ?? Number.POSITIVE_INFINITY
|
||||
);
|
||||
if (
|
||||
state.operationStartEpochMs > 0 &&
|
||||
measuredEndEpochMs > measuredStartEpochMs
|
||||
) {
|
||||
state.longTasksMs.push(
|
||||
measuredEndEpochMs - measuredStartEpochMs
|
||||
);
|
||||
}
|
||||
},
|
||||
routeReadyEpochMs: null,
|
||||
terminalEpochMs: null,
|
||||
uiPaintFrameRequestId: null,
|
||||
uiPaintedEpochMs: null,
|
||||
};
|
||||
const performancePhaseHook: RendererPerformancePhaseHook = (
|
||||
event
|
||||
) => {
|
||||
state.performancePhaseEvents.push(event);
|
||||
};
|
||||
target[hookSymbol] = performancePhaseHook;
|
||||
const terminalTarget = (): string | null => {
|
||||
const route = location.pathname.match(
|
||||
/\/workspace\/playlists\/([^/]+)\/all$/
|
||||
);
|
||||
if (
|
||||
!route ||
|
||||
!document.querySelector('[data-test-id="channel-item"]')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return route[1] ?? null;
|
||||
};
|
||||
const inspectTerminal = (): void => {
|
||||
if (
|
||||
state.operationStartEpochMs <= 0 ||
|
||||
state.terminalEpochMs !== null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const route = location.pathname.match(
|
||||
/\/workspace\/playlists\/([^/]+)\/all$/
|
||||
);
|
||||
if (!route) {
|
||||
return;
|
||||
}
|
||||
state.routeReadyEpochMs ??= epoch();
|
||||
if (!document.querySelector('[data-test-id="channel-item"]')) {
|
||||
return;
|
||||
}
|
||||
state.firstChannelVisibleEpochMs ??= epoch();
|
||||
if (state.uiPaintFrameRequestId !== null) {
|
||||
return;
|
||||
}
|
||||
state.uiPaintFrameRequestId = requestAnimationFrame(() => {
|
||||
state.uiPaintFrameRequestId = requestAnimationFrame(
|
||||
(terminalFrameAt) => {
|
||||
state.uiPaintFrameRequestId = null;
|
||||
const playlistId = terminalTarget();
|
||||
if (playlistId === null) {
|
||||
return;
|
||||
}
|
||||
state.recordFrameGap(terminalFrameAt);
|
||||
state.playlistId = playlistId;
|
||||
state.uiPaintedEpochMs = epoch();
|
||||
state.terminalEpochMs = state.uiPaintedEpochMs;
|
||||
cancelAnimationFrame(state.frameRequestId);
|
||||
state.frameRequestId = 0;
|
||||
for (const entry of state.longTaskObserver?.takeRecords() ??
|
||||
[]) {
|
||||
state.recordLongTask(entry);
|
||||
}
|
||||
state.longTaskObserver?.disconnect();
|
||||
state.longTaskObserver = null;
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
const frame = (timestamp: number): void => {
|
||||
state.recordFrameGap(timestamp);
|
||||
if (state.operationStartEpochMs > 0) {
|
||||
inspectTerminal();
|
||||
}
|
||||
state.frameRequestId =
|
||||
state.terminalEpochMs === null
|
||||
? requestAnimationFrame(frame)
|
||||
: 0;
|
||||
};
|
||||
state.frameRequestId = requestAnimationFrame(frame);
|
||||
if (
|
||||
typeof PerformanceObserver === 'function' &&
|
||||
PerformanceObserver.supportedEntryTypes.includes('longtask')
|
||||
) {
|
||||
state.longTaskObserver = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
state.recordLongTask(entry);
|
||||
}
|
||||
});
|
||||
state.longTaskObserver.observe({ entryTypes: ['longtask'] });
|
||||
}
|
||||
target[stateKey] = state;
|
||||
},
|
||||
{
|
||||
hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
stateKey: M3U_IMPORT_RENDERER_STATE_KEY,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function triggerM3uImport(page: Page): Promise<number> {
|
||||
return page.evaluate((stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const state = target[stateKey] as
|
||||
| {
|
||||
operationStartEpochMs: number;
|
||||
operationStartMonotonicMs: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!state || state.operationStartEpochMs > 0) {
|
||||
throw new Error('m3u-import-renderer-probe-unavailable');
|
||||
}
|
||||
const dialogs = Array.from(
|
||||
document.querySelectorAll('mat-dialog-container')
|
||||
);
|
||||
const dialog = dialogs.at(-1);
|
||||
const button = Array.from(dialog?.querySelectorAll('button') ?? [])
|
||||
.reverse()
|
||||
.find(
|
||||
(candidate) =>
|
||||
candidate.textContent?.trim() === 'Add playlist' &&
|
||||
!candidate.disabled
|
||||
);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error('m3u-import-final-add-unavailable');
|
||||
}
|
||||
state.operationStartMonotonicMs = performance.now();
|
||||
state.operationStartEpochMs =
|
||||
performance.timeOrigin + state.operationStartMonotonicMs;
|
||||
button.click();
|
||||
return state.operationStartEpochMs;
|
||||
}, M3U_IMPORT_RENDERER_STATE_KEY);
|
||||
}
|
||||
|
||||
export async function recordM3uImportHeartbeat(
|
||||
page: Page,
|
||||
deadlineEpochMs: number
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ deadlineEpochMs, stateKey }) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const state = target[stateKey] as
|
||||
| {
|
||||
heartbeatDelaysMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
terminalEpochMs: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!state || state.operationStartEpochMs <= 0) {
|
||||
throw new Error('m3u-import-renderer-probe-unavailable');
|
||||
}
|
||||
if (
|
||||
deadlineEpochMs >= state.operationStartEpochMs &&
|
||||
(state.terminalEpochMs === null ||
|
||||
deadlineEpochMs <= state.terminalEpochMs)
|
||||
) {
|
||||
const receivedEpochMs =
|
||||
performance.timeOrigin + performance.now();
|
||||
const measuredEpochMs =
|
||||
state.terminalEpochMs === null
|
||||
? receivedEpochMs
|
||||
: Math.min(receivedEpochMs, state.terminalEpochMs);
|
||||
state.heartbeatDelaysMs.push(
|
||||
Math.max(0, measuredEpochMs - deadlineEpochMs)
|
||||
);
|
||||
}
|
||||
},
|
||||
{ deadlineEpochMs, stateKey: M3U_IMPORT_RENDERER_STATE_KEY }
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForM3uImportTerminal(page: Page): Promise<void> {
|
||||
await page
|
||||
.waitForFunction(
|
||||
(stateKey) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
const state = target[stateKey] as
|
||||
{ terminalEpochMs: number | null } | undefined;
|
||||
return typeof state?.terminalEpochMs === 'number';
|
||||
},
|
||||
M3U_IMPORT_RENDERER_STATE_KEY,
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.then(() => undefined);
|
||||
}
|
||||
|
||||
export async function stopM3uImportRendererProbe(
|
||||
page: Page
|
||||
): Promise<M3uImportRendererProbeMetrics> {
|
||||
return page.evaluate(
|
||||
({ hookKey, stateKey }) => {
|
||||
const target = globalThis as unknown as Record<
|
||||
PropertyKey,
|
||||
unknown
|
||||
>;
|
||||
const hookSymbol = Symbol.for(hookKey);
|
||||
const state = target[stateKey] as
|
||||
| {
|
||||
firstChannelVisibleEpochMs: number | null;
|
||||
frameGapsMs: number[];
|
||||
frameRequestId: number;
|
||||
heartbeatDelaysMs: number[];
|
||||
longTaskObserver: PerformanceObserver | null;
|
||||
longTasksMs: number[];
|
||||
operationStartEpochMs: number;
|
||||
performancePhaseEvents: RendererPerformancePhaseEvent[];
|
||||
playlistId: string | null;
|
||||
recordLongTask(entry: PerformanceEntry): void;
|
||||
routeReadyEpochMs: number | null;
|
||||
terminalEpochMs: number | null;
|
||||
uiPaintFrameRequestId: number | null;
|
||||
uiPaintedEpochMs: number | null;
|
||||
}
|
||||
| undefined;
|
||||
if (!state) {
|
||||
throw new Error('m3u-import-renderer-probe-unavailable');
|
||||
}
|
||||
cancelAnimationFrame(state.frameRequestId);
|
||||
if (state.uiPaintFrameRequestId !== null) {
|
||||
cancelAnimationFrame(state.uiPaintFrameRequestId);
|
||||
}
|
||||
for (const entry of state.longTaskObserver?.takeRecords() ?? []) {
|
||||
state.recordLongTask(entry);
|
||||
}
|
||||
state.longTaskObserver?.disconnect();
|
||||
delete target[stateKey];
|
||||
delete target[hookSymbol];
|
||||
return {
|
||||
firstChannelVisibleEpochMs: state.firstChannelVisibleEpochMs,
|
||||
frameGapsMs: [...state.frameGapsMs],
|
||||
heartbeatDelaysMs: [...state.heartbeatDelaysMs],
|
||||
longTasksMs: [...state.longTasksMs],
|
||||
operationStartEpochMs: state.operationStartEpochMs,
|
||||
performancePhaseEvents: [...state.performancePhaseEvents],
|
||||
playlistId: state.playlistId,
|
||||
routeReadyEpochMs: state.routeReadyEpochMs,
|
||||
terminalEpochMs: state.terminalEpochMs,
|
||||
uiPaintedEpochMs: state.uiPaintedEpochMs,
|
||||
};
|
||||
},
|
||||
{
|
||||
hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
stateKey: M3U_IMPORT_RENDERER_STATE_KEY,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
|
||||
import { deriveM3uImportPhaseMetrics } from './m3u-import-report';
|
||||
import {
|
||||
createTestMainCapture,
|
||||
createTestRendererCapture,
|
||||
TEST_M3U_CHANNEL_COUNT,
|
||||
TEST_M3U_FIXTURE_BYTES,
|
||||
} from './m3u-import-report.test-helpers';
|
||||
|
||||
describe('formal M3U import phase report', () => {
|
||||
it('attributes every required phase without presenting indexes as zero work', () => {
|
||||
const phases = deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: createTestMainCapture(),
|
||||
renderer: createTestRendererCapture(),
|
||||
});
|
||||
|
||||
assert.deepEqual(phases, {
|
||||
angularRenderingMs: 10,
|
||||
dataAcquireMs: 20,
|
||||
databaseWorkerToMainCloneProxyMs: 12,
|
||||
indexAndCommitMs: null,
|
||||
indexAndCommitUnavailableReason:
|
||||
'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write',
|
||||
ipcStructuredCloneProxyMs: 44,
|
||||
mainToDatabaseWorkerCloneProxyMs: 2,
|
||||
mainToRendererCloneProxyMs: 28,
|
||||
m3uParsingMs: 40,
|
||||
normalizationMs: 10,
|
||||
playlistDeserializationMs: 12,
|
||||
playlistSerializationMs: 20,
|
||||
rendererToMainCloneProxyMs: 2,
|
||||
sqliteReadMs: 5,
|
||||
sqliteWriteMs: 30,
|
||||
storeImportDispatchMs: 2,
|
||||
storePublishChannelsMs: 10,
|
||||
storeUpdateMs: 12,
|
||||
totalMs: 215,
|
||||
});
|
||||
assert.equal(
|
||||
phases.ipcStructuredCloneProxyMs,
|
||||
phases.mainToRendererCloneProxyMs +
|
||||
phases.rendererToMainCloneProxyMs +
|
||||
phases.mainToDatabaseWorkerCloneProxyMs +
|
||||
phases.databaseWorkerToMainCloneProxyMs
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when any structured-clone boundary is missing, ambiguous, or out of order', () => {
|
||||
const main = createTestMainCapture();
|
||||
const databaseWorker = main.workers[0];
|
||||
const upsert = databaseWorker?.requests[0];
|
||||
assert.ok(databaseWorker);
|
||||
assert.ok(upsert);
|
||||
|
||||
for (const invalidMain of [
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.filter(
|
||||
(event) => event.type !== 'db-request'
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: [
|
||||
...main.timeline,
|
||||
main.timeline.find((event) => event.type === 'db-request'),
|
||||
].filter(
|
||||
(event): event is (typeof main.timeline)[number] =>
|
||||
event !== undefined
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
sourceEpochMs: 1080,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
requestReceivedEpochMs: 1078,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
ipcCallId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-missing',
|
||||
sourceEpochMs: null,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
workStartedEpochMs: 1079,
|
||||
},
|
||||
...databaseWorker.requests.slice(1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: invalidMain,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when a required phase, worker identity, or fixture count is ambiguous', () => {
|
||||
const missingNormalize = createTestMainCapture();
|
||||
const missingTimeline = missingNormalize.timeline.filter(
|
||||
(event) => event.phase !== 'normalize'
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...missingNormalize,
|
||||
timeline: missingTimeline,
|
||||
},
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...createTestMainCapture(),
|
||||
workers: [
|
||||
...createTestMainCapture().workers,
|
||||
{
|
||||
...createTestMainCapture().workers[0],
|
||||
ordinal: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
|
||||
const wrongBytes = createTestMainCapture();
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES + 1,
|
||||
main: wrongBytes,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it('requires the route-reload GET and both worker-to-renderer return paths', () => {
|
||||
const main = createTestMainCapture();
|
||||
const databaseWorker = main.workers[0];
|
||||
const upsert = databaseWorker?.requests[0];
|
||||
const get = databaseWorker?.requests[1];
|
||||
assert.ok(databaseWorker);
|
||||
assert.ok(upsert);
|
||||
assert.ok(get);
|
||||
|
||||
const invalidCaptures: MainCaptureMetrics[] = [
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: databaseWorker.requests.slice(0, 1),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === 'preload-performance-success' &&
|
||||
event.operation === 'DB_GET_APP_PLAYLIST'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === 'preload-performance-success' &&
|
||||
event.operation === 'DB_UPSERT_APP_PLAYLIST'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
timeline: main.timeline.map((event) =>
|
||||
event.type === 'preload-performance-success' &&
|
||||
event.operation === 'DB_GET_APP_PLAYLIST'
|
||||
? {
|
||||
...event,
|
||||
sourceEpochMs: 1196,
|
||||
}
|
||||
: event
|
||||
),
|
||||
},
|
||||
{
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
upsert,
|
||||
{
|
||||
...get,
|
||||
responsePostedEpochMs: 1175,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const invalidMain of invalidCaptures) {
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: invalidMain,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects playlist workers and cross-playlist database writes', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...createTestMainCapture(),
|
||||
workers: [
|
||||
...createTestMainCapture().workers,
|
||||
{
|
||||
kind: 'playlist-refresh.worker',
|
||||
requests: [],
|
||||
},
|
||||
],
|
||||
} as MainCaptureMetrics,
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
|
||||
const main = createTestMainCapture();
|
||||
const databaseWorker = main.workers[0];
|
||||
assert.ok(databaseWorker);
|
||||
const upsert = databaseWorker.requests[0];
|
||||
assert.ok(upsert);
|
||||
assert.throws(
|
||||
() =>
|
||||
deriveM3uImportPhaseMetrics({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
main: {
|
||||
...main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
playlistId: 'other-playlist',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: createTestRendererCapture(),
|
||||
}),
|
||||
/m3u-import-phase-attribution-invalid/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
|
||||
import type { M3uImportBenchmarkManifest } from './m3u-import-summary';
|
||||
|
||||
export const TEST_M3U_CHANNEL_COUNT = 10_000;
|
||||
export const TEST_M3U_FIXTURE_BYTES = 1_024_000;
|
||||
|
||||
export function createTestMainCapture(): MainCaptureMetrics {
|
||||
return {
|
||||
timeline: [
|
||||
mainPhase('start', 'data.acquire', 1001, null),
|
||||
mainPhase('end', 'data.acquire', 1021, 20, {
|
||||
byteCount: TEST_M3U_FIXTURE_BYTES,
|
||||
}),
|
||||
mainPhase('start', 'parse.m3u', 1021, null),
|
||||
mainPhase('end', 'parse.m3u', 1061, 40, {
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}),
|
||||
mainPhase('start', 'normalize', 1061, null),
|
||||
mainPhase('end', 'normalize', 1071, 10, {
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}),
|
||||
{
|
||||
epochMs: 1080,
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-1',
|
||||
sourceEpochMs: 1079,
|
||||
type: 'db-request',
|
||||
},
|
||||
{
|
||||
epochMs: 1137,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-1',
|
||||
success: true,
|
||||
type: 'db-response',
|
||||
},
|
||||
{
|
||||
epochMs: 1140,
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 1140,
|
||||
success: true,
|
||||
type: 'preload-performance-success',
|
||||
},
|
||||
{
|
||||
epochMs: 1142,
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-2',
|
||||
sourceEpochMs: 1141,
|
||||
type: 'db-request',
|
||||
},
|
||||
{
|
||||
epochMs: 1174,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-2',
|
||||
success: true,
|
||||
type: 'db-response',
|
||||
},
|
||||
{
|
||||
epochMs: 1194,
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
playlistId: 'playlist-1',
|
||||
sourceEpochMs: 1194,
|
||||
success: true,
|
||||
type: 'preload-performance-success',
|
||||
},
|
||||
],
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
requests: [
|
||||
{
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_UPSERT_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-uncorrelated',
|
||||
performanceCaptureUnavailableReason: null,
|
||||
phaseEvents: [
|
||||
workerPhase(
|
||||
'start',
|
||||
'serialize.playlist',
|
||||
1082,
|
||||
null,
|
||||
'db-request-1'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'serialize.playlist',
|
||||
1102,
|
||||
20,
|
||||
'db-request-1',
|
||||
{
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}
|
||||
),
|
||||
workerPhase(
|
||||
'start',
|
||||
'sqlite.write',
|
||||
1102,
|
||||
null,
|
||||
'db-request-1'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'sqlite.write',
|
||||
1132,
|
||||
30,
|
||||
'db-request-1',
|
||||
{
|
||||
itemCount: 1,
|
||||
}
|
||||
),
|
||||
],
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-1',
|
||||
requestReceivedEpochMs: 1081,
|
||||
responseEpochMs: 1137,
|
||||
responsePostedEpochMs: 1135,
|
||||
sourceEpochMs: 1079,
|
||||
success: true,
|
||||
workEndedEpochMs: 1133,
|
||||
workStartedEpochMs: 1082,
|
||||
},
|
||||
{
|
||||
ipcCallId: 8,
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
operationId: null,
|
||||
operationIdUnavailableReason:
|
||||
'preload-performance-marker-uncorrelated',
|
||||
performanceCaptureUnavailableReason: null,
|
||||
phaseEvents: [
|
||||
workerPhase(
|
||||
'start',
|
||||
'sqlite.read',
|
||||
1144,
|
||||
null,
|
||||
'db-request-2'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'sqlite.read',
|
||||
1149,
|
||||
5,
|
||||
'db-request-2',
|
||||
{
|
||||
itemCount: 1,
|
||||
}
|
||||
),
|
||||
workerPhase(
|
||||
'start',
|
||||
'deserialize.playlist',
|
||||
1149,
|
||||
null,
|
||||
'db-request-2'
|
||||
),
|
||||
workerPhase(
|
||||
'end',
|
||||
'deserialize.playlist',
|
||||
1161,
|
||||
12,
|
||||
'db-request-2',
|
||||
{
|
||||
itemCount: TEST_M3U_CHANNEL_COUNT,
|
||||
}
|
||||
),
|
||||
],
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'db-request-2',
|
||||
requestReceivedEpochMs: 1143,
|
||||
responseEpochMs: 1174,
|
||||
responsePostedEpochMs: 1164,
|
||||
sourceEpochMs: 1141,
|
||||
success: true,
|
||||
workEndedEpochMs: 1162,
|
||||
workStartedEpochMs: 1144,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as MainCaptureMetrics;
|
||||
}
|
||||
|
||||
export function createTestRendererCapture(): M3uImportRendererCaptureMetrics {
|
||||
return {
|
||||
phaseTimestamps: {
|
||||
firstChannelVisibleEpochMs: 1210,
|
||||
operationStartEpochMs: 1000,
|
||||
routeReadyEpochMs: 1140,
|
||||
terminalEpochMs: 1215,
|
||||
uiPaintedEpochMs: 1215,
|
||||
},
|
||||
probe: {
|
||||
performancePhaseEvents: [
|
||||
rendererPhase(
|
||||
'start',
|
||||
'store.m3u-import-dispatch',
|
||||
'renderer-1',
|
||||
1076,
|
||||
76
|
||||
),
|
||||
rendererPhase(
|
||||
'end',
|
||||
'store.m3u-import-dispatch',
|
||||
'renderer-1',
|
||||
1078,
|
||||
78
|
||||
),
|
||||
rendererPhase(
|
||||
'start',
|
||||
'store.m3u-publish-channels',
|
||||
'renderer-2',
|
||||
1195,
|
||||
195,
|
||||
TEST_M3U_CHANNEL_COUNT
|
||||
),
|
||||
rendererPhase(
|
||||
'end',
|
||||
'store.m3u-publish-channels',
|
||||
'renderer-2',
|
||||
1205,
|
||||
205,
|
||||
TEST_M3U_CHANNEL_COUNT
|
||||
),
|
||||
],
|
||||
playlistId: 'playlist-1',
|
||||
},
|
||||
} as unknown as M3uImportRendererCaptureMetrics;
|
||||
}
|
||||
|
||||
export function createCompleteTestMainCapture(
|
||||
overrides: { mainRss?: number; rendererRss?: number } = {}
|
||||
): MainCaptureMetrics {
|
||||
const base = createTestMainCapture();
|
||||
const databaseWorker = base.workers[0];
|
||||
const upsert = databaseWorker?.requests[0];
|
||||
const get = databaseWorker?.requests[1];
|
||||
if (!databaseWorker || !upsert || !get) {
|
||||
throw new Error('invalid-test-main-capture');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
cpuProfilePath: null,
|
||||
cpuSystemMicros: 20,
|
||||
cpuUserMicros: 30,
|
||||
eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 },
|
||||
eventLoopUtilization: null,
|
||||
eventLoopUtilizationUnavailableReason:
|
||||
'electron-main-embedded-event-loop',
|
||||
heapSnapshotPath: null,
|
||||
memory: {
|
||||
peakHeapUsedBytes: 2_000,
|
||||
peakRssBytes: overrides.mainRss ?? 5_000,
|
||||
postGcHeapUsedBytes: 1_000,
|
||||
postGcRssBytes: 4_500,
|
||||
},
|
||||
rendererWindow: {
|
||||
responsiveEvents: 0,
|
||||
rss: {
|
||||
identity: { creationTime: 100, pid: 42 },
|
||||
missingSampleCount: 0,
|
||||
peakRssBytes: overrides.rendererRss ?? 4_000,
|
||||
unavailableReason: null,
|
||||
validSampleCount: 5,
|
||||
},
|
||||
unresponsiveEvents: 0,
|
||||
windowIdentity: {
|
||||
browserWindowId: 1,
|
||||
webContentsId: 2,
|
||||
},
|
||||
},
|
||||
rssScope:
|
||||
'electron-main-process-including-worker-threads-and-native-memory',
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
externalMemorySampleCount: 4,
|
||||
heapUsedSampleCount: 4,
|
||||
peakExternalBytes: 600,
|
||||
peakExternalUnavailableReason: null,
|
||||
peakHeapUsedBytes: 1_500,
|
||||
peakHeapUsedUnavailableReason: null,
|
||||
postGcHeapUnavailableReason: null,
|
||||
postGcHeapUsedBytes: 900,
|
||||
requests: [
|
||||
{
|
||||
...upsert,
|
||||
eventLoopDelay: { maxMs: 6, p95Ms: 4, p99Ms: 5 },
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: 0.8,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs: 1134,
|
||||
invalidReason: null,
|
||||
threadCpuSystemMicros: 40,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 50,
|
||||
},
|
||||
{
|
||||
...get,
|
||||
eventLoopDelay: { maxMs: 9, p95Ms: 7, p99Ms: 8 },
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: 0.9,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs: 1163,
|
||||
invalidReason: null,
|
||||
threadCpuSystemMicros: 60,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createCompleteTestRendererCapture(
|
||||
overrides: { rendererHeap?: number } = {}
|
||||
): M3uImportRendererCaptureMetrics {
|
||||
const base = createTestRendererCapture();
|
||||
return {
|
||||
...base,
|
||||
cpuProfilePath: null,
|
||||
frameGap: distribution([16, 40]),
|
||||
heapSnapshotPath: null,
|
||||
heartbeatDelay: distribution([0, 5]),
|
||||
longTask: distribution([60]),
|
||||
peakHeapUsedBytes: overrides.rendererHeap ?? 3_000,
|
||||
postGcHeapUsedBytes: 1_100,
|
||||
probe: {
|
||||
...base.probe,
|
||||
firstChannelVisibleEpochMs: 1210,
|
||||
frameGapsMs: [16, 40],
|
||||
heartbeatDelaysMs: [0, 5],
|
||||
longTasksMs: [60],
|
||||
operationStartEpochMs: 1000,
|
||||
routeReadyEpochMs: 1140,
|
||||
terminalEpochMs: 1215,
|
||||
uiPaintedEpochMs: 1215,
|
||||
},
|
||||
tracePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestM3uImportManifest(): M3uImportBenchmarkManifest {
|
||||
return {
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
fixtureSha256: 'fixture-sha',
|
||||
gitCommit: 'commit',
|
||||
gitDiffSha256: 'diff',
|
||||
gitDirty: false,
|
||||
measuredRuns: 1,
|
||||
memoryAccounting: {
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
mainRss:
|
||||
'electron-main-process-including-worker-threads-and-native-memory',
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
rendererRss: 'separate-renderer-process',
|
||||
workerJsHeap: 'separate-v8-isolate',
|
||||
},
|
||||
runtime: {
|
||||
arch: 'arm64',
|
||||
cdpPort: 9222,
|
||||
electronVersion: '1',
|
||||
harnessNodeVersion: '1',
|
||||
platform: 'darwin',
|
||||
},
|
||||
scenario: 'm3u-import-10k',
|
||||
syntheticSeed: 240_724,
|
||||
variant: 'baseline',
|
||||
warmupRuns: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function mainPhase(
|
||||
boundary: 'end' | 'start',
|
||||
phase: string,
|
||||
epochMs: number,
|
||||
durationMs: number | null,
|
||||
metadata: { byteCount?: number; itemCount?: number } = {}
|
||||
) {
|
||||
return {
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
...metadata,
|
||||
phase,
|
||||
requestId: 'main-request-1',
|
||||
type: 'm3u-import-phase',
|
||||
};
|
||||
}
|
||||
|
||||
function workerPhase(
|
||||
boundary: 'end' | 'start',
|
||||
phase:
|
||||
| 'deserialize.playlist'
|
||||
| 'serialize.playlist'
|
||||
| 'sqlite.read'
|
||||
| 'sqlite.write',
|
||||
epochMs: number,
|
||||
durationMs: number | null,
|
||||
requestId: string,
|
||||
metadata?: { byteCount?: number; itemCount?: number }
|
||||
) {
|
||||
return {
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
...(metadata ? { metadata } : {}),
|
||||
phase,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function rendererPhase(
|
||||
boundary: 'end' | 'start',
|
||||
phase: 'store.m3u-import-dispatch' | 'store.m3u-publish-channels',
|
||||
phaseId: string,
|
||||
epochMs: number,
|
||||
monotonicMs: number,
|
||||
items?: number
|
||||
) {
|
||||
return {
|
||||
boundary,
|
||||
epochMs,
|
||||
...(items === undefined ? {} : { metadata: { items } }),
|
||||
monotonicMs,
|
||||
...(boundary === 'end' ? { outcome: 'success' as const } : {}),
|
||||
phase,
|
||||
phaseId,
|
||||
schemaVersion: 1 as const,
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(values: readonly number[]) {
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
return {
|
||||
count: values.length,
|
||||
max,
|
||||
mean,
|
||||
median: mean,
|
||||
min,
|
||||
p95: values.length === 1 ? max : min + (max - min) * 0.95,
|
||||
p99: values.length === 1 ? max : min + (max - min) * 0.99,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import type {
|
||||
MainCaptureMetrics,
|
||||
MainTimelineRecord,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { deriveM3uImportIpcAttribution } from './m3u-import-ipc-attribution';
|
||||
import {
|
||||
pairPerformancePhaseEvents,
|
||||
requirePerformancePhase,
|
||||
type PairedPerformancePhase,
|
||||
} from './performance-phase-events';
|
||||
import {
|
||||
pairRendererPerformancePhaseEvents,
|
||||
requireRendererPerformancePhase,
|
||||
} from './renderer-performance-phase-events';
|
||||
|
||||
export const M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON =
|
||||
'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write';
|
||||
|
||||
export interface M3uImportPhaseMetrics {
|
||||
readonly angularRenderingMs: number;
|
||||
readonly dataAcquireMs: number;
|
||||
readonly databaseWorkerToMainCloneProxyMs: number;
|
||||
readonly indexAndCommitMs: null;
|
||||
readonly indexAndCommitUnavailableReason: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON;
|
||||
readonly ipcStructuredCloneProxyMs: number;
|
||||
readonly mainToDatabaseWorkerCloneProxyMs: number;
|
||||
readonly mainToRendererCloneProxyMs: number;
|
||||
readonly m3uParsingMs: number;
|
||||
readonly normalizationMs: number;
|
||||
readonly playlistDeserializationMs: number;
|
||||
readonly playlistSerializationMs: number;
|
||||
readonly rendererToMainCloneProxyMs: number;
|
||||
readonly sqliteReadMs: number;
|
||||
readonly sqliteWriteMs: number;
|
||||
readonly storeImportDispatchMs: number;
|
||||
readonly storePublishChannelsMs: number;
|
||||
readonly storeUpdateMs: number;
|
||||
readonly totalMs: number;
|
||||
}
|
||||
|
||||
export interface M3uImportPhaseDerivationInput {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly main: MainCaptureMetrics;
|
||||
readonly renderer: M3uImportRendererCaptureMetrics;
|
||||
}
|
||||
|
||||
export function deriveM3uImportPhaseMetrics(
|
||||
input: M3uImportPhaseDerivationInput
|
||||
): M3uImportPhaseMetrics {
|
||||
try {
|
||||
return deriveM3uImportPhaseMetricsStrict(input);
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`m3u-import-phase-attribution-invalid:${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveM3uImportPhaseMetricsStrict(
|
||||
input: M3uImportPhaseDerivationInput
|
||||
): M3uImportPhaseMetrics {
|
||||
requirePositiveSafeCount(input.channelCount, 'channel-count');
|
||||
requirePositiveSafeCount(input.fixtureBytes, 'fixture-bytes');
|
||||
|
||||
const mainPhases = pairPerformancePhaseEvents(
|
||||
input.main.timeline
|
||||
.filter((event) => event.type === 'm3u-import-phase')
|
||||
.map(toPerformancePhaseEvent)
|
||||
);
|
||||
if (mainPhases.length !== 3) {
|
||||
throw new Error('main-phase-cardinality');
|
||||
}
|
||||
const mainRequestIds = new Set(mainPhases.map((phase) => phase.requestId));
|
||||
if (mainRequestIds.size !== 1) {
|
||||
throw new Error('main-request-correlation');
|
||||
}
|
||||
const mainRequestId = mainPhases[0]?.requestId;
|
||||
if (!mainRequestId) {
|
||||
throw new Error('main-request-missing');
|
||||
}
|
||||
const dataAcquire = requirePerformancePhase(
|
||||
mainPhases,
|
||||
mainRequestId,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE
|
||||
);
|
||||
const parseM3u = requirePerformancePhase(
|
||||
mainPhases,
|
||||
mainRequestId,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U
|
||||
);
|
||||
const normalize = requirePerformancePhase(
|
||||
mainPhases,
|
||||
mainRequestId,
|
||||
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE
|
||||
);
|
||||
requireMetadataCount(
|
||||
dataAcquire,
|
||||
'byteCount',
|
||||
input.fixtureBytes,
|
||||
'data-acquire-bytes'
|
||||
);
|
||||
requireMetadataCount(
|
||||
parseM3u,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'parse-items'
|
||||
);
|
||||
requireMetadataCount(
|
||||
normalize,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'normalize-items'
|
||||
);
|
||||
requireOrdered(dataAcquire.endedEpochMs, parseM3u.startedEpochMs);
|
||||
requireOrdered(parseM3u.endedEpochMs, normalize.startedEpochMs);
|
||||
|
||||
const rendererEvents = (
|
||||
input.renderer.probe as typeof input.renderer.probe & {
|
||||
readonly performancePhaseEvents?: readonly unknown[];
|
||||
}
|
||||
).performancePhaseEvents;
|
||||
if (!rendererEvents) {
|
||||
throw new Error('renderer-phase-events-missing');
|
||||
}
|
||||
const rendererPhases = pairRendererPerformancePhaseEvents(rendererEvents);
|
||||
if (rendererPhases.length !== 2) {
|
||||
throw new Error('renderer-phase-cardinality');
|
||||
}
|
||||
const importDispatch = requireRendererPerformancePhase(
|
||||
rendererPhases,
|
||||
'store.m3u-import-dispatch'
|
||||
);
|
||||
const publishChannels = requireRendererPerformancePhase(
|
||||
rendererPhases,
|
||||
'store.m3u-publish-channels'
|
||||
);
|
||||
requireRendererItems(
|
||||
publishChannels.metadata?.items,
|
||||
input.channelCount,
|
||||
'publish-channels-items'
|
||||
);
|
||||
|
||||
const playlistId = input.renderer.probe.playlistId;
|
||||
if (typeof playlistId !== 'string' || playlistId.length === 0) {
|
||||
throw new Error('renderer-playlist-id-missing');
|
||||
}
|
||||
const databaseWorkers = input.main.workers.filter(
|
||||
(worker) => worker.kind === 'database.worker'
|
||||
);
|
||||
if (
|
||||
databaseWorkers.length !== 1 ||
|
||||
input.main.workers.some(
|
||||
(worker) => worker.kind === 'playlist-refresh.worker'
|
||||
)
|
||||
) {
|
||||
throw new Error('worker-cardinality');
|
||||
}
|
||||
const databaseWorker = databaseWorkers[0];
|
||||
if (!databaseWorker) {
|
||||
throw new Error('database-worker-missing');
|
||||
}
|
||||
const upserts = databaseWorker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_UPSERT_APP_PLAYLIST' &&
|
||||
request.playlistId === playlistId &&
|
||||
request.success
|
||||
);
|
||||
if (upserts.length !== 1 || !upserts[0]) {
|
||||
throw new Error('database-upsert-correlation');
|
||||
}
|
||||
const upsert = upserts[0];
|
||||
const gets = databaseWorker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_GET_APP_PLAYLIST' &&
|
||||
request.playlistId === playlistId &&
|
||||
request.success
|
||||
);
|
||||
if (
|
||||
gets.length !== 1 ||
|
||||
!gets[0] ||
|
||||
databaseWorker.requests.length !== 2
|
||||
) {
|
||||
throw new Error('database-get-correlation');
|
||||
}
|
||||
const get = gets[0];
|
||||
const databasePhases = pairPerformancePhaseEvents(upsert.phaseEvents);
|
||||
if (
|
||||
databasePhases.length !== 2 ||
|
||||
!upsert.requestId ||
|
||||
databasePhases.some((phase) => phase.requestId !== upsert.requestId)
|
||||
) {
|
||||
throw new Error('database-phase-cardinality');
|
||||
}
|
||||
const serializePlaylist = requireDatabasePhase(
|
||||
databasePhases,
|
||||
upsert,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST
|
||||
);
|
||||
const sqliteWrite = requireDatabasePhase(
|
||||
databasePhases,
|
||||
upsert,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE
|
||||
);
|
||||
requireMetadataCount(
|
||||
serializePlaylist,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'serialization-items'
|
||||
);
|
||||
requireMetadataCount(sqliteWrite, 'itemCount', 1, 'sqlite-row-count');
|
||||
const getPhases = pairPerformancePhaseEvents(get.phaseEvents);
|
||||
if (
|
||||
getPhases.length !== 2 ||
|
||||
!get.requestId ||
|
||||
getPhases.some((phase) => phase.requestId !== get.requestId)
|
||||
) {
|
||||
throw new Error('database-get-phase-cardinality');
|
||||
}
|
||||
const sqliteRead = requireDatabasePhase(
|
||||
getPhases,
|
||||
get,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ
|
||||
);
|
||||
const deserializePlaylist = requireDatabasePhase(
|
||||
getPhases,
|
||||
get,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST
|
||||
);
|
||||
requireMetadataCount(sqliteRead, 'itemCount', 1, 'sqlite-read-row-count');
|
||||
requireMetadataCount(
|
||||
deserializePlaylist,
|
||||
'itemCount',
|
||||
input.channelCount,
|
||||
'deserialization-items'
|
||||
);
|
||||
|
||||
const operationStart = input.renderer.phaseTimestamps.operationStartEpochMs;
|
||||
const uiPainted = input.renderer.phaseTimestamps.uiPaintedEpochMs;
|
||||
const terminal = input.renderer.phaseTimestamps.terminalEpochMs;
|
||||
requireOrdered(operationStart, dataAcquire.startedEpochMs);
|
||||
requireOrdered(serializePlaylist.endedEpochMs, sqliteWrite.startedEpochMs);
|
||||
requireOrdered(sqliteWrite.endedEpochMs, sqliteRead.startedEpochMs);
|
||||
requireOrdered(sqliteRead.endedEpochMs, deserializePlaylist.startedEpochMs);
|
||||
requireOrdered(
|
||||
deserializePlaylist.endedEpochMs,
|
||||
publishChannels.startedEpochMs
|
||||
);
|
||||
requireOrdered(publishChannels.endedEpochMs, uiPainted);
|
||||
requireOrdered(uiPainted, terminal);
|
||||
|
||||
const ipc = deriveM3uImportIpcAttribution({
|
||||
get,
|
||||
getReadStartedEpochMs: sqliteRead.startedEpochMs,
|
||||
importDispatchEndedEpochMs: importDispatch.endedEpochMs,
|
||||
importDispatchStartedEpochMs: importDispatch.startedEpochMs,
|
||||
normalizeEndedEpochMs: normalize.endedEpochMs,
|
||||
playlistId,
|
||||
publishChannelsStartedEpochMs: publishChannels.startedEpochMs,
|
||||
serializeStartedEpochMs: serializePlaylist.startedEpochMs,
|
||||
timeline: input.main.timeline,
|
||||
upsert,
|
||||
});
|
||||
const angularRenderingMs = difference(
|
||||
uiPainted,
|
||||
publishChannels.endedEpochMs
|
||||
);
|
||||
const totalMs = difference(terminal, operationStart);
|
||||
|
||||
return Object.freeze({
|
||||
angularRenderingMs,
|
||||
dataAcquireMs: dataAcquire.durationMs,
|
||||
indexAndCommitMs: null,
|
||||
indexAndCommitUnavailableReason:
|
||||
M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
...ipc,
|
||||
m3uParsingMs: parseM3u.durationMs,
|
||||
normalizationMs: normalize.durationMs,
|
||||
playlistDeserializationMs: deserializePlaylist.durationMs,
|
||||
playlistSerializationMs: serializePlaylist.durationMs,
|
||||
sqliteReadMs: sqliteRead.durationMs,
|
||||
sqliteWriteMs: sqliteWrite.durationMs,
|
||||
storeImportDispatchMs: importDispatch.durationMs,
|
||||
storePublishChannelsMs: publishChannels.durationMs,
|
||||
storeUpdateMs: importDispatch.durationMs + publishChannels.durationMs,
|
||||
totalMs,
|
||||
});
|
||||
}
|
||||
|
||||
function toPerformancePhaseEvent(event: MainTimelineRecord): unknown {
|
||||
const metadata = {
|
||||
...(event.byteCount === undefined
|
||||
? {}
|
||||
: { byteCount: event.byteCount }),
|
||||
...(event.itemCount === undefined
|
||||
? {}
|
||||
: { itemCount: event.itemCount }),
|
||||
};
|
||||
return {
|
||||
boundary: event.boundary,
|
||||
durationMs: event.durationMs,
|
||||
epochMs: event.epochMs,
|
||||
...(Object.keys(metadata).length === 0 ? {} : { metadata }),
|
||||
phase: event.phase,
|
||||
requestId: event.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function requireDatabasePhase(
|
||||
phases: readonly PairedPerformancePhase[],
|
||||
request: WorkerRequestPerformanceMetrics,
|
||||
phase: string
|
||||
): PairedPerformancePhase {
|
||||
if (!request.requestId) {
|
||||
throw new Error('database-request-id-missing');
|
||||
}
|
||||
return requirePerformancePhase(phases, request.requestId, phase);
|
||||
}
|
||||
|
||||
function requireMetadataCount(
|
||||
phase: PairedPerformancePhase,
|
||||
key: 'byteCount' | 'itemCount',
|
||||
expected: number,
|
||||
label: string
|
||||
): void {
|
||||
if (phase.metadata?.[key] !== expected) {
|
||||
throw new Error(label);
|
||||
}
|
||||
}
|
||||
|
||||
function requireRendererItems(
|
||||
value: number | undefined,
|
||||
expected: number,
|
||||
label: string
|
||||
): void {
|
||||
if (value !== expected) {
|
||||
throw new Error(label);
|
||||
}
|
||||
}
|
||||
|
||||
function requirePositiveSafeCount(value: number, label: string): void {
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(label);
|
||||
}
|
||||
}
|
||||
|
||||
function requireOrdered(startEpochMs: number, endEpochMs: number): void {
|
||||
if (
|
||||
!Number.isFinite(startEpochMs) ||
|
||||
!Number.isFinite(endEpochMs) ||
|
||||
endEpochMs < startEpochMs
|
||||
) {
|
||||
throw new Error('phase-order');
|
||||
}
|
||||
}
|
||||
|
||||
function difference(endEpochMs: number, startEpochMs: number): number {
|
||||
requireOrdered(startEpochMs, endEpochMs);
|
||||
return endEpochMs - startEpochMs;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity';
|
||||
import type { DatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity';
|
||||
import type { M3uImportScenarioId } from './m3u-import-benchmark-contract';
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import {
|
||||
M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
type M3uImportPhaseMetrics,
|
||||
} from './m3u-import-report';
|
||||
import type {
|
||||
DatabaseWorkerPostGcValidity,
|
||||
MainCaptureMetrics,
|
||||
NumericDistribution,
|
||||
PerformanceIterationKind,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import type { RendererRssValidity } from './renderer-rss-validity';
|
||||
|
||||
export interface M3uImportIterationResult {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly kind: PerformanceIterationKind;
|
||||
readonly main: MainCaptureMetrics;
|
||||
readonly phases: M3uImportPhaseMetrics;
|
||||
readonly renderer: M3uImportRendererCaptureMetrics;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: M3uImportScenarioId;
|
||||
}
|
||||
|
||||
export interface M3uImportBenchmarkManifest {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly fixtureSha256: string;
|
||||
readonly gitCommit: string;
|
||||
readonly gitDiffSha256: string;
|
||||
readonly gitDirty: boolean;
|
||||
readonly measuredRuns: number;
|
||||
readonly memoryAccounting: {
|
||||
readonly databaseWorkerRss: 'unavailable-per-worker-thread';
|
||||
readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory';
|
||||
readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable';
|
||||
readonly rendererRss: 'separate-renderer-process';
|
||||
readonly workerJsHeap: 'separate-v8-isolate';
|
||||
};
|
||||
readonly runtime: {
|
||||
readonly arch: string;
|
||||
readonly cdpPort: number;
|
||||
readonly electronVersion: string;
|
||||
readonly harnessNodeVersion: string;
|
||||
readonly platform: string;
|
||||
};
|
||||
readonly scenario: M3uImportScenarioId;
|
||||
readonly syntheticSeed: number;
|
||||
readonly variant: string;
|
||||
readonly warmupRuns: number;
|
||||
}
|
||||
|
||||
export interface M3uImportMeasuredSummary {
|
||||
readonly angularRenderingMs: NumericDistribution;
|
||||
readonly dataAcquireMs: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerGetEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerGetThreadCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerGetThreadCpuUserMicros: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerExternalPeakBytes: NumericDistribution;
|
||||
readonly databaseWorkerHeapPeakBytes: NumericDistribution;
|
||||
readonly databaseWorkerPostGcHeapBytes: NumericDistribution;
|
||||
readonly databaseWorkerThreadCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerThreadCpuUserMicros: NumericDistribution;
|
||||
readonly databaseWorkerToMainCloneProxyMs: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerUpsertEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerUpsertThreadCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerUpsertThreadCpuUserMicros: NumericDistribution;
|
||||
readonly ipcStructuredCloneProxyMs: NumericDistribution;
|
||||
readonly mainToDatabaseWorkerCloneProxyMs: NumericDistribution;
|
||||
readonly mainToRendererCloneProxyMs: NumericDistribution;
|
||||
readonly m3uParsingMs: NumericDistribution;
|
||||
readonly mainEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly mainEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly mainEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly mainEventLoopUtilization: NumericDistribution;
|
||||
readonly mainHeapPeakBytes: NumericDistribution;
|
||||
readonly mainHeapPostGcBytes: NumericDistribution;
|
||||
readonly mainRssPeakBytes: NumericDistribution;
|
||||
readonly mainRssPostGcBytes: NumericDistribution;
|
||||
readonly normalizationMs: NumericDistribution;
|
||||
readonly playlistDeserializationMs: NumericDistribution;
|
||||
readonly playlistSerializationMs: NumericDistribution;
|
||||
readonly rendererFrameGapMs: NumericDistribution;
|
||||
readonly rendererHeartbeatDelayMs: NumericDistribution;
|
||||
readonly rendererHeapPeakBytes: NumericDistribution;
|
||||
readonly rendererHeapPostGcBytes: NumericDistribution;
|
||||
readonly rendererLongTaskCount: number;
|
||||
readonly rendererLongTaskMs: NumericDistribution;
|
||||
readonly rendererRssPeakBytes: NumericDistribution;
|
||||
readonly rendererToMainCloneProxyMs: NumericDistribution;
|
||||
readonly responsiveEvents: number;
|
||||
readonly sqliteReadMs: NumericDistribution;
|
||||
readonly sqliteWriteMs: NumericDistribution;
|
||||
readonly storeImportDispatchMs: NumericDistribution;
|
||||
readonly storePublishChannelsMs: NumericDistribution;
|
||||
readonly storeUpdateMs: NumericDistribution;
|
||||
readonly totalMs: NumericDistribution;
|
||||
readonly unresponsiveEvents: number;
|
||||
}
|
||||
|
||||
export interface M3uImportBenchmarkSummary {
|
||||
readonly iterations: readonly M3uImportIterationResult[];
|
||||
readonly manifest: M3uImportBenchmarkManifest;
|
||||
readonly measured: M3uImportMeasuredSummary;
|
||||
readonly notApplicable: {
|
||||
readonly cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation';
|
||||
readonly databaseWorkerRss: 'unavailable-per-worker-thread';
|
||||
readonly indexAndCommitMs: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON;
|
||||
readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable';
|
||||
readonly playlistRefreshWorker: 'not-used-by-initial-import';
|
||||
};
|
||||
readonly validity: {
|
||||
readonly databaseWorkerPeakMemory: DatabaseWorkerPeakMemoryValidity;
|
||||
readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity;
|
||||
readonly databaseWorkerRequestMetrics: DatabaseWorkerRequestMetricsValidity;
|
||||
readonly rendererRss: RendererRssValidity;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { it } from 'node:test';
|
||||
|
||||
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
createM3uImportBenchmarkSummary,
|
||||
createM3uImportIterationResult,
|
||||
type M3uImportBenchmarkSummary,
|
||||
} from './m3u-import-summary';
|
||||
import { assertM3uImportComparisonValidity } from './m3u-import.benchmark';
|
||||
import {
|
||||
createCompleteTestMainCapture,
|
||||
createCompleteTestRendererCapture,
|
||||
createTestM3uImportManifest,
|
||||
TEST_M3U_CHANNEL_COUNT,
|
||||
TEST_M3U_FIXTURE_BYTES,
|
||||
} from './m3u-import-report.test-helpers';
|
||||
|
||||
it('keeps summary aggregation below the hard line limit without a suppression', () => {
|
||||
const source = readFileSync(
|
||||
resolve(process.cwd(), 'src/performance/m3u-import-summary.ts'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.doesNotMatch(source, /eslint-disable max-lines/);
|
||||
assert.ok(source.split(/\r?\n/).length <= 400);
|
||||
});
|
||||
|
||||
it('summarizes measured runs only while preserving per-process memory boundaries', () => {
|
||||
const warmup = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'warmup-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const measured = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'run-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const diagnostic = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
main: createCompleteTestMainCapture({
|
||||
mainRss: 9_999,
|
||||
rendererRss: 8_888,
|
||||
}),
|
||||
renderer: createCompleteTestRendererCapture({
|
||||
rendererHeap: 7_777,
|
||||
}),
|
||||
runId: 'diagnostic',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const summary = createM3uImportBenchmarkSummary(
|
||||
createTestM3uImportManifest(),
|
||||
[warmup, measured, diagnostic]
|
||||
);
|
||||
|
||||
assert.equal(summary.measured.totalMs.count, 1);
|
||||
assert.equal(summary.measured.totalMs.mean, 215);
|
||||
assert.equal(summary.measured.ipcStructuredCloneProxyMs.mean, 44);
|
||||
assert.equal(summary.measured.mainToDatabaseWorkerCloneProxyMs.mean, 2);
|
||||
assert.equal(summary.measured.databaseWorkerToMainCloneProxyMs.mean, 12);
|
||||
assert.equal(summary.measured.mainToRendererCloneProxyMs.mean, 28);
|
||||
assert.equal(summary.measured.rendererToMainCloneProxyMs.mean, 2);
|
||||
assert.equal(summary.measured.sqliteReadMs.mean, 5);
|
||||
assert.equal(summary.measured.playlistDeserializationMs.mean, 12);
|
||||
assert.equal(summary.measured.databaseWorkerThreadCpuSystemMicros.mean, 100);
|
||||
assert.equal(summary.measured.databaseWorkerThreadCpuUserMicros.mean, 120);
|
||||
assert.equal(
|
||||
summary.measured.databaseWorkerUpsertEventLoopDelayP95Ms.mean,
|
||||
4
|
||||
);
|
||||
assert.equal(
|
||||
summary.measured.databaseWorkerGetEventLoopDelayP95Ms.mean,
|
||||
7
|
||||
);
|
||||
assert.equal(summary.measured.mainRssPeakBytes.mean, 5_000);
|
||||
assert.equal(summary.measured.mainHeapPeakBytes.mean, 2_000);
|
||||
assert.equal(summary.measured.rendererRssPeakBytes.mean, 4_000);
|
||||
assert.equal(summary.measured.rendererHeapPeakBytes.mean, 3_000);
|
||||
assert.equal(summary.measured.databaseWorkerHeapPeakBytes.mean, 1_500);
|
||||
assert.equal(summary.measured.databaseWorkerPostGcHeapBytes.mean, 900);
|
||||
assert.equal(summary.measured.rendererLongTaskCount, 1);
|
||||
assert.equal(summary.measured.unresponsiveEvents, 0);
|
||||
assert.deepEqual(summary.notApplicable, {
|
||||
cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation',
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
indexAndCommitMs:
|
||||
'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write',
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
playlistRefreshWorker: 'not-used-by-initial-import',
|
||||
});
|
||||
assert.equal(summary.validity.rendererRss.validForComparison, true);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerPeakMemory.validForComparison,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerPostGc.validForComparison,
|
||||
true
|
||||
);
|
||||
assert.doesNotThrow(() => assertM3uImportComparisonValidity(summary));
|
||||
|
||||
const databaseWorker = measured.main.workers[0];
|
||||
assert.ok(databaseWorker);
|
||||
const invalidMeasured = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
main: {
|
||||
...measured.main,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
externalMemorySampleCount: 0,
|
||||
heapUsedSampleCount: 0,
|
||||
peakExternalBytes: null,
|
||||
peakExternalUnavailableReason:
|
||||
'worker-external-memory-samples-missing',
|
||||
peakHeapUsedBytes: null,
|
||||
peakHeapUsedUnavailableReason:
|
||||
'worker-heap-used-samples-missing',
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: measured.renderer,
|
||||
runId: 'run-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const invalidPeakSummary = createM3uImportBenchmarkSummary(
|
||||
createTestM3uImportManifest(),
|
||||
[warmup, invalidMeasured, diagnostic]
|
||||
);
|
||||
assert.equal(
|
||||
invalidPeakSummary.validity.databaseWorkerPeakMemory.validForComparison,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
invalidPeakSummary.measured.databaseWorkerHeapPeakBytes.count,
|
||||
0
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertM3uImportComparisonValidity(
|
||||
invalidPeakSummary as M3uImportBenchmarkSummary
|
||||
),
|
||||
/Database worker peak-memory capture is invalid/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createM3uImportBenchmarkSummary(createTestM3uImportManifest(), [
|
||||
measured,
|
||||
diagnostic,
|
||||
]),
|
||||
/m3u-import-summary-schedule-invalid/
|
||||
);
|
||||
});
|
||||
|
||||
it('fails comparison when any measured worker request omits mandatory event-loop or CPU metrics', () => {
|
||||
const completeMain = createCompleteTestMainCapture();
|
||||
const databaseWorker = completeMain.workers[0];
|
||||
const get = databaseWorker?.requests[1];
|
||||
assert.ok(databaseWorker);
|
||||
assert.ok(get);
|
||||
const invalidMain = {
|
||||
...completeMain,
|
||||
workers: [
|
||||
{
|
||||
...databaseWorker,
|
||||
requests: [
|
||||
databaseWorker.requests[0],
|
||||
{
|
||||
...get,
|
||||
eventLoopDelay: null,
|
||||
eventLoopDelayUnavailableReason:
|
||||
'event-loop-delay-capture-unavailable',
|
||||
histogramFlushedEpochMs: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const warmup = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.WARMUP,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'warmup-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const measured = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
|
||||
main: invalidMain,
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'run-01',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const diagnostic = createM3uImportIterationResult({
|
||||
channelCount: TEST_M3U_CHANNEL_COUNT,
|
||||
fixtureBytes: TEST_M3U_FIXTURE_BYTES,
|
||||
kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC,
|
||||
main: createCompleteTestMainCapture(),
|
||||
renderer: createCompleteTestRendererCapture(),
|
||||
runId: 'diagnostic',
|
||||
scenarioId: 'm3u-import-10k',
|
||||
});
|
||||
const summary = createM3uImportBenchmarkSummary(
|
||||
createTestM3uImportManifest(),
|
||||
[warmup, measured, diagnostic]
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerRequestMetrics.validForComparison,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerRequestMetrics.expectedRequestCount,
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
summary.validity.databaseWorkerRequestMetrics.validRequestCount,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
summary.measured.databaseWorkerGetEventLoopDelayP95Ms.count,
|
||||
0
|
||||
);
|
||||
assert.throws(
|
||||
() => assertM3uImportComparisonValidity(summary),
|
||||
/Database worker request metrics are invalid/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture';
|
||||
import type { M3uImportScenarioId } from './m3u-import-benchmark-contract';
|
||||
import type {
|
||||
MainCaptureMetrics,
|
||||
NumericDistribution,
|
||||
PerformanceIterationKind,
|
||||
WorkerCaptureMetrics,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
PERFORMANCE_ITERATION_KIND,
|
||||
PERFORMANCE_WORKER_KIND,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
deriveM3uImportPhaseMetrics,
|
||||
M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
} from './m3u-import-report';
|
||||
import { summarizeNumbers } from './performance-statistics';
|
||||
import { assessDatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity';
|
||||
import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity';
|
||||
import { assessDatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity';
|
||||
import { assessRendererRssValidity } from './renderer-rss-validity';
|
||||
import type {
|
||||
M3uImportBenchmarkManifest,
|
||||
M3uImportBenchmarkSummary,
|
||||
M3uImportIterationResult,
|
||||
} from './m3u-import-summary-contract';
|
||||
|
||||
export type {
|
||||
M3uImportBenchmarkManifest,
|
||||
M3uImportBenchmarkSummary,
|
||||
M3uImportIterationResult,
|
||||
M3uImportMeasuredSummary,
|
||||
} from './m3u-import-summary-contract';
|
||||
|
||||
export function createM3uImportIterationResult(input: {
|
||||
readonly channelCount: number;
|
||||
readonly fixtureBytes: number;
|
||||
readonly kind: PerformanceIterationKind;
|
||||
readonly main: MainCaptureMetrics;
|
||||
readonly renderer: M3uImportRendererCaptureMetrics;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: M3uImportScenarioId;
|
||||
}): M3uImportIterationResult {
|
||||
return Object.freeze({
|
||||
channelCount: input.channelCount,
|
||||
fixtureBytes: input.fixtureBytes,
|
||||
kind: input.kind,
|
||||
main: input.main,
|
||||
phases: deriveM3uImportPhaseMetrics(input),
|
||||
renderer: input.renderer,
|
||||
runId: input.runId,
|
||||
scenarioId: input.scenarioId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createM3uImportBenchmarkSummary(
|
||||
manifest: M3uImportBenchmarkManifest,
|
||||
iterations: readonly M3uImportIterationResult[]
|
||||
): M3uImportBenchmarkSummary {
|
||||
validateIterationSchedule(manifest, iterations);
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
);
|
||||
validateMeasuredIterations(manifest, measured);
|
||||
const values = (
|
||||
select: (iteration: M3uImportIterationResult) => number | null
|
||||
): NumericDistribution => summarizeNumbers(measured.map(select));
|
||||
const rendererSamples = (
|
||||
select: (
|
||||
probe: M3uImportRendererCaptureMetrics['probe']
|
||||
) => readonly number[]
|
||||
): NumericDistribution =>
|
||||
summarizeNumbers(
|
||||
measured.flatMap((iteration) => [
|
||||
...select(iteration.renderer.probe),
|
||||
])
|
||||
);
|
||||
const databaseWorkers = measured.map(requireDatabaseWorker);
|
||||
const databaseRequestPairs = databaseWorkers.map(
|
||||
requireInitialImportRequests
|
||||
);
|
||||
const databaseUpserts = databaseRequestPairs.map((pair) => pair.upsert);
|
||||
const databaseGets = databaseRequestPairs.map((pair) => pair.get);
|
||||
const databaseRequests = databaseRequestPairs.flatMap((pair) => [
|
||||
pair.upsert,
|
||||
pair.get,
|
||||
]);
|
||||
|
||||
return Object.freeze({
|
||||
iterations: Object.freeze([...iterations]),
|
||||
manifest,
|
||||
measured: Object.freeze({
|
||||
angularRenderingMs: values(
|
||||
(iteration) => iteration.phases.angularRenderingMs
|
||||
),
|
||||
dataAcquireMs: values(
|
||||
(iteration) => iteration.phases.dataAcquireMs
|
||||
),
|
||||
databaseWorkerGetEventLoopDelayMaxMs: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopDelay?.maxMs)
|
||||
),
|
||||
databaseWorkerGetEventLoopDelayP95Ms: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopDelay?.p95Ms)
|
||||
),
|
||||
databaseWorkerGetEventLoopDelayP99Ms: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopDelay?.p99Ms)
|
||||
),
|
||||
databaseWorkerGetEventLoopUtilization: summarizeNumbers(
|
||||
databaseGets.map((request) => request.eventLoopUtilization)
|
||||
),
|
||||
databaseWorkerGetThreadCpuSystemMicros: summarizeNumbers(
|
||||
databaseGets.map((request) => request.threadCpuSystemMicros)
|
||||
),
|
||||
databaseWorkerGetThreadCpuUserMicros: summarizeNumbers(
|
||||
databaseGets.map((request) => request.threadCpuUserMicros)
|
||||
),
|
||||
databaseWorkerEventLoopDelayMaxMs: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopDelay?.maxMs)
|
||||
),
|
||||
databaseWorkerEventLoopDelayP95Ms: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopDelay?.p95Ms)
|
||||
),
|
||||
databaseWorkerEventLoopDelayP99Ms: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopDelay?.p99Ms)
|
||||
),
|
||||
databaseWorkerEventLoopUtilization: summarizeNumbers(
|
||||
databaseRequests.map((request) => request.eventLoopUtilization)
|
||||
),
|
||||
databaseWorkerExternalPeakBytes: summarizeNumbers(
|
||||
databaseWorkers.map((worker) => worker.peakExternalBytes)
|
||||
),
|
||||
databaseWorkerHeapPeakBytes: summarizeNumbers(
|
||||
databaseWorkers.map((worker) => worker.peakHeapUsedBytes)
|
||||
),
|
||||
databaseWorkerPostGcHeapBytes: summarizeNumbers(
|
||||
databaseWorkers.map((worker) => worker.postGcHeapUsedBytes)
|
||||
),
|
||||
databaseWorkerThreadCpuSystemMicros: summarizeNumbers(
|
||||
databaseRequestPairs.map((pair) =>
|
||||
sumRequestMetric(
|
||||
pair.upsert.threadCpuSystemMicros,
|
||||
pair.get.threadCpuSystemMicros
|
||||
)
|
||||
)
|
||||
),
|
||||
databaseWorkerThreadCpuUserMicros: summarizeNumbers(
|
||||
databaseRequestPairs.map((pair) =>
|
||||
sumRequestMetric(
|
||||
pair.upsert.threadCpuUserMicros,
|
||||
pair.get.threadCpuUserMicros
|
||||
)
|
||||
)
|
||||
),
|
||||
databaseWorkerToMainCloneProxyMs: values(
|
||||
(iteration) =>
|
||||
iteration.phases.databaseWorkerToMainCloneProxyMs
|
||||
),
|
||||
databaseWorkerUpsertEventLoopDelayMaxMs: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopDelay?.maxMs)
|
||||
),
|
||||
databaseWorkerUpsertEventLoopDelayP95Ms: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopDelay?.p95Ms)
|
||||
),
|
||||
databaseWorkerUpsertEventLoopDelayP99Ms: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopDelay?.p99Ms)
|
||||
),
|
||||
databaseWorkerUpsertEventLoopUtilization: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.eventLoopUtilization)
|
||||
),
|
||||
databaseWorkerUpsertThreadCpuSystemMicros: summarizeNumbers(
|
||||
databaseUpserts.map(
|
||||
(request) => request.threadCpuSystemMicros
|
||||
)
|
||||
),
|
||||
databaseWorkerUpsertThreadCpuUserMicros: summarizeNumbers(
|
||||
databaseUpserts.map((request) => request.threadCpuUserMicros)
|
||||
),
|
||||
ipcStructuredCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.ipcStructuredCloneProxyMs
|
||||
),
|
||||
mainToDatabaseWorkerCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.mainToDatabaseWorkerCloneProxyMs
|
||||
),
|
||||
mainToRendererCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.mainToRendererCloneProxyMs
|
||||
),
|
||||
m3uParsingMs: values((iteration) => iteration.phases.m3uParsingMs),
|
||||
mainEventLoopDelayMaxMs: values(
|
||||
(iteration) => iteration.main.eventLoopDelay.maxMs
|
||||
),
|
||||
mainEventLoopDelayP95Ms: values(
|
||||
(iteration) => iteration.main.eventLoopDelay.p95Ms
|
||||
),
|
||||
mainEventLoopDelayP99Ms: values(
|
||||
(iteration) => iteration.main.eventLoopDelay.p99Ms
|
||||
),
|
||||
mainEventLoopUtilization: values(
|
||||
(iteration) => iteration.main.eventLoopUtilization
|
||||
),
|
||||
mainHeapPeakBytes: values(
|
||||
(iteration) => iteration.main.memory.peakHeapUsedBytes
|
||||
),
|
||||
mainHeapPostGcBytes: values(
|
||||
(iteration) => iteration.main.memory.postGcHeapUsedBytes
|
||||
),
|
||||
mainRssPeakBytes: values(
|
||||
(iteration) => iteration.main.memory.peakRssBytes
|
||||
),
|
||||
mainRssPostGcBytes: values(
|
||||
(iteration) => iteration.main.memory.postGcRssBytes
|
||||
),
|
||||
normalizationMs: values(
|
||||
(iteration) => iteration.phases.normalizationMs
|
||||
),
|
||||
playlistDeserializationMs: values(
|
||||
(iteration) => iteration.phases.playlistDeserializationMs
|
||||
),
|
||||
playlistSerializationMs: values(
|
||||
(iteration) => iteration.phases.playlistSerializationMs
|
||||
),
|
||||
rendererFrameGapMs: rendererSamples((probe) => probe.frameGapsMs),
|
||||
rendererHeartbeatDelayMs: rendererSamples(
|
||||
(probe) => probe.heartbeatDelaysMs
|
||||
),
|
||||
rendererHeapPeakBytes: values(
|
||||
(iteration) => iteration.renderer.peakHeapUsedBytes
|
||||
),
|
||||
rendererHeapPostGcBytes: values(
|
||||
(iteration) => iteration.renderer.postGcHeapUsedBytes
|
||||
),
|
||||
rendererLongTaskCount: measured.reduce(
|
||||
(total, iteration) =>
|
||||
total + iteration.renderer.probe.longTasksMs.length,
|
||||
0
|
||||
),
|
||||
rendererLongTaskMs: rendererSamples((probe) => probe.longTasksMs),
|
||||
rendererRssPeakBytes: values(
|
||||
(iteration) => iteration.main.rendererWindow.rss.peakRssBytes
|
||||
),
|
||||
rendererToMainCloneProxyMs: values(
|
||||
(iteration) => iteration.phases.rendererToMainCloneProxyMs
|
||||
),
|
||||
responsiveEvents: measured.reduce(
|
||||
(total, iteration) =>
|
||||
total + iteration.main.rendererWindow.responsiveEvents,
|
||||
0
|
||||
),
|
||||
sqliteReadMs: values((iteration) => iteration.phases.sqliteReadMs),
|
||||
sqliteWriteMs: values(
|
||||
(iteration) => iteration.phases.sqliteWriteMs
|
||||
),
|
||||
storeImportDispatchMs: values(
|
||||
(iteration) => iteration.phases.storeImportDispatchMs
|
||||
),
|
||||
storePublishChannelsMs: values(
|
||||
(iteration) => iteration.phases.storePublishChannelsMs
|
||||
),
|
||||
storeUpdateMs: values(
|
||||
(iteration) => iteration.phases.storeUpdateMs
|
||||
),
|
||||
totalMs: values((iteration) => iteration.phases.totalMs),
|
||||
unresponsiveEvents: measured.reduce(
|
||||
(total, iteration) =>
|
||||
total + iteration.main.rendererWindow.unresponsiveEvents,
|
||||
0
|
||||
),
|
||||
}),
|
||||
notApplicable: Object.freeze({
|
||||
cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation',
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
indexAndCommitMs: M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON,
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
playlistRefreshWorker: 'not-used-by-initial-import',
|
||||
}),
|
||||
validity: Object.freeze({
|
||||
databaseWorkerPeakMemory:
|
||||
assessDatabaseWorkerPeakMemoryValidity(iterations),
|
||||
databaseWorkerPostGc:
|
||||
assessDatabaseWorkerPostGcValidity(iterations),
|
||||
databaseWorkerRequestMetrics:
|
||||
assessDatabaseWorkerRequestMetricsValidity(iterations),
|
||||
rendererRss: assessRendererRssValidity(iterations),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function validateIterationSchedule(
|
||||
manifest: M3uImportBenchmarkManifest,
|
||||
iterations: readonly M3uImportIterationResult[]
|
||||
): void {
|
||||
const warmupCount = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.WARMUP
|
||||
).length;
|
||||
const measuredCount = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
).length;
|
||||
const diagnosticCount = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC
|
||||
).length;
|
||||
if (
|
||||
warmupCount !== manifest.warmupRuns ||
|
||||
measuredCount !== manifest.measuredRuns ||
|
||||
diagnosticCount !== 1 ||
|
||||
iterations.length !== warmupCount + measuredCount + diagnosticCount ||
|
||||
new Set(iterations.map((iteration) => iteration.runId)).size !==
|
||||
iterations.length ||
|
||||
iterations.some(
|
||||
(iteration) =>
|
||||
iteration.channelCount !== manifest.channelCount ||
|
||||
iteration.fixtureBytes !== manifest.fixtureBytes ||
|
||||
iteration.scenarioId !== manifest.scenario
|
||||
)
|
||||
) {
|
||||
throw new Error('m3u-import-summary-schedule-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function validateMeasuredIterations(
|
||||
manifest: M3uImportBenchmarkManifest,
|
||||
measured: readonly M3uImportIterationResult[]
|
||||
): void {
|
||||
if (
|
||||
measured.length !== manifest.measuredRuns ||
|
||||
new Set(measured.map((iteration) => iteration.runId)).size !==
|
||||
measured.length ||
|
||||
measured.some(
|
||||
(iteration) =>
|
||||
iteration.channelCount !== manifest.channelCount ||
|
||||
iteration.fixtureBytes !== manifest.fixtureBytes ||
|
||||
iteration.scenarioId !== manifest.scenario
|
||||
)
|
||||
) {
|
||||
throw new Error('m3u-import-summary-measured-runs-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function requireDatabaseWorker(
|
||||
iteration: M3uImportIterationResult
|
||||
): WorkerCaptureMetrics {
|
||||
const databaseWorkers = iteration.main.workers.filter(
|
||||
(worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE
|
||||
);
|
||||
if (databaseWorkers.length !== 1 || !databaseWorkers[0]) {
|
||||
throw new Error('m3u-import-summary-database-worker-invalid');
|
||||
}
|
||||
return databaseWorkers[0];
|
||||
}
|
||||
|
||||
function requireInitialImportRequests(
|
||||
worker: WorkerCaptureMetrics
|
||||
): {
|
||||
readonly get: WorkerRequestPerformanceMetrics;
|
||||
readonly upsert: WorkerRequestPerformanceMetrics;
|
||||
} {
|
||||
const upserts = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success
|
||||
);
|
||||
const gets = worker.requests.filter(
|
||||
(request) =>
|
||||
request.operation === 'DB_GET_APP_PLAYLIST' && request.success
|
||||
);
|
||||
if (
|
||||
worker.requests.length !== 2 ||
|
||||
upserts.length !== 1 ||
|
||||
!upserts[0] ||
|
||||
gets.length !== 1 ||
|
||||
!gets[0]
|
||||
) {
|
||||
throw new Error('m3u-import-summary-database-request-invalid');
|
||||
}
|
||||
return { get: gets[0], upsert: upserts[0] };
|
||||
}
|
||||
|
||||
function sumRequestMetric(
|
||||
first: number | null,
|
||||
second: number | null
|
||||
): number | null {
|
||||
return first === null || second === null ? null : first + second;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
import { validateInitialImportDiagnosticArtifacts } from './initial-import-diagnostic-artifacts';
|
||||
import {
|
||||
assertRendererCdpTarget,
|
||||
assertTcpPortAvailable,
|
||||
type M3uImportBenchmarkConfiguration,
|
||||
resolveM3uImportBenchmarkConfiguration,
|
||||
resolveRendererWindowIdentity,
|
||||
writeM3uImportJson as writeJson,
|
||||
} from './m3u-import-benchmark-support';
|
||||
import {
|
||||
assertSyntheticM3uImportUrl,
|
||||
createM3uImportIterationDefinitions,
|
||||
listM3uImportScenarios,
|
||||
type M3uImportIterationDefinition,
|
||||
type M3uImportScenario,
|
||||
} from './m3u-import-benchmark-contract';
|
||||
import {
|
||||
createM3uImportBenchmarkSummary,
|
||||
createM3uImportIterationResult,
|
||||
type M3uImportBenchmarkManifest,
|
||||
type M3uImportBenchmarkSummary,
|
||||
type M3uImportIterationResult,
|
||||
} from './m3u-import-summary';
|
||||
import {
|
||||
startM3uImportRendererCapture,
|
||||
type RunningM3uImportRendererCapture,
|
||||
} from './m3u-import-renderer-capture';
|
||||
import {
|
||||
installMainCapture,
|
||||
readMainCaptureStatus,
|
||||
startMainCapture,
|
||||
stopMainCapture,
|
||||
} from './m3u-refresh-main-capture';
|
||||
import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract';
|
||||
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
|
||||
import {
|
||||
startSyntheticM3uServer,
|
||||
type SyntheticM3uServer,
|
||||
} from './synthetic-m3u-server';
|
||||
import {
|
||||
createSyntheticM3uFixture,
|
||||
SYNTHETIC_M3U_SEED,
|
||||
type SyntheticM3uFixture,
|
||||
} from './synthetic-m3u';
|
||||
|
||||
export async function runM3uImportBenchmark(): Promise<void> {
|
||||
const config = await resolveM3uImportBenchmarkConfiguration();
|
||||
const definitions = createM3uImportIterationDefinitions(config.smoke);
|
||||
|
||||
for (const scenario of listM3uImportScenarios()) {
|
||||
await runScenario(
|
||||
config,
|
||||
scenario,
|
||||
definitions.filter(
|
||||
(definition) => definition.scenarioId === scenario.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
config: M3uImportBenchmarkConfiguration,
|
||||
scenario: M3uImportScenario,
|
||||
definitions: readonly M3uImportIterationDefinition[]
|
||||
): Promise<void> {
|
||||
const fixture = createSyntheticM3uFixture(scenario.channelCount);
|
||||
const server = await startSyntheticM3uServer(fixture);
|
||||
const scenarioDirectory = join(config.variantDirectory, scenario.id);
|
||||
const iterations: M3uImportIterationResult[] = [];
|
||||
|
||||
try {
|
||||
assertSyntheticM3uImportUrl(server.resourceUrl);
|
||||
await server.prime();
|
||||
await assertPerformanceArtifactCapacity(scenarioDirectory);
|
||||
await mkdir(scenarioDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
for (const definition of definitions) {
|
||||
console.log(
|
||||
`[performance] ${config.variant}/${scenario.id}/${definition.runId} starting`
|
||||
);
|
||||
const result = await runIteration(
|
||||
config,
|
||||
definition,
|
||||
fixture,
|
||||
server,
|
||||
scenarioDirectory
|
||||
);
|
||||
iterations.push(result);
|
||||
console.log(
|
||||
`[performance] ${definition.runId} total=${result.phases.totalMs.toFixed(3)}ms`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
if (server.requestCount() !== definitions.length + 1) {
|
||||
throw new Error('Synthetic M3U server request total is invalid');
|
||||
}
|
||||
const manifest = createManifest(config, scenario, fixture);
|
||||
const summary = createM3uImportBenchmarkSummary(manifest, iterations);
|
||||
await writeJson(join(scenarioDirectory, 'manifest.json'), manifest);
|
||||
await writeJson(join(scenarioDirectory, 'summary.json'), summary);
|
||||
const diagnostic = requireDiagnosticIteration(iterations);
|
||||
await validateInitialImportDiagnosticArtifacts(
|
||||
{ main: diagnostic.main, renderer: diagnostic.renderer },
|
||||
join(scenarioDirectory, diagnostic.runId)
|
||||
);
|
||||
assertM3uImportComparisonValidity(summary);
|
||||
console.log(
|
||||
`[performance] completed: ${join(scenarioDirectory, 'summary.json')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function assertM3uImportComparisonValidity(
|
||||
summary: M3uImportBenchmarkSummary
|
||||
): void {
|
||||
if (!summary.validity.rendererRss.validForComparison) {
|
||||
throw new Error(
|
||||
`Renderer RSS capture is invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.rendererRss.invalidMeasuredRuns
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!summary.validity.databaseWorkerPostGc.validForComparison) {
|
||||
throw new Error(
|
||||
`Database worker post-GC capture is invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.databaseWorkerPostGc.invalidMeasuredRuns
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!summary.validity.databaseWorkerPeakMemory.validForComparison) {
|
||||
throw new Error(
|
||||
`Database worker peak-memory capture is invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.databaseWorkerPeakMemory.invalidMeasuredRuns
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (!summary.validity.databaseWorkerRequestMetrics.validForComparison) {
|
||||
throw new Error(
|
||||
`Database worker request metrics are invalid for comparison: ${JSON.stringify(
|
||||
summary.validity.databaseWorkerRequestMetrics.invalidRequests
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runIteration(
|
||||
config: M3uImportBenchmarkConfiguration,
|
||||
definition: M3uImportIterationDefinition,
|
||||
fixture: SyntheticM3uFixture,
|
||||
server: SyntheticM3uServer,
|
||||
scenarioDirectory: string
|
||||
): Promise<M3uImportIterationResult> {
|
||||
const iterationDirectory = join(scenarioDirectory, definition.runId);
|
||||
await assertPerformanceArtifactCapacity(iterationDirectory);
|
||||
await mkdir(iterationDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
const dataDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-m3u-import-performance-')
|
||||
);
|
||||
let app: LaunchedElectronApp | null = null;
|
||||
let renderer: RunningM3uImportRendererCapture | null = null;
|
||||
|
||||
try {
|
||||
await assertTcpPortAvailable(config.rendererCdpPort);
|
||||
app = await launchElectronApp(dataDirectory, {
|
||||
args: [
|
||||
'--js-flags=--expose-gc',
|
||||
'--remote-debugging-address=127.0.0.1',
|
||||
`--remote-debugging-port=${config.rendererCdpPort}`,
|
||||
`--user-data-dir=${join(dataDirectory, 'user-data')}`,
|
||||
],
|
||||
environmentInheritance: 'runtime-only',
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
IPTVNATOR_PERF_WORKER_PROFILING: '1',
|
||||
IPTVNATOR_TRACE_RENDERER_CONSOLE:
|
||||
process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0',
|
||||
},
|
||||
});
|
||||
app.mainWindow.setDefaultTimeout(120_000);
|
||||
await assertRendererCdpTarget(app.mainWindow, config.rendererCdpPort);
|
||||
await installMainCapture(app.electronApp);
|
||||
const rendererWindowIdentity = await resolveRendererWindowIdentity(app);
|
||||
const requestsBefore = server.requestCount();
|
||||
const diagnostic =
|
||||
definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC;
|
||||
renderer = await startM3uImportRendererCapture(app.mainWindow, {
|
||||
diagnostic,
|
||||
outputDirectory: iterationDirectory,
|
||||
playlistTitle: `Synthetic M3U import ${definition.channelCount}`,
|
||||
playlistUrl: server.resourceUrl,
|
||||
});
|
||||
|
||||
await startMainCapture(app.electronApp, {
|
||||
diagnostic,
|
||||
outputDirectory: iterationDirectory,
|
||||
rendererWindowIdentity,
|
||||
});
|
||||
await renderer.triggerImport();
|
||||
await renderer.waitForTerminal();
|
||||
await waitForDatabaseSettlement(app);
|
||||
assertM3uImportRequestDelta(requestsBefore, server.requestCount());
|
||||
|
||||
const [rendererMetrics, mainMetrics] = await Promise.all([
|
||||
renderer.stop(),
|
||||
stopMainCapture(app.electronApp),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeJson(
|
||||
join(iterationDirectory, 'main-capture.json'),
|
||||
mainMetrics
|
||||
),
|
||||
writeJson(
|
||||
join(iterationDirectory, 'renderer-capture.json'),
|
||||
rendererMetrics
|
||||
),
|
||||
]);
|
||||
const result = createM3uImportIterationResult({
|
||||
channelCount: definition.channelCount,
|
||||
fixtureBytes: fixture.bytes,
|
||||
kind: definition.kind,
|
||||
main: mainMetrics,
|
||||
renderer: rendererMetrics,
|
||||
runId: definition.runId,
|
||||
scenarioId: definition.scenarioId,
|
||||
});
|
||||
await writeJson(join(iterationDirectory, 'result.json'), result);
|
||||
return result;
|
||||
} finally {
|
||||
if (renderer) {
|
||||
await renderer.dispose().catch((error: unknown) => {
|
||||
console.error(
|
||||
'[performance] Renderer capture cleanup failed',
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
if (app) {
|
||||
await closeElectronApp(app).catch((error: unknown) => {
|
||||
console.error('[performance] Electron cleanup failed', error);
|
||||
});
|
||||
}
|
||||
await rm(dataDirectory, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDatabaseSettlement(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const status = await readMainCaptureStatus(app.electronApp);
|
||||
return (
|
||||
status.databaseRequests === 2 &&
|
||||
status.databaseUpsertsCompleted === 1 &&
|
||||
status.databaseGetsCompleted === 1 &&
|
||||
status.databasePending === 0 &&
|
||||
status.preloadSuccessMarkers === 2
|
||||
);
|
||||
},
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
function createManifest(
|
||||
config: M3uImportBenchmarkConfiguration,
|
||||
scenario: M3uImportScenario,
|
||||
fixture: SyntheticM3uFixture
|
||||
): M3uImportBenchmarkManifest {
|
||||
return Object.freeze({
|
||||
channelCount: fixture.channelCount,
|
||||
fixtureBytes: fixture.bytes,
|
||||
fixtureSha256: fixture.sha256,
|
||||
gitCommit: config.sourceState.commit,
|
||||
gitDiffSha256: config.sourceState.diffSha256,
|
||||
gitDirty: config.sourceState.dirty,
|
||||
measuredRuns: config.measuredRuns,
|
||||
memoryAccounting: Object.freeze({
|
||||
databaseWorkerRss: 'unavailable-per-worker-thread',
|
||||
mainRss:
|
||||
'electron-main-process-including-worker-threads-and-native-memory',
|
||||
nativeAndSqliteMemory:
|
||||
'included-in-main-rss-not-separately-attributable',
|
||||
rendererRss: 'separate-renderer-process',
|
||||
workerJsHeap: 'separate-v8-isolate',
|
||||
}),
|
||||
runtime: Object.freeze({
|
||||
arch: process.arch,
|
||||
cdpPort: config.rendererCdpPort,
|
||||
electronVersion: config.electronVersion,
|
||||
harnessNodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
}),
|
||||
scenario: scenario.id,
|
||||
syntheticSeed: SYNTHETIC_M3U_SEED,
|
||||
variant: config.variant,
|
||||
warmupRuns: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function requireDiagnosticIteration(
|
||||
iterations: readonly M3uImportIterationResult[]
|
||||
): M3uImportIterationResult {
|
||||
const diagnostic = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC
|
||||
);
|
||||
if (diagnostic.length !== 1 || !diagnostic[0]) {
|
||||
throw new Error('M3U import diagnostic iteration is missing');
|
||||
}
|
||||
return diagnostic[0];
|
||||
}
|
||||
|
||||
export function assertM3uImportRequestDelta(
|
||||
before: number,
|
||||
after: number
|
||||
): void {
|
||||
const oneRequest =
|
||||
Number.isSafeInteger(before) &&
|
||||
Number.isSafeInteger(after) &&
|
||||
after === before + 1;
|
||||
if (!oneRequest) {
|
||||
throw new Error(
|
||||
'Synthetic M3U import must issue exactly one application GET'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { RendererProcessRssCapture } from './renderer-process-rss-capture';
|
||||
import type { RendererRssValidity } from './renderer-rss-validity';
|
||||
import type {
|
||||
RendererProcessRssCapture,
|
||||
RendererProcessRssUnavailableReason,
|
||||
} from './renderer-process-rss-capture';
|
||||
DatabaseWorkerPerformancePhase,
|
||||
PerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const PERFORMANCE_ITERATION_KIND = {
|
||||
DIAGNOSTIC: 'diagnostic',
|
||||
@@ -54,6 +56,11 @@ export type WorkerPostGcHeapCapture =
|
||||
readonly postGcHeapUsedBytes: null;
|
||||
};
|
||||
|
||||
export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = {
|
||||
EXTERNAL_MEMORY_SAMPLES_MISSING: 'worker-external-memory-samples-missing',
|
||||
HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing',
|
||||
} as const;
|
||||
|
||||
export interface NumericDistribution {
|
||||
readonly count: number;
|
||||
readonly max: number | null;
|
||||
@@ -78,12 +85,25 @@ export interface ProcessMemoryMetrics {
|
||||
}
|
||||
|
||||
export interface MainTimelineRecord {
|
||||
readonly action?: string | null;
|
||||
readonly boundary?: 'end' | 'start';
|
||||
readonly byteCount?: number;
|
||||
readonly categoryType?: string | null;
|
||||
readonly contentType?: string | null;
|
||||
readonly durationMs?: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly ipcCallId?: number;
|
||||
readonly itemCount?: number | null;
|
||||
readonly method?: string;
|
||||
readonly operation?: string;
|
||||
readonly operationId?: string;
|
||||
readonly playlistId?: string;
|
||||
readonly operationId?: string | null;
|
||||
readonly outcome?: 'error' | 'success' | null;
|
||||
readonly phase?: string;
|
||||
readonly playlistId?: string | null;
|
||||
readonly requestId?: string;
|
||||
readonly sessionId?: string | null;
|
||||
readonly success?: boolean;
|
||||
readonly sourceEpochMs?: number;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
@@ -93,15 +113,19 @@ export interface WorkerRequestPerformanceMetrics {
|
||||
readonly eventLoopUtilization: number | null;
|
||||
readonly eventLoopUtilizationUnavailableReason: string | null;
|
||||
readonly histogramFlushedEpochMs: number | null;
|
||||
readonly ipcCallId: number | null;
|
||||
readonly invalidReason: string | null;
|
||||
readonly operation: string | null;
|
||||
readonly operationId: string | null;
|
||||
readonly operationIdUnavailableReason: string | null;
|
||||
readonly performanceCaptureUnavailableReason: string | null;
|
||||
readonly phaseEvents: readonly PerformancePhaseEvent<DatabaseWorkerPerformancePhase>[];
|
||||
readonly playlistId: string | null;
|
||||
readonly requestId: string | null;
|
||||
readonly requestReceivedEpochMs: number | null;
|
||||
readonly responseEpochMs: number;
|
||||
readonly responsePostedEpochMs: number | null;
|
||||
readonly sourceEpochMs: number | null;
|
||||
readonly success: boolean;
|
||||
readonly threadCpuSystemMicros: number | null;
|
||||
readonly threadCpuUnavailableReason: string | null;
|
||||
@@ -117,11 +141,19 @@ export type WorkerCaptureMetrics = {
|
||||
readonly eventLoopDelay: EventLoopDelayMetrics | null;
|
||||
readonly eventLoopDelayUnavailableReason: string | null;
|
||||
readonly eventLoopUtilization: number | null;
|
||||
readonly externalMemorySampleCount: number;
|
||||
readonly heapUsedSampleCount: number;
|
||||
readonly kind: PerformanceWorkerKind;
|
||||
readonly operationId: string | null;
|
||||
readonly ordinal: number;
|
||||
readonly peakExternalBytes: number;
|
||||
readonly peakHeapUsedBytes: number;
|
||||
readonly peakExternalBytes: number | null;
|
||||
readonly peakExternalUnavailableReason:
|
||||
| typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING
|
||||
| null;
|
||||
readonly peakHeapUsedBytes: number | null;
|
||||
readonly peakHeapUsedUnavailableReason:
|
||||
| typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.HEAP_USED_SAMPLES_MISSING
|
||||
| null;
|
||||
readonly playlistId: string | null;
|
||||
readonly profilePath: string | null;
|
||||
readonly requests: readonly WorkerRequestPerformanceMetrics[];
|
||||
@@ -257,22 +289,6 @@ export interface DatabaseWorkerPostGcValidity {
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
export interface InvalidRendererRssMeasuredRun {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason:
|
||||
RendererProcessRssUnavailableReason | 'renderer-rss-capture-invalid';
|
||||
readonly runId: string;
|
||||
readonly validSampleCount: number;
|
||||
}
|
||||
|
||||
export interface RendererRssValidity {
|
||||
readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForBenchmark: boolean;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
export interface CancellationBenchmarkSummary {
|
||||
readonly cancellationEffectRate: number;
|
||||
readonly iterations: readonly CancellationIterationResult[];
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
NumericDistribution,
|
||||
PerformanceWorkerKind,
|
||||
RendererCaptureMetrics,
|
||||
RendererRssValidity,
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import {
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
summarizeNumbers,
|
||||
} from './performance-statistics';
|
||||
import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity';
|
||||
import { assessRendererRssValidity } from './renderer-rss-validity';
|
||||
|
||||
export function createCancellationIterationResult(input: {
|
||||
readonly kind: CancellationIterationResult['kind'];
|
||||
@@ -316,50 +316,6 @@ export function createCancellationBenchmarkSummary(
|
||||
});
|
||||
}
|
||||
|
||||
function assessRendererRssValidity(
|
||||
iterations: readonly CancellationIterationResult[]
|
||||
): RendererRssValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED
|
||||
);
|
||||
const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] =
|
||||
[];
|
||||
let validMeasuredRunCount = 0;
|
||||
|
||||
for (const iteration of measured) {
|
||||
const rss = iteration.main.rendererWindow.rss;
|
||||
if (
|
||||
rss.identity !== null &&
|
||||
Number.isSafeInteger(rss.peakRssBytes) &&
|
||||
Number(rss.peakRssBytes) > 0 &&
|
||||
rss.unavailableReason === null &&
|
||||
rss.missingSampleCount === 0 &&
|
||||
Number.isSafeInteger(rss.validSampleCount) &&
|
||||
rss.validSampleCount > 0
|
||||
) {
|
||||
validMeasuredRunCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
invalidMeasuredRuns.push({
|
||||
missingSampleCount: rss.missingSampleCount,
|
||||
reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid',
|
||||
runId: iteration.runId,
|
||||
validSampleCount: rss.validSampleCount,
|
||||
});
|
||||
}
|
||||
|
||||
const valid =
|
||||
measured.length > 0 && validMeasuredRunCount === measured.length;
|
||||
return Object.freeze({
|
||||
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
|
||||
measuredRunCount: measured.length,
|
||||
validForBenchmark: valid,
|
||||
validForComparison: valid,
|
||||
validMeasuredRunCount,
|
||||
});
|
||||
}
|
||||
|
||||
function derivePhases(
|
||||
main: MainCaptureMetrics,
|
||||
renderer: RendererCaptureMetrics
|
||||
|
||||
@@ -186,7 +186,10 @@ async function runIteration(
|
||||
): Promise<CancellationIterationResult> {
|
||||
const iterationDirectory = join(config.outputDirectory, definition.runId);
|
||||
await assertPerformanceArtifactCapacity(iterationDirectory);
|
||||
await mkdir(iterationDirectory, { recursive: false });
|
||||
await mkdir(iterationDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
const dataDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-m3u-performance-')
|
||||
);
|
||||
@@ -204,6 +207,7 @@ async function runIteration(
|
||||
`--remote-debugging-port=${RENDERER_CDP_PORT}`,
|
||||
`--user-data-dir=${join(dataDirectory, 'user-data')}`,
|
||||
],
|
||||
environmentInheritance: 'runtime-only',
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
@@ -448,7 +452,10 @@ async function resolveConfiguration(): Promise<BenchmarkConfiguration> {
|
||||
const outputDirectory = join(outputRoot, variant);
|
||||
await assertMissing(outputDirectory);
|
||||
await assertPerformanceArtifactCapacity(outputDirectory);
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
await mkdir(outputDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: true,
|
||||
});
|
||||
return Object.freeze({
|
||||
electronVersion: electronPackage.version,
|
||||
gitCommit: sourceState.commit,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */
|
||||
import { once } from 'node:events';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
import type { CDPSession, Page } from '@playwright/test';
|
||||
|
||||
@@ -106,7 +106,8 @@ export async function startRendererCapture(
|
||||
await session.send('Profiler.enable');
|
||||
await session.send('Profiler.start');
|
||||
traceOutput = createWriteStream(
|
||||
requireArtifactPath(tracePath, 'renderer trace')
|
||||
requireArtifactPath(tracePath, 'renderer trace'),
|
||||
{ flags: 'wx', mode: 0o600 }
|
||||
);
|
||||
traceOutput.write('{"traceEvents":[');
|
||||
session.on('Tracing.dataCollected', onTraceData);
|
||||
@@ -143,7 +144,7 @@ export async function startRendererCapture(
|
||||
await writeFile(
|
||||
requireArtifactPath(cpuProfilePath, 'renderer CPU profile'),
|
||||
JSON.stringify(cpuProfile['profile']),
|
||||
'utf8'
|
||||
{ encoding: 'utf8', flag: 'wx', mode: 0o600 }
|
||||
);
|
||||
await session.send('Tracing.end');
|
||||
await traceComplete;
|
||||
@@ -152,7 +153,7 @@ export async function startRendererCapture(
|
||||
traceOutput?.write(']}');
|
||||
traceOutput?.end();
|
||||
if (traceOutput) {
|
||||
await once(traceOutput, 'finish');
|
||||
await finished(traceOutput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +168,8 @@ export async function startRendererCapture(
|
||||
requireArtifactPath(
|
||||
heapSnapshotPath,
|
||||
'renderer heap snapshot'
|
||||
)
|
||||
),
|
||||
{ flags: 'wx', mode: 0o600 }
|
||||
);
|
||||
const onSnapshotChunk = (message: {
|
||||
readonly chunk: string;
|
||||
@@ -186,7 +188,7 @@ export async function startRendererCapture(
|
||||
onSnapshotChunk
|
||||
);
|
||||
output.end();
|
||||
await once(output, 'finish');
|
||||
await finished(output);
|
||||
}
|
||||
|
||||
await session.detach();
|
||||
@@ -211,8 +213,7 @@ export async function startRendererCapture(
|
||||
unknown
|
||||
>;
|
||||
const state = target[stateKey] as
|
||||
| { cancelClickEpochMs: number | null }
|
||||
| undefined;
|
||||
{ cancelClickEpochMs: number | null } | undefined;
|
||||
return typeof state?.cancelClickEpochMs === 'number';
|
||||
},
|
||||
RENDERER_CAPTURE_STATE_KEY,
|
||||
@@ -228,8 +229,7 @@ export async function startRendererCapture(
|
||||
unknown
|
||||
>;
|
||||
const state = target[stateKey] as
|
||||
| { uiPaintedEpochMs: number | null }
|
||||
| undefined;
|
||||
{ uiPaintedEpochMs: number | null } | undefined;
|
||||
return typeof state?.uiPaintedEpochMs === 'number';
|
||||
},
|
||||
RENDERER_CAPTURE_STATE_KEY,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
function section(startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker);
|
||||
const end = source.indexOf(endMarker, start);
|
||||
assert.ok(start >= 0, `missing start marker: ${startMarker}`);
|
||||
assert.ok(end > start, `missing end marker: ${endMarker}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('samples main and worker memory from one shared interval scheduler', () => {
|
||||
const workerRecord = section(
|
||||
'interface WorkerRecord',
|
||||
'interface TimelineRecord'
|
||||
);
|
||||
const resetWorker = section(
|
||||
'const resetWorkerForCapture',
|
||||
'const startWorker'
|
||||
);
|
||||
const startWorker = section(
|
||||
'const startWorker',
|
||||
'const stopWorkerSampling'
|
||||
);
|
||||
const stopWorker = section(
|
||||
'const stopWorkerSampling',
|
||||
'const joinFinalWorkerSample'
|
||||
);
|
||||
const joinFinalWorker = section(
|
||||
'const joinFinalWorkerSample',
|
||||
'const stopWorkerProfile'
|
||||
);
|
||||
const sampleMain = section('const sampleMain', 'const startCapture');
|
||||
const beginMeasurement = section(
|
||||
'const beginMeasurement = async',
|
||||
'const startCapture'
|
||||
);
|
||||
const armCapture = section('const startCapture', 'const api =');
|
||||
const stopCapture = section(
|
||||
'stop: async (',
|
||||
'xtreamStatus: (): XtreamMainCaptureStatus'
|
||||
);
|
||||
|
||||
assert.match(workerRecord, /samplingStarted: boolean/);
|
||||
assert.doesNotMatch(workerRecord, /sampleTimer/);
|
||||
assert.match(resetWorker, /record\.samplingStarted = false/);
|
||||
assert.match(startWorker, /if \(record\.samplingStarted\)/);
|
||||
assert.match(
|
||||
startWorker,
|
||||
/record\.samplingStarted = true[\s\S]*const initialSample = sampleWorker\(record\)/
|
||||
);
|
||||
assert.doesNotMatch(startWorker, /setInterval/);
|
||||
assert.match(stopWorker, /record\.samplingStarted = false/);
|
||||
assert.match(joinFinalWorker, /await sampleWorker\(record\)/);
|
||||
assert.match(
|
||||
sampleMain,
|
||||
/for \(const record of records\.values\(\)\)[\s\S]*record\.samplingStarted &&[\s\S]*isCurrentCaptureRecord\(record\)[\s\S]*void sampleWorker\(record\)/
|
||||
);
|
||||
assert.match(
|
||||
beginMeasurement,
|
||||
/sampleMain\(\)[\s\S]*state\.sampleTimer = setInterval\(sampleMain, 20\)/
|
||||
);
|
||||
assert.doesNotMatch(armCapture, /setInterval\(sampleMain/);
|
||||
assert.match(
|
||||
stopCapture,
|
||||
/clearInterval\(state\.sampleTimer\)[\s\S]*state\.sampleTimer = null[\s\S]*await startCapture\(nextOptions\)/
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
source.match(/\bsetInterval\s*\(/g)?.length,
|
||||
1,
|
||||
'the injected main capture must own exactly one interval timer'
|
||||
);
|
||||
});
|
||||
@@ -270,3 +270,34 @@ test('the cancellation benchmark exposes GC to Electron worker isolates', () =>
|
||||
assert.match(source, /'--js-flags=--expose-gc'/);
|
||||
assert.doesNotMatch(source, /execArgv/);
|
||||
});
|
||||
|
||||
test('the initial M3U import benchmark uses the production-equivalent performance build', () => {
|
||||
const target = e2eProject.targets['benchmark-m3u-import'];
|
||||
|
||||
assert.ok(target, 'electron-backend-e2e must define benchmark-m3u-import');
|
||||
assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']);
|
||||
});
|
||||
|
||||
test('the initial M3U import benchmark command is pinned to its Playwright test file', () => {
|
||||
const target = e2eProject.targets['benchmark-m3u-import'];
|
||||
|
||||
assert.equal(
|
||||
target.options?.['command'],
|
||||
'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts'
|
||||
);
|
||||
});
|
||||
|
||||
test('the initial M3U import benchmark enables opt-in capture and worker GC', () => {
|
||||
const source = readFileSync(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/);
|
||||
assert.match(source, /IPTVNATOR_PERF_WORKER_PROFILING:\s*'1'/);
|
||||
assert.match(source, /'--js-flags=--expose-gc'/);
|
||||
assert.doesNotMatch(source, /execArgv/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Phase duration and epoch endpoints are read from separate clocks in separate
|
||||
* calls. The enclosing request/operation window is the causal upper bound.
|
||||
*/
|
||||
export function fitsPerformanceDurationEnvelope(
|
||||
durationMs: number,
|
||||
envelopeStartEpochMs: number,
|
||||
envelopeEndEpochMs: number
|
||||
): boolean {
|
||||
return (
|
||||
Number.isFinite(durationMs) &&
|
||||
durationMs >= 0 &&
|
||||
Number.isFinite(envelopeStartEpochMs) &&
|
||||
Number.isFinite(envelopeEndEpochMs) &&
|
||||
envelopeEndEpochMs >= envelopeStartEpochMs &&
|
||||
durationMs <= envelopeEndEpochMs - envelopeStartEpochMs
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createDiagnosticsPerformancePhaseEventParser,
|
||||
pairPerformancePhaseEvents,
|
||||
requirePerformancePhase,
|
||||
} from './performance-phase-events';
|
||||
|
||||
describe('performance phase event pairing', () => {
|
||||
it('restores and invokes the diagnostics parser without module-scoped helpers', () => {
|
||||
const restore = <T>(source: string): T =>
|
||||
new Function(`"use strict"; return (${source});`)() as T;
|
||||
const restored = restore<
|
||||
typeof createDiagnosticsPerformancePhaseEventParser
|
||||
>(createDiagnosticsPerformancePhaseEventParser.toString());
|
||||
const parse = restored({
|
||||
allowedPhases: ['xtream.network.total'],
|
||||
timelineType: 'xtream-main-phase',
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
parse({
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'xtream.network.total',
|
||||
requestId: 'xtream-mock1-1',
|
||||
}),
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'xtream.network.total',
|
||||
requestId: 'xtream-mock1-1',
|
||||
type: 'xtream-main-phase',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the valid M3U diagnostics normalization byte-identical when subscriptions are parameterized', () => {
|
||||
const parse = createDiagnosticsPerformancePhaseEventParser({
|
||||
allowedPhases: ['data.acquire', 'parse.m3u', 'normalize'],
|
||||
timelineType: 'm3u-import-phase',
|
||||
});
|
||||
const incoming = {
|
||||
boundary: 'end',
|
||||
durationMs: 12.5,
|
||||
epochMs: 113,
|
||||
metadata: { byteCount: 2048, itemCount: 10 },
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
};
|
||||
const legacyNormalized = {
|
||||
boundary: 'end',
|
||||
byteCount: 2048,
|
||||
durationMs: 12.5,
|
||||
epochMs: 113,
|
||||
itemCount: 10,
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
type: 'm3u-import-phase',
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
JSON.stringify(parse(incoming)),
|
||||
JSON.stringify(legacyNormalized)
|
||||
);
|
||||
assert.equal(parse({ ...incoming, secret: 'not allowed' }), null);
|
||||
assert.equal(parse({ ...incoming, metadata: { itemCount: -1 } }), null);
|
||||
});
|
||||
|
||||
it('pairs request-scoped boundaries and preserves end metadata', () => {
|
||||
const phases = pairPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 12.5,
|
||||
epochMs: 113,
|
||||
metadata: { byteCount: 2048, itemCount: 10 },
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(phases, [
|
||||
{
|
||||
durationMs: 12.5,
|
||||
endedEpochMs: 113,
|
||||
metadata: { byteCount: 2048, itemCount: 10 },
|
||||
phase: 'data.acquire',
|
||||
requestId: 'request-1',
|
||||
startedEpochMs: 100,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
requirePerformancePhase(phases, 'request-1', 'data.acquire'),
|
||||
phases[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('pairs interleaved requests without cross-correlating them', () => {
|
||||
const phases = pairPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-a',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 101,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-b',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 3,
|
||||
epochMs: 104,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-b',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 8,
|
||||
epochMs: 108,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-a',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
phases.map(({ durationMs, requestId }) => ({
|
||||
durationMs,
|
||||
requestId,
|
||||
})),
|
||||
[
|
||||
{ durationMs: 8, requestId: 'request-a' },
|
||||
{ durationMs: 3, requestId: 'request-b' },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for orphaned, duplicate, malformed, or reversed boundaries', () => {
|
||||
const validStart = {
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
} as const;
|
||||
const validEnd = {
|
||||
boundary: 'end',
|
||||
durationMs: 2,
|
||||
epochMs: 102,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
} as const;
|
||||
|
||||
for (const invalid of [
|
||||
[validStart],
|
||||
[validStart, validStart, validEnd],
|
||||
[validStart, { ...validEnd, durationMs: null }],
|
||||
[validStart, { ...validEnd, epochMs: 99 }],
|
||||
[{ ...validStart, durationMs: 0 }, validEnd],
|
||||
[
|
||||
validStart,
|
||||
{
|
||||
...validEnd,
|
||||
metadata: { byteCount: -1 },
|
||||
},
|
||||
],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => pairPerformancePhaseEvents(invalid),
|
||||
/performance-phase-events-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires one exact request and phase match', () => {
|
||||
const phases = pairPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 100,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 2,
|
||||
epochMs: 102,
|
||||
phase: 'normalize',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.throws(
|
||||
() => requirePerformancePhase(phases, 'request-2', 'normalize'),
|
||||
/performance-phase-missing:request-2:normalize/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
requirePerformancePhase(
|
||||
[...phases, ...phases],
|
||||
'request-1',
|
||||
'normalize'
|
||||
),
|
||||
/performance-phase-ambiguous:request-1:normalize/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { PerformancePhaseMetadata } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface PairedPerformancePhase {
|
||||
readonly durationMs: number;
|
||||
readonly endedEpochMs: number;
|
||||
readonly metadata: PerformancePhaseMetadata | null;
|
||||
readonly phase: string;
|
||||
readonly requestId: string;
|
||||
readonly startedEpochMs: number;
|
||||
}
|
||||
|
||||
interface ParsedPerformancePhaseEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly durationMs: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly metadata: PerformancePhaseMetadata | null;
|
||||
readonly phase: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticsPerformancePhaseEventParserOptions {
|
||||
readonly allowedPhases: readonly string[];
|
||||
readonly timelineType: string;
|
||||
}
|
||||
|
||||
export interface DiagnosticsPerformancePhaseTimelineEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly byteCount?: number;
|
||||
readonly durationMs: number | null;
|
||||
readonly epochMs: number;
|
||||
readonly itemCount?: number;
|
||||
readonly phase: string;
|
||||
readonly requestId: string;
|
||||
readonly type: string;
|
||||
}
|
||||
|
||||
export function createDiagnosticsPerformancePhaseEventParser(
|
||||
options: DiagnosticsPerformancePhaseEventParserOptions
|
||||
): (input: unknown) => DiagnosticsPerformancePhaseTimelineEvent | null {
|
||||
const allowedPhases = new Set(options.allowedPhases);
|
||||
const eventKeys = new Set([
|
||||
'boundary',
|
||||
'durationMs',
|
||||
'epochMs',
|
||||
'metadata',
|
||||
'phase',
|
||||
'requestId',
|
||||
]);
|
||||
const metadataKeys = new Set(['byteCount', 'itemCount']);
|
||||
const helpers = {
|
||||
isCount(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
},
|
||||
isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (input: unknown) => {
|
||||
if (
|
||||
!helpers.isRecord(input) ||
|
||||
Object.keys(input).some((key) => !eventKeys.has(key))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const boundary = input['boundary'];
|
||||
const durationMs = input['durationMs'];
|
||||
const epochMs = input['epochMs'];
|
||||
const metadata = input['metadata'];
|
||||
const phase = input['phase'];
|
||||
const requestId = input['requestId'];
|
||||
if (
|
||||
(boundary !== 'start' && boundary !== 'end') ||
|
||||
typeof epochMs !== 'number' ||
|
||||
!Number.isFinite(epochMs) ||
|
||||
epochMs < 0 ||
|
||||
typeof phase !== 'string' ||
|
||||
!allowedPhases.has(phase) ||
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length === 0 ||
|
||||
(boundary === 'start' && durationMs !== null) ||
|
||||
(boundary === 'end' &&
|
||||
(typeof durationMs !== 'number' ||
|
||||
!Number.isFinite(durationMs) ||
|
||||
durationMs < 0))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
metadata !== undefined &&
|
||||
(!helpers.isRecord(metadata) ||
|
||||
Object.keys(metadata).some((key) => !metadataKeys.has(key)) ||
|
||||
Object.values(metadata).some(
|
||||
(value) => !helpers.isCount(value)
|
||||
))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const byteCount = helpers.isRecord(metadata)
|
||||
? metadata['byteCount']
|
||||
: undefined;
|
||||
const itemCount = helpers.isRecord(metadata)
|
||||
? metadata['itemCount']
|
||||
: undefined;
|
||||
return {
|
||||
boundary,
|
||||
...(helpers.isCount(byteCount) ? { byteCount } : {}),
|
||||
durationMs: durationMs as number | null,
|
||||
epochMs,
|
||||
...(helpers.isCount(itemCount) ? { itemCount } : {}),
|
||||
phase,
|
||||
requestId,
|
||||
type: options.timelineType,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function pairPerformancePhaseEvents(
|
||||
input: readonly unknown[]
|
||||
): readonly PairedPerformancePhase[] {
|
||||
const grouped = new Map<string, ParsedPerformancePhaseEvent[]>();
|
||||
|
||||
for (const value of input) {
|
||||
const event = parsePerformancePhaseEvent(value);
|
||||
const key = `${event.requestId}\u0000${event.phase}`;
|
||||
const events = grouped.get(key) ?? [];
|
||||
events.push(event);
|
||||
if (events.length > 2) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
grouped.set(key, events);
|
||||
}
|
||||
|
||||
const paired: PairedPerformancePhase[] = [];
|
||||
for (const events of grouped.values()) {
|
||||
const start = events.find((event) => event.boundary === 'start');
|
||||
const end = events.find((event) => event.boundary === 'end');
|
||||
if (
|
||||
events.length !== 2 ||
|
||||
!start ||
|
||||
!end ||
|
||||
start.durationMs !== null ||
|
||||
start.metadata !== null ||
|
||||
end.durationMs === null ||
|
||||
end.epochMs < start.epochMs
|
||||
) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
paired.push(
|
||||
Object.freeze({
|
||||
durationMs: end.durationMs,
|
||||
endedEpochMs: end.epochMs,
|
||||
metadata: end.metadata,
|
||||
phase: start.phase,
|
||||
requestId: start.requestId,
|
||||
startedEpochMs: start.epochMs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
paired.sort(
|
||||
(left, right) =>
|
||||
left.startedEpochMs - right.startedEpochMs ||
|
||||
left.endedEpochMs - right.endedEpochMs ||
|
||||
left.requestId.localeCompare(right.requestId) ||
|
||||
left.phase.localeCompare(right.phase)
|
||||
);
|
||||
return Object.freeze(paired);
|
||||
}
|
||||
|
||||
export function requirePerformancePhase(
|
||||
phases: readonly PairedPerformancePhase[],
|
||||
requestId: string,
|
||||
phase: string
|
||||
): PairedPerformancePhase {
|
||||
const matches = phases.filter(
|
||||
(candidate) =>
|
||||
candidate.requestId === requestId && candidate.phase === phase
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`performance-phase-missing:${requestId}:${phase}`);
|
||||
}
|
||||
if (matches.length !== 1 || !matches[0]) {
|
||||
throw new Error(`performance-phase-ambiguous:${requestId}:${phase}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function parsePerformancePhaseEvent(
|
||||
value: unknown
|
||||
): ParsedPerformancePhaseEvent {
|
||||
if (!isRecord(value)) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
const boundary = value['boundary'];
|
||||
const durationMs = value['durationMs'];
|
||||
const epochMs = value['epochMs'];
|
||||
const phase = value['phase'];
|
||||
const requestId = value['requestId'];
|
||||
if (
|
||||
(boundary !== 'start' && boundary !== 'end') ||
|
||||
(durationMs !== null && !isFiniteNonNegativeNumber(durationMs)) ||
|
||||
!isFiniteNonNegativeNumber(epochMs) ||
|
||||
typeof phase !== 'string' ||
|
||||
phase.length === 0 ||
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length === 0
|
||||
) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
|
||||
return {
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
metadata: parseMetadata(value['metadata']),
|
||||
phase,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): PerformancePhaseMetadata | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
const keys = Object.keys(value);
|
||||
if (
|
||||
keys.some((key) => key !== 'byteCount' && key !== 'itemCount') ||
|
||||
keys.some((key) => !isSafeCount(value[key]))
|
||||
) {
|
||||
throw invalidPhaseEvents();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(value['byteCount'] === undefined
|
||||
? {}
|
||||
: { byteCount: Number(value['byteCount']) }),
|
||||
...(value['itemCount'] === undefined
|
||||
? {}
|
||||
: { itemCount: Number(value['itemCount']) }),
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isSafeCount(value: unknown): boolean {
|
||||
return (
|
||||
value === undefined ||
|
||||
(Number.isSafeInteger(value) && Number(value) >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
function invalidPhaseEvents(): Error {
|
||||
return new Error('performance-phase-events-invalid');
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, test } from 'node:test';
|
||||
|
||||
import {
|
||||
createRendererArtifactCapture,
|
||||
startRendererDiagnosticCapture,
|
||||
stopRendererDiagnosticCapture,
|
||||
takeRendererHeapSnapshot,
|
||||
} from './m3u-import-renderer-artifacts';
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
});
|
||||
|
||||
test('creates renderer raw profiles exclusively with owner-only permissions', async () => {
|
||||
const directory = await createDirectory();
|
||||
const session = new FakeCdpSession();
|
||||
const artifacts = createRendererArtifactCapture({
|
||||
diagnostic: true,
|
||||
outputDirectory: directory,
|
||||
});
|
||||
|
||||
await startRendererDiagnosticCapture(session as never, artifacts);
|
||||
await stopRendererDiagnosticCapture(session as never, artifacts);
|
||||
await takeRendererHeapSnapshot(
|
||||
session as never,
|
||||
artifacts.heapSnapshotPath
|
||||
);
|
||||
|
||||
for (const filename of [
|
||||
'renderer.cpuprofile',
|
||||
'renderer.heapsnapshot',
|
||||
'renderer.trace.json',
|
||||
]) {
|
||||
assert.equal(
|
||||
(await stat(join(directory, filename))).mode & 0o777,
|
||||
0o600,
|
||||
filename
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('never replaces an existing renderer CPU profile', async () => {
|
||||
const directory = await createDirectory();
|
||||
const profilePath = join(directory, 'renderer.cpuprofile');
|
||||
await writeFile(profilePath, 'original', 'utf8');
|
||||
const session = new FakeCdpSession();
|
||||
const artifacts = createRendererArtifactCapture({
|
||||
diagnostic: true,
|
||||
outputDirectory: directory,
|
||||
});
|
||||
|
||||
await startRendererDiagnosticCapture(session as never, artifacts);
|
||||
await assert.rejects(
|
||||
stopRendererDiagnosticCapture(session as never, artifacts),
|
||||
{ code: 'EEXIST' }
|
||||
);
|
||||
assert.equal(await readFile(profilePath, 'utf8'), 'original');
|
||||
});
|
||||
|
||||
test('uses exclusive owner-only writes for main and worker raw artifacts', async () => {
|
||||
const source = await readFile(
|
||||
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/fs\.writeFileSync\(\s*record\.profilePath,[\s\S]{0,220}flag: 'wx',[\s\S]{0,80}mode: 0o600/
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/fs\.createWriteStream\(record\.snapshotPath,\s*\{[\s\S]{0,80}flags: 'wx',[\s\S]{0,80}mode: 0o600/
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/fs\.writeFileSync\(\s*state\.mainProfilePath,[\s\S]{0,220}flag: 'wx',[\s\S]{0,80}mode: 0o600/
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/fs\.createWriteStream\(\s*state\.mainSnapshotPath,\s*\{[\s\S]{0,80}flags: 'wx',[\s\S]{0,80}mode: 0o600/
|
||||
);
|
||||
});
|
||||
|
||||
class FakeCdpSession extends EventEmitter {
|
||||
async send(method: string): Promise<Record<string, unknown>> {
|
||||
if (method === 'Profiler.stop') {
|
||||
return {
|
||||
profile: {
|
||||
endTime: 2,
|
||||
nodes: [{ callFrame: {}, hitCount: 1, id: 1 }],
|
||||
samples: [1],
|
||||
startTime: 1,
|
||||
timeDeltas: [1],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'Tracing.end') {
|
||||
this.emit('Tracing.tracingComplete');
|
||||
}
|
||||
if (method === 'HeapProfiler.takeHeapSnapshot') {
|
||||
this.emit('HeapProfiler.addHeapSnapshotChunk', {
|
||||
chunk: '{"snapshot":{}}',
|
||||
});
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function createDirectory(): Promise<string> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-performance-artifact-')
|
||||
);
|
||||
directories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createPerformanceTimelineMergeApi,
|
||||
type PerformanceTimelineMergeApi,
|
||||
} from './performance-timeline-merge';
|
||||
|
||||
describe('performance timeline merge', () => {
|
||||
it('restores without module closures for Electron-main injection', () => {
|
||||
const source = createPerformanceTimelineMergeApi.toString();
|
||||
assert.doesNotMatch(source, /__name/);
|
||||
const factory = new Function(
|
||||
`"use strict"; return (${source});`
|
||||
)() as typeof createPerformanceTimelineMergeApi;
|
||||
assert.equal(factory().merge([], []).length, 0);
|
||||
});
|
||||
|
||||
it('inserts Xtream records by epoch without reordering existing M3U JSON', () => {
|
||||
const api: PerformanceTimelineMergeApi =
|
||||
createPerformanceTimelineMergeApi();
|
||||
const baseline = [
|
||||
{ epochMs: 100, type: 'm3u-a' },
|
||||
{ epochMs: 300, type: 'm3u-b' },
|
||||
{ epochMs: 300, type: 'm3u-c' },
|
||||
];
|
||||
const additions = [
|
||||
{ epochMs: 300, type: 'xtream-c' },
|
||||
{ epochMs: 50, type: 'xtream-a' },
|
||||
{ epochMs: 200, type: 'xtream-b' },
|
||||
{ epochMs: 400, type: 'xtream-d' },
|
||||
];
|
||||
|
||||
const merged = api.merge(baseline, additions);
|
||||
|
||||
assert.deepEqual(
|
||||
merged.map(({ type }) => type),
|
||||
[
|
||||
'xtream-a',
|
||||
'm3u-a',
|
||||
'xtream-b',
|
||||
'm3u-b',
|
||||
'm3u-c',
|
||||
'xtream-c',
|
||||
'xtream-d',
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
JSON.stringify(merged.filter(({ type }) => type.startsWith('m3u'))),
|
||||
JSON.stringify(baseline)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface PerformanceTimelineRecord {
|
||||
readonly epochMs: number;
|
||||
}
|
||||
|
||||
export interface PerformanceTimelineMergeApi {
|
||||
merge<T extends PerformanceTimelineRecord>(
|
||||
baseline: readonly T[],
|
||||
additions: readonly T[]
|
||||
): T[];
|
||||
}
|
||||
|
||||
export function createPerformanceTimelineMergeApi(): PerformanceTimelineMergeApi {
|
||||
const helpers = {
|
||||
compare(
|
||||
left: {
|
||||
readonly index: number;
|
||||
readonly record: PerformanceTimelineRecord;
|
||||
},
|
||||
right: {
|
||||
readonly index: number;
|
||||
readonly record: PerformanceTimelineRecord;
|
||||
}
|
||||
): number {
|
||||
return (
|
||||
left.record.epochMs - right.record.epochMs ||
|
||||
left.index - right.index
|
||||
);
|
||||
},
|
||||
};
|
||||
const api: PerformanceTimelineMergeApi = {
|
||||
merge<T extends PerformanceTimelineRecord>(
|
||||
baseline: readonly T[],
|
||||
additions: readonly T[]
|
||||
): T[] {
|
||||
const indexed: Array<{ index: number; record: T }> = [];
|
||||
for (let index = 0; index < additions.length; index += 1) {
|
||||
indexed.push({ index, record: additions[index] as T });
|
||||
}
|
||||
indexed.sort(helpers.compare);
|
||||
|
||||
const merged: T[] = [];
|
||||
let additionIndex = 0;
|
||||
for (const record of baseline) {
|
||||
while (
|
||||
additionIndex < indexed.length &&
|
||||
(indexed[additionIndex]?.record.epochMs ??
|
||||
Number.POSITIVE_INFINITY) < record.epochMs
|
||||
) {
|
||||
merged.push(indexed[additionIndex]?.record as T);
|
||||
additionIndex += 1;
|
||||
}
|
||||
merged.push(record);
|
||||
}
|
||||
while (additionIndex < indexed.length) {
|
||||
merged.push(indexed[additionIndex]?.record as T);
|
||||
additionIndex += 1;
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
};
|
||||
return Object.freeze(api);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
pairRendererPerformancePhaseEvents,
|
||||
requireRendererPerformancePhase,
|
||||
} from './renderer-performance-phase-events';
|
||||
|
||||
describe('renderer performance phase event pairing', () => {
|
||||
it('pairs a successful renderer phase using monotonic duration', () => {
|
||||
const phases = pairRendererPerformancePhaseEvents([
|
||||
{
|
||||
boundary: 'start',
|
||||
epochMs: 100,
|
||||
metadata: { items: 10_000 },
|
||||
monotonicMs: 20,
|
||||
phase: 'store.m3u-publish-channels',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
epochMs: 106,
|
||||
metadata: { items: 10_000 },
|
||||
monotonicMs: 25.5,
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(phases, [
|
||||
{
|
||||
durationMs: 5.5,
|
||||
endedEpochMs: 106,
|
||||
metadata: { items: 10_000 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-publish-channels',
|
||||
phaseId: 'phase-1',
|
||||
startedEpochMs: 100,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
requireRendererPerformancePhase(
|
||||
phases,
|
||||
'store.m3u-publish-channels'
|
||||
),
|
||||
phases[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for inconsistent identity, metadata, outcome, and clocks', () => {
|
||||
const start = {
|
||||
boundary: 'start',
|
||||
epochMs: 100,
|
||||
metadata: { items: 50_000 },
|
||||
monotonicMs: 20,
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
} as const;
|
||||
const end = {
|
||||
boundary: 'end',
|
||||
epochMs: 102,
|
||||
metadata: { items: 50_000 },
|
||||
monotonicMs: 22,
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
phaseId: 'phase-1',
|
||||
schemaVersion: 1,
|
||||
} as const;
|
||||
|
||||
for (const invalid of [
|
||||
[start],
|
||||
[start, start, end],
|
||||
[start, { ...end, phase: 'store.m3u-publish-channels' }],
|
||||
[start, { ...end, metadata: { items: 10_000 } }],
|
||||
[{ ...start, outcome: 'success' }, end],
|
||||
[start, { ...end, outcome: undefined }],
|
||||
[start, { ...end, epochMs: 99 }],
|
||||
[start, { ...end, monotonicMs: 19 }],
|
||||
[start, { ...end, metadata: { items: -1 } }],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => pairRendererPerformancePhaseEvents(invalid),
|
||||
/renderer-performance-phase-events-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires exactly one successful event for a named phase', () => {
|
||||
const successful = {
|
||||
durationMs: 2,
|
||||
endedEpochMs: 102,
|
||||
metadata: { items: 10_000 },
|
||||
outcome: 'success',
|
||||
phase: 'store.m3u-import-dispatch',
|
||||
phaseId: 'phase-1',
|
||||
startedEpochMs: 100,
|
||||
} as const;
|
||||
const failed = {
|
||||
...successful,
|
||||
outcome: 'error',
|
||||
phaseId: 'phase-2',
|
||||
} as const;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
requireRendererPerformancePhase(
|
||||
[successful],
|
||||
'store.m3u-publish-channels'
|
||||
),
|
||||
/renderer-performance-phase-missing:store.m3u-publish-channels/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
requireRendererPerformancePhase(
|
||||
[successful, successful],
|
||||
'store.m3u-import-dispatch'
|
||||
),
|
||||
/renderer-performance-phase-ambiguous:store.m3u-import-dispatch/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
requireRendererPerformancePhase(
|
||||
[failed],
|
||||
'store.m3u-import-dispatch'
|
||||
),
|
||||
/renderer-performance-phase-failed:store.m3u-import-dispatch/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
export interface PairedRendererPerformancePhase {
|
||||
readonly durationMs: number;
|
||||
readonly endedEpochMs: number;
|
||||
readonly metadata: Readonly<{ items: number }> | null;
|
||||
readonly outcome: 'error' | 'success';
|
||||
readonly phase: string;
|
||||
readonly phaseId: string;
|
||||
readonly startedEpochMs: number;
|
||||
}
|
||||
|
||||
interface ParsedRendererPerformancePhaseEvent {
|
||||
readonly boundary: 'end' | 'start';
|
||||
readonly epochMs: number;
|
||||
readonly metadata: Readonly<{ items: number }> | null;
|
||||
readonly monotonicMs: number;
|
||||
readonly outcome: 'error' | 'success' | null;
|
||||
readonly phase: string;
|
||||
readonly phaseId: string;
|
||||
}
|
||||
|
||||
export function pairRendererPerformancePhaseEvents(
|
||||
input: readonly unknown[]
|
||||
): readonly PairedRendererPerformancePhase[] {
|
||||
const grouped = new Map<string, ParsedRendererPerformancePhaseEvent[]>();
|
||||
|
||||
for (const value of input) {
|
||||
const event = parseRendererPerformancePhaseEvent(value);
|
||||
const events = grouped.get(event.phaseId) ?? [];
|
||||
events.push(event);
|
||||
if (events.length > 2) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
grouped.set(event.phaseId, events);
|
||||
}
|
||||
|
||||
const paired: PairedRendererPerformancePhase[] = [];
|
||||
for (const events of grouped.values()) {
|
||||
const start = events.find((event) => event.boundary === 'start');
|
||||
const end = events.find((event) => event.boundary === 'end');
|
||||
if (
|
||||
events.length !== 2 ||
|
||||
!start ||
|
||||
!end ||
|
||||
start.outcome !== null ||
|
||||
end.outcome === null ||
|
||||
start.phase !== end.phase ||
|
||||
!sameMetadata(start.metadata, end.metadata) ||
|
||||
end.epochMs < start.epochMs ||
|
||||
end.monotonicMs < start.monotonicMs
|
||||
) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
paired.push(
|
||||
Object.freeze({
|
||||
durationMs: end.monotonicMs - start.monotonicMs,
|
||||
endedEpochMs: end.epochMs,
|
||||
metadata: end.metadata,
|
||||
outcome: end.outcome,
|
||||
phase: end.phase,
|
||||
phaseId: end.phaseId,
|
||||
startedEpochMs: start.epochMs,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
paired.sort(
|
||||
(left, right) =>
|
||||
left.startedEpochMs - right.startedEpochMs ||
|
||||
left.endedEpochMs - right.endedEpochMs ||
|
||||
left.phaseId.localeCompare(right.phaseId)
|
||||
);
|
||||
return Object.freeze(paired);
|
||||
}
|
||||
|
||||
export function requireRendererPerformancePhase(
|
||||
phases: readonly PairedRendererPerformancePhase[],
|
||||
phase: string
|
||||
): PairedRendererPerformancePhase {
|
||||
const matches = phases.filter((candidate) => candidate.phase === phase);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`renderer-performance-phase-missing:${phase}`);
|
||||
}
|
||||
if (matches.length !== 1 || !matches[0]) {
|
||||
throw new Error(`renderer-performance-phase-ambiguous:${phase}`);
|
||||
}
|
||||
if (matches[0].outcome !== 'success') {
|
||||
throw new Error(`renderer-performance-phase-failed:${phase}`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function parseRendererPerformancePhaseEvent(
|
||||
value: unknown
|
||||
): ParsedRendererPerformancePhaseEvent {
|
||||
if (!isRecord(value) || value['schemaVersion'] !== 1) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
const boundary = value['boundary'];
|
||||
const epochMs = value['epochMs'];
|
||||
const monotonicMs = value['monotonicMs'];
|
||||
const outcome = value['outcome'];
|
||||
const phase = value['phase'];
|
||||
const phaseId = value['phaseId'];
|
||||
if (
|
||||
(boundary !== 'start' && boundary !== 'end') ||
|
||||
!isFiniteNonNegativeNumber(epochMs) ||
|
||||
!isFiniteNonNegativeNumber(monotonicMs) ||
|
||||
typeof phase !== 'string' ||
|
||||
phase.length === 0 ||
|
||||
typeof phaseId !== 'string' ||
|
||||
phaseId.length === 0 ||
|
||||
(boundary === 'start' && outcome !== undefined) ||
|
||||
(boundary === 'end' && outcome !== 'success' && outcome !== 'error')
|
||||
) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
|
||||
return {
|
||||
boundary,
|
||||
epochMs,
|
||||
metadata: parseMetadata(value['metadata']),
|
||||
monotonicMs,
|
||||
outcome: outcome === 'success' || outcome === 'error' ? outcome : null,
|
||||
phase,
|
||||
phaseId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): Readonly<{ items: number }> | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
!Number.isSafeInteger(value['items']) ||
|
||||
Number(value['items']) < 0
|
||||
) {
|
||||
throw invalidRendererEvents();
|
||||
}
|
||||
return Object.freeze({ items: Number(value['items']) });
|
||||
}
|
||||
|
||||
function sameMetadata(
|
||||
left: Readonly<{ items: number }> | null,
|
||||
right: Readonly<{ items: number }> | null
|
||||
): boolean {
|
||||
return left === null || right === null
|
||||
? left === right
|
||||
: left.items === right.items;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function invalidRendererEvents(): Error {
|
||||
return new Error('renderer-performance-phase-events-invalid');
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
interface TestIteration {
|
||||
readonly kind: 'diagnostic' | 'measured' | 'warmup';
|
||||
readonly main: {
|
||||
readonly rendererWindow: {
|
||||
readonly rss: {
|
||||
readonly identity: {
|
||||
readonly creationTime: number;
|
||||
readonly pid: number;
|
||||
} | null;
|
||||
readonly missingSampleCount: number;
|
||||
readonly peakRssBytes: number | null;
|
||||
readonly unavailableReason: string | null;
|
||||
readonly validSampleCount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
interface RendererRssValidity {
|
||||
readonly invalidMeasuredRuns: readonly {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason: string;
|
||||
readonly runId: string;
|
||||
readonly validSampleCount: number;
|
||||
}[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForBenchmark: boolean;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
interface RendererRssValidityModule {
|
||||
assessRendererRssValidity?: (
|
||||
iterations: readonly TestIteration[]
|
||||
) => RendererRssValidity;
|
||||
}
|
||||
|
||||
const validityModulePromise = import(
|
||||
new URL('./renderer-rss-validity.ts', import.meta.url).href
|
||||
)
|
||||
.then((module) => module as RendererRssValidityModule)
|
||||
.catch(() => null);
|
||||
|
||||
function iteration(
|
||||
runId: string,
|
||||
kind: TestIteration['kind'],
|
||||
input: Partial<TestIteration['main']['rendererWindow']['rss']> = {}
|
||||
): TestIteration {
|
||||
return {
|
||||
kind,
|
||||
main: {
|
||||
rendererWindow: {
|
||||
rss: {
|
||||
identity: {
|
||||
creationTime: 1_721_234_567_890,
|
||||
pid: 42,
|
||||
},
|
||||
missingSampleCount: 0,
|
||||
peakRssBytes: 4_096,
|
||||
unavailableReason: null,
|
||||
validSampleCount: 3,
|
||||
...input,
|
||||
},
|
||||
},
|
||||
},
|
||||
runId,
|
||||
};
|
||||
}
|
||||
|
||||
test('validates only measured renderer RSS runs and preserves the exact summary shape', async () => {
|
||||
const module = await validityModulePromise;
|
||||
assert.ok(module, 'generic renderer RSS validity helper must exist');
|
||||
const assess = module.assessRendererRssValidity;
|
||||
assert.equal(typeof assess, 'function');
|
||||
|
||||
const result = assess([
|
||||
iteration('warmup-invalid', 'warmup', { identity: null }),
|
||||
iteration('run-01', 'measured'),
|
||||
iteration('diagnostic-invalid', 'diagnostic', {
|
||||
unavailableReason: 'renderer-process-gone',
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
invalidMeasuredRuns: [],
|
||||
measuredRunCount: 1,
|
||||
validForBenchmark: true,
|
||||
validForComparison: true,
|
||||
validMeasuredRunCount: 1,
|
||||
});
|
||||
assert.ok(Object.isFrozen(result));
|
||||
assert.ok(Object.isFrozen(result.invalidMeasuredRuns));
|
||||
});
|
||||
|
||||
test('fails every incomplete measured capture without losing its raw reason and counts', async () => {
|
||||
const module = await validityModulePromise;
|
||||
assert.ok(module);
|
||||
const assess = module.assessRendererRssValidity;
|
||||
assert.equal(typeof assess, 'function');
|
||||
|
||||
const result = assess([
|
||||
iteration('run-no-identity', 'measured', { identity: null }),
|
||||
iteration('run-missing-sample', 'measured', {
|
||||
missingSampleCount: 1,
|
||||
peakRssBytes: null,
|
||||
unavailableReason: 'renderer-process-metric-missing-during-capture',
|
||||
validSampleCount: 2,
|
||||
}),
|
||||
iteration('run-zero-peak', 'measured', { peakRssBytes: 0 }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
invalidMeasuredRuns: [
|
||||
{
|
||||
missingSampleCount: 0,
|
||||
reason: 'renderer-rss-capture-invalid',
|
||||
runId: 'run-no-identity',
|
||||
validSampleCount: 3,
|
||||
},
|
||||
{
|
||||
missingSampleCount: 1,
|
||||
reason: 'renderer-process-metric-missing-during-capture',
|
||||
runId: 'run-missing-sample',
|
||||
validSampleCount: 2,
|
||||
},
|
||||
{
|
||||
missingSampleCount: 0,
|
||||
reason: 'renderer-rss-capture-invalid',
|
||||
runId: 'run-zero-peak',
|
||||
validSampleCount: 3,
|
||||
},
|
||||
],
|
||||
measuredRunCount: 3,
|
||||
validForBenchmark: false,
|
||||
validForComparison: false,
|
||||
validMeasuredRunCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('requires at least one measured run', async () => {
|
||||
const module = await validityModulePromise;
|
||||
assert.ok(module);
|
||||
const assess = module.assessRendererRssValidity;
|
||||
assert.equal(typeof assess, 'function');
|
||||
|
||||
assert.deepEqual(assess([iteration('warmup-01', 'warmup')]), {
|
||||
invalidMeasuredRuns: [],
|
||||
measuredRunCount: 0,
|
||||
validForBenchmark: false,
|
||||
validForComparison: false,
|
||||
validMeasuredRunCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('the cancellation report delegates renderer RSS validity to the generic helper', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./m3u-refresh-cancellation-report.ts', import.meta.url),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/import \{ assessRendererRssValidity \} from '\.\/renderer-rss-validity';/
|
||||
);
|
||||
assert.doesNotMatch(source, /function assessRendererRssValidity\(/);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { RendererProcessRssCapture } from './renderer-process-rss-capture';
|
||||
|
||||
export interface RendererRssValidityIteration {
|
||||
readonly kind: string;
|
||||
readonly main: {
|
||||
readonly rendererWindow: {
|
||||
readonly rss: RendererProcessRssCapture;
|
||||
};
|
||||
};
|
||||
readonly runId: string;
|
||||
}
|
||||
|
||||
export interface InvalidRendererRssMeasuredRun {
|
||||
readonly missingSampleCount: number;
|
||||
readonly reason:
|
||||
| Exclude<RendererProcessRssCapture['unavailableReason'], null>
|
||||
| 'renderer-rss-capture-invalid';
|
||||
readonly runId: string;
|
||||
readonly validSampleCount: number;
|
||||
}
|
||||
|
||||
export interface RendererRssValidity {
|
||||
readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[];
|
||||
readonly measuredRunCount: number;
|
||||
readonly validForBenchmark: boolean;
|
||||
readonly validForComparison: boolean;
|
||||
readonly validMeasuredRunCount: number;
|
||||
}
|
||||
|
||||
export function assessRendererRssValidity(
|
||||
iterations: readonly RendererRssValidityIteration[]
|
||||
): RendererRssValidity {
|
||||
const measured = iterations.filter(
|
||||
(iteration) => iteration.kind === 'measured'
|
||||
);
|
||||
const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] =
|
||||
[];
|
||||
let validMeasuredRunCount = 0;
|
||||
|
||||
for (const iteration of measured) {
|
||||
const rss = iteration.main.rendererWindow.rss;
|
||||
if (
|
||||
rss.identity !== null &&
|
||||
Number.isSafeInteger(rss.peakRssBytes) &&
|
||||
Number(rss.peakRssBytes) > 0 &&
|
||||
rss.unavailableReason === null &&
|
||||
rss.missingSampleCount === 0 &&
|
||||
Number.isSafeInteger(rss.validSampleCount) &&
|
||||
rss.validSampleCount > 0
|
||||
) {
|
||||
validMeasuredRunCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
invalidMeasuredRuns.push({
|
||||
missingSampleCount: rss.missingSampleCount,
|
||||
reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid',
|
||||
runId: iteration.runId,
|
||||
validSampleCount: rss.validSampleCount,
|
||||
});
|
||||
}
|
||||
|
||||
const valid =
|
||||
measured.length > 0 && validMeasuredRunCount === measured.length;
|
||||
return Object.freeze({
|
||||
invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns),
|
||||
measuredRunCount: measured.length,
|
||||
validForBenchmark: valid,
|
||||
validForComparison: valid,
|
||||
validMeasuredRunCount,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable playwright/expect-expect -- Node assertions define the loopback-only fixture server contract. */
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createSyntheticM3uFixture } from './synthetic-m3u';
|
||||
import { startSyntheticM3uServer } from './synthetic-m3u-server';
|
||||
|
||||
test('serves and primes only the deterministic fixture on ephemeral IPv4 loopback', async () => {
|
||||
const fixture = createSyntheticM3uFixture(10_000);
|
||||
const server = await startSyntheticM3uServer(fixture);
|
||||
|
||||
try {
|
||||
const url = new URL(server.resourceUrl);
|
||||
assert.equal(url.protocol, 'http:');
|
||||
assert.equal(url.hostname, '127.0.0.1');
|
||||
assert.notEqual(url.port, '');
|
||||
assert.equal(url.username, '');
|
||||
assert.equal(url.password, '');
|
||||
assert.equal(server.requestCount(), 0);
|
||||
|
||||
await server.prime();
|
||||
assert.equal(server.requestCount(), 1);
|
||||
|
||||
const response = await fetch(server.resourceUrl);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
response.headers.get('content-length'),
|
||||
String(fixture.bytes)
|
||||
);
|
||||
assert.equal(await response.text(), fixture.body);
|
||||
assert.equal(server.requestCount(), 2);
|
||||
|
||||
const missing = await fetch(`${server.origin}/not-the-fixture.m3u`);
|
||||
assert.equal(missing.status, 404);
|
||||
assert.equal(server.requestCount(), 2);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('closes idempotently and rejects priming after close', async () => {
|
||||
const server = await startSyntheticM3uServer(
|
||||
createSyntheticM3uFixture(10_000)
|
||||
);
|
||||
|
||||
await server.close();
|
||||
await server.close();
|
||||
await assert.rejects(server.prime(), /closed/i);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { once } from 'node:events';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
|
||||
import type { SyntheticM3uFixture } from './synthetic-m3u';
|
||||
|
||||
export const SYNTHETIC_M3U_RESOURCE_PATH = '/synthetic-performance.m3u';
|
||||
|
||||
export interface SyntheticM3uServer {
|
||||
readonly origin: string;
|
||||
readonly resourceUrl: string;
|
||||
close(): Promise<void>;
|
||||
prime(): Promise<void>;
|
||||
requestCount(): number;
|
||||
}
|
||||
|
||||
export async function startSyntheticM3uServer(
|
||||
fixture: SyntheticM3uFixture
|
||||
): Promise<SyntheticM3uServer> {
|
||||
let successfulRequestCount = 0;
|
||||
const server = createServer((request, response) => {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||
if (
|
||||
request.method !== 'GET' ||
|
||||
requestUrl.pathname !== SYNTHETIC_M3U_RESOURCE_PATH ||
|
||||
requestUrl.search.length > 0
|
||||
) {
|
||||
response.writeHead(404, {
|
||||
'Content-Length': '0',
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
successfulRequestCount += 1;
|
||||
response.writeHead(200, {
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': String(fixture.bytes),
|
||||
'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8',
|
||||
});
|
||||
response.end(fixture.body);
|
||||
});
|
||||
server.listen({
|
||||
exclusive: true,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
});
|
||||
try {
|
||||
await once(server, 'listening');
|
||||
} catch (error: unknown) {
|
||||
await closeServer(server).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const address = server.address();
|
||||
if (
|
||||
address === null ||
|
||||
typeof address === 'string' ||
|
||||
address.address !== '127.0.0.1' ||
|
||||
!Number.isSafeInteger(address.port) ||
|
||||
address.port <= 0
|
||||
) {
|
||||
await closeServer(server);
|
||||
throw new Error(
|
||||
'Synthetic M3U server did not bind exact IPv4 loopback'
|
||||
);
|
||||
}
|
||||
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const resourceUrl = `${origin}${SYNTHETIC_M3U_RESOURCE_PATH}`;
|
||||
let closePromise: Promise<void> | null = null;
|
||||
|
||||
return Object.freeze({
|
||||
origin,
|
||||
resourceUrl,
|
||||
close: () => {
|
||||
closePromise ??= closeServer(server);
|
||||
return closePromise;
|
||||
},
|
||||
prime: async () => {
|
||||
if (closePromise !== null) {
|
||||
throw new Error('Synthetic M3U server is closed');
|
||||
}
|
||||
const response = await fetch(resourceUrl);
|
||||
const body = await response.arrayBuffer();
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
Number(response.headers.get('content-length')) !==
|
||||
fixture.bytes ||
|
||||
body.byteLength !== fixture.bytes
|
||||
) {
|
||||
throw new Error(
|
||||
'Synthetic M3U server priming response was invalid'
|
||||
);
|
||||
}
|
||||
},
|
||||
requestCount: () => successfulRequestCount,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
rejectPromise(error);
|
||||
return;
|
||||
}
|
||||
resolvePromise();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createSyntheticM3uFixture,
|
||||
SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS,
|
||||
SYNTHETIC_M3U_SEED,
|
||||
} from './synthetic-m3u';
|
||||
|
||||
const GOLDEN_SHA256 = {
|
||||
10_000: '1d151ab2a878db8818f2bbfbdd689007cbe390182cdd1520c316bd163b7e7db3',
|
||||
50_000: '4a5336a5d1c66b8b706752784815617a012499cb7a231ab35c88df9bd55ee78a',
|
||||
100_000: '2ffe912f61ea106ba8de3414b0a927cdaa3264ac637d44c4b219f199eb94c353',
|
||||
} as const;
|
||||
|
||||
describe('synthetic M3U performance fixture', () => {
|
||||
it('generates each supported catalog deterministically with exact metadata', () => {
|
||||
assert.deepEqual(
|
||||
SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS,
|
||||
[10_000, 50_000, 100_000]
|
||||
);
|
||||
assert.equal(SYNTHETIC_M3U_SEED, 240_724);
|
||||
|
||||
for (const channelCount of SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS) {
|
||||
const fixture = createSyntheticM3uFixture(channelCount);
|
||||
const lines = fixture.body.trimEnd().split('\n');
|
||||
|
||||
assert.equal(fixture.channelCount, channelCount);
|
||||
assert.equal(
|
||||
fixture.bytes,
|
||||
Buffer.byteLength(fixture.body, 'utf8')
|
||||
);
|
||||
assert.equal(
|
||||
fixture.sha256,
|
||||
createHash('sha256').update(fixture.body, 'utf8').digest('hex')
|
||||
);
|
||||
assert.equal(fixture.sha256, GOLDEN_SHA256[channelCount]);
|
||||
assert.equal(lines[0], '#EXTM3U');
|
||||
assert.equal(lines.length, 1 + channelCount * 2);
|
||||
assert.equal(
|
||||
lines.filter((line) => line.startsWith('#EXTINF:')).length,
|
||||
channelCount
|
||||
);
|
||||
assert.doesNotMatch(fixture.body, /\b(?:tvg-|logo|provider)\b/i);
|
||||
assertOnlyLoopbackUrls(fixture.body);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unsupported channel counts', () => {
|
||||
assert.throws(
|
||||
() => createSyntheticM3uFixture(9_999),
|
||||
/Unsupported synthetic M3U channel count/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function assertOnlyLoopbackUrls(body: string): void {
|
||||
const urls = body.match(/https?:\/\/[^\s"]+/g) ?? [];
|
||||
|
||||
for (const rawUrl of urls) {
|
||||
const url = new URL(rawUrl);
|
||||
assert.equal(url.protocol, 'http:');
|
||||
assert.equal(url.hostname, '127.0.0.1');
|
||||
assert.equal(url.username, '');
|
||||
assert.equal(url.password, '');
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,30 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
export const SYNTHETIC_M3U_SEED = 240_724;
|
||||
export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000;
|
||||
export const SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS = [
|
||||
10_000,
|
||||
50_000,
|
||||
SYNTHETIC_M3U_CHANNEL_COUNT,
|
||||
] as const;
|
||||
|
||||
export type SyntheticM3uChannelCount =
|
||||
(typeof SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS)[number];
|
||||
|
||||
export interface SyntheticM3uFixture {
|
||||
readonly body: string;
|
||||
readonly bytes: number;
|
||||
readonly channelCount: number;
|
||||
readonly channelCount: SyntheticM3uChannelCount;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export function createSyntheticM3uFixture(): SyntheticM3uFixture {
|
||||
const lines = new Array<string>(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2);
|
||||
export function createSyntheticM3uFixture(
|
||||
channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT
|
||||
): SyntheticM3uFixture {
|
||||
assertSupportedChannelCount(channelCount);
|
||||
const lines = new Array<string>(1 + channelCount * 2);
|
||||
lines[0] = '#EXTM3U';
|
||||
|
||||
for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) {
|
||||
for (let offset = 0; offset < channelCount; offset += 1) {
|
||||
const index = offset + 1;
|
||||
const group = (offset % 100) + 1;
|
||||
const lineOffset = 1 + offset * 2;
|
||||
@@ -34,7 +45,21 @@ export function createSyntheticM3uFixture(): SyntheticM3uFixture {
|
||||
return Object.freeze({
|
||||
body,
|
||||
bytes: Buffer.byteLength(body, 'utf8'),
|
||||
channelCount: SYNTHETIC_M3U_CHANNEL_COUNT,
|
||||
channelCount,
|
||||
sha256: createHash('sha256').update(body, 'utf8').digest('hex'),
|
||||
});
|
||||
}
|
||||
|
||||
function assertSupportedChannelCount(
|
||||
channelCount: number
|
||||
): asserts channelCount is SyntheticM3uChannelCount {
|
||||
if (
|
||||
!SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS.some(
|
||||
(supported) => supported === channelCount
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Unsupported synthetic M3U channel count: ${channelCount}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events';
|
||||
import { parseXtreamWorkerPerformancePhaseEvents } from './xtream-worker-phase-events';
|
||||
|
||||
function phasePair(
|
||||
phase: string,
|
||||
startEpochMs: number,
|
||||
durationMs: number,
|
||||
requestId = 'request-1'
|
||||
) {
|
||||
return [
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: startEpochMs,
|
||||
phase,
|
||||
requestId,
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs,
|
||||
epochMs: startEpochMs + 1,
|
||||
metadata: { itemCount: 1 },
|
||||
phase,
|
||||
requestId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
test('accepts producer-shaped worker output when separately read clocks have a wider monotonic duration', () => {
|
||||
const events = phasePair('sqlite.categories.read', 11, 1.6115);
|
||||
const parsed = parseXtreamWorkerPerformancePhaseEvents(
|
||||
'DB_GET_CATEGORIES',
|
||||
events,
|
||||
10,
|
||||
30,
|
||||
true
|
||||
);
|
||||
assert.deepEqual(parsed, events);
|
||||
assert.equal(Number(events[1]?.epochMs) - Number(events[0]?.epochMs), 1);
|
||||
});
|
||||
|
||||
test('rejects an Xtream worker duration outside its request envelope', () => {
|
||||
assert.equal(
|
||||
parseXtreamWorkerPerformancePhaseEvents(
|
||||
'DB_GET_CATEGORIES',
|
||||
phasePair('sqlite.categories.read', 11, 999),
|
||||
10,
|
||||
30,
|
||||
true
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an out-of-envelope duration in a failed partial DB_SAVE_CONTENT capture', () => {
|
||||
const events = [
|
||||
...phasePair('sqlite.content.category-map-read', 11, 1),
|
||||
...phasePair('normalize.content', 13, 999),
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
parseXtreamWorkerPerformancePhaseEvents(
|
||||
'DB_SAVE_CONTENT',
|
||||
events,
|
||||
10,
|
||||
30,
|
||||
false
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts cross-clock skew but rejects an app-playlist duration outside the request envelope', () => {
|
||||
const producerShaped = [
|
||||
...phasePair('sqlite.read', 11, 1.6115),
|
||||
...phasePair('deserialize.playlist', 13, 1),
|
||||
];
|
||||
assert.deepEqual(
|
||||
parseWorkerPerformancePhaseEvents(
|
||||
'DB_GET_APP_PLAYLIST',
|
||||
producerShaped,
|
||||
10,
|
||||
30,
|
||||
true
|
||||
),
|
||||
producerShaped
|
||||
);
|
||||
|
||||
const outsideEnvelope = [
|
||||
...phasePair('sqlite.read', 11, 20.001),
|
||||
...phasePair('deserialize.playlist', 13, 1),
|
||||
];
|
||||
assert.equal(
|
||||
parseWorkerPerformancePhaseEvents(
|
||||
'DB_GET_APP_PLAYLIST',
|
||||
outsideEnvelope,
|
||||
10,
|
||||
30,
|
||||
true
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
|
||||
type AppPlaylistPerformancePhase,
|
||||
type DatabaseWorkerPerformancePhase,
|
||||
type PerformancePhaseEvent,
|
||||
type PerformancePhaseMetadata,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { fitsPerformanceDurationEnvelope } from './performance-phase-duration-envelope';
|
||||
import { parseXtreamWorkerPerformancePhaseEvents } from './xtream-worker-phase-events';
|
||||
|
||||
const PHASE_SEQUENCES = {
|
||||
DB_GET_APP_PLAYLIST: [
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
|
||||
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
|
||||
],
|
||||
DB_UPSERT_APP_PLAYLIST: [
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
|
||||
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
|
||||
],
|
||||
} as const satisfies Readonly<
|
||||
Record<string, readonly AppPlaylistPerformancePhase[]>
|
||||
>;
|
||||
const EVENT_KEYS = new Set([
|
||||
'boundary',
|
||||
'durationMs',
|
||||
'epochMs',
|
||||
'metadata',
|
||||
'phase',
|
||||
'requestId',
|
||||
]);
|
||||
|
||||
export function parseWorkerPerformancePhaseEvents(
|
||||
operation: string | null,
|
||||
input: unknown,
|
||||
workStartedEpochMs: number,
|
||||
workEndedEpochMs: number,
|
||||
requestSucceeded: boolean
|
||||
): readonly PerformancePhaseEvent<DatabaseWorkerPerformancePhase>[] | null {
|
||||
const xtreamEvents = parseXtreamWorkerPerformancePhaseEvents(
|
||||
operation,
|
||||
input,
|
||||
workStartedEpochMs,
|
||||
workEndedEpochMs,
|
||||
requestSucceeded
|
||||
);
|
||||
if (xtreamEvents !== undefined) {
|
||||
return xtreamEvents;
|
||||
}
|
||||
|
||||
const phaseSequence = getPhaseSequence(operation);
|
||||
if (phaseSequence.length === 0) {
|
||||
return input === undefined ||
|
||||
(Array.isArray(input) && input.length === 0)
|
||||
? []
|
||||
: null;
|
||||
}
|
||||
if (!Array.isArray(input) || input.length !== phaseSequence.length * 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed: PerformancePhaseEvent<AppPlaylistPerformancePhase>[] = [];
|
||||
let lastEpochMs = workStartedEpochMs;
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const raw = input[index];
|
||||
if (
|
||||
!isRecord(raw) ||
|
||||
Object.keys(raw).some((key) => !EVENT_KEYS.has(key))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const boundary = raw['boundary'];
|
||||
const durationMs = raw['durationMs'];
|
||||
const epochMs = raw['epochMs'];
|
||||
const metadata = parseMetadata(raw['metadata']);
|
||||
const phase = raw['phase'];
|
||||
const requestId = raw['requestId'];
|
||||
if (
|
||||
boundary !== (index % 2 === 0 ? 'start' : 'end') ||
|
||||
phase !== phaseSequence[Math.floor(index / 2)] ||
|
||||
typeof requestId !== 'string' ||
|
||||
requestId.length === 0 ||
|
||||
!isFiniteNonNegativeNumber(epochMs) ||
|
||||
epochMs < lastEpochMs ||
|
||||
epochMs > workEndedEpochMs ||
|
||||
(boundary === 'start' &&
|
||||
(durationMs !== null || metadata !== undefined)) ||
|
||||
(boundary === 'end' && !isFiniteNonNegativeNumber(durationMs)) ||
|
||||
metadata === null ||
|
||||
(parsed.length > 0 && parsed[0]?.requestId !== requestId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const phaseStart = parsed[parsed.length - 1];
|
||||
if (
|
||||
boundary === 'end' &&
|
||||
(!phaseStart ||
|
||||
!fitsPerformanceDurationEnvelope(
|
||||
Number(durationMs),
|
||||
workStartedEpochMs,
|
||||
workEndedEpochMs
|
||||
))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
parsed.push({
|
||||
boundary: boundary as 'end' | 'start',
|
||||
durationMs: durationMs as number | null,
|
||||
epochMs,
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
phase: phase as AppPlaylistPerformancePhase,
|
||||
requestId,
|
||||
});
|
||||
lastEpochMs = epochMs;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getPhaseSequence(
|
||||
operation: string | null
|
||||
): readonly AppPlaylistPerformancePhase[] {
|
||||
if (operation === 'DB_GET_APP_PLAYLIST') {
|
||||
return PHASE_SEQUENCES.DB_GET_APP_PLAYLIST;
|
||||
}
|
||||
if (operation === 'DB_UPSERT_APP_PLAYLIST') {
|
||||
return PHASE_SEQUENCES.DB_UPSERT_APP_PLAYLIST;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseMetadata(
|
||||
input: unknown
|
||||
): PerformancePhaseMetadata | undefined | null {
|
||||
if (input === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!isRecord(input) ||
|
||||
Object.keys(input).some(
|
||||
(key) => key !== 'byteCount' && key !== 'itemCount'
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const byteCount = input['byteCount'];
|
||||
const itemCount = input['itemCount'];
|
||||
if (
|
||||
(byteCount !== undefined && !isSafeCount(byteCount)) ||
|
||||
(itemCount !== undefined && !isSafeCount(itemCount))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(byteCount === undefined ? {} : { byteCount: Number(byteCount) }),
|
||||
...(itemCount === undefined ? {} : { itemCount: Number(itemCount) }),
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === 'object' && input !== null && !Array.isArray(input);
|
||||
}
|
||||
|
||||
function isFiniteNonNegativeNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function isSafeCount(value: unknown): boolean {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
+216
-2
@@ -13,6 +13,7 @@ const validCapture = {
|
||||
histogramFlushedEpochMs: 1_050,
|
||||
invalidReason: null,
|
||||
requestReceivedEpochMs: 1_000,
|
||||
responsePostedEpochMs: 1_055,
|
||||
threadCpuSystemMicros: 50,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 100,
|
||||
@@ -20,19 +21,25 @@ const validCapture = {
|
||||
workStartedEpochMs: 1_010,
|
||||
} as const;
|
||||
|
||||
function normalize(performanceCapture: unknown) {
|
||||
function normalizeForOperation(operation: string, performanceCapture: unknown) {
|
||||
return normalizeWorkerRequestPerformanceOutcome({
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
ipcCallId: 2,
|
||||
operation,
|
||||
operationId: 'operation-42',
|
||||
operationIdUnavailableReason: null,
|
||||
performanceCapture,
|
||||
playlistId: 'playlist-1',
|
||||
requestId: 'request-1',
|
||||
responseEpochMs: 1_060,
|
||||
sourceEpochMs: 990,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
function normalize(performanceCapture: unknown) {
|
||||
return normalizeForOperation('DB_HAS_CONTENT', performanceCapture);
|
||||
}
|
||||
|
||||
function assertClosedCapture(
|
||||
capture: WorkerRequestPerformanceMetrics,
|
||||
reason: string
|
||||
@@ -47,6 +54,7 @@ function assertClosedCapture(
|
||||
capture.histogramFlushedEpochMs,
|
||||
capture.invalidReason,
|
||||
capture.requestReceivedEpochMs,
|
||||
capture.responsePostedEpochMs,
|
||||
capture.threadCpuSystemMicros,
|
||||
capture.threadCpuUnavailableReason,
|
||||
capture.threadCpuUserMicros,
|
||||
@@ -55,6 +63,7 @@ function assertClosedCapture(
|
||||
]) {
|
||||
assert.equal(value, null);
|
||||
}
|
||||
assert.deepEqual(capture.phaseEvents, []);
|
||||
}
|
||||
|
||||
test('absent captures are missing while present non-record captures are invalid', () => {
|
||||
@@ -118,6 +127,9 @@ test('malformed nested fields and cross-field invariants fail the whole capture
|
||||
histogramFlushedEpochMs: null,
|
||||
},
|
||||
{ ...validCapture, histogramFlushedEpochMs: 1_039 },
|
||||
{ ...validCapture, responsePostedEpochMs: undefined },
|
||||
{ ...validCapture, responsePostedEpochMs: 1_039 },
|
||||
{ ...validCapture, responsePostedEpochMs: 1_061 },
|
||||
{ ...validCapture, workStartedEpochMs: 999 },
|
||||
{
|
||||
...validCapture,
|
||||
@@ -170,3 +182,205 @@ test('coherent metric-unavailable and invalid worker results remain valid raw ca
|
||||
assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null);
|
||||
assert.equal(legitimateInvalid.invalidReason, invalidReason);
|
||||
});
|
||||
|
||||
test('requires the exact operation-specific worker phase sequence', () => {
|
||||
const upsertPhaseEvents = [
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_015,
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 5,
|
||||
epochMs: 1_020,
|
||||
metadata: { byteCount: 2048, itemCount: 10_000 },
|
||||
phase: 'serialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_021,
|
||||
phase: 'sqlite.write',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 12,
|
||||
epochMs: 1_033,
|
||||
metadata: { byteCount: 2048, itemCount: 1 },
|
||||
phase: 'sqlite.write',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
] as const;
|
||||
const getPhaseEvents = [
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_015,
|
||||
phase: 'sqlite.read',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 5,
|
||||
epochMs: 1_020,
|
||||
metadata: { itemCount: 1 },
|
||||
phase: 'sqlite.read',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_021,
|
||||
phase: 'deserialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 12,
|
||||
epochMs: 1_033,
|
||||
metadata: { itemCount: 10_000 },
|
||||
phase: 'deserialize.playlist',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
] as const;
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeForOperation('DB_UPSERT_APP_PLAYLIST', {
|
||||
...validCapture,
|
||||
phaseEvents: upsertPhaseEvents,
|
||||
}).phaseEvents,
|
||||
upsertPhaseEvents
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalizeForOperation('DB_GET_APP_PLAYLIST', {
|
||||
...validCapture,
|
||||
phaseEvents: getPhaseEvents,
|
||||
}).phaseEvents,
|
||||
getPhaseEvents
|
||||
);
|
||||
|
||||
for (const invalidPhaseEvents of [
|
||||
getPhaseEvents.slice(0, 2),
|
||||
getPhaseEvents.slice(1),
|
||||
[...getPhaseEvents].reverse(),
|
||||
getPhaseEvents.map((event) => ({
|
||||
...event,
|
||||
requestId: 'other-request',
|
||||
})),
|
||||
[
|
||||
getPhaseEvents[0],
|
||||
{
|
||||
...getPhaseEvents[1],
|
||||
metadata: { itemCount: 1, secret: 'not allowed' },
|
||||
},
|
||||
],
|
||||
[
|
||||
getPhaseEvents[0],
|
||||
{
|
||||
...getPhaseEvents[1],
|
||||
phase: 'arbitrary.phase',
|
||||
},
|
||||
],
|
||||
]) {
|
||||
assertClosedCapture(
|
||||
normalizeForOperation('DB_GET_APP_PLAYLIST', {
|
||||
...validCapture,
|
||||
phaseEvents: invalidPhaseEvents,
|
||||
}),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
for (const [operation, phaseEvents] of [
|
||||
['DB_GET_APP_PLAYLIST', upsertPhaseEvents],
|
||||
['DB_UPSERT_APP_PLAYLIST', getPhaseEvents],
|
||||
['DB_GET_CONTENT', getPhaseEvents],
|
||||
] as const) {
|
||||
assertClosedCapture(
|
||||
normalizeForOperation(operation, {
|
||||
...validCapture,
|
||||
phaseEvents,
|
||||
}),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
for (const operation of ['DB_GET_APP_PLAYLIST', 'DB_UPSERT_APP_PLAYLIST']) {
|
||||
assertClosedCapture(
|
||||
normalizeForOperation(operation, validCapture),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
}
|
||||
|
||||
assert.deepEqual(normalize(validCapture).phaseEvents, []);
|
||||
});
|
||||
|
||||
test('routes finalized Xtream phase sequences before the legacy M3U parser', () => {
|
||||
const xtreamPhaseEvents = [
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_015,
|
||||
phase: 'sqlite.content.category-map-read',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 2,
|
||||
epochMs: 1_017,
|
||||
metadata: { itemCount: 60 },
|
||||
phase: 'sqlite.content.category-map-read',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_018,
|
||||
phase: 'normalize.content',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 5,
|
||||
epochMs: 1_023,
|
||||
metadata: { itemCount: 60_000 },
|
||||
phase: 'normalize.content',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: 1_024,
|
||||
phase: 'sqlite.content.write-transactions',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
durationMs: 10,
|
||||
epochMs: 1_034,
|
||||
metadata: { itemCount: 60_000 },
|
||||
phase: 'sqlite.content.write-transactions',
|
||||
requestId: 'request-1',
|
||||
},
|
||||
] as const;
|
||||
|
||||
assert.deepEqual(
|
||||
normalizeForOperation('DB_SAVE_CONTENT', {
|
||||
...validCapture,
|
||||
phaseEvents: xtreamPhaseEvents,
|
||||
}).phaseEvents,
|
||||
xtreamPhaseEvents
|
||||
);
|
||||
assertClosedCapture(
|
||||
normalizeForOperation('DB_SAVE_CONTENT', {
|
||||
...validCapture,
|
||||
phaseEvents: xtreamPhaseEvents.slice(0, -2),
|
||||
}),
|
||||
'worker-performance-capture-invalid'
|
||||
);
|
||||
});
|
||||
@@ -35,9 +35,10 @@ function requestPerformance(
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: p95Ms / 100,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs: 1_050 + p95Ms,
|
||||
histogramFlushedEpochMs: 1_050,
|
||||
invalidReason: null,
|
||||
requestReceivedEpochMs: 1_000,
|
||||
responsePostedEpochMs: 1_055,
|
||||
threadCpuSystemMicros: threadCpuUserMicros / 2,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros,
|
||||
@@ -53,13 +54,15 @@ function requestTransport(
|
||||
requestId = 'request-1'
|
||||
) {
|
||||
return {
|
||||
operation: 'DB_GET_APP_PLAYLIST',
|
||||
ipcCallId: 7,
|
||||
operation: 'DB_HAS_CONTENT',
|
||||
operationId: 'operation-42',
|
||||
operationIdUnavailableReason: null,
|
||||
performanceCapture,
|
||||
playlistId: 'playlist-1',
|
||||
requestId,
|
||||
responseEpochMs: 1_060,
|
||||
sourceEpochMs: 990,
|
||||
success: true,
|
||||
} as const;
|
||||
}
|
||||
@@ -77,6 +80,7 @@ function assertClosedCapture(
|
||||
capture.histogramFlushedEpochMs,
|
||||
capture.invalidReason,
|
||||
capture.requestReceivedEpochMs,
|
||||
capture.responsePostedEpochMs,
|
||||
capture.threadCpuSystemMicros,
|
||||
capture.threadCpuUnavailableReason,
|
||||
capture.threadCpuUserMicros,
|
||||
@@ -220,8 +224,10 @@ test('main capture retains raw request identity, timestamps, metrics, and reason
|
||||
assert.match(source, /requestPerformance: \[\]/);
|
||||
assert.match(source, /record\.requestPerformance\.map/);
|
||||
assert.match(source, /operationId: request\.identity\.operationId/);
|
||||
assert.match(source, /ipcCallId: request\.identity\.ipcCallId/);
|
||||
assert.match(source, /identity: request\.identity/);
|
||||
assert.match(source, /performanceCapture:\s+message\['performance'\]/);
|
||||
assert.match(source, /sourceEpochMs:\s+request\.identity\.sourceEpochMs/);
|
||||
assert.match(source, /captureGeneration: state\.captureGeneration/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
@@ -229,6 +235,13 @@ test('main capture retains raw request identity, timestamps, metrics, and reason
|
||||
);
|
||||
});
|
||||
|
||||
test('normalized raw request metrics retain the exact preload IPC call identity', () => {
|
||||
const capture = requestPerformance('request-1', 5, 100);
|
||||
|
||||
assert.equal(capture.ipcCallId, 7);
|
||||
assert.equal(capture.sourceEpochMs, 990);
|
||||
});
|
||||
|
||||
test('benchmark resolves one exact BrowserWindow and main capture never scans all windows', () => {
|
||||
const benchmarkSource = readFileSync(
|
||||
new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url),
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
WorkerRequestPerformanceMetrics,
|
||||
} from './m3u-refresh-cancellation-contract';
|
||||
import { WORKER_POST_GC_HEAP_UNAVAILABLE_REASON } from './m3u-refresh-cancellation-contract';
|
||||
import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events';
|
||||
|
||||
export interface WorkerRequestPerformanceOutcomeTransport {
|
||||
readonly ipcCallId: number | null;
|
||||
readonly operation: string | null;
|
||||
readonly operationId: string | null;
|
||||
readonly operationIdUnavailableReason: string | null;
|
||||
@@ -15,6 +17,7 @@ export interface WorkerRequestPerformanceOutcomeTransport {
|
||||
readonly playlistId: string | null;
|
||||
readonly requestId: string | null;
|
||||
readonly responseEpochMs: number;
|
||||
readonly sourceEpochMs: number | null;
|
||||
readonly success: boolean;
|
||||
}
|
||||
|
||||
@@ -39,7 +42,9 @@ type ParsedWorkerPerformanceCapture = Pick<
|
||||
| 'eventLoopUtilizationUnavailableReason'
|
||||
| 'histogramFlushedEpochMs'
|
||||
| 'invalidReason'
|
||||
| 'phaseEvents'
|
||||
| 'requestReceivedEpochMs'
|
||||
| 'responsePostedEpochMs'
|
||||
| 'threadCpuSystemMicros'
|
||||
| 'threadCpuUnavailableReason'
|
||||
| 'threadCpuUserMicros'
|
||||
@@ -146,7 +151,9 @@ function parseEventLoopDelay(
|
||||
}
|
||||
|
||||
function parseWorkerPerformanceCapture(
|
||||
input: unknown
|
||||
input: unknown,
|
||||
operation: string | null,
|
||||
requestSucceeded: boolean
|
||||
): ParsedWorkerPerformanceCapture | null {
|
||||
if (!isRecord(input)) {
|
||||
return null;
|
||||
@@ -161,6 +168,7 @@ function parseWorkerPerformanceCapture(
|
||||
const histogramFlushedEpochMs = input['histogramFlushedEpochMs'];
|
||||
const invalidReason = input['invalidReason'];
|
||||
const requestReceivedEpochMs = input['requestReceivedEpochMs'];
|
||||
const responsePostedEpochMs = input['responsePostedEpochMs'];
|
||||
const threadCpuSystemMicros = input['threadCpuSystemMicros'];
|
||||
const threadCpuUnavailableReason = input['threadCpuUnavailableReason'];
|
||||
const threadCpuUserMicros = input['threadCpuUserMicros'];
|
||||
@@ -182,6 +190,7 @@ function parseWorkerPerformanceCapture(
|
||||
!isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) ||
|
||||
!isNullableReason(invalidReason, INVALID_REASONS) ||
|
||||
!isFiniteNonNegativeNumber(requestReceivedEpochMs) ||
|
||||
!isFiniteNonNegativeNumber(responsePostedEpochMs) ||
|
||||
!isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) ||
|
||||
!isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) ||
|
||||
!isNullableFiniteNonNegativeNumber(threadCpuUserMicros) ||
|
||||
@@ -191,11 +200,24 @@ function parseWorkerPerformanceCapture(
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseEvents = parseWorkerPerformancePhaseEvents(
|
||||
operation,
|
||||
input['phaseEvents'],
|
||||
workStartedEpochMs,
|
||||
workEndedEpochMs,
|
||||
requestSucceeded
|
||||
);
|
||||
if (phaseEvents === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestampsAreOrdered =
|
||||
requestReceivedEpochMs <= workStartedEpochMs &&
|
||||
workStartedEpochMs <= workEndedEpochMs &&
|
||||
workEndedEpochMs <= responsePostedEpochMs &&
|
||||
(histogramFlushedEpochMs === null ||
|
||||
workEndedEpochMs <= histogramFlushedEpochMs);
|
||||
(workEndedEpochMs <= histogramFlushedEpochMs &&
|
||||
histogramFlushedEpochMs <= responsePostedEpochMs));
|
||||
const eventLoopDelayIsCoherent =
|
||||
eventLoopDelay === null
|
||||
? eventLoopDelayUnavailableReason !== null &&
|
||||
@@ -241,7 +263,9 @@ function parseWorkerPerformanceCapture(
|
||||
eventLoopUtilizationUnavailableReason,
|
||||
histogramFlushedEpochMs,
|
||||
invalidReason,
|
||||
phaseEvents,
|
||||
requestReceivedEpochMs,
|
||||
responsePostedEpochMs,
|
||||
threadCpuSystemMicros,
|
||||
threadCpuUnavailableReason,
|
||||
threadCpuUserMicros,
|
||||
@@ -254,12 +278,14 @@ export function normalizeWorkerRequestPerformanceOutcome(
|
||||
input: WorkerRequestPerformanceOutcomeTransport
|
||||
): WorkerRequestPerformanceMetrics {
|
||||
const identity = {
|
||||
ipcCallId: input.ipcCallId,
|
||||
operation: input.operation,
|
||||
operationId: input.operationId,
|
||||
operationIdUnavailableReason: input.operationIdUnavailableReason,
|
||||
playlistId: input.playlistId,
|
||||
requestId: input.requestId,
|
||||
responseEpochMs: input.responseEpochMs,
|
||||
sourceEpochMs: input.sourceEpochMs,
|
||||
success: input.success,
|
||||
};
|
||||
const unavailable = (
|
||||
@@ -275,7 +301,9 @@ export function normalizeWorkerRequestPerformanceOutcome(
|
||||
invalidReason: null,
|
||||
...identity,
|
||||
performanceCaptureUnavailableReason: reason,
|
||||
phaseEvents: [],
|
||||
requestReceivedEpochMs: null,
|
||||
responsePostedEpochMs: null,
|
||||
threadCpuSystemMicros: null,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: null,
|
||||
@@ -291,11 +319,28 @@ export function normalizeWorkerRequestPerformanceOutcome(
|
||||
}
|
||||
|
||||
const performanceCapture = parseWorkerPerformanceCapture(
|
||||
input.performanceCapture
|
||||
input.performanceCapture,
|
||||
input.operation,
|
||||
input.success
|
||||
);
|
||||
if (!performanceCapture) {
|
||||
return unavailable('worker-performance-capture-invalid');
|
||||
}
|
||||
if (
|
||||
!isFiniteNonNegativeNumber(input.responseEpochMs) ||
|
||||
performanceCapture.responsePostedEpochMs > input.responseEpochMs
|
||||
) {
|
||||
return unavailable('worker-performance-capture-invalid');
|
||||
}
|
||||
if (
|
||||
performanceCapture.phaseEvents.length > 0 &&
|
||||
(input.requestId === null ||
|
||||
performanceCapture.phaseEvents.some(
|
||||
(event) => event.requestId !== input.requestId
|
||||
))
|
||||
) {
|
||||
return unavailable('worker-performance-capture-invalid');
|
||||
}
|
||||
|
||||
return {
|
||||
...performanceCapture,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamScenarioId,
|
||||
} from './xtream-benchmark-contract';
|
||||
import { attributeXtreamPhases } from './xtream-phase-attribution';
|
||||
import { scenarioCapture } from './xtream-phase-attribution.fixtures';
|
||||
import {
|
||||
expectedXtreamIpc,
|
||||
expectedXtreamPhases,
|
||||
XTREAM_ATTRIBUTION_PHASE as PHASE,
|
||||
XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC,
|
||||
} from './xtream-phase-inventory';
|
||||
|
||||
const EXPECTED_PROVIDER_CALLS: Readonly<Record<XtreamScenarioId, number>> = {
|
||||
[XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: 8,
|
||||
[XTREAM_SCENARIO_ID.REFRESH_LARGE]: 8,
|
||||
[XTREAM_SCENARIO_ID.DELETE_LARGE]: 0,
|
||||
[XTREAM_SCENARIO_ID.CANCEL_IMPORT]: 6,
|
||||
[XTREAM_SCENARIO_ID.BACKGROUND_UI]: 8,
|
||||
};
|
||||
|
||||
const EXPECTED_ACCOUNT_CALLS: Readonly<Record<XtreamScenarioId, number>> = {
|
||||
[XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: 2,
|
||||
[XTREAM_SCENARIO_ID.REFRESH_LARGE]: 2,
|
||||
[XTREAM_SCENARIO_ID.DELETE_LARGE]: 0,
|
||||
[XTREAM_SCENARIO_ID.CANCEL_IMPORT]: 2,
|
||||
[XTREAM_SCENARIO_ID.BACKGROUND_UI]: 2,
|
||||
};
|
||||
|
||||
describe('Xtream account phase inventory', () => {
|
||||
it('counts every scenario-specific provider IPC and phase lifecycle', () => {
|
||||
for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) {
|
||||
const providerCalls = EXPECTED_PROVIDER_CALLS[scenarioId];
|
||||
assert.equal(
|
||||
expectedXtreamPhases(scenarioId).get(PHASE.NETWORK_TOTAL)
|
||||
?.occurrences,
|
||||
providerCalls
|
||||
);
|
||||
assert.equal(
|
||||
expectedXtreamIpc(scenarioId).get('xtreamRequest')?.success ??
|
||||
0,
|
||||
providerCalls
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('fixtures contain the exact account lifecycle count and remain valid', () => {
|
||||
for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) {
|
||||
const capture = scenarioCapture(scenarioId);
|
||||
const accounts = capture.events.filter(
|
||||
({ boundary, phase, semanticType }) =>
|
||||
boundary === 'start' &&
|
||||
phase === PHASE.NETWORK_TOTAL &&
|
||||
semanticType === SEMANTIC.ACCOUNT
|
||||
);
|
||||
assert.equal(accounts.length, EXPECTED_ACCOUNT_CALLS[scenarioId]);
|
||||
assert.doesNotThrow(() => attributeXtreamPhases(capture));
|
||||
}
|
||||
});
|
||||
|
||||
it('allows the two independent fresh-add account phases to overlap', () => {
|
||||
const capture = scenarioCapture(
|
||||
XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE
|
||||
);
|
||||
const events = capture.events.map((event) =>
|
||||
event.correlationId === 'provider-account-route'
|
||||
? { ...event, epochMs: event.epochMs - 6.5 }
|
||||
: event
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
attributeXtreamPhases({ ...capture, events })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
XTREAM_MAIN_PERFORMANCE_PHASE,
|
||||
XTREAM_PRELOAD_PERFORMANCE_METHOD,
|
||||
XtreamCodeActions,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
import {
|
||||
createXtreamIpcMarkerCapture,
|
||||
type XtreamIpcMarkerCapture,
|
||||
type XtreamIpcMarkerCaptureOptions,
|
||||
} from './xtream-ipc-marker-events';
|
||||
|
||||
const OPTIONS: XtreamIpcMarkerCaptureOptions = {
|
||||
actions: Object.values(XtreamCodeActions),
|
||||
mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE),
|
||||
methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD),
|
||||
schemaVersion: 1,
|
||||
};
|
||||
|
||||
describe('Xtream account phase correlation', () => {
|
||||
it('accepts overlapping null-session calls and links main phases FIFO', () => {
|
||||
const capture = createXtreamIpcMarkerCapture(OPTIONS);
|
||||
capture.start(42, 90);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 100, accountMarker(1, 99)),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 102, accountMarker(2, 101)),
|
||||
true
|
||||
);
|
||||
acceptAccountMainLifecycle(capture, 1, 103);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 113, accountMarker(1, 112, 'end')),
|
||||
true
|
||||
);
|
||||
acceptAccountMainLifecycle(capture, 2, 114);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 124, accountMarker(2, 123, 'end')),
|
||||
true
|
||||
);
|
||||
|
||||
const snapshot = capture.stop();
|
||||
assert.deepEqual(snapshot.invalidReasons, []);
|
||||
assert.equal(snapshot.quarantinedKeyCount, 0);
|
||||
const phases = snapshot.timeline.filter(
|
||||
({ type }) => type === 'xtream-main-phase'
|
||||
);
|
||||
assert.deepEqual(
|
||||
phases.map(({ action, ipcCallId }) => ({ action, ipcCallId })),
|
||||
[
|
||||
...Array.from({ length: 6 }, () => ({
|
||||
action: XtreamCodeActions.GetAccountInfo,
|
||||
ipcCallId: 1,
|
||||
})),
|
||||
...Array.from({ length: 6 }, () => ({
|
||||
action: XtreamCodeActions.GetAccountInfo,
|
||||
ipcCallId: 2,
|
||||
})),
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for duplicate and malformed preload call IDs', () => {
|
||||
const capture = createXtreamIpcMarkerCapture(OPTIONS);
|
||||
capture.start(42, 90);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 100, accountMarker(1, 99)),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 102, accountMarker(1, 101)),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
capture.acceptPreload(42, 104, accountMarker(0, 103)),
|
||||
false
|
||||
);
|
||||
|
||||
const snapshot = capture.stop();
|
||||
assert.equal(snapshot.quarantinedKeyCount, 1);
|
||||
assert.ok(
|
||||
snapshot.invalidReasons.includes('preload-marker-duplicate-key')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function acceptAccountMainLifecycle(
|
||||
capture: XtreamIpcMarkerCapture,
|
||||
ipcCallId: number,
|
||||
offset: number
|
||||
): void {
|
||||
const requestId = `xtream-account-${ipcCallId}`;
|
||||
acceptMainBoundary(
|
||||
capture,
|
||||
requestId,
|
||||
'xtream.network.total',
|
||||
offset + 1,
|
||||
'start'
|
||||
);
|
||||
acceptMainPair(
|
||||
capture,
|
||||
requestId,
|
||||
'xtream.json.transform',
|
||||
offset + 2,
|
||||
offset + 3
|
||||
);
|
||||
acceptMainBoundary(
|
||||
capture,
|
||||
requestId,
|
||||
'xtream.network.total',
|
||||
offset + 6,
|
||||
'end',
|
||||
5
|
||||
);
|
||||
acceptMainPair(
|
||||
capture,
|
||||
requestId,
|
||||
'xtream.response.ready',
|
||||
offset + 7,
|
||||
offset + 8
|
||||
);
|
||||
}
|
||||
|
||||
function acceptMainBoundary(
|
||||
capture: XtreamIpcMarkerCapture,
|
||||
requestId: string,
|
||||
phase: string,
|
||||
epochMs: number,
|
||||
boundary: 'end' | 'start',
|
||||
durationMs: number | null = null
|
||||
): void {
|
||||
assert.equal(
|
||||
capture.acceptMainPhase({
|
||||
boundary,
|
||||
durationMs,
|
||||
epochMs,
|
||||
phase,
|
||||
requestId,
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function acceptMainPair(
|
||||
capture: XtreamIpcMarkerCapture,
|
||||
requestId: string,
|
||||
phase: string,
|
||||
startEpochMs: number,
|
||||
endEpochMs: number
|
||||
): void {
|
||||
assert.equal(
|
||||
capture.acceptMainPhase({
|
||||
boundary: 'start',
|
||||
durationMs: null,
|
||||
epochMs: startEpochMs,
|
||||
phase,
|
||||
requestId,
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
capture.acceptMainPhase({
|
||||
boundary: 'end',
|
||||
durationMs: endEpochMs - startEpochMs,
|
||||
epochMs: endEpochMs,
|
||||
phase,
|
||||
requestId,
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function accountMarker(
|
||||
ipcCallId: number,
|
||||
sourceEpochMs: number,
|
||||
boundary: 'end' | 'start' = 'start'
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
sourceEpochMs,
|
||||
ipcCallId,
|
||||
method: 'xtreamRequest',
|
||||
boundary,
|
||||
outcome: boundary === 'start' ? null : 'success',
|
||||
playlistId: null,
|
||||
operationId: null,
|
||||
sessionId: null,
|
||||
action: XtreamCodeActions.GetAccountInfo,
|
||||
categoryType: null,
|
||||
contentType: null,
|
||||
itemCount: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function deriveXtreamAcquisitionDurationMs(
|
||||
networkTotalDurationMs: number,
|
||||
jsonTransformDurationMs: number
|
||||
): number {
|
||||
if (
|
||||
!Number.isFinite(networkTotalDurationMs) ||
|
||||
networkTotalDurationMs < 0 ||
|
||||
!Number.isFinite(jsonTransformDurationMs) ||
|
||||
jsonTransformDurationMs < 0 ||
|
||||
jsonTransformDurationMs > networkTotalDurationMs
|
||||
) {
|
||||
throw new Error('xtream-acquisition-duration-invalid');
|
||||
}
|
||||
return networkTotalDurationMs - jsonTransformDurationMs;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
serializeXtreamArtifactForPersistence,
|
||||
type XtreamDiagnosticArtifactEvidence,
|
||||
} from './xtream-diagnostic-artifacts';
|
||||
import { writeXtreamExclusiveArtifact } from './xtream-exclusive-artifact-writer';
|
||||
import { persistThenValidateXtreamIteration } from './xtream-benchmark-report';
|
||||
import { XTREAM_SYNTHETIC_CREDENTIALS } from './xtream-scenario-driver';
|
||||
import type {
|
||||
XtreamArtifactSafetyContext,
|
||||
XtreamRawIterationResult,
|
||||
XtreamValidatedIterationResult,
|
||||
} from './xtream-summary-contract';
|
||||
|
||||
export interface PersistedXtreamIteration {
|
||||
readonly diagnosticEvidence: XtreamDiagnosticArtifactEvidence | null;
|
||||
readonly iteration: XtreamValidatedIterationResult;
|
||||
}
|
||||
|
||||
export function createXtreamArtifactSafetyContext(
|
||||
controlToken: string
|
||||
): XtreamArtifactSafetyContext {
|
||||
return Object.freeze({
|
||||
controlToken,
|
||||
syntheticCredentials: XTREAM_SYNTHETIC_CREDENTIALS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function writeXtreamSafeJson(
|
||||
absolutePath: string,
|
||||
value: unknown,
|
||||
safety: XtreamArtifactSafetyContext
|
||||
): Promise<void> {
|
||||
const serialized = serializeXtreamArtifactForPersistence(value, safety);
|
||||
await writeXtreamExclusiveArtifact(absolutePath, serialized);
|
||||
}
|
||||
|
||||
export async function persistXtreamIteration(
|
||||
iterationDirectory: string,
|
||||
raw: XtreamRawIterationResult,
|
||||
diagnosticEvidence: XtreamDiagnosticArtifactEvidence | null,
|
||||
safety: XtreamArtifactSafetyContext
|
||||
): Promise<PersistedXtreamIteration> {
|
||||
const iteration = await persistThenValidateXtreamIteration(
|
||||
raw,
|
||||
safety,
|
||||
(serialized) =>
|
||||
writeXtreamExclusiveArtifact(
|
||||
join(iterationDirectory, 'result.json'),
|
||||
serialized
|
||||
)
|
||||
);
|
||||
return Object.freeze({ diagnosticEvidence, iteration });
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import { workspaceRoot } from '../electron-test-fixtures';
|
||||
import { createXtreamIterationDefinitions } from './xtream-benchmark-contract';
|
||||
import { BUILD_IDENTITY } from './xtream-benchmark-report.fixtures';
|
||||
import {
|
||||
type XtreamBenchmarkConfiguration,
|
||||
assertXtreamBenchmarkBuildUnchanged,
|
||||
assertXtreamBenchmarkOutputPathHasNoSymlinks,
|
||||
createXtreamBenchmarkManifest,
|
||||
resolveXtreamBenchmarkCdpPort,
|
||||
resolveXtreamBenchmarkConfiguration,
|
||||
resolveXtreamBenchmarkOutputLayout,
|
||||
XTREAM_BENCHMARK_MOCK_ORIGIN,
|
||||
} from './xtream-benchmark-configuration';
|
||||
import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract';
|
||||
|
||||
const SOURCE = Object.freeze({
|
||||
gitCommit: '0123456789abcdef0123456789abcdef01234567',
|
||||
gitDiffSha256:
|
||||
'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd',
|
||||
gitDirty: false,
|
||||
});
|
||||
const FIXTURE = Object.freeze({
|
||||
bytes: 12_345,
|
||||
catalogSha256:
|
||||
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
|
||||
counts: {
|
||||
categories: { live: 60, vod: 20, series: 20, total: 100 },
|
||||
items: {
|
||||
live: 60_000,
|
||||
vod: 20_000,
|
||||
series: 20_000,
|
||||
total: 100_000,
|
||||
},
|
||||
},
|
||||
epoch: 1,
|
||||
scenario: 'performance-100k',
|
||||
seed: 91_001,
|
||||
} as const);
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('Xtream benchmark output configuration', () => {
|
||||
it('accepts only a fresh timestamp directory directly below dist/performance', async () => {
|
||||
const repositoryRoot = await createRepositoryRoot();
|
||||
const performanceRoot = join(repositoryRoot, 'dist', 'performance');
|
||||
const outputRoot = join(
|
||||
performanceRoot,
|
||||
'20260728T123456Z-xtream-smoke'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot,
|
||||
outputRoot,
|
||||
'baseline'
|
||||
),
|
||||
{
|
||||
outputDirectory: join(outputRoot, 'baseline'),
|
||||
outputRoot,
|
||||
performanceRoot,
|
||||
}
|
||||
);
|
||||
|
||||
for (const requestedRoot of [
|
||||
'dist/performance/20260728T123456Z',
|
||||
performanceRoot,
|
||||
join(performanceRoot, 'nested', '20260728T123456Z'),
|
||||
join(performanceRoot, 'not-a-timestamp'),
|
||||
join(repositoryRoot, 'outside', '20260728T123456Z'),
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot,
|
||||
requestedRoot,
|
||||
'baseline'
|
||||
),
|
||||
/absolute fresh timestamp path below dist\/performance/
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot,
|
||||
outputRoot,
|
||||
'../after'
|
||||
),
|
||||
/safe Xtream variant/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects every existing symbolic-link component', async () => {
|
||||
const repositoryRoot = await createRepositoryRoot();
|
||||
const realDist = join(repositoryRoot, 'real-dist');
|
||||
await mkdir(realDist);
|
||||
await import('node:fs/promises').then(({ symlink }) =>
|
||||
symlink(realDist, join(repositoryRoot, 'dist'))
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
assertXtreamBenchmarkOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
join(
|
||||
repositoryRoot,
|
||||
'dist',
|
||||
'performance',
|
||||
'20260728T123456Z'
|
||||
)
|
||||
),
|
||||
/contains a symbolic link/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xtream benchmark mode configuration', () => {
|
||||
it('pins formal CDP to 9222 and bounds smoke-only alternate ports', () => {
|
||||
assert.equal(resolveXtreamBenchmarkCdpPort('formal', undefined), 9222);
|
||||
assert.equal(resolveXtreamBenchmarkCdpPort('smoke', '9322'), 9322);
|
||||
assert.throws(
|
||||
() => resolveXtreamBenchmarkCdpPort('formal', '9322'),
|
||||
/Formal Xtream performance runs require CDP port 9222/
|
||||
);
|
||||
for (const requested of ['1023', '65536', '9322.5', '0x246a']) {
|
||||
assert.throws(
|
||||
() => resolveXtreamBenchmarkCdpPort('smoke', requested),
|
||||
/IPTVNATOR_PERF_CDP_PORT is invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates output only after preflight and uses exclusive child mkdir calls', async () => {
|
||||
const repositoryRoot = await createRepositoryRoot();
|
||||
const performanceRoot = join(repositoryRoot, 'dist', 'performance');
|
||||
await mkdir(performanceRoot, { recursive: true });
|
||||
const outputRoot = join(
|
||||
performanceRoot,
|
||||
'20260728T123456Z-xtream-smoke'
|
||||
);
|
||||
const calls: string[] = [];
|
||||
const configuration = await resolveXtreamBenchmarkConfiguration({
|
||||
dependencies: {
|
||||
assertCapacity: async () => {
|
||||
calls.push('preflight');
|
||||
return {
|
||||
availableBytes: BigInt(3 * 1_024 ** 3),
|
||||
checkedPath: performanceRoot,
|
||||
minimumBytes: BigInt(2 * 1_024 ** 3),
|
||||
};
|
||||
},
|
||||
captureBuildIdentity: async () => BUILD_IDENTITY,
|
||||
mkdir: async (path, options) => {
|
||||
calls.push(
|
||||
`mkdir:${path}:${String(
|
||||
options?.recursive ?? false
|
||||
)}:${String(options?.mode ?? 'default')}`
|
||||
);
|
||||
await mkdir(path, options);
|
||||
},
|
||||
readElectronVersion: async () => '41.0.0',
|
||||
readGitSourceState: () => SOURCE,
|
||||
},
|
||||
environment: {
|
||||
IPTVNATOR_PERF_CDP_PORT: '9322',
|
||||
IPTVNATOR_PERF_OUTPUT_DIR: outputRoot,
|
||||
IPTVNATOR_PERF_SMOKE: '1',
|
||||
IPTVNATOR_PERF_VARIANT: 'smoke',
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'a'.repeat(64),
|
||||
},
|
||||
workspaceRoot: repositoryRoot,
|
||||
});
|
||||
|
||||
assert.equal(calls[0], 'preflight');
|
||||
assert.deepEqual(calls.slice(1), [
|
||||
`mkdir:${performanceRoot}:true:${0o700}`,
|
||||
`mkdir:${outputRoot}:false:${0o700}`,
|
||||
`mkdir:${join(outputRoot, 'smoke')}:false:${0o700}`,
|
||||
]);
|
||||
assert.equal((await stat(outputRoot)).mode & 0o777, 0o700);
|
||||
assert.equal(
|
||||
(await stat(join(outputRoot, 'smoke'))).mode & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert.equal(configuration.mode, 'smoke');
|
||||
assert.equal(configuration.measuredRuns, 1);
|
||||
assert.equal(configuration.outputDirectory, join(outputRoot, 'smoke'));
|
||||
assert.equal(configuration.mockOrigin, XTREAM_BENCHMARK_MOCK_ORIGIN);
|
||||
assert.equal(
|
||||
createXtreamIterationDefinitions(configuration.smoke)
|
||||
.filter((entry) => entry.kind === 'measured')
|
||||
.every((entry) => !entry.eligibleForComparison),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('fails formal resolution before preflight when tracked source is dirty', async () => {
|
||||
const repositoryRoot = await createRepositoryRoot();
|
||||
let preflightCalled = false;
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveXtreamBenchmarkConfiguration({
|
||||
dependencies: {
|
||||
assertCapacity: async () => {
|
||||
preflightCalled = true;
|
||||
throw new Error('unexpected preflight');
|
||||
},
|
||||
captureBuildIdentity: async () => BUILD_IDENTITY,
|
||||
readElectronVersion: async () => '41.0.0',
|
||||
readGitSourceState: () => ({
|
||||
...SOURCE,
|
||||
gitDirty: true,
|
||||
}),
|
||||
},
|
||||
environment: {
|
||||
IPTVNATOR_PERF_OUTPUT_DIR: join(
|
||||
repositoryRoot,
|
||||
'dist',
|
||||
'performance',
|
||||
'20260728T123456Z-xtream'
|
||||
),
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'b'.repeat(64),
|
||||
},
|
||||
workspaceRoot: repositoryRoot,
|
||||
}),
|
||||
/Formal Xtream performance runs require a clean Git worktree/
|
||||
);
|
||||
assert.equal(preflightCalled, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xtream benchmark manifest provenance', () => {
|
||||
it('creates an exact manifest from the canonical prepared mock fixture only', () => {
|
||||
const configuration = configurationFixture();
|
||||
const manifest = createXtreamBenchmarkManifest(configuration, FIXTURE);
|
||||
|
||||
assert.equal(manifest.fixture.categories, 100);
|
||||
assert.equal(manifest.fixture.items, 100_000);
|
||||
assert.deepEqual(manifest.build, BUILD_IDENTITY);
|
||||
assert.deepEqual(manifest.source, SOURCE);
|
||||
assert.deepEqual(manifest.memoryAccounting, XTREAM_MEMORY_ACCOUNTING);
|
||||
assert.equal(
|
||||
JSON.stringify(manifest).includes(configuration.controlToken),
|
||||
false
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkManifest(configuration, {
|
||||
...FIXTURE,
|
||||
scenario: 'large',
|
||||
}),
|
||||
/invalid Xtream performance fixture/
|
||||
);
|
||||
});
|
||||
|
||||
it('recaptures and requires byte-identical build identity at suite end', async () => {
|
||||
const configuration = configurationFixture();
|
||||
await assert.doesNotReject(() =>
|
||||
assertXtreamBenchmarkBuildUnchanged(configuration, async () =>
|
||||
structuredClone(BUILD_IDENTITY)
|
||||
)
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
assertXtreamBenchmarkBuildUnchanged(
|
||||
configuration,
|
||||
async () => ({
|
||||
...BUILD_IDENTITY,
|
||||
renderer: {
|
||||
...BUILD_IDENTITY.renderer,
|
||||
aggregateSha256: 'f'.repeat(64),
|
||||
},
|
||||
})
|
||||
),
|
||||
/Xtream performance build changed during the suite/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xtream Playwright and Nx plumbing', () => {
|
||||
it('uses one isolated control-enabled mock and one no-retry 30-minute test', async () => {
|
||||
const configPath = join(
|
||||
workspaceRoot,
|
||||
'apps/electron-backend-e2e/playwright.xtream-performance.config.ts'
|
||||
);
|
||||
const source = await readFile(configPath, 'utf8');
|
||||
|
||||
assert.match(source, /randomBytes\(32\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/process\.env\['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'\]\s*\?\?/
|
||||
);
|
||||
assert.match(source, /http:\/\/127\.0\.0\.1:3221\/health/);
|
||||
assert.match(source, /reuseExistingServer:\s*false/);
|
||||
assert.match(source, /IPTVNATOR_XTREAM_MOCK_CONTROL:\s*'1'/);
|
||||
assert.match(
|
||||
source,
|
||||
/IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN:\s*controlToken/
|
||||
);
|
||||
assert.match(source, /HOST:\s*'127\.0\.0\.1'/);
|
||||
assert.match(source, /PORT:\s*'3221'/);
|
||||
assert.match(source, /retries:\s*0/);
|
||||
assert.match(source, /workers:\s*1/);
|
||||
assert.match(source, /timeout:\s*30\s*\*\s*60\s*\*\s*1_000/);
|
||||
});
|
||||
|
||||
it('pins the Nx target and keeps the entrypoint to one orchestrator call', async () => {
|
||||
const [project, entrypoint] = await Promise.all([
|
||||
readFile(
|
||||
join(workspaceRoot, 'apps/electron-backend-e2e/project.json'),
|
||||
'utf8'
|
||||
).then(
|
||||
(value) =>
|
||||
JSON.parse(value) as {
|
||||
targets: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
),
|
||||
readFile(
|
||||
join(
|
||||
workspaceRoot,
|
||||
'apps/electron-backend-e2e/src/xtream.performance.ts'
|
||||
),
|
||||
'utf8'
|
||||
),
|
||||
]);
|
||||
const target = project.targets['benchmark-xtream'];
|
||||
|
||||
assert.deepEqual(target?.['dependsOn'], [
|
||||
'electron-backend:build-performance',
|
||||
]);
|
||||
assert.equal(target?.['cache'], false);
|
||||
assert.equal(target?.['parallelism'], false);
|
||||
assert.equal(
|
||||
(target?.['options'] as Record<string, unknown>)?.['command'],
|
||||
'pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts'
|
||||
);
|
||||
assert.equal(
|
||||
(entrypoint.match(/runXtreamBenchmark\(\)/g) ?? []).length,
|
||||
1
|
||||
);
|
||||
assert.equal((entrypoint.match(/\btest\(/g) ?? []).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
async function createRepositoryRoot(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'iptvnator-xtream-config-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function configurationFixture(): XtreamBenchmarkConfiguration {
|
||||
return {
|
||||
build: BUILD_IDENTITY,
|
||||
cdpPort: 9222,
|
||||
controlToken: 'c'.repeat(64),
|
||||
electronVersion: '41.0.0',
|
||||
measuredRuns: 5,
|
||||
mockOrigin: XTREAM_BENCHMARK_MOCK_ORIGIN,
|
||||
mode: 'formal' as const,
|
||||
outputDirectory: resolve('/tmp/xtream/baseline'),
|
||||
outputRoot: resolve('/tmp/xtream'),
|
||||
runtime: {
|
||||
arch: 'arm64',
|
||||
electronVersion: '41.0.0',
|
||||
harnessNodeVersion: '22.0.0',
|
||||
platform: 'darwin',
|
||||
},
|
||||
smoke: false,
|
||||
source: SOURCE,
|
||||
variant: 'baseline',
|
||||
workspaceRoot: resolve('/repository'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { mkdir, readFile } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { workspaceRoot as defaultWorkspaceRoot } from '../electron-test-fixtures';
|
||||
import type {
|
||||
XtreamBenchmarkManifest,
|
||||
XtreamBenchmarkRuntime,
|
||||
XtreamBenchmarkSource,
|
||||
XtreamPerformanceManifest,
|
||||
} from './xtream-benchmark-contract';
|
||||
import { parseXtreamBenchmarkManifest } from './xtream-benchmark-manifest-schema';
|
||||
import {
|
||||
captureXtreamBuildIdentity,
|
||||
type XtreamBenchmarkBuildIdentity,
|
||||
} from './xtream-build-identity';
|
||||
import { parseXtreamBenchmarkBuildIdentity } from './xtream-build-identity-schema';
|
||||
import { parseXtreamManifest } from './xtream-control-state-schema';
|
||||
import {
|
||||
assertXtreamBenchmarkOutputPathHasNoSymlinks,
|
||||
createExclusiveXtreamBenchmarkOutputLayout,
|
||||
resolveXtreamBenchmarkCdpPort,
|
||||
resolveXtreamBenchmarkOutputLayout,
|
||||
type XtreamMkdir,
|
||||
} from './xtream-benchmark-output-configuration';
|
||||
import {
|
||||
assertPerformanceArtifactCapacity,
|
||||
type PerformanceArtifactCapacity,
|
||||
} from './performance-artifact-preflight';
|
||||
import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract';
|
||||
|
||||
export const XTREAM_BENCHMARK_MOCK_ORIGIN = 'http://127.0.0.1:3221';
|
||||
const CONTROL_TOKEN = /^[A-Za-z0-9_-]{43,128}$/u;
|
||||
|
||||
export {
|
||||
assertXtreamBenchmarkOutputPathHasNoSymlinks,
|
||||
resolveXtreamBenchmarkCdpPort,
|
||||
resolveXtreamBenchmarkOutputLayout,
|
||||
XTREAM_BENCHMARK_FORMAL_CDP_PORT,
|
||||
} from './xtream-benchmark-output-configuration';
|
||||
|
||||
export interface XtreamBenchmarkConfiguration {
|
||||
readonly build: XtreamBenchmarkBuildIdentity;
|
||||
readonly cdpPort: number;
|
||||
/** Control-plane secret. Never serialize or log this field. */
|
||||
readonly controlToken: string;
|
||||
readonly electronVersion: string;
|
||||
readonly measuredRuns: 1 | 5;
|
||||
readonly mockOrigin: typeof XTREAM_BENCHMARK_MOCK_ORIGIN;
|
||||
readonly mode: 'formal' | 'smoke';
|
||||
readonly outputDirectory: string;
|
||||
readonly outputRoot: string;
|
||||
readonly runtime: XtreamBenchmarkRuntime;
|
||||
readonly smoke: boolean;
|
||||
readonly source: XtreamBenchmarkSource;
|
||||
readonly variant: string;
|
||||
readonly workspaceRoot: string;
|
||||
}
|
||||
|
||||
type XtreamGitSourceState = XtreamBenchmarkSource;
|
||||
|
||||
interface XtreamConfigurationDependencies {
|
||||
readonly assertCapacity?: (
|
||||
targetPath: string
|
||||
) => Promise<PerformanceArtifactCapacity>;
|
||||
readonly captureBuildIdentity?: (
|
||||
workspaceRoot: string
|
||||
) => Promise<XtreamBenchmarkBuildIdentity>;
|
||||
readonly mkdir?: XtreamMkdir;
|
||||
readonly readElectronVersion?: (workspaceRoot: string) => Promise<string>;
|
||||
readonly readGitSourceState?: (
|
||||
workspaceRoot: string
|
||||
) => XtreamGitSourceState;
|
||||
}
|
||||
|
||||
export interface ResolveXtreamBenchmarkConfigurationOptions {
|
||||
readonly dependencies?: XtreamConfigurationDependencies;
|
||||
readonly environment?: NodeJS.ProcessEnv;
|
||||
readonly workspaceRoot?: string;
|
||||
}
|
||||
|
||||
export async function resolveXtreamBenchmarkConfiguration(
|
||||
options: ResolveXtreamBenchmarkConfigurationOptions = {}
|
||||
): Promise<XtreamBenchmarkConfiguration> {
|
||||
const environment = options.environment ?? process.env;
|
||||
const repositoryRoot = resolve(
|
||||
options.workspaceRoot ?? defaultWorkspaceRoot
|
||||
);
|
||||
const requestedRoot = environment['IPTVNATOR_PERF_OUTPUT_DIR'];
|
||||
if (!requestedRoot) {
|
||||
throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required');
|
||||
}
|
||||
const smoke = resolveSmokeMode(environment['IPTVNATOR_PERF_SMOKE']);
|
||||
const mode = smoke ? 'smoke' : 'formal';
|
||||
const variant = environment['IPTVNATOR_PERF_VARIANT'] ?? 'baseline';
|
||||
const layout = resolveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot,
|
||||
requestedRoot,
|
||||
variant
|
||||
);
|
||||
const cdpPort = resolveXtreamBenchmarkCdpPort(
|
||||
mode,
|
||||
environment['IPTVNATOR_PERF_CDP_PORT']
|
||||
);
|
||||
const controlToken =
|
||||
environment['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ?? '';
|
||||
if (!CONTROL_TOKEN.test(controlToken)) {
|
||||
throw new Error(
|
||||
'Xtream benchmark requires a high-entropy mock control token'
|
||||
);
|
||||
}
|
||||
|
||||
const dependencies = options.dependencies ?? {};
|
||||
const readSource = dependencies.readGitSourceState ?? readGitSourceState;
|
||||
const source = Object.freeze(readSource(repositoryRoot));
|
||||
assertSourceState(mode, source);
|
||||
const captureBuild =
|
||||
dependencies.captureBuildIdentity ?? captureXtreamBuildIdentity;
|
||||
const build = parseXtreamBenchmarkBuildIdentity(
|
||||
await captureBuild(repositoryRoot)
|
||||
);
|
||||
const electronVersion = await (
|
||||
dependencies.readElectronVersion ?? readElectronRuntimeVersion
|
||||
)(repositoryRoot);
|
||||
if (!electronVersion.trim()) {
|
||||
throw new Error('Unable to resolve the Electron runtime version');
|
||||
}
|
||||
|
||||
await assertXtreamBenchmarkOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
layout.outputRoot
|
||||
);
|
||||
await (dependencies.assertCapacity ?? assertPerformanceArtifactCapacity)(
|
||||
layout.outputDirectory
|
||||
);
|
||||
await createExclusiveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot,
|
||||
layout,
|
||||
dependencies.mkdir ?? mkdir
|
||||
);
|
||||
|
||||
const runtime = Object.freeze({
|
||||
arch: process.arch,
|
||||
electronVersion,
|
||||
harnessNodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
});
|
||||
return Object.freeze({
|
||||
build,
|
||||
cdpPort,
|
||||
controlToken,
|
||||
electronVersion,
|
||||
measuredRuns: smoke ? 1 : 5,
|
||||
mockOrigin: XTREAM_BENCHMARK_MOCK_ORIGIN,
|
||||
mode,
|
||||
outputDirectory: layout.outputDirectory,
|
||||
outputRoot: layout.outputRoot,
|
||||
runtime,
|
||||
smoke,
|
||||
source,
|
||||
variant,
|
||||
workspaceRoot: repositoryRoot,
|
||||
});
|
||||
}
|
||||
|
||||
export function createXtreamBenchmarkManifest(
|
||||
configuration: XtreamBenchmarkConfiguration,
|
||||
preparedFixture: unknown
|
||||
): XtreamBenchmarkManifest {
|
||||
let fixture: XtreamPerformanceManifest;
|
||||
try {
|
||||
fixture = parseXtreamManifest(preparedFixture);
|
||||
} catch {
|
||||
throw new Error('invalid Xtream performance fixture');
|
||||
}
|
||||
return parseXtreamBenchmarkManifest({
|
||||
build: configuration.build,
|
||||
environment: {
|
||||
cdpAddress: '127.0.0.1',
|
||||
cdpPort: configuration.cdpPort,
|
||||
databaseWorkerBatchDelayMs: 0,
|
||||
exposeGc: true,
|
||||
freshUserDataPerIteration: true,
|
||||
perfCapture: '1',
|
||||
perfWorkerProfiling: '1',
|
||||
traceRendererConsole: '0',
|
||||
},
|
||||
fixture: {
|
||||
bytes: fixture.bytes,
|
||||
catalogSha256: fixture.catalogSha256,
|
||||
categories: fixture.counts.categories.total,
|
||||
items: fixture.counts.items.total,
|
||||
scenario: fixture.scenario,
|
||||
seed: fixture.seed,
|
||||
},
|
||||
memoryAccounting: XTREAM_MEMORY_ACCOUNTING,
|
||||
mode: configuration.mode,
|
||||
runtime: configuration.runtime,
|
||||
source: configuration.source,
|
||||
suite: 'xtream-performance-100k',
|
||||
variant: configuration.variant,
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertXtreamBenchmarkBuildUnchanged(
|
||||
configuration: XtreamBenchmarkConfiguration,
|
||||
capture: (
|
||||
workspaceRoot: string
|
||||
) => Promise<XtreamBenchmarkBuildIdentity> = captureXtreamBuildIdentity
|
||||
): Promise<void> {
|
||||
let current: XtreamBenchmarkBuildIdentity;
|
||||
try {
|
||||
current = parseXtreamBenchmarkBuildIdentity(
|
||||
await capture(configuration.workspaceRoot)
|
||||
);
|
||||
} catch {
|
||||
throw new Error('Xtream performance build changed during the suite');
|
||||
}
|
||||
if (!isDeepStrictEqual(configuration.build, current)) {
|
||||
throw new Error('Xtream performance build changed during the suite');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSmokeMode(value: string | undefined): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (value === '1') return true;
|
||||
throw new Error('IPTVNATOR_PERF_SMOKE is invalid');
|
||||
}
|
||||
|
||||
function assertSourceState(
|
||||
mode: 'formal' | 'smoke',
|
||||
source: XtreamGitSourceState
|
||||
): void {
|
||||
if (mode === 'formal' && source.gitDirty) {
|
||||
throw new Error(
|
||||
'Formal Xtream performance runs require a clean Git worktree'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readGitSourceState(repositoryRoot: string): XtreamGitSourceState {
|
||||
const git = (arguments_: readonly string[]): string =>
|
||||
execFileSync('git', [...arguments_], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const gitCommit = git(['rev-parse', 'HEAD']).trim();
|
||||
const status = git(['status', '--porcelain=v1', '--untracked-files=all']);
|
||||
const gitDiffSha256 = createHash('sha256')
|
||||
.update(status)
|
||||
.update('\0')
|
||||
.update(git(['diff', '--binary', 'HEAD']))
|
||||
.update('\0')
|
||||
.update(git(['diff', '--binary', '--cached', 'HEAD']))
|
||||
.digest('hex');
|
||||
return Object.freeze({
|
||||
gitCommit,
|
||||
gitDiffSha256,
|
||||
gitDirty: status.trim().length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function readElectronRuntimeVersion(
|
||||
repositoryRoot: string
|
||||
): Promise<string> {
|
||||
const value = JSON.parse(
|
||||
await readFile(
|
||||
join(repositoryRoot, 'node_modules', 'electron', 'package.json'),
|
||||
'utf8'
|
||||
)
|
||||
) as { version?: unknown };
|
||||
if (typeof value.version !== 'string' || !value.version.trim()) {
|
||||
throw new Error('Unable to resolve the Electron runtime version');
|
||||
}
|
||||
return value.version;
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
assertXtreamArtifactRedacted,
|
||||
assertXtreamControlReadyAfterObservationReset,
|
||||
assertXtreamControlStateForScenario,
|
||||
assertXtreamManifest,
|
||||
assertXtreamManifestIdentity,
|
||||
assertXtreamMockOrigin,
|
||||
createXtreamIterationDefinitions,
|
||||
listXtreamBenchmarkScenarios,
|
||||
XTREAM_ITERATION_KIND,
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamControlState,
|
||||
type XtreamPerformanceManifest,
|
||||
} from './xtream-benchmark-contract';
|
||||
|
||||
const MANIFEST: XtreamPerformanceManifest = {
|
||||
epoch: 1,
|
||||
scenario: 'performance-100k',
|
||||
seed: 91_001,
|
||||
counts: {
|
||||
categories: { live: 60, vod: 20, series: 20, total: 100 },
|
||||
items: {
|
||||
live: 60_000,
|
||||
vod: 20_000,
|
||||
series: 20_000,
|
||||
total: 100_000,
|
||||
},
|
||||
},
|
||||
bytes: 12_345_678,
|
||||
catalogSha256:
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
};
|
||||
|
||||
describe('Xtream benchmark contract', () => {
|
||||
it('defines exactly the five requested scenarios', () => {
|
||||
assert.deepEqual(
|
||||
listXtreamBenchmarkScenarios().map(({ id }) => id),
|
||||
[
|
||||
XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
XTREAM_SCENARIO_ID.REFRESH_LARGE,
|
||||
XTREAM_SCENARIO_ID.DELETE_LARGE,
|
||||
XTREAM_SCENARIO_ID.CANCEL_IMPORT,
|
||||
XTREAM_SCENARIO_ID.BACKGROUND_UI,
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('schedules warm-up, five comparable measurements, and one diagnostic', () => {
|
||||
const definitions = createXtreamIterationDefinitions(false);
|
||||
|
||||
assert.equal(definitions.length, 35);
|
||||
for (const { id } of listXtreamBenchmarkScenarios()) {
|
||||
const runs = definitions.filter(
|
||||
(definition) => definition.scenarioId === id
|
||||
);
|
||||
assert.deepEqual(
|
||||
runs.map(({ eligibleForComparison, kind, runId }) => ({
|
||||
eligibleForComparison,
|
||||
kind,
|
||||
runId,
|
||||
})),
|
||||
[
|
||||
{
|
||||
eligibleForComparison: false,
|
||||
kind: XTREAM_ITERATION_KIND.WARMUP,
|
||||
runId: 'warmup-01',
|
||||
},
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
eligibleForComparison: true,
|
||||
kind: XTREAM_ITERATION_KIND.MEASURED,
|
||||
runId: `run-${String(index + 1).padStart(2, '0')}`,
|
||||
})),
|
||||
{
|
||||
eligibleForComparison: false,
|
||||
kind: XTREAM_ITERATION_KIND.DIAGNOSTIC,
|
||||
runId: 'diagnostic',
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps smoke measurements ineligible for formal comparison', () => {
|
||||
const definitions = createXtreamIterationDefinitions(true);
|
||||
|
||||
assert.equal(definitions.length, 15);
|
||||
assert.ok(
|
||||
definitions.every((definition) => !definition.eligibleForComparison)
|
||||
);
|
||||
for (const { id } of listXtreamBenchmarkScenarios()) {
|
||||
assert.deepEqual(
|
||||
definitions
|
||||
.filter((definition) => definition.scenarioId === id)
|
||||
.map(({ kind, runId }) => ({ kind, runId })),
|
||||
[
|
||||
{
|
||||
kind: XTREAM_ITERATION_KIND.WARMUP,
|
||||
runId: 'warmup-01',
|
||||
},
|
||||
{
|
||||
kind: XTREAM_ITERATION_KIND.MEASURED,
|
||||
runId: 'run-01',
|
||||
},
|
||||
{
|
||||
kind: XTREAM_ITERATION_KIND.DIAGNOSTIC,
|
||||
runId: 'diagnostic',
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts only an exact credential-free HTTP loopback origin', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertXtreamMockOrigin('http://127.0.0.1:43123')
|
||||
);
|
||||
|
||||
for (const unsafe of [
|
||||
'https://127.0.0.1:43123',
|
||||
'http://localhost:43123',
|
||||
'http://127.0.0.1',
|
||||
'http://127.0.0.1:43123/',
|
||||
'http://user:pass@127.0.0.1:43123',
|
||||
'http://127.0.0.1:43123/player_api.php',
|
||||
'http://127.0.0.1:43123?token=secret',
|
||||
'http://127.0.0.1:43123#fragment',
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertXtreamMockOrigin(unsafe),
|
||||
/exact Xtream HTTP loopback origin/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unexpected fixture names, counts, hashes, and identity drift', () => {
|
||||
assert.doesNotThrow(() => assertXtreamManifest(MANIFEST));
|
||||
|
||||
for (const invalid of [
|
||||
{ ...MANIFEST, scenario: 'provider-data' },
|
||||
{ ...MANIFEST, seed: 42 },
|
||||
{
|
||||
...MANIFEST,
|
||||
counts: {
|
||||
...MANIFEST.counts,
|
||||
items: { ...MANIFEST.counts.items, total: 99_999 },
|
||||
},
|
||||
},
|
||||
{ ...MANIFEST, catalogSha256: 'not-a-hash' },
|
||||
{ ...MANIFEST, username: 'performance' },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertXtreamManifest(invalid),
|
||||
/invalid Xtream performance manifest/
|
||||
);
|
||||
}
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
assertXtreamManifestIdentity(MANIFEST, {
|
||||
...MANIFEST,
|
||||
epoch: 2,
|
||||
})
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertXtreamManifestIdentity(MANIFEST, {
|
||||
...MANIFEST,
|
||||
epoch: 2,
|
||||
bytes: MANIFEST.bytes + 1,
|
||||
}),
|
||||
/fixture identity changed/
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a clean, same-epoch pre-trigger state after observation reset', () => {
|
||||
const ready = controlState([]);
|
||||
assert.doesNotThrow(() =>
|
||||
assertXtreamControlReadyAfterObservationReset(ready, MANIFEST)
|
||||
);
|
||||
|
||||
for (const invalid of [
|
||||
{ ...ready, epoch: 2 },
|
||||
{ ...ready, rules: [{ kind: 'barrier' }] },
|
||||
{ ...ready, heldCount: 1, heldIds: ['leftover'] },
|
||||
controlState([['get_account_info', 'all', 1]]),
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertXtreamControlReadyAfterObservationReset(
|
||||
invalid,
|
||||
MANIFEST
|
||||
),
|
||||
/invalid Xtream mock readiness/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows category requests to arrive in any parallel order', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertXtreamControlStateForScenario(
|
||||
XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
controlState(
|
||||
[
|
||||
['get_account_info', 'all', 1],
|
||||
['get_series_categories', 'all', 2],
|
||||
['get_vod_categories', 'all', 3],
|
||||
['get_live_categories', 'all', 4],
|
||||
['get_live_streams', 'all', 5],
|
||||
['get_vod_streams', 'all', 6],
|
||||
['get_series', 'all', 7],
|
||||
],
|
||||
2
|
||||
),
|
||||
MANIFEST
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed on action drift, proxy traffic, residue, and ordering drift', () => {
|
||||
const cases: XtreamControlState[] = [
|
||||
controlState(fullActions().slice(0, -1), 2),
|
||||
controlState([...fullActions(), ['get_vod_info', 'all', 8]], 2),
|
||||
{
|
||||
...controlState(fullActions(), 2),
|
||||
occurrences: controlState(fullActions(), 2).occurrences.map(
|
||||
(entry, index) =>
|
||||
index === 0 ? { ...entry, transport: 'proxy' } : entry
|
||||
),
|
||||
},
|
||||
{
|
||||
...controlState(fullActions(), 2),
|
||||
rules: [{ kind: 'delay' }],
|
||||
},
|
||||
{
|
||||
...controlState(fullActions(), 2),
|
||||
heldCount: 1,
|
||||
heldIds: ['leftover'],
|
||||
},
|
||||
controlState(
|
||||
[
|
||||
['get_account_info', 'all', 1],
|
||||
['get_live_streams', 'all', 2],
|
||||
['get_live_categories', 'all', 3],
|
||||
['get_vod_categories', 'all', 4],
|
||||
['get_series_categories', 'all', 5],
|
||||
['get_vod_streams', 'all', 6],
|
||||
['get_series', 'all', 7],
|
||||
],
|
||||
2
|
||||
),
|
||||
];
|
||||
|
||||
for (const invalid of cases) {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertXtreamControlStateForScenario(
|
||||
XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
invalid,
|
||||
MANIFEST
|
||||
),
|
||||
/invalid Xtream mock observations/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects credential keys and secret values from persisted artifacts', () => {
|
||||
assert.doesNotThrow(() =>
|
||||
assertXtreamArtifactRedacted({
|
||||
fixture: {
|
||||
bytes: MANIFEST.bytes,
|
||||
catalogSha256: MANIFEST.catalogSha256,
|
||||
},
|
||||
})
|
||||
);
|
||||
for (const unsafe of [
|
||||
{ username: 'performance' },
|
||||
{ nested: { password: 'performance' } },
|
||||
{ serverUrl: 'http://127.0.0.1:43123' },
|
||||
{ note: 'leaked benchmark-control-token' },
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertXtreamArtifactRedacted(unsafe, [
|
||||
'benchmark-control-token',
|
||||
]),
|
||||
/credential-safe/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
type Action = readonly [string, string, number];
|
||||
|
||||
function fullActions(): readonly Action[] {
|
||||
return [
|
||||
['get_account_info', 'all', 1],
|
||||
['get_live_categories', 'all', 2],
|
||||
['get_vod_categories', 'all', 3],
|
||||
['get_series_categories', 'all', 4],
|
||||
['get_live_streams', 'all', 5],
|
||||
['get_vod_streams', 'all', 6],
|
||||
['get_series', 'all', 7],
|
||||
];
|
||||
}
|
||||
|
||||
function controlState(
|
||||
actions: readonly Action[],
|
||||
accountCount = 1
|
||||
): XtreamControlState {
|
||||
return {
|
||||
epoch: MANIFEST.epoch,
|
||||
prepared: MANIFEST,
|
||||
rules: [],
|
||||
heldIds: [],
|
||||
heldCount: 0,
|
||||
occurrences: actions.map(([action, categoryId]) => ({
|
||||
scenario: 'performance-100k',
|
||||
transport: 'direct',
|
||||
action,
|
||||
categoryId,
|
||||
count: action === 'get_account_info' ? accountCount : 1,
|
||||
})),
|
||||
ledger: actions.flatMap(([action, categoryId, order]) =>
|
||||
Array.from(
|
||||
{
|
||||
length: action === 'get_account_info' ? accountCount : 1,
|
||||
},
|
||||
(_, index) => {
|
||||
const shiftedOrder =
|
||||
order +
|
||||
index +
|
||||
(action === 'get_account_info' ? 0 : accountCount - 1);
|
||||
return [
|
||||
{
|
||||
epoch: MANIFEST.epoch,
|
||||
scenario: 'performance-100k',
|
||||
transport: 'direct' as const,
|
||||
action,
|
||||
categoryId,
|
||||
occurrence: index + 1,
|
||||
status: 'arrived' as const,
|
||||
atMs: shiftedOrder * 10,
|
||||
},
|
||||
{
|
||||
epoch: MANIFEST.epoch,
|
||||
scenario: 'performance-100k',
|
||||
transport: 'direct' as const,
|
||||
action,
|
||||
categoryId,
|
||||
occurrence: index + 1,
|
||||
status: 'responded' as const,
|
||||
atMs: shiftedOrder * 10 + 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
).flat()
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import type { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract';
|
||||
import type { XtreamBenchmarkBuildIdentity } from './xtream-build-identity';
|
||||
|
||||
export const XTREAM_SCENARIO_ID = {
|
||||
INITIAL_IMPORT_LARGE: 'xtream-initial-import-large',
|
||||
REFRESH_LARGE: 'xtream-refresh-large',
|
||||
DELETE_LARGE: 'xtream-delete-large',
|
||||
CANCEL_IMPORT: 'xtream-cancel-import',
|
||||
BACKGROUND_UI: 'xtream-background-ui',
|
||||
} as const;
|
||||
|
||||
export type XtreamScenarioId =
|
||||
(typeof XTREAM_SCENARIO_ID)[keyof typeof XTREAM_SCENARIO_ID];
|
||||
|
||||
export const XTREAM_ITERATION_KIND = {
|
||||
DIAGNOSTIC: 'diagnostic',
|
||||
MEASURED: 'measured',
|
||||
WARMUP: 'warmup',
|
||||
} as const;
|
||||
|
||||
export type XtreamIterationKind =
|
||||
(typeof XTREAM_ITERATION_KIND)[keyof typeof XTREAM_ITERATION_KIND];
|
||||
|
||||
export interface XtreamScenario {
|
||||
readonly id: XtreamScenarioId;
|
||||
}
|
||||
|
||||
export interface XtreamWorkerRequestInventoryEntry {
|
||||
readonly count: number;
|
||||
readonly operation: string;
|
||||
readonly phases: readonly string[];
|
||||
readonly success: boolean;
|
||||
}
|
||||
|
||||
export interface XtreamIterationDefinition {
|
||||
readonly eligibleForComparison: boolean;
|
||||
readonly kind: XtreamIterationKind;
|
||||
readonly runId: string;
|
||||
readonly scenarioId: XtreamScenarioId;
|
||||
}
|
||||
|
||||
export interface XtreamPerformanceManifest {
|
||||
readonly bytes: number;
|
||||
readonly catalogSha256: string;
|
||||
readonly counts: {
|
||||
readonly categories: XtreamCatalogCounts;
|
||||
readonly items: XtreamCatalogCounts;
|
||||
};
|
||||
readonly epoch: number;
|
||||
readonly scenario: 'performance-100k';
|
||||
readonly seed: 91_001;
|
||||
}
|
||||
|
||||
export interface XtreamCatalogCounts {
|
||||
readonly live: number;
|
||||
readonly series: number;
|
||||
readonly total: number;
|
||||
readonly vod: number;
|
||||
}
|
||||
|
||||
export interface XtreamOccurrence {
|
||||
readonly action: string;
|
||||
readonly categoryId: string;
|
||||
readonly count: number;
|
||||
readonly scenario: string;
|
||||
readonly transport: string;
|
||||
}
|
||||
|
||||
export interface XtreamLedgerEntry {
|
||||
readonly action: string;
|
||||
readonly atMs: number;
|
||||
readonly categoryId: string;
|
||||
readonly epoch: number;
|
||||
readonly occurrence: number;
|
||||
readonly scenario: string;
|
||||
readonly status: string;
|
||||
readonly transport: string;
|
||||
}
|
||||
|
||||
export interface XtreamControlState {
|
||||
readonly epoch: number;
|
||||
readonly heldCount: number;
|
||||
readonly heldIds: readonly string[];
|
||||
readonly ledger: readonly XtreamLedgerEntry[];
|
||||
readonly occurrences: readonly XtreamOccurrence[];
|
||||
readonly prepared: XtreamPerformanceManifest | null;
|
||||
readonly rules: readonly unknown[];
|
||||
}
|
||||
|
||||
export interface XtreamBenchmarkManifest {
|
||||
readonly build: XtreamBenchmarkBuildIdentity;
|
||||
readonly environment: XtreamBenchmarkEnvironment;
|
||||
readonly fixture: XtreamBenchmarkFixture;
|
||||
readonly memoryAccounting: typeof XTREAM_MEMORY_ACCOUNTING;
|
||||
readonly mode: 'formal' | 'smoke';
|
||||
readonly runtime: XtreamBenchmarkRuntime;
|
||||
readonly source: XtreamBenchmarkSource;
|
||||
readonly suite: 'xtream-performance-100k';
|
||||
readonly variant: string;
|
||||
}
|
||||
|
||||
export interface XtreamBenchmarkEnvironment {
|
||||
readonly cdpAddress: '127.0.0.1';
|
||||
readonly cdpPort: number;
|
||||
readonly databaseWorkerBatchDelayMs: 0;
|
||||
readonly exposeGc: true;
|
||||
readonly freshUserDataPerIteration: true;
|
||||
readonly perfCapture: '1';
|
||||
readonly perfWorkerProfiling: '1';
|
||||
readonly traceRendererConsole: '0';
|
||||
}
|
||||
|
||||
export interface XtreamBenchmarkFixture {
|
||||
readonly bytes: number;
|
||||
readonly catalogSha256: string;
|
||||
readonly categories: 100;
|
||||
readonly items: 100_000;
|
||||
readonly scenario: 'performance-100k';
|
||||
readonly seed: 91_001;
|
||||
}
|
||||
|
||||
export interface XtreamBenchmarkRuntime {
|
||||
readonly arch: string;
|
||||
readonly electronVersion: string;
|
||||
readonly harnessNodeVersion: string;
|
||||
readonly platform: string;
|
||||
}
|
||||
|
||||
export interface XtreamBenchmarkSource {
|
||||
readonly gitCommit: string;
|
||||
readonly gitDiffSha256: string;
|
||||
readonly gitDirty: boolean;
|
||||
}
|
||||
|
||||
export interface NumericDistribution {
|
||||
readonly count: number;
|
||||
readonly max: number | null;
|
||||
readonly mean: number | null;
|
||||
readonly median: number | null;
|
||||
readonly min: number | null;
|
||||
readonly p95: number | null;
|
||||
readonly p99: number | null;
|
||||
}
|
||||
|
||||
export function summarizeXtreamNumbers(
|
||||
values: readonly number[]
|
||||
): NumericDistribution {
|
||||
const samples = [...values].sort((left, right) => left - right);
|
||||
const total = samples.reduce((sum, value) => sum + value, 0);
|
||||
const percentile = (rank: number): number => {
|
||||
if (samples.length === 1) return samples[0] ?? 0;
|
||||
const position = ((samples.length - 1) * rank) / 100;
|
||||
const lowerIndex = Math.floor(position);
|
||||
const upperIndex = Math.ceil(position);
|
||||
const lower = samples[lowerIndex] ?? 0;
|
||||
const upper = samples[upperIndex] ?? lower;
|
||||
return lower + (upper - lower) * (position - lowerIndex);
|
||||
};
|
||||
return {
|
||||
count: samples.length,
|
||||
max: samples.at(-1) ?? null,
|
||||
mean: total / samples.length,
|
||||
median: percentile(50),
|
||||
min: samples[0] ?? null,
|
||||
p95: percentile(95),
|
||||
p99: percentile(99),
|
||||
};
|
||||
}
|
||||
|
||||
export interface XtreamScenarioMetrics {
|
||||
readonly angularRenderingMs: NumericDistribution;
|
||||
readonly cancelAcknowledgementLatencyMs: NumericDistribution | null;
|
||||
readonly dataAcquisitionMs: NumericDistribution;
|
||||
readonly deserializationMs: NumericDistribution;
|
||||
readonly databaseWorkerCpuSystemMicros: NumericDistribution;
|
||||
readonly databaseWorkerCpuUserMicros: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerPeakExternalBytes: NumericDistribution;
|
||||
readonly databaseWorkerPeakHeapBytes: NumericDistribution;
|
||||
readonly databaseWorkerPostGcHeapBytes: NumericDistribution;
|
||||
readonly databaseWorkerRequestInvalidSampleCount: NumericDistribution;
|
||||
readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution;
|
||||
readonly databaseWorkerRequestTotalCount: NumericDistribution;
|
||||
readonly databaseWorkerRequestValidSampleCount: NumericDistribution;
|
||||
readonly jsonParsingMs: NumericDistribution;
|
||||
readonly mainCpuSystemMicros: NumericDistribution;
|
||||
readonly mainCpuUserMicros: NumericDistribution;
|
||||
readonly mainEventLoopDelayMaxMs: NumericDistribution;
|
||||
readonly mainEventLoopDelayP95Ms: NumericDistribution;
|
||||
readonly mainEventLoopDelayP99Ms: NumericDistribution;
|
||||
readonly mainPeakHeapBytes: NumericDistribution;
|
||||
readonly mainPeakRssBytes: NumericDistribution;
|
||||
readonly mainPostGcHeapBytes: NumericDistribution;
|
||||
readonly normalizationMs: NumericDistribution;
|
||||
readonly rendererActionLatencyMs: NumericDistribution | null;
|
||||
readonly rendererFrameGapMaxMs: NumericDistribution;
|
||||
readonly rendererFrameGapP95Ms: NumericDistribution;
|
||||
readonly rendererHeartbeatMaxMs: NumericDistribution;
|
||||
readonly rendererHeartbeatP95Ms: NumericDistribution;
|
||||
readonly rendererLongTaskCount: NumericDistribution;
|
||||
readonly rendererLongTaskMaxMs: NumericDistribution;
|
||||
readonly rendererNavigationLatencyMs: NumericDistribution | null;
|
||||
readonly rendererPeakHeapBytes: NumericDistribution;
|
||||
readonly rendererPeakRssBytes: NumericDistribution;
|
||||
readonly rendererPostGcHeapBytes: NumericDistribution;
|
||||
readonly rendererSearchLatencyMs: NumericDistribution | null;
|
||||
readonly serializationMs: NumericDistribution;
|
||||
readonly sqliteReadMs: NumericDistribution;
|
||||
readonly sqliteWriteMs: NumericDistribution;
|
||||
readonly storeUpdateMs: NumericDistribution;
|
||||
readonly structuredCloneProxyMs: NumericDistribution;
|
||||
readonly totalMs: NumericDistribution;
|
||||
}
|
||||
|
||||
const SCENARIOS: readonly XtreamScenario[] = Object.freeze([
|
||||
Object.freeze({ id: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE }),
|
||||
Object.freeze({ id: XTREAM_SCENARIO_ID.REFRESH_LARGE }),
|
||||
Object.freeze({ id: XTREAM_SCENARIO_ID.DELETE_LARGE }),
|
||||
Object.freeze({ id: XTREAM_SCENARIO_ID.CANCEL_IMPORT }),
|
||||
Object.freeze({ id: XTREAM_SCENARIO_ID.BACKGROUND_UI }),
|
||||
]);
|
||||
|
||||
export function listXtreamBenchmarkScenarios(): readonly XtreamScenario[] {
|
||||
return SCENARIOS;
|
||||
}
|
||||
|
||||
export function createXtreamIterationDefinitions(
|
||||
smoke: boolean
|
||||
): readonly XtreamIterationDefinition[] {
|
||||
const definitions: XtreamIterationDefinition[] = [];
|
||||
for (const scenario of SCENARIOS) {
|
||||
definitions.push(iteration(scenario.id, 'warmup-01', 'warmup', false));
|
||||
for (let index = 1; index <= (smoke ? 1 : 5); index += 1) {
|
||||
definitions.push(
|
||||
iteration(
|
||||
scenario.id,
|
||||
`run-${String(index).padStart(2, '0')}`,
|
||||
'measured',
|
||||
!smoke
|
||||
)
|
||||
);
|
||||
}
|
||||
definitions.push(
|
||||
iteration(scenario.id, 'diagnostic', 'diagnostic', false)
|
||||
);
|
||||
}
|
||||
return Object.freeze(definitions);
|
||||
}
|
||||
|
||||
export function expectedXtreamWorkerRequestInventory(
|
||||
scenarioId: XtreamScenarioId
|
||||
): readonly XtreamWorkerRequestInventoryEntry[] {
|
||||
return WORKER_REQUEST_INVENTORY[scenarioId];
|
||||
}
|
||||
|
||||
const PHASES = Object.freeze({
|
||||
categoriesRead: ['sqlite.categories.read'],
|
||||
categoriesWrite: [
|
||||
'normalize.categories',
|
||||
'sqlite.categories.write-transactions',
|
||||
],
|
||||
contentRead: ['sqlite.content.read'],
|
||||
contentWrite: [
|
||||
'sqlite.content.category-map-read',
|
||||
'normalize.content',
|
||||
'sqlite.content.write-transactions',
|
||||
],
|
||||
playlistDelete: [
|
||||
'sqlite.playlist-delete.collect-ids',
|
||||
'sqlite.playlist-delete.write-transactions',
|
||||
],
|
||||
playlistGet: ['sqlite.read', 'deserialize.playlist'],
|
||||
playlistUpsert: ['serialize.playlist', 'sqlite.write'],
|
||||
xtreamCacheClear: ['sqlite.xtream-cache-clear.write-transactions'],
|
||||
xtreamDelete: [
|
||||
'sqlite.xtream-delete.collect-user-data',
|
||||
'sqlite.xtream-delete.write-transactions',
|
||||
],
|
||||
});
|
||||
|
||||
const WORKER_REQUEST_INVENTORY: Readonly<
|
||||
Record<XtreamScenarioId, readonly XtreamWorkerRequestInventoryEntry[]>
|
||||
> = Object.freeze({
|
||||
[XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: inventory(
|
||||
request('DB_UPSERT_APP_PLAYLIST', 1, PHASES.playlistUpsert),
|
||||
request('DB_GET_PLAYLIST', 1),
|
||||
request('DB_GET_ALL_PLAYBACK_POSITIONS', 1),
|
||||
...fullImportRequests()
|
||||
),
|
||||
[XTREAM_SCENARIO_ID.REFRESH_LARGE]: refreshRequests(),
|
||||
[XTREAM_SCENARIO_ID.DELETE_LARGE]: inventory(
|
||||
request('DB_DELETE_PLAYLIST', 2, PHASES.playlistDelete)
|
||||
),
|
||||
[XTREAM_SCENARIO_ID.CANCEL_IMPORT]: inventory(
|
||||
request('DB_UPSERT_APP_PLAYLIST', 1, PHASES.playlistUpsert),
|
||||
request('DB_GET_PLAYLIST', 1),
|
||||
request('DB_GET_ALL_PLAYBACK_POSITIONS', 1),
|
||||
request('DB_GET_APP_STATE', 4),
|
||||
request('DB_GET_CATEGORIES', 6, PHASES.categoriesRead),
|
||||
request('DB_SAVE_CATEGORIES', 3, PHASES.categoriesWrite),
|
||||
request('DB_GET_CONTENT', 1, PHASES.contentRead),
|
||||
request('DB_SAVE_CONTENT', 1, PHASES.contentWrite, false),
|
||||
request('DB_SET_APP_STATE', 3),
|
||||
request('DB_CLEAR_XTREAM_IMPORT_CACHE', 3, PHASES.xtreamCacheClear)
|
||||
),
|
||||
[XTREAM_SCENARIO_ID.BACKGROUND_UI]: inventory(
|
||||
...refreshRequests(),
|
||||
request('DB_GET_RECENTLY_VIEWED', 1),
|
||||
request('DB_GET_ALL_GLOBAL_FAVORITES', 1),
|
||||
request('DB_GET_GLOBAL_RECENTLY_ADDED', 1)
|
||||
),
|
||||
});
|
||||
|
||||
function fullImportRequests(
|
||||
reads: Readonly<{
|
||||
appState: number;
|
||||
categories: number;
|
||||
content: number;
|
||||
setAppState: number;
|
||||
}> = { appState: 6, categories: 6, content: 6, setAppState: 3 }
|
||||
): readonly XtreamWorkerRequestInventoryEntry[] {
|
||||
return inventory(
|
||||
request('DB_GET_APP_STATE', reads.appState),
|
||||
request('DB_GET_CATEGORIES', reads.categories, PHASES.categoriesRead),
|
||||
request('DB_SAVE_CATEGORIES', 3, PHASES.categoriesWrite),
|
||||
request('DB_GET_CONTENT', reads.content, PHASES.contentRead),
|
||||
request('DB_SAVE_CONTENT', 3, PHASES.contentWrite),
|
||||
request('DB_SET_APP_STATE', reads.setAppState)
|
||||
);
|
||||
}
|
||||
|
||||
function refreshRequests(): readonly XtreamWorkerRequestInventoryEntry[] {
|
||||
return inventory(
|
||||
request('DB_DELETE_XTREAM_CONTENT', 1, PHASES.xtreamDelete),
|
||||
request('DB_GET_ALL_PLAYBACK_POSITIONS', 1),
|
||||
request('DB_UPDATE_PLAYLIST', 1),
|
||||
request('DB_GET_APP_PLAYLIST', 1, PHASES.playlistGet),
|
||||
request('DB_GET_PLAYLIST', 2),
|
||||
request('DB_UPSERT_APP_PLAYLIST', 1, PHASES.playlistUpsert),
|
||||
...fullImportRequests({
|
||||
appState: 12,
|
||||
categories: 9,
|
||||
content: 9,
|
||||
setAppState: 6,
|
||||
}),
|
||||
request('DB_RESTORE_XTREAM_USER_DATA', 1),
|
||||
request('DB_CLEAR_ALL_PLAYBACK_POSITIONS', 1)
|
||||
);
|
||||
}
|
||||
|
||||
function inventory(
|
||||
...entries: readonly XtreamWorkerRequestInventoryEntry[]
|
||||
): readonly XtreamWorkerRequestInventoryEntry[] {
|
||||
return Object.freeze(entries);
|
||||
}
|
||||
|
||||
function request(
|
||||
operation: string,
|
||||
count: number,
|
||||
phases: readonly string[] = [],
|
||||
success = true
|
||||
): XtreamWorkerRequestInventoryEntry {
|
||||
return Object.freeze({
|
||||
count,
|
||||
operation,
|
||||
phases: Object.freeze([...phases]),
|
||||
success,
|
||||
});
|
||||
}
|
||||
|
||||
function iteration(
|
||||
scenarioId: XtreamScenarioId,
|
||||
runId: string,
|
||||
kind: XtreamIterationKind,
|
||||
eligibleForComparison: boolean
|
||||
): XtreamIterationDefinition {
|
||||
return Object.freeze({
|
||||
eligibleForComparison,
|
||||
kind,
|
||||
runId,
|
||||
scenarioId,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
assertXtreamArtifactRedacted,
|
||||
assertXtreamControlReadyAfterObservationReset,
|
||||
assertXtreamControlStateForScenario,
|
||||
assertXtreamManifest,
|
||||
assertXtreamManifestIdentity,
|
||||
assertXtreamMockOrigin,
|
||||
parseXtreamControlState,
|
||||
} from './xtream-control-client';
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import type { XtreamBenchmarkManifest } from './xtream-benchmark-contract';
|
||||
import { createXtreamBenchmarkSummary } from './xtream-benchmark-report';
|
||||
import {
|
||||
MANIFEST,
|
||||
clearXtreamReportFixtureDirectories,
|
||||
} from './xtream-benchmark-report.fixtures';
|
||||
import {
|
||||
clearXtreamProvenanceFixtureDirectories,
|
||||
smokeReportFixture,
|
||||
} from './xtream-benchmark-report-provenance.fixtures';
|
||||
import { assertXtreamBenchmarkManifest } from './xtream-exact-schema';
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([
|
||||
clearXtreamReportFixtureDirectories(),
|
||||
clearXtreamProvenanceFixtureDirectories(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Xtream benchmark CDP environment', () => {
|
||||
it('allows a bounded alternate smoke port but keeps formal capture on 9222', async () => {
|
||||
const smokeManifest: XtreamBenchmarkManifest = {
|
||||
...MANIFEST,
|
||||
environment: { ...MANIFEST.environment, cdpPort: 9322 },
|
||||
mode: 'smoke',
|
||||
};
|
||||
assert.doesNotThrow(() => assertXtreamBenchmarkManifest(smokeManifest));
|
||||
assert.throws(
|
||||
() =>
|
||||
assertXtreamBenchmarkManifest({
|
||||
...smokeManifest,
|
||||
mode: 'formal',
|
||||
}),
|
||||
/invalid Xtream benchmark manifest/
|
||||
);
|
||||
|
||||
const { diagnosticEvidence, iterations } = await smokeReportFixture();
|
||||
const summary = createXtreamBenchmarkSummary(
|
||||
smokeManifest,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
);
|
||||
assert.equal(summary.validForComparison, false);
|
||||
});
|
||||
|
||||
it('requires an integer CDP port from 1024 through 65535', () => {
|
||||
for (const cdpPort of [1023, 65536, 9322.5, Number.NaN]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertXtreamBenchmarkManifest({
|
||||
...MANIFEST,
|
||||
environment: { ...MANIFEST.environment, cdpPort },
|
||||
mode: 'smoke',
|
||||
}),
|
||||
/invalid Xtream benchmark manifest/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { createXtreamIterationDefinitions } from './xtream-benchmark-contract';
|
||||
import { createXtreamBenchmarkSummary } from './xtream-benchmark-report';
|
||||
import {
|
||||
MANIFEST,
|
||||
clearXtreamReportFixtureDirectories,
|
||||
iteration,
|
||||
persist,
|
||||
persistWith,
|
||||
sha256,
|
||||
} from './xtream-benchmark-report.fixtures';
|
||||
import { toXtreamRawIteration } from './xtream-exact-schema';
|
||||
import {
|
||||
clearXtreamProvenanceFixtureDirectories,
|
||||
formalReportFixture,
|
||||
} from './xtream-benchmark-report-provenance.fixtures';
|
||||
import type {
|
||||
XtreamBenchmarkManifest,
|
||||
XtreamPersistenceReceipt,
|
||||
} from './xtream-summary-contract';
|
||||
|
||||
describe('Xtream benchmark integrity boundaries', () => {
|
||||
it('uses one intrinsic snapshot instead of caller-owned array methods', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
const adversarial = [...iterations];
|
||||
const invoked: string[] = [];
|
||||
for (const method of ['filter', 'find', 'map'] as const) {
|
||||
Object.defineProperty(adversarial, method, {
|
||||
value() {
|
||||
invoked.push(method);
|
||||
throw new Error(`caller-owned-${method}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
Object.defineProperty(adversarial, Symbol.iterator, {
|
||||
value() {
|
||||
invoked.push('iterator');
|
||||
throw new Error('caller-owned-iterator');
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
assert.doesNotThrow(() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
adversarial,
|
||||
diagnosticEvidence
|
||||
)
|
||||
);
|
||||
assert.deepEqual(invoked, []);
|
||||
} finally {
|
||||
await clearXtreamProvenanceFixtureDirectories();
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot hide a reused process identity behind caller-owned map', async () => {
|
||||
const fixture = await formalReportFixture();
|
||||
const definitions = createXtreamIterationDefinitions(false);
|
||||
const iterations = [...fixture.iterations];
|
||||
const sourceDuplicate = iteration(required(definitions, 1), 9_999);
|
||||
const duplicate = {
|
||||
...sourceDuplicate,
|
||||
processIdentity: {
|
||||
...sourceDuplicate.processIdentity,
|
||||
electronPid: required(iterations, 0).processIdentity
|
||||
.electronPid,
|
||||
},
|
||||
};
|
||||
iterations[1] = await persist(duplicate, 9_999);
|
||||
Object.defineProperty(iterations, 'map', {
|
||||
value() {
|
||||
return Array.from(
|
||||
{ length: iterations.length },
|
||||
(_, index) => index
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
iterations,
|
||||
fixture.diagnosticEvidence
|
||||
),
|
||||
/fresh process identity/
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([
|
||||
clearXtreamReportFixtureDirectories(),
|
||||
clearXtreamProvenanceFixtureDirectories(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses native Map entries after validating diagnostic evidence', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
const entries = [...diagnosticEvidence.entries()];
|
||||
const [firstIteration, firstEvidence] = required(entries, 0);
|
||||
const [, secondEvidence] = required(entries, 1);
|
||||
const adversarial = new Map(entries);
|
||||
Object.defineProperty(adversarial, 'get', {
|
||||
value(key: (typeof entries)[number][0]) {
|
||||
return key === firstIteration
|
||||
? secondEvidence
|
||||
: Map.prototype.get.call(this, key);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const summary = createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
iterations,
|
||||
adversarial
|
||||
);
|
||||
const scenario = summary.scenarios.find(
|
||||
({ scenarioId }) => scenarioId === firstIteration.scenarioId
|
||||
);
|
||||
assert.equal(
|
||||
scenario?.diagnosticProfiles.artifactProvenance.directorySha256,
|
||||
firstEvidence.directorySha256
|
||||
);
|
||||
} finally {
|
||||
await clearXtreamProvenanceFixtureDirectories();
|
||||
}
|
||||
});
|
||||
|
||||
it('snapshots every persistence receipt accessor exactly once', async () => {
|
||||
const definition = required(createXtreamIterationDefinitions(false), 1);
|
||||
const raw = iteration(definition, 1);
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-xtream-receipt-accessor-')
|
||||
);
|
||||
const regularPath = join(directory, 'raw.json');
|
||||
const symlinkPath = join(directory, 'raw-link.json');
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
persistWith(raw, async (serialized) => {
|
||||
await writeFile(regularPath, serialized);
|
||||
await symlink(regularPath, symlinkPath);
|
||||
let pathReads = 0;
|
||||
return {
|
||||
get absolutePath() {
|
||||
pathReads += 1;
|
||||
return pathReads === 1
|
||||
? symlinkPath
|
||||
: regularPath;
|
||||
},
|
||||
bytes: Buffer.byteLength(serialized),
|
||||
sha256: sha256(serialized),
|
||||
} as XtreamPersistenceReceipt;
|
||||
}),
|
||||
/persistence receipt/
|
||||
);
|
||||
|
||||
let stablePathReads = 0;
|
||||
await persistWith(raw, async (serialized) => ({
|
||||
get absolutePath() {
|
||||
stablePathReads += 1;
|
||||
return regularPath;
|
||||
},
|
||||
bytes: Buffer.byteLength(serialized),
|
||||
sha256: sha256(serialized),
|
||||
}));
|
||||
assert.equal(stablePathReads, 1);
|
||||
} finally {
|
||||
await rm(directory, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses one canonical manifest snapshot for validation and output', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
let variantReads = 0;
|
||||
const manifest = {
|
||||
...MANIFEST,
|
||||
get variant() {
|
||||
variantReads += 1;
|
||||
return variantReads <= 3 ? 'baseline' : 'CON.json';
|
||||
},
|
||||
} as XtreamBenchmarkManifest;
|
||||
|
||||
try {
|
||||
const summary = createXtreamBenchmarkSummary(
|
||||
manifest,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
);
|
||||
assert.equal(summary.manifest.variant, 'baseline');
|
||||
assert.equal(variantReads, 1);
|
||||
} finally {
|
||||
await clearXtreamProvenanceFixtureDirectories();
|
||||
}
|
||||
});
|
||||
|
||||
it('binds diagnostic profile durations and worker ordinal to artifact evidence', async () => {
|
||||
const fixture = await formalReportFixture();
|
||||
const [diagnostic, evidence] = required(
|
||||
[...fixture.diagnosticEvidence.entries()],
|
||||
0
|
||||
);
|
||||
const diagnosticIndex = fixture.iterations.indexOf(diagnostic);
|
||||
assert.notEqual(diagnosticIndex, -1);
|
||||
const raw = toXtreamRawIteration(diagnostic);
|
||||
const mismatches = [
|
||||
{
|
||||
...raw,
|
||||
renderer: {
|
||||
...raw.renderer,
|
||||
cpuProfileDurationMicros: 2_001,
|
||||
},
|
||||
},
|
||||
{
|
||||
...raw,
|
||||
main: {
|
||||
...raw.main,
|
||||
cpuProfileDurationMicros: 2_001,
|
||||
},
|
||||
},
|
||||
{
|
||||
...raw,
|
||||
databaseWorker: {
|
||||
...raw.databaseWorker,
|
||||
cpuProfileDurationMicros: 2_001,
|
||||
},
|
||||
},
|
||||
{
|
||||
...raw,
|
||||
databaseWorker: {
|
||||
...raw.databaseWorker,
|
||||
ordinal: raw.databaseWorker.ordinal + 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
for (const [index, mismatch] of mismatches.entries()) {
|
||||
const replacement = await persist(mismatch, 20_000 + index);
|
||||
const iterations = [...fixture.iterations];
|
||||
iterations[diagnosticIndex] = replacement;
|
||||
const diagnosticEvidence = new Map(fixture.diagnosticEvidence);
|
||||
diagnosticEvidence.delete(diagnostic);
|
||||
diagnosticEvidence.set(replacement, evidence);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
),
|
||||
/diagnostic artifact evidence binding/
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([
|
||||
clearXtreamReportFixtureDirectories(),
|
||||
clearXtreamProvenanceFixtureDirectories(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function required<T>(values: readonly T[], index: number): T {
|
||||
const value = values[index];
|
||||
assert.ok(value);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
ELECTRON_RUNTIME_ENVIRONMENT_KEYS,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
import {
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamIterationDefinition,
|
||||
type XtreamPerformanceManifest,
|
||||
type XtreamScenarioId,
|
||||
} from './xtream-benchmark-contract';
|
||||
import { writeXtreamSafeJson } from './xtream-benchmark-artifacts';
|
||||
import type { XtreamControlClient } from './xtream-control-client';
|
||||
import { assertXtreamControlStateForScenario } from './xtream-control-client';
|
||||
import {
|
||||
validateXtreamDiagnosticArtifacts,
|
||||
type XtreamDiagnosticArtifactEvidence,
|
||||
} from './xtream-diagnostic-artifacts';
|
||||
import {
|
||||
readXtreamMainCaptureStatus,
|
||||
startMainCapture,
|
||||
type MainCaptureStartOptions,
|
||||
type XtreamMainCaptureResult,
|
||||
type XtreamMainCaptureStatus,
|
||||
} from './m3u-refresh-main-capture';
|
||||
import { isXtreamMainCaptureSettled } from './xtream-main-settlement';
|
||||
import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture';
|
||||
import {
|
||||
assertCompleteXtreamRendererProbe,
|
||||
beginXtreamRendererOperation,
|
||||
stopXtreamRendererProbe,
|
||||
waitForXtreamRendererTerminal,
|
||||
} from './xtream-renderer-probe-control';
|
||||
import { installXtreamRendererProbe } from './xtream-renderer-probe';
|
||||
import { prepareXtreamScenario } from './xtream-scenario-driver';
|
||||
import type { XtreamArtifactSafetyContext } from './xtream-summary-contract';
|
||||
|
||||
export interface SeedXtreamExistingPortalOptions {
|
||||
readonly app: LaunchedElectronApp;
|
||||
readonly captureOptions: MainCaptureStartOptions;
|
||||
readonly control: XtreamControlClient;
|
||||
readonly fixture: XtreamPerformanceManifest;
|
||||
readonly iterationDirectory: string;
|
||||
readonly safety: XtreamArtifactSafetyContext;
|
||||
readonly serverUrl: string;
|
||||
readonly sourceTitle: string;
|
||||
}
|
||||
|
||||
export async function prearmXtreamDatabaseWorker(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
const playlists = await app.mainWindow.evaluate(() =>
|
||||
window.electron.dbGetAppPlaylists()
|
||||
);
|
||||
if (!Array.isArray(playlists)) {
|
||||
throw new Error('xtream-database-worker-prearm-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedXtreamExistingPortal(
|
||||
options: SeedXtreamExistingPortalOptions
|
||||
): Promise<void> {
|
||||
const seed = await prepareXtreamScenario(options.app.mainWindow, {
|
||||
scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
serverUrl: options.serverUrl,
|
||||
sourceTitle: options.sourceTitle,
|
||||
});
|
||||
let rendererProbeInstalled = false;
|
||||
try {
|
||||
await installXtreamRendererProbe(options.app.mainWindow, {
|
||||
scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
sourceTitle: options.sourceTitle,
|
||||
});
|
||||
rendererProbeInstalled = true;
|
||||
await startMainCapture(options.app.electronApp, {
|
||||
...options.captureOptions,
|
||||
deferMeasurement: false,
|
||||
diagnostic: false,
|
||||
});
|
||||
const captureGeneration = (
|
||||
await readXtreamMainCaptureStatus(options.app.electronApp)
|
||||
).captureGeneration;
|
||||
await beginXtreamRendererOperation(options.app.mainWindow);
|
||||
await seed.trigger();
|
||||
const driverTerminal = seed.waitForTerminal();
|
||||
await waitForXtreamRendererTerminal(options.app.mainWindow);
|
||||
const rendererProbe = await stopXtreamRendererProbe(
|
||||
options.app.mainWindow
|
||||
);
|
||||
rendererProbeInstalled = false;
|
||||
assertCompleteXtreamRendererProbe(rendererProbe);
|
||||
await assertXtreamSettlementAfterTerminal({
|
||||
captureGeneration,
|
||||
readStatus: () =>
|
||||
readXtreamMainCaptureStatus(options.app.electronApp),
|
||||
scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
terminal: driverTerminal,
|
||||
});
|
||||
const state = await options.control.state();
|
||||
await writeXtreamSafeJson(
|
||||
join(options.iterationDirectory, 'seed-control-state.json'),
|
||||
state,
|
||||
options.safety
|
||||
);
|
||||
assertXtreamControlStateForScenario(
|
||||
XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE,
|
||||
state,
|
||||
options.fixture
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
rendererProbeInstalled
|
||||
? stopXtreamRendererProbe(options.app.mainWindow)
|
||||
: Promise.resolve(),
|
||||
seed.dispose(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AssertXtreamSettlementAfterTerminalOptions {
|
||||
readonly captureGeneration: number;
|
||||
readonly readStatus: () => Promise<XtreamMainCaptureStatus>;
|
||||
readonly scenarioId: XtreamScenarioId;
|
||||
readonly terminal: Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function assertXtreamSettlementAfterTerminal(
|
||||
options: AssertXtreamSettlementAfterTerminalOptions
|
||||
): Promise<void> {
|
||||
await options.terminal;
|
||||
const status = await options.readStatus();
|
||||
if (status.captureGeneration !== options.captureGeneration) {
|
||||
throw new Error('xtream-measured-capture-generation-changed');
|
||||
}
|
||||
if (!isXtreamMainCaptureSettled(status, options.scenarioId)) {
|
||||
throw new Error('xtream-main-not-settled-after-terminal');
|
||||
}
|
||||
}
|
||||
|
||||
export interface FinalizeXtreamCaptureAfterRendererTerminalOptions<
|
||||
DriverTerminal,
|
||||
MainCapture,
|
||||
RendererCapture,
|
||||
> extends Omit<AssertXtreamSettlementAfterTerminalOptions, 'terminal'> {
|
||||
readonly driverTerminal: Promise<DriverTerminal>;
|
||||
readonly rendererTerminal: Promise<unknown>;
|
||||
readonly stopMain: () => Promise<MainCapture>;
|
||||
readonly stopRenderer: () => Promise<RendererCapture>;
|
||||
}
|
||||
|
||||
export interface FinalizedXtreamCapture<
|
||||
DriverTerminal,
|
||||
MainCapture,
|
||||
RendererCapture,
|
||||
> {
|
||||
readonly driverTerminal: DriverTerminal;
|
||||
readonly mainCapture: MainCapture;
|
||||
readonly rendererCapture: RendererCapture;
|
||||
}
|
||||
|
||||
export async function finalizeXtreamCaptureAfterRendererTerminal<
|
||||
DriverTerminal,
|
||||
MainCapture,
|
||||
RendererCapture,
|
||||
>(
|
||||
options: FinalizeXtreamCaptureAfterRendererTerminalOptions<
|
||||
DriverTerminal,
|
||||
MainCapture,
|
||||
RendererCapture
|
||||
>
|
||||
): Promise<
|
||||
FinalizedXtreamCapture<DriverTerminal, MainCapture, RendererCapture>
|
||||
> {
|
||||
await assertXtreamSettlementAfterTerminal({
|
||||
captureGeneration: options.captureGeneration,
|
||||
readStatus: options.readStatus,
|
||||
scenarioId: options.scenarioId,
|
||||
terminal: options.rendererTerminal,
|
||||
});
|
||||
const rendererCapture = options.stopRenderer();
|
||||
const mainCapture = options.stopMain();
|
||||
const [resolvedRenderer, resolvedMain, driverTerminal] = await Promise.all([
|
||||
rendererCapture,
|
||||
mainCapture,
|
||||
options.driverTerminal,
|
||||
]);
|
||||
return Object.freeze({
|
||||
driverTerminal,
|
||||
mainCapture: resolvedMain,
|
||||
rendererCapture: resolvedRenderer,
|
||||
});
|
||||
}
|
||||
|
||||
export async function assertXtreamBenchmarkEnvironmentIsolated(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
const keys = await app.electronApp.evaluate(() => Object.keys(process.env));
|
||||
const allowed = new Set(
|
||||
[
|
||||
...ELECTRON_RUNTIME_ENVIRONMENT_KEYS,
|
||||
'ELECTRON_IS_DEV',
|
||||
'IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS',
|
||||
'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS',
|
||||
'IPTVNATOR_E2E_DATA_DIR',
|
||||
'IPTVNATOR_PERF_CAPTURE',
|
||||
'IPTVNATOR_PERF_WORKER_PROFILING',
|
||||
'IPTVNATOR_TRACE_RENDERER_CONSOLE',
|
||||
'NODE_ENV',
|
||||
].map((key) => key.toUpperCase())
|
||||
);
|
||||
if (
|
||||
!Array.isArray(keys) ||
|
||||
keys.some(
|
||||
(key) => typeof key !== 'string' || !allowed.has(key.toUpperCase())
|
||||
)
|
||||
) {
|
||||
throw new Error('xtream-benchmark-environment-reached-electron');
|
||||
}
|
||||
}
|
||||
|
||||
export function createXtreamSourceTitle(
|
||||
definition: XtreamIterationDefinition
|
||||
): string {
|
||||
return `Synthetic Xtream 100k ${definition.scenarioId} ${definition.runId}`;
|
||||
}
|
||||
|
||||
export async function createXtreamDiagnosticEvidence(
|
||||
kind: XtreamIterationDefinition['kind'],
|
||||
main: XtreamMainCaptureResult,
|
||||
renderer: XtreamRendererCaptureMetrics,
|
||||
iterationDirectory: string
|
||||
): Promise<XtreamDiagnosticArtifactEvidence | null> {
|
||||
if (kind !== 'diagnostic') return null;
|
||||
return validateXtreamDiagnosticArtifacts(
|
||||
{
|
||||
main: {
|
||||
cpuProfilePath: main.capture.cpuProfilePath,
|
||||
heapSnapshotPath: main.capture.heapSnapshotPath,
|
||||
workers: main.workers.map(
|
||||
({
|
||||
kind: workerKind,
|
||||
ordinal,
|
||||
profilePath,
|
||||
snapshotPath,
|
||||
}) => ({
|
||||
kind: workerKind,
|
||||
ordinal,
|
||||
profilePath,
|
||||
snapshotPath,
|
||||
})
|
||||
),
|
||||
},
|
||||
renderer: {
|
||||
cpuProfilePath: renderer.cpuProfilePath,
|
||||
heapSnapshotPath: renderer.heapSnapshotPath,
|
||||
tracePath: renderer.tracePath,
|
||||
},
|
||||
},
|
||||
iterationDirectory
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
closeElectronApp,
|
||||
launchElectronApp,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
import {
|
||||
type XtreamIterationDefinition,
|
||||
type XtreamPerformanceManifest,
|
||||
XTREAM_SCENARIO_ID,
|
||||
} from './xtream-benchmark-contract';
|
||||
import type { XtreamBenchmarkConfiguration } from './xtream-benchmark-configuration';
|
||||
import type { XtreamControlClient } from './xtream-control-client';
|
||||
import {
|
||||
assertXtreamControlReadyAfterObservationReset,
|
||||
assertXtreamControlStateForScenario,
|
||||
assertXtreamManifestIdentity,
|
||||
} from './xtream-control-client';
|
||||
import { createXtreamCatalogVerification } from './xtream-catalog-database-verification';
|
||||
import { readXtreamCatalogDatabaseSnapshot } from './xtream-catalog-database-reader';
|
||||
import { assembleXtreamRawIteration } from './xtream-iteration-assembler';
|
||||
import {
|
||||
beginXtreamMainMeasurement,
|
||||
installMainCapture,
|
||||
readXtreamMainCaptureStatus,
|
||||
rolloverXtreamMainCapture,
|
||||
startMainCapture,
|
||||
stopXtreamMainCapture,
|
||||
type MainCaptureStartOptions,
|
||||
} from './m3u-refresh-main-capture';
|
||||
import {
|
||||
assertRendererCdpTarget,
|
||||
assertTcpPortAvailable,
|
||||
resolveRendererWindowIdentity,
|
||||
} from './m3u-import-benchmark-support';
|
||||
import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight';
|
||||
import {
|
||||
createXtreamArtifactSafetyContext,
|
||||
persistXtreamIteration,
|
||||
writeXtreamSafeJson,
|
||||
type PersistedXtreamIteration,
|
||||
} from './xtream-benchmark-artifacts';
|
||||
import {
|
||||
createXtreamSingleAttemptCapture,
|
||||
persistXtreamIterationFailureEvidenceAndThrow,
|
||||
type XtreamBenchmarkFailureStage,
|
||||
} from './xtream-benchmark-lifecycle';
|
||||
import {
|
||||
assertXtreamBenchmarkEnvironmentIsolated,
|
||||
createXtreamDiagnosticEvidence,
|
||||
createXtreamSourceTitle,
|
||||
finalizeXtreamCaptureAfterRendererTerminal,
|
||||
prearmXtreamDatabaseWorker,
|
||||
seedXtreamExistingPortal,
|
||||
} from './xtream-benchmark-iteration-support';
|
||||
import {
|
||||
deriveXtreamMeasuredPlaylistId,
|
||||
disableGenericElectronFixtureProbes,
|
||||
xtreamScenarioRequiresSeed,
|
||||
} from './xtream-benchmark-runtime';
|
||||
import {
|
||||
captureXtreamLaunchIdentity,
|
||||
createXtreamIterationProcessEvidence,
|
||||
} from './xtream-process-evidence';
|
||||
import {
|
||||
startXtreamRendererCapture,
|
||||
type RunningXtreamRendererCapture,
|
||||
} from './xtream-renderer-capture';
|
||||
import {
|
||||
prepareXtreamScenario,
|
||||
type PreparedXtreamScenario,
|
||||
type XtreamScenarioTerminalEvidence,
|
||||
type XtreamScenarioTriggerEvidence,
|
||||
} from './xtream-scenario-driver';
|
||||
import { runXtreamBackgroundUiActions } from './xtream-ui-action-probe';
|
||||
|
||||
export interface XtreamBenchmarkIterationContext {
|
||||
readonly configuration: XtreamBenchmarkConfiguration;
|
||||
readonly control: XtreamControlClient;
|
||||
readonly definition: XtreamIterationDefinition;
|
||||
readonly expectedFixture: XtreamPerformanceManifest;
|
||||
readonly scenarioDirectory: string;
|
||||
readonly seenElectronPids: Set<number>;
|
||||
}
|
||||
export async function runXtreamBenchmarkIteration(
|
||||
context: XtreamBenchmarkIterationContext
|
||||
): Promise<PersistedXtreamIteration> {
|
||||
const { configuration, control, definition, scenarioDirectory } = context;
|
||||
const iterationDirectory = join(scenarioDirectory, definition.runId);
|
||||
await assertPerformanceArtifactCapacity(iterationDirectory);
|
||||
await mkdir(iterationDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
const dataDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-xtream-performance-')
|
||||
);
|
||||
const safety = createXtreamArtifactSafetyContext(
|
||||
configuration.controlToken
|
||||
);
|
||||
let app: LaunchedElectronApp | null = null;
|
||||
let renderer: RunningXtreamRendererCapture | null = null;
|
||||
let prepared: PreparedXtreamScenario | null = null;
|
||||
let controlState: Awaited<ReturnType<XtreamControlClient['state']>> | null =
|
||||
null;
|
||||
let failureStage: XtreamBenchmarkFailureStage = 'capture-setup';
|
||||
let stopMainCaptureOnce:
|
||||
(() => ReturnType<typeof stopXtreamMainCapture>) | null = null;
|
||||
let stopRendererCaptureOnce: RunningXtreamRendererCapture['stop'] | null =
|
||||
null;
|
||||
let terminal: XtreamScenarioTerminalEvidence | null = null;
|
||||
let trigger: XtreamScenarioTriggerEvidence | null = null;
|
||||
try {
|
||||
await control.resetAll();
|
||||
const fixture = await control.prepare();
|
||||
assertXtreamManifestIdentity(context.expectedFixture, fixture);
|
||||
await assertTcpPortAvailable(configuration.cdpPort);
|
||||
app = await launchElectronApp(dataDirectory, {
|
||||
args: [
|
||||
'--js-flags=--expose-gc',
|
||||
'--remote-debugging-address=127.0.0.1',
|
||||
`--remote-debugging-port=${configuration.cdpPort}`,
|
||||
`--user-data-dir=${join(dataDirectory, 'user-data')}`,
|
||||
],
|
||||
environmentInheritance: 'runtime-only',
|
||||
env: {
|
||||
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
IPTVNATOR_PERF_WORKER_PROFILING: '1',
|
||||
IPTVNATOR_TRACE_RENDERER_CONSOLE: '0',
|
||||
},
|
||||
omitEnvKeys: [
|
||||
'IPTVNATOR_XTREAM_MOCK_CONTROL',
|
||||
'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN',
|
||||
],
|
||||
});
|
||||
app.mainWindow.setDefaultTimeout(10 * 60 * 1_000);
|
||||
await assertXtreamBenchmarkEnvironmentIsolated(app);
|
||||
await disableGenericElectronFixtureProbes(app.mainWindow);
|
||||
await assertRendererCdpTarget(app.mainWindow, configuration.cdpPort);
|
||||
await installMainCapture(app.electronApp);
|
||||
await prearmXtreamDatabaseWorker(app);
|
||||
const electronApp = app.electronApp;
|
||||
const launch = await captureXtreamLaunchIdentity(
|
||||
app.electronApp,
|
||||
app.electronApp.process().pid,
|
||||
context.seenElectronPids
|
||||
);
|
||||
const rendererWindowIdentity = await resolveRendererWindowIdentity(app);
|
||||
const captureOptions: MainCaptureStartOptions = {
|
||||
deferMeasurement: true,
|
||||
diagnostic: definition.kind === 'diagnostic',
|
||||
outputDirectory: iterationDirectory,
|
||||
rendererWindowIdentity,
|
||||
};
|
||||
const sourceTitle = createXtreamSourceTitle(definition);
|
||||
const seeded = xtreamScenarioRequiresSeed(definition.scenarioId);
|
||||
if (seeded) {
|
||||
failureStage = 'seed';
|
||||
stopMainCaptureOnce = createXtreamSingleAttemptCapture(() =>
|
||||
stopXtreamMainCapture(electronApp)
|
||||
);
|
||||
await seedXtreamExistingPortal({
|
||||
app,
|
||||
captureOptions,
|
||||
control,
|
||||
fixture,
|
||||
iterationDirectory,
|
||||
safety,
|
||||
serverUrl: configuration.mockOrigin,
|
||||
sourceTitle,
|
||||
});
|
||||
}
|
||||
failureStage = 'scenario-preparation';
|
||||
prepared = await prepareXtreamScenario(app.mainWindow, {
|
||||
scenarioId: definition.scenarioId,
|
||||
serverUrl: configuration.mockOrigin,
|
||||
sourceTitle,
|
||||
});
|
||||
await control.resetObservations();
|
||||
assertXtreamControlReadyAfterObservationReset(
|
||||
await control.state(),
|
||||
fixture
|
||||
);
|
||||
let captureGeneration: number;
|
||||
failureStage = 'measured-capture-start';
|
||||
if (seeded) {
|
||||
const rolloverOnce = createXtreamSingleAttemptCapture(() =>
|
||||
rolloverXtreamMainCapture(electronApp, captureOptions)
|
||||
);
|
||||
stopMainCaptureOnce = rolloverOnce;
|
||||
const rollover = await rolloverOnce();
|
||||
stopMainCaptureOnce = createXtreamSingleAttemptCapture(() =>
|
||||
stopXtreamMainCapture(electronApp)
|
||||
);
|
||||
if (
|
||||
!rollover.rollover?.nextCaptureStarted ||
|
||||
rollover.rollover.nextCaptureUnavailableReason !== null
|
||||
) {
|
||||
throw new Error('xtream-measured-capture-rollover-failed');
|
||||
}
|
||||
await writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'seed-main-capture.json'),
|
||||
rollover,
|
||||
safety
|
||||
);
|
||||
captureGeneration = rollover.captureGeneration + 1;
|
||||
if (
|
||||
(await readXtreamMainCaptureStatus(app.electronApp))
|
||||
.captureGeneration !== captureGeneration
|
||||
) {
|
||||
throw new Error('xtream-measured-capture-generation-changed');
|
||||
}
|
||||
} else {
|
||||
stopMainCaptureOnce = createXtreamSingleAttemptCapture(() =>
|
||||
stopXtreamMainCapture(electronApp)
|
||||
);
|
||||
await startMainCapture(app.electronApp, captureOptions);
|
||||
captureGeneration = (
|
||||
await readXtreamMainCaptureStatus(app.electronApp)
|
||||
).captureGeneration;
|
||||
}
|
||||
failureStage = 'renderer-capture-start';
|
||||
renderer = await startXtreamRendererCapture(app.mainWindow, {
|
||||
diagnostic: definition.kind === 'diagnostic',
|
||||
outputDirectory: iterationDirectory,
|
||||
scenarioId: definition.scenarioId,
|
||||
sourceTitle,
|
||||
});
|
||||
const runningRenderer = renderer;
|
||||
stopRendererCaptureOnce = createXtreamSingleAttemptCapture(() =>
|
||||
runningRenderer.stop()
|
||||
);
|
||||
await beginXtreamMainMeasurement(app.electronApp);
|
||||
await renderer.beginOperation();
|
||||
failureStage = 'operation-trigger';
|
||||
trigger = await prepared.trigger();
|
||||
if (definition.scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI) {
|
||||
failureStage = 'background-ui';
|
||||
await prepared.runBackgroundProbe(
|
||||
({ operationId, page, playlistId }) =>
|
||||
runXtreamBackgroundUiActions(page, {
|
||||
operationId,
|
||||
playlistId,
|
||||
searchTerm: 'Performance',
|
||||
})
|
||||
);
|
||||
}
|
||||
failureStage = 'terminal-settlement';
|
||||
const terminalPromise = prepared.waitForTerminal().then((value) => {
|
||||
terminal = value;
|
||||
return value;
|
||||
});
|
||||
if (!stopRendererCaptureOnce || !stopMainCaptureOnce) {
|
||||
throw new Error('xtream-measured-capture-lifecycle-invalid');
|
||||
}
|
||||
const finalized = await finalizeXtreamCaptureAfterRendererTerminal({
|
||||
captureGeneration,
|
||||
driverTerminal: terminalPromise,
|
||||
readStatus: () => readXtreamMainCaptureStatus(app.electronApp),
|
||||
rendererTerminal: renderer.waitForTerminal(),
|
||||
scenarioId: definition.scenarioId,
|
||||
stopMain: stopMainCaptureOnce,
|
||||
stopRenderer: stopRendererCaptureOnce,
|
||||
});
|
||||
terminal = finalized.driverTerminal;
|
||||
const { mainCapture, rendererCapture } = finalized;
|
||||
if (!terminal) {
|
||||
throw new Error('xtream-measured-capture-lifecycle-invalid');
|
||||
}
|
||||
if (mainCapture.captureGeneration !== captureGeneration) {
|
||||
throw new Error('xtream-measured-capture-generation-changed');
|
||||
}
|
||||
controlState = await control.state();
|
||||
failureStage = 'evidence-validation';
|
||||
await Promise.all([
|
||||
writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'main-capture.json'),
|
||||
mainCapture,
|
||||
safety
|
||||
),
|
||||
writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'renderer-capture.json'),
|
||||
rendererCapture,
|
||||
safety
|
||||
),
|
||||
writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'terminal-evidence.json'),
|
||||
terminal,
|
||||
safety
|
||||
),
|
||||
writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'trigger-evidence.json'),
|
||||
trigger,
|
||||
safety
|
||||
),
|
||||
writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'control-state.json'),
|
||||
controlState,
|
||||
safety
|
||||
),
|
||||
]);
|
||||
assertXtreamControlStateForScenario(
|
||||
definition.scenarioId,
|
||||
controlState,
|
||||
fixture
|
||||
);
|
||||
const playlistId = deriveXtreamMeasuredPlaylistId(mainCapture.requests);
|
||||
const catalogSnapshot = await readXtreamCatalogDatabaseSnapshot(
|
||||
app.electronApp,
|
||||
{
|
||||
captureStoppedEpochMs: mainCapture.captureStoppedEpochMs,
|
||||
dataDirectory,
|
||||
playlistId,
|
||||
requireFromPath: join(
|
||||
configuration.workspaceRoot,
|
||||
'dist/apps/electron-backend/main.js'
|
||||
),
|
||||
}
|
||||
);
|
||||
await writeXtreamSafeJson(
|
||||
join(iterationDirectory, 'catalog-snapshot.json'),
|
||||
catalogSnapshot,
|
||||
safety
|
||||
);
|
||||
const catalogVerification = createXtreamCatalogVerification(
|
||||
catalogSnapshot,
|
||||
fixture,
|
||||
definition.scenarioId
|
||||
);
|
||||
const diagnosticEvidence = await createXtreamDiagnosticEvidence(
|
||||
definition.kind,
|
||||
mainCapture,
|
||||
rendererCapture,
|
||||
iterationDirectory
|
||||
);
|
||||
const processEvidence = await createXtreamIterationProcessEvidence({
|
||||
...launch,
|
||||
diagnosticDirectorySha256:
|
||||
diagnosticEvidence?.directorySha256 ?? null,
|
||||
iterationDirectory,
|
||||
});
|
||||
const raw = assembleXtreamRawIteration({
|
||||
catalogVerification,
|
||||
definition,
|
||||
diagnosticArtifacts: diagnosticEvidence,
|
||||
mainCapture,
|
||||
process: processEvidence,
|
||||
rendererCapture,
|
||||
terminal,
|
||||
trigger,
|
||||
});
|
||||
failureStage = 'result-persistence';
|
||||
return persistXtreamIteration(
|
||||
iterationDirectory,
|
||||
raw,
|
||||
diagnosticEvidence,
|
||||
safety
|
||||
);
|
||||
} catch (failure) {
|
||||
const electronApp = app?.electronApp;
|
||||
await persistXtreamIterationFailureEvidenceAndThrow(failure, {
|
||||
evidence: {
|
||||
control: async () => controlState ?? control.state(),
|
||||
...(electronApp
|
||||
? {
|
||||
mainStatus: () =>
|
||||
readXtreamMainCaptureStatus(electronApp),
|
||||
}
|
||||
: {}),
|
||||
...(stopMainCaptureOnce
|
||||
? { mainCapture: stopMainCaptureOnce }
|
||||
: {}),
|
||||
...(stopRendererCaptureOnce
|
||||
? { rendererCapture: stopRendererCaptureOnce }
|
||||
: {}),
|
||||
...(terminal ? { terminal: async () => terminal } : {}),
|
||||
...(trigger ? { trigger: async () => trigger } : {}),
|
||||
},
|
||||
persist: (filename, value) =>
|
||||
writeXtreamSafeJson(
|
||||
join(iterationDirectory, filename),
|
||||
value,
|
||||
safety
|
||||
),
|
||||
stage: failureStage,
|
||||
});
|
||||
} finally {
|
||||
await prepared?.dispose().catch(() => undefined);
|
||||
await renderer?.dispose().catch(() => undefined);
|
||||
if (app) await closeElectronApp(app);
|
||||
await rm(dataDirectory, { force: true, recursive: true }).catch(
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createXtreamSingleAttemptCapture,
|
||||
persistXtreamIterationFailureEvidence,
|
||||
persistXtreamIterationFailureEvidenceAndThrow,
|
||||
} from './xtream-benchmark-lifecycle';
|
||||
|
||||
const performanceDirectory = resolve(process.cwd(), 'src/performance');
|
||||
|
||||
describe('Xtream benchmark failure lifecycle', () => {
|
||||
it('memoizes capture finalization across success and failure paths', async () => {
|
||||
let attempts = 0;
|
||||
const capture = createXtreamSingleAttemptCapture(async () => {
|
||||
attempts += 1;
|
||||
return Object.freeze({ capture: 'complete' });
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([capture(), capture()]);
|
||||
const third = await capture();
|
||||
|
||||
assert.equal(attempts, 1);
|
||||
assert.strictEqual(first, second);
|
||||
assert.strictEqual(second, third);
|
||||
|
||||
let failedAttempts = 0;
|
||||
const failedCapture = createXtreamSingleAttemptCapture(async () => {
|
||||
failedAttempts += 1;
|
||||
throw new Error('fixed-test-failure');
|
||||
});
|
||||
await assert.rejects(failedCapture, /fixed-test-failure/);
|
||||
await assert.rejects(failedCapture, /fixed-test-failure/);
|
||||
assert.equal(failedAttempts, 1);
|
||||
});
|
||||
|
||||
it('persists all available evidence and only fixed failure metadata', async () => {
|
||||
const persisted = new Map<string, unknown>();
|
||||
const attempts: string[] = [];
|
||||
|
||||
await persistXtreamIterationFailureEvidence({
|
||||
evidence: {
|
||||
control: async () => {
|
||||
attempts.push('control');
|
||||
return { epoch: 11 };
|
||||
},
|
||||
mainCapture: async () => {
|
||||
attempts.push('mainCapture');
|
||||
return { captureGeneration: 3 };
|
||||
},
|
||||
mainStatus: async () => {
|
||||
attempts.push('mainStatus');
|
||||
return { captureGeneration: 3, pending: 1 };
|
||||
},
|
||||
rendererCapture: async () => {
|
||||
attempts.push('rendererCapture');
|
||||
throw new Error(
|
||||
'https://performance:performance@example.invalid?token=secret'
|
||||
);
|
||||
},
|
||||
terminal: async () => {
|
||||
attempts.push('terminal');
|
||||
return { terminalEpochMs: 42 };
|
||||
},
|
||||
},
|
||||
persist: async (filename, value) => {
|
||||
persisted.set(filename, value);
|
||||
},
|
||||
stage: 'terminal-settlement',
|
||||
});
|
||||
|
||||
assert.deepEqual(attempts.sort(), [
|
||||
'control',
|
||||
'mainCapture',
|
||||
'mainStatus',
|
||||
'rendererCapture',
|
||||
'terminal',
|
||||
]);
|
||||
assert.deepEqual([...persisted.keys()].sort(), [
|
||||
'failure-control-state.json',
|
||||
'failure-main-capture.json',
|
||||
'failure-main-status.json',
|
||||
'failure-terminal-evidence.json',
|
||||
'failure.json',
|
||||
]);
|
||||
const metadata = persisted.get('failure.json');
|
||||
assert.deepEqual(metadata, {
|
||||
evidence: {
|
||||
control: 'captured',
|
||||
mainCapture: 'captured',
|
||||
mainStatus: 'captured',
|
||||
rendererCapture: 'capture-failed',
|
||||
terminal: 'captured',
|
||||
trigger: 'not-available',
|
||||
},
|
||||
reason: 'xtream-benchmark-iteration-failed',
|
||||
stage: 'terminal-settlement',
|
||||
status: 'failed',
|
||||
validForComparison: false,
|
||||
});
|
||||
const serialized = JSON.stringify([...persisted.values()]);
|
||||
assert.doesNotMatch(serialized, /performance:performance|secret/iu);
|
||||
assert.doesNotMatch(serialized, /message|stack/iu);
|
||||
});
|
||||
|
||||
it('persists fixed failure metadata when capture setup never completed', async () => {
|
||||
const persisted = new Map<string, unknown>();
|
||||
|
||||
await persistXtreamIterationFailureEvidence({
|
||||
evidence: {},
|
||||
persist: async (filename, value) => {
|
||||
persisted.set(filename, value);
|
||||
},
|
||||
stage: 'capture-setup',
|
||||
});
|
||||
|
||||
assert.deepEqual([...persisted.keys()], ['failure.json']);
|
||||
assert.deepEqual(persisted.get('failure.json'), {
|
||||
evidence: {
|
||||
control: 'not-available',
|
||||
mainCapture: 'not-available',
|
||||
mainStatus: 'not-available',
|
||||
rendererCapture: 'not-available',
|
||||
terminal: 'not-available',
|
||||
trigger: 'not-available',
|
||||
},
|
||||
reason: 'xtream-benchmark-iteration-failed',
|
||||
stage: 'capture-setup',
|
||||
status: 'failed',
|
||||
validForComparison: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when aggregate failure evidence cannot be persisted', async () => {
|
||||
await assert.rejects(
|
||||
persistXtreamIterationFailureEvidence({
|
||||
evidence: {},
|
||||
persist: async (filename) => {
|
||||
if (filename === 'failure.json') {
|
||||
throw new Error('fixed-persistence-failure');
|
||||
}
|
||||
},
|
||||
stage: 'capture-setup',
|
||||
}),
|
||||
/fixed-persistence-failure/
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows the original iteration failure after evidence persistence succeeds', async () => {
|
||||
const originalFailure = new Error('fixed-original-failure');
|
||||
|
||||
await assert.rejects(
|
||||
persistXtreamIterationFailureEvidenceAndThrow(originalFailure, {
|
||||
evidence: {},
|
||||
persist: async () => undefined,
|
||||
stage: 'capture-setup',
|
||||
}),
|
||||
(failure: unknown) => {
|
||||
assert.strictEqual(failure, originalFailure);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves both the original and evidence persistence failures', async () => {
|
||||
const originalFailure = new Error('fixed-original-failure');
|
||||
const persistenceFailure = new Error('fixed-persistence-failure');
|
||||
|
||||
await assert.rejects(
|
||||
persistXtreamIterationFailureEvidenceAndThrow(originalFailure, {
|
||||
evidence: {},
|
||||
persist: async (filename) => {
|
||||
if (filename === 'failure.json') {
|
||||
throw persistenceFailure;
|
||||
}
|
||||
},
|
||||
stage: 'capture-setup',
|
||||
}),
|
||||
(failure: unknown) => {
|
||||
assert.ok(failure instanceof AggregateError);
|
||||
assert.deepEqual(failure.errors, [
|
||||
originalFailure,
|
||||
persistenceFailure,
|
||||
]);
|
||||
assert.strictEqual(failure.cause, originalFailure);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('collects failure evidence before teardown and writes summary before its guard', () => {
|
||||
const iteration = readFileSync(
|
||||
resolve(performanceDirectory, 'xtream-benchmark-iteration.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const failureCatch = iteration.indexOf('} catch (failure) {');
|
||||
const failurePersistence = iteration.indexOf(
|
||||
'persistXtreamIterationFailureEvidenceAndThrow(',
|
||||
failureCatch
|
||||
);
|
||||
const teardown = iteration.indexOf('} finally {', failureCatch);
|
||||
|
||||
assert.ok(failureCatch >= 0, 'iteration failure catch is missing');
|
||||
assert.ok(
|
||||
failurePersistence > failureCatch,
|
||||
'failure evidence persistence is missing'
|
||||
);
|
||||
assert.ok(
|
||||
teardown > failurePersistence,
|
||||
'teardown occurs before failure evidence persistence'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
iteration.slice(failureCatch, teardown),
|
||||
/if \(captureInstalled\)/u,
|
||||
'early capture-setup failure metadata is incorrectly gated'
|
||||
);
|
||||
assert.match(iteration, /createXtreamSingleAttemptCapture/);
|
||||
const seededBranch = iteration.indexOf('if (seeded) {');
|
||||
const seedStop = iteration.indexOf(
|
||||
'stopMainCaptureOnce = createXtreamSingleAttemptCapture',
|
||||
seededBranch
|
||||
);
|
||||
const seedRun = iteration.indexOf(
|
||||
'await seedXtreamExistingPortal',
|
||||
seededBranch
|
||||
);
|
||||
assert.ok(
|
||||
seedStop > seededBranch && seedRun > seedStop,
|
||||
'seed capture does not have failure finalization armed'
|
||||
);
|
||||
|
||||
const orchestrator = readFileSync(
|
||||
resolve(performanceDirectory, 'xtream-benchmark-orchestrator.ts'),
|
||||
'utf8'
|
||||
);
|
||||
const summaryWrite = orchestrator.indexOf(
|
||||
"join(configuration.outputDirectory, 'summary.json')"
|
||||
);
|
||||
const validityGuard = orchestrator.indexOf(
|
||||
'summary.validForComparison !=='
|
||||
);
|
||||
assert.ok(summaryWrite >= 0, 'summary persistence is missing');
|
||||
assert.ok(
|
||||
validityGuard > summaryWrite,
|
||||
'comparison-validity guard runs before summary persistence'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
export type XtreamBenchmarkFailureStage =
|
||||
| 'background-ui'
|
||||
| 'capture-setup'
|
||||
| 'capture-stop'
|
||||
| 'evidence-validation'
|
||||
| 'measured-capture-start'
|
||||
| 'operation-trigger'
|
||||
| 'renderer-capture-start'
|
||||
| 'result-persistence'
|
||||
| 'scenario-preparation'
|
||||
| 'seed'
|
||||
| 'terminal-settlement';
|
||||
|
||||
type FailureEvidenceName =
|
||||
| 'control'
|
||||
| 'mainCapture'
|
||||
| 'mainStatus'
|
||||
| 'rendererCapture'
|
||||
| 'terminal'
|
||||
| 'trigger';
|
||||
|
||||
type FailureEvidenceStatus = 'capture-failed' | 'captured' | 'not-available';
|
||||
|
||||
interface PersistXtreamIterationFailureEvidenceOptions {
|
||||
readonly evidence: Partial<
|
||||
Record<FailureEvidenceName, () => Promise<unknown>>
|
||||
>;
|
||||
readonly persist: (filename: string, value: unknown) => Promise<void>;
|
||||
readonly stage: XtreamBenchmarkFailureStage;
|
||||
}
|
||||
|
||||
const EVIDENCE_ARTIFACTS: readonly [FailureEvidenceName, string][] =
|
||||
Object.freeze([
|
||||
['mainStatus', 'failure-main-status.json'],
|
||||
['mainCapture', 'failure-main-capture.json'],
|
||||
['rendererCapture', 'failure-renderer-capture.json'],
|
||||
['trigger', 'failure-trigger-evidence.json'],
|
||||
['terminal', 'failure-terminal-evidence.json'],
|
||||
['control', 'failure-control-state.json'],
|
||||
]);
|
||||
|
||||
export function createXtreamSingleAttemptCapture<T>(
|
||||
capture: () => Promise<T>
|
||||
): () => Promise<T> {
|
||||
let attempt: Promise<T> | null = null;
|
||||
return () => {
|
||||
attempt ??= Promise.resolve().then(capture);
|
||||
return attempt;
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistXtreamIterationFailureEvidence(
|
||||
options: PersistXtreamIterationFailureEvidenceOptions
|
||||
): Promise<void> {
|
||||
const status = {} as Record<FailureEvidenceName, FailureEvidenceStatus>;
|
||||
for (const [name, filename] of EVIDENCE_ARTIFACTS) {
|
||||
const capture = options.evidence[name];
|
||||
if (!capture) {
|
||||
status[name] = 'not-available';
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const value = await capture();
|
||||
await options.persist(filename, value);
|
||||
status[name] = 'captured';
|
||||
} catch {
|
||||
status[name] = 'capture-failed';
|
||||
}
|
||||
}
|
||||
await options.persist('failure.json', {
|
||||
evidence: status,
|
||||
reason: 'xtream-benchmark-iteration-failed',
|
||||
stage: options.stage,
|
||||
status: 'failed',
|
||||
validForComparison: false,
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistXtreamIterationFailureEvidenceAndThrow(
|
||||
failure: unknown,
|
||||
options: PersistXtreamIterationFailureEvidenceOptions
|
||||
): Promise<never> {
|
||||
try {
|
||||
await persistXtreamIterationFailureEvidence(options);
|
||||
} catch (persistenceFailure) {
|
||||
throw new AggregateError(
|
||||
[failure, persistenceFailure],
|
||||
'Xtream benchmark iteration and failure evidence persistence failed',
|
||||
{ cause: failure }
|
||||
);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { XtreamBenchmarkManifest } from './xtream-benchmark-contract';
|
||||
import { parseXtreamBenchmarkBuildIdentity } from './xtream-build-identity-schema';
|
||||
import { assertXtreamArtifactRedacted } from './xtream-diagnostic-artifacts';
|
||||
import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract';
|
||||
|
||||
export const XTREAM_SHA256 = /^[a-f0-9]{64}$/u;
|
||||
export const XTREAM_IDENTIFIER =
|
||||
/^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$))[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9_-])?$/iu;
|
||||
|
||||
const ROOT_KEYS = [
|
||||
'build',
|
||||
'environment',
|
||||
'fixture',
|
||||
'memoryAccounting',
|
||||
'mode',
|
||||
'runtime',
|
||||
'source',
|
||||
'suite',
|
||||
'variant',
|
||||
] as const;
|
||||
const ENVIRONMENT_KEYS = [
|
||||
'cdpAddress',
|
||||
'cdpPort',
|
||||
'databaseWorkerBatchDelayMs',
|
||||
'exposeGc',
|
||||
'freshUserDataPerIteration',
|
||||
'perfCapture',
|
||||
'perfWorkerProfiling',
|
||||
'traceRendererConsole',
|
||||
] as const;
|
||||
const FIXTURE_KEYS = [
|
||||
'bytes',
|
||||
'catalogSha256',
|
||||
'categories',
|
||||
'items',
|
||||
'scenario',
|
||||
'seed',
|
||||
] as const;
|
||||
const RUNTIME_KEYS = [
|
||||
'arch',
|
||||
'electronVersion',
|
||||
'harnessNodeVersion',
|
||||
'platform',
|
||||
] as const;
|
||||
const SOURCE_KEYS = ['gitCommit', 'gitDiffSha256', 'gitDirty'] as const;
|
||||
const MEMORY_KEYS = Object.keys(XTREAM_MEMORY_ACCOUNTING);
|
||||
|
||||
export function parseXtreamBenchmarkManifest(
|
||||
value: unknown
|
||||
): XtreamBenchmarkManifest {
|
||||
try {
|
||||
const input = snapshotExactRecord(value, ROOT_KEYS);
|
||||
const build = parseXtreamBenchmarkBuildIdentity(input['build']);
|
||||
const environment = snapshotExactRecord(
|
||||
input['environment'],
|
||||
ENVIRONMENT_KEYS
|
||||
);
|
||||
const fixture = snapshotExactRecord(input['fixture'], FIXTURE_KEYS);
|
||||
const memoryAccounting = snapshotExactRecord(
|
||||
input['memoryAccounting'],
|
||||
MEMORY_KEYS
|
||||
);
|
||||
const runtime = snapshotExactRecord(input['runtime'], RUNTIME_KEYS);
|
||||
const source = snapshotExactRecord(input['source'], SOURCE_KEYS);
|
||||
const snapshot = {
|
||||
build,
|
||||
environment: { ...environment },
|
||||
fixture: { ...fixture },
|
||||
memoryAccounting: { ...memoryAccounting },
|
||||
mode: input['mode'],
|
||||
runtime: { ...runtime },
|
||||
source: { ...source },
|
||||
suite: input['suite'],
|
||||
variant: input['variant'],
|
||||
};
|
||||
assertXtreamArtifactRedacted(snapshot);
|
||||
if (!isValidManifest(snapshot)) invalid();
|
||||
return freezeDeep(snapshot) as unknown as XtreamBenchmarkManifest;
|
||||
} catch {
|
||||
throw new Error('invalid Xtream benchmark manifest');
|
||||
}
|
||||
}
|
||||
|
||||
export function assertXtreamBenchmarkManifest(
|
||||
value: unknown
|
||||
): asserts value is XtreamBenchmarkManifest {
|
||||
parseXtreamBenchmarkManifest(value);
|
||||
}
|
||||
|
||||
function isValidManifest(
|
||||
manifest: Record<string, unknown> & {
|
||||
environment: Record<string, unknown>;
|
||||
fixture: Record<string, unknown>;
|
||||
memoryAccounting: Record<string, unknown>;
|
||||
runtime: Record<string, unknown>;
|
||||
source: Record<string, unknown>;
|
||||
}
|
||||
): boolean {
|
||||
const { environment, fixture, memoryAccounting, runtime, source } =
|
||||
manifest;
|
||||
return (
|
||||
environment['cdpAddress'] === '127.0.0.1' &&
|
||||
isValidXtreamCdpPort(manifest['mode'], environment['cdpPort']) &&
|
||||
environment['databaseWorkerBatchDelayMs'] === 0 &&
|
||||
environment['exposeGc'] === true &&
|
||||
environment['freshUserDataPerIteration'] === true &&
|
||||
environment['perfCapture'] === '1' &&
|
||||
environment['perfWorkerProfiling'] === '1' &&
|
||||
environment['traceRendererConsole'] === '0' &&
|
||||
fixture['scenario'] === 'performance-100k' &&
|
||||
fixture['seed'] === 91_001 &&
|
||||
fixture['categories'] === 100 &&
|
||||
fixture['items'] === 100_000 &&
|
||||
isPositiveSafeInteger(fixture['bytes']) &&
|
||||
typeof fixture['catalogSha256'] === 'string' &&
|
||||
XTREAM_SHA256.test(fixture['catalogSha256']) &&
|
||||
Object.values(runtime).every(
|
||||
(entry) => typeof entry === 'string' && entry.length > 0
|
||||
) &&
|
||||
typeof source['gitCommit'] === 'string' &&
|
||||
/^[a-f0-9]{40}$/u.test(source['gitCommit']) &&
|
||||
typeof source['gitDiffSha256'] === 'string' &&
|
||||
XTREAM_SHA256.test(source['gitDiffSha256']) &&
|
||||
typeof source['gitDirty'] === 'boolean' &&
|
||||
(manifest['mode'] !== 'formal' || !source['gitDirty']) &&
|
||||
(manifest['mode'] === 'formal' || manifest['mode'] === 'smoke') &&
|
||||
manifest['suite'] === 'xtream-performance-100k' &&
|
||||
typeof manifest['variant'] === 'string' &&
|
||||
XTREAM_IDENTIFIER.test(manifest['variant']) &&
|
||||
MEMORY_KEYS.every(
|
||||
(key) =>
|
||||
memoryAccounting[key] ===
|
||||
XTREAM_MEMORY_ACCOUNTING[
|
||||
key as keyof typeof XTREAM_MEMORY_ACCOUNTING
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isValidXtreamCdpPort(mode: unknown, cdpPort: unknown): boolean {
|
||||
return (
|
||||
(mode === 'formal' || mode === 'smoke') &&
|
||||
typeof cdpPort === 'number' &&
|
||||
Number.isInteger(cdpPort) &&
|
||||
cdpPort >= 1024 &&
|
||||
cdpPort <= 65_535 &&
|
||||
(mode === 'smoke' || cdpPort === 9222)
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotExactRecord(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[]
|
||||
): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
invalid();
|
||||
}
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (
|
||||
keys.length !== expectedKeys.length ||
|
||||
keys.some(
|
||||
(key) => typeof key !== 'string' || !expectedKeys.includes(key)
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.fromEntries(
|
||||
expectedKeys.map((key) => [key, Reflect.get(value, key)])
|
||||
);
|
||||
}
|
||||
|
||||
function freezeDeep<T>(value: T): T {
|
||||
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
||||
Object.values(value).forEach(freezeDeep);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: unknown): boolean {
|
||||
return Number.isSafeInteger(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('invalid manifest');
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
createXtreamIterationDefinitions,
|
||||
listXtreamBenchmarkScenarios,
|
||||
} from './xtream-benchmark-contract';
|
||||
import {
|
||||
assertXtreamBenchmarkBuildUnchanged,
|
||||
createXtreamBenchmarkManifest,
|
||||
resolveXtreamBenchmarkConfiguration,
|
||||
} from './xtream-benchmark-configuration';
|
||||
import {
|
||||
createXtreamArtifactSafetyContext,
|
||||
writeXtreamSafeJson,
|
||||
} from './xtream-benchmark-artifacts';
|
||||
import { runXtreamBenchmarkIteration } from './xtream-benchmark-iteration';
|
||||
import { createXtreamBenchmarkSummary } from './xtream-benchmark-report';
|
||||
import {
|
||||
assertXtreamControlReadyAfterObservationReset,
|
||||
createXtreamControlClient,
|
||||
} from './xtream-control-client';
|
||||
import type { XtreamDiagnosticArtifactEvidence } from './xtream-diagnostic-artifacts';
|
||||
import type { XtreamValidatedIterationResult } from './xtream-summary-contract';
|
||||
|
||||
export async function runXtreamBenchmark(): Promise<void> {
|
||||
const configuration = await resolveXtreamBenchmarkConfiguration();
|
||||
const safety = createXtreamArtifactSafetyContext(
|
||||
configuration.controlToken
|
||||
);
|
||||
const control = createXtreamControlClient({
|
||||
origin: configuration.mockOrigin,
|
||||
token: configuration.controlToken,
|
||||
});
|
||||
const iterations: XtreamValidatedIterationResult[] = [];
|
||||
const diagnosticEvidence = new Map<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>();
|
||||
const seenElectronPids = new Set<number>();
|
||||
try {
|
||||
await control.resetAll();
|
||||
const expectedFixture = await control.prepare();
|
||||
await control.resetObservations();
|
||||
assertXtreamControlReadyAfterObservationReset(
|
||||
await control.state(),
|
||||
expectedFixture
|
||||
);
|
||||
const manifest = createXtreamBenchmarkManifest(
|
||||
configuration,
|
||||
expectedFixture
|
||||
);
|
||||
await writeXtreamSafeJson(
|
||||
join(configuration.outputDirectory, 'manifest.json'),
|
||||
manifest,
|
||||
safety
|
||||
);
|
||||
const definitions = createXtreamIterationDefinitions(
|
||||
configuration.smoke
|
||||
);
|
||||
for (const scenario of listXtreamBenchmarkScenarios()) {
|
||||
const scenarioDirectory = join(
|
||||
configuration.outputDirectory,
|
||||
scenario.id
|
||||
);
|
||||
await mkdir(scenarioDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
for (const definition of definitions.filter(
|
||||
({ scenarioId }) => scenarioId === scenario.id
|
||||
)) {
|
||||
console.log(
|
||||
`[performance] ${configuration.variant}/${scenario.id}/${definition.runId} starting`
|
||||
);
|
||||
const persisted = await runXtreamBenchmarkIteration({
|
||||
configuration,
|
||||
control,
|
||||
definition,
|
||||
expectedFixture,
|
||||
scenarioDirectory,
|
||||
seenElectronPids,
|
||||
});
|
||||
iterations.push(persisted.iteration);
|
||||
if (persisted.diagnosticEvidence) {
|
||||
diagnosticEvidence.set(
|
||||
persisted.iteration,
|
||||
persisted.diagnosticEvidence
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[performance] ${scenario.id}/${definition.runId} total=${persisted.iteration.phases.totalMs.toFixed(3)}ms`
|
||||
);
|
||||
}
|
||||
}
|
||||
await assertXtreamBenchmarkBuildUnchanged(configuration);
|
||||
const summary = createXtreamBenchmarkSummary(
|
||||
manifest,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
);
|
||||
await writeXtreamSafeJson(
|
||||
join(configuration.outputDirectory, 'summary.json'),
|
||||
summary,
|
||||
safety
|
||||
);
|
||||
if (summary.validForComparison !== (configuration.mode === 'formal')) {
|
||||
throw new Error('xtream-benchmark-comparison-validity-invalid');
|
||||
}
|
||||
console.log(
|
||||
`[performance] completed: ${join(
|
||||
configuration.outputDirectory,
|
||||
'summary.json'
|
||||
)}`
|
||||
);
|
||||
} finally {
|
||||
await control.resetAll().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { MakeDirectoryOptions } from 'node:fs';
|
||||
import { lstat, mkdir, rmdir } from 'node:fs/promises';
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from 'node:path';
|
||||
|
||||
import { assertXtreamSafePathIdentifier } from './xtream-exact-schema';
|
||||
|
||||
export const XTREAM_BENCHMARK_FORMAL_CDP_PORT = 9222;
|
||||
|
||||
const OUTPUT_TIMESTAMP =
|
||||
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z(?:-[a-z][a-z0-9-]{0,63})?$/u;
|
||||
|
||||
export interface XtreamBenchmarkOutputLayout {
|
||||
readonly outputDirectory: string;
|
||||
readonly outputRoot: string;
|
||||
readonly performanceRoot: string;
|
||||
}
|
||||
|
||||
export type XtreamMkdir = (
|
||||
path: string,
|
||||
options?: MakeDirectoryOptions
|
||||
) => Promise<string | undefined | void>;
|
||||
|
||||
export function resolveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot: string,
|
||||
requestedRoot: string,
|
||||
variant: string
|
||||
): XtreamBenchmarkOutputLayout {
|
||||
const root = resolve(repositoryRoot);
|
||||
const performanceRoot = join(root, 'dist', 'performance');
|
||||
const outputRoot = resolve(requestedRoot);
|
||||
if (
|
||||
!isAbsolute(repositoryRoot) ||
|
||||
!isAbsolute(requestedRoot) ||
|
||||
requestedRoot !== outputRoot ||
|
||||
dirname(outputRoot) !== performanceRoot ||
|
||||
!isValidTimestampDirectory(basename(outputRoot))
|
||||
) {
|
||||
throw new Error(
|
||||
'Performance output must be an absolute fresh timestamp path below dist/performance'
|
||||
);
|
||||
}
|
||||
try {
|
||||
assertXtreamSafePathIdentifier(variant, 'variant');
|
||||
} catch {
|
||||
throw new Error('IPTVNATOR_PERF_VARIANT must be a safe Xtream variant');
|
||||
}
|
||||
const outputDirectory = join(outputRoot, variant);
|
||||
if (!isStrictDescendant(outputRoot, outputDirectory)) {
|
||||
throw new Error('IPTVNATOR_PERF_VARIANT must be a safe Xtream variant');
|
||||
}
|
||||
return Object.freeze({ outputDirectory, outputRoot, performanceRoot });
|
||||
}
|
||||
|
||||
export function resolveXtreamBenchmarkCdpPort(
|
||||
mode: 'formal' | 'smoke',
|
||||
requestedPort: string | undefined
|
||||
): number {
|
||||
const port =
|
||||
requestedPort === undefined
|
||||
? XTREAM_BENCHMARK_FORMAL_CDP_PORT
|
||||
: Number(requestedPort);
|
||||
if (
|
||||
(requestedPort !== undefined && !/^[1-9]\d*$/u.test(requestedPort)) ||
|
||||
!Number.isSafeInteger(port) ||
|
||||
port < 1024 ||
|
||||
port > 65_535
|
||||
) {
|
||||
throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid');
|
||||
}
|
||||
if (mode === 'formal' && port !== XTREAM_BENCHMARK_FORMAL_CDP_PORT) {
|
||||
throw new Error(
|
||||
`Formal Xtream performance runs require CDP port ${XTREAM_BENCHMARK_FORMAL_CDP_PORT}`
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export async function assertXtreamBenchmarkOutputPathHasNoSymlinks(
|
||||
repositoryRoot: string,
|
||||
outputRoot: string
|
||||
): Promise<void> {
|
||||
const root = resolve(repositoryRoot);
|
||||
const output = resolve(outputRoot);
|
||||
if (!isStrictDescendant(root, output)) {
|
||||
throw new Error('Xtream performance output escapes the repository');
|
||||
}
|
||||
const components = relative(root, output).split(sep);
|
||||
let current = root;
|
||||
for (const component of ['', ...components]) {
|
||||
current = component ? join(current, component) : current;
|
||||
try {
|
||||
if ((await lstat(current)).isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Xtream performance output contains a symbolic link: ${current}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingPath(error)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createExclusiveXtreamBenchmarkOutputLayout(
|
||||
repositoryRoot: string,
|
||||
layout: XtreamBenchmarkOutputLayout,
|
||||
createDirectory: XtreamMkdir = mkdir
|
||||
): Promise<void> {
|
||||
await createDirectory(layout.performanceRoot, {
|
||||
mode: 0o700,
|
||||
recursive: true,
|
||||
});
|
||||
await assertXtreamBenchmarkOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
layout.outputRoot
|
||||
);
|
||||
await createDirectory(layout.outputRoot, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
try {
|
||||
await createDirectory(layout.outputDirectory, {
|
||||
mode: 0o700,
|
||||
recursive: false,
|
||||
});
|
||||
} catch (error) {
|
||||
await rmdir(layout.outputRoot).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
await assertXtreamBenchmarkOutputPathHasNoSymlinks(
|
||||
repositoryRoot,
|
||||
layout.outputDirectory
|
||||
);
|
||||
}
|
||||
|
||||
function isValidTimestampDirectory(value: string): boolean {
|
||||
const match = OUTPUT_TIMESTAMP.exec(value);
|
||||
if (!match) return false;
|
||||
const [, year, month, day, hour, minute, second] = match;
|
||||
const timestamp = Date.UTC(
|
||||
Number(year),
|
||||
Number(month) - 1,
|
||||
Number(day),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
Number(second)
|
||||
);
|
||||
return (
|
||||
Number.isFinite(timestamp) &&
|
||||
new Date(timestamp).toISOString().replace(/[-:]/gu, '').slice(0, 15) ===
|
||||
`${year}${month}${day}T${hour}${minute}${second}`
|
||||
);
|
||||
}
|
||||
|
||||
function isStrictDescendant(parent: string, candidate: string): boolean {
|
||||
const pathFromParent = relative(resolve(parent), resolve(candidate));
|
||||
return (
|
||||
pathFromParent.length > 0 &&
|
||||
pathFromParent !== '..' &&
|
||||
!pathFromParent.startsWith(`..${sep}`) &&
|
||||
!isAbsolute(pathFromParent)
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingPath(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
);
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
createXtreamIterationDefinitions,
|
||||
XTREAM_ITERATION_KIND,
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamIterationDefinition,
|
||||
} from './xtream-benchmark-contract';
|
||||
import {
|
||||
validateXtreamDiagnosticArtifacts,
|
||||
type XtreamDiagnosticArtifactEvidence,
|
||||
type XtreamDiagnosticCapture,
|
||||
} from './xtream-diagnostic-artifacts';
|
||||
import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures';
|
||||
import {
|
||||
iteration,
|
||||
persist,
|
||||
withWorkerRequestOverlap,
|
||||
} from './xtream-benchmark-report.fixtures';
|
||||
import type { XtreamValidatedIterationResult } from './xtream-summary-contract';
|
||||
|
||||
const HEAP_SNAPSHOT = {
|
||||
edges: [0, 0, 0],
|
||||
nodes: [0, 0, 1, 0, 1, 0],
|
||||
snapshot: {
|
||||
meta: {
|
||||
edge_fields: ['type', 'name_or_index', 'to_node'],
|
||||
edge_types: [['context'], 'string_or_number', 'node'],
|
||||
node_fields: [
|
||||
'type',
|
||||
'name',
|
||||
'id',
|
||||
'self_size',
|
||||
'edge_count',
|
||||
'detachedness',
|
||||
],
|
||||
node_types: [
|
||||
['hidden'],
|
||||
'string',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
'number',
|
||||
],
|
||||
},
|
||||
},
|
||||
strings: ['', 'root'],
|
||||
};
|
||||
const RENDERER_TRACE = {
|
||||
traceEvents: [{ name: 'xtream-diagnostic', ph: 'X', ts: 1 }],
|
||||
};
|
||||
const directories: string[] = [];
|
||||
|
||||
export interface XtreamReportFixture {
|
||||
readonly diagnosticEvidence: ReadonlyMap<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>;
|
||||
readonly iterations: readonly XtreamValidatedIterationResult[];
|
||||
}
|
||||
|
||||
export async function formalReportFixture(): Promise<XtreamReportFixture> {
|
||||
return reportFixture(createXtreamIterationDefinitions(false), 10, true);
|
||||
}
|
||||
|
||||
export async function smokeReportFixture(): Promise<XtreamReportFixture> {
|
||||
return reportFixture(createXtreamIterationDefinitions(true), 100, false);
|
||||
}
|
||||
|
||||
export async function clearXtreamProvenanceFixtureDirectories(): Promise<void> {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
}
|
||||
|
||||
async function reportFixture(
|
||||
definitions: readonly XtreamIterationDefinition[],
|
||||
identityOffset: number,
|
||||
mixedInitialOverlap: boolean
|
||||
): Promise<XtreamReportFixture> {
|
||||
const entries = await Promise.all(
|
||||
definitions.map(async (definition, index) => {
|
||||
const identity = index + 1;
|
||||
let raw = iteration(definition, identity);
|
||||
let evidence: XtreamDiagnosticArtifactEvidence | null = null;
|
||||
if (definition.kind === XTREAM_ITERATION_KIND.DIAGNOSTIC) {
|
||||
evidence = await diagnosticEvidence(identity + identityOffset);
|
||||
raw = {
|
||||
...raw,
|
||||
processIdentity: {
|
||||
...raw.processIdentity,
|
||||
profileDirectorySha256: evidence.directorySha256,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
mixedInitialOverlap &&
|
||||
definition.scenarioId ===
|
||||
XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE &&
|
||||
['run-02', 'run-04'].includes(definition.runId)
|
||||
) {
|
||||
raw = withWorkerRequestOverlap(raw, 2);
|
||||
}
|
||||
return {
|
||||
evidence,
|
||||
iteration: await persist(raw, identity + identityOffset),
|
||||
};
|
||||
})
|
||||
);
|
||||
return {
|
||||
diagnosticEvidence: new Map(
|
||||
entries.flatMap(({ evidence, iteration: result }) =>
|
||||
evidence ? [[result, evidence] as const] : []
|
||||
)
|
||||
),
|
||||
iterations: entries.map(({ iteration: result }) => result),
|
||||
};
|
||||
}
|
||||
|
||||
async function diagnosticEvidence(
|
||||
identity: number
|
||||
): Promise<XtreamDiagnosticArtifactEvidence> {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), `iptvnator-xtream-report-diagnostic-${identity}-`)
|
||||
);
|
||||
directories.push(directory);
|
||||
await writeArtifacts(directory);
|
||||
return validateXtreamDiagnosticArtifacts(capture(directory), directory);
|
||||
}
|
||||
|
||||
function capture(directory: string): XtreamDiagnosticCapture {
|
||||
return {
|
||||
main: {
|
||||
cpuProfilePath: join(directory, 'main.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'main.heapsnapshot'),
|
||||
workers: [
|
||||
{
|
||||
kind: 'database.worker',
|
||||
ordinal: 1,
|
||||
profilePath: join(
|
||||
directory,
|
||||
'database.worker-1.cpuprofile'
|
||||
),
|
||||
snapshotPath: join(
|
||||
directory,
|
||||
'database.worker-1.heapsnapshot'
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
renderer: {
|
||||
cpuProfilePath: join(directory, 'renderer.cpuprofile'),
|
||||
heapSnapshotPath: join(directory, 'renderer.heapsnapshot'),
|
||||
tracePath: join(directory, 'renderer.trace.json'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function writeArtifacts(directory: string): Promise<void> {
|
||||
const artifacts = [
|
||||
['main.cpuprofile', CPU_PROFILE],
|
||||
['renderer.cpuprofile', CPU_PROFILE],
|
||||
['database.worker-1.cpuprofile', CPU_PROFILE],
|
||||
['main.heapsnapshot', HEAP_SNAPSHOT],
|
||||
['renderer.heapsnapshot', HEAP_SNAPSHOT],
|
||||
['database.worker-1.heapsnapshot', HEAP_SNAPSHOT],
|
||||
['renderer.trace.json', RENDERER_TRACE],
|
||||
] as const;
|
||||
await Promise.all(
|
||||
artifacts.map(([filename, value]) =>
|
||||
writeFile(join(directory, filename), JSON.stringify(value))
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
XTREAM_ITERATION_KIND,
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamIterationDefinition,
|
||||
} from './xtream-benchmark-contract';
|
||||
import {
|
||||
computeXtreamBuildFilesAggregate,
|
||||
type XtreamBenchmarkBuildIdentity,
|
||||
type XtreamBuildFileIdentity,
|
||||
} from './xtream-build-identity';
|
||||
import { persistThenValidateXtreamIteration } from './xtream-benchmark-report';
|
||||
import { bindFixtureCancellationEvidence } from './xtream-cancellation-evidence.fixture';
|
||||
import { bindFixtureIpcWorkerIdentities } from './xtream-ipc-worker-identity.fixture';
|
||||
import { scenarioCapture } from './xtream-phase-attribution.fixtures';
|
||||
import { attributeXtreamPhases } from './xtream-phase-attribution';
|
||||
import {
|
||||
XTREAM_MEMORY_ACCOUNTING,
|
||||
type XtreamArtifactSafetyContext,
|
||||
type XtreamBenchmarkManifest,
|
||||
type XtreamPersistenceReceipt,
|
||||
type XtreamRawIterationResult,
|
||||
type XtreamValidatedIterationResult,
|
||||
} from './xtream-summary-contract';
|
||||
import {
|
||||
invalidateWorkerRequestMetrics,
|
||||
workerRequestEvidence,
|
||||
} from './xtream-worker-request-evidence.fixtures';
|
||||
import { bindFixtureWorkerEvidence } from './xtream-worker-phase-evidence.fixture';
|
||||
import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract';
|
||||
|
||||
export const TOKEN = 'random-control-token-that-must-never-persist';
|
||||
const buildFile = (path: string, marker: string): XtreamBuildFileIdentity => ({
|
||||
bytes: marker.length,
|
||||
path,
|
||||
sha256: sha256(marker),
|
||||
});
|
||||
const rendererFiles = [
|
||||
buildFile('dist/apps/web/index.html', 'renderer-index'),
|
||||
buildFile('dist/apps/web/main-FIXTURE.js', 'renderer-javascript'),
|
||||
buildFile('dist/apps/web/main-FIXTURE.js.map', 'renderer-source-map'),
|
||||
] as const;
|
||||
export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = {
|
||||
electron: {
|
||||
databaseWorker: {
|
||||
javascript: buildFile(
|
||||
'dist/apps/electron-backend/workers/database.worker.js',
|
||||
'database-worker-javascript'
|
||||
),
|
||||
sourceMap: buildFile(
|
||||
'dist/apps/electron-backend/workers/database.worker.js.map',
|
||||
'database-worker-source-map'
|
||||
),
|
||||
},
|
||||
main: {
|
||||
javascript: buildFile(
|
||||
'dist/apps/electron-backend/main.js',
|
||||
'main-javascript'
|
||||
),
|
||||
sourceMap: buildFile(
|
||||
'dist/apps/electron-backend/main.js.map',
|
||||
'main-source-map'
|
||||
),
|
||||
},
|
||||
playlistRefreshWorker: {
|
||||
javascript: buildFile(
|
||||
'dist/apps/electron-backend/workers/playlist-refresh.worker.js',
|
||||
'playlist-worker-javascript'
|
||||
),
|
||||
sourceMap: buildFile(
|
||||
'dist/apps/electron-backend/workers/playlist-refresh.worker.js.map',
|
||||
'playlist-worker-source-map'
|
||||
),
|
||||
},
|
||||
preload: {
|
||||
javascript: buildFile(
|
||||
'dist/apps/electron-backend/main.preload.js',
|
||||
'preload-javascript'
|
||||
),
|
||||
sourceMap: buildFile(
|
||||
'dist/apps/electron-backend/main.preload.js.map',
|
||||
'preload-source-map'
|
||||
),
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
aggregateSha256: computeXtreamBuildFilesAggregate(rendererFiles),
|
||||
bytes: rendererFiles.reduce((total, file) => total + file.bytes, 0),
|
||||
executableJavaScriptCount: 1,
|
||||
files: rendererFiles,
|
||||
sourceMapCount: 1,
|
||||
},
|
||||
};
|
||||
const SAFETY: XtreamArtifactSafetyContext = {
|
||||
controlToken: TOKEN,
|
||||
syntheticCredentials: {
|
||||
password: 'performance',
|
||||
username: 'performance',
|
||||
},
|
||||
};
|
||||
export const MANIFEST: XtreamBenchmarkManifest = {
|
||||
build: BUILD_IDENTITY,
|
||||
environment: {
|
||||
cdpAddress: '127.0.0.1',
|
||||
cdpPort: 9222,
|
||||
databaseWorkerBatchDelayMs: 0,
|
||||
exposeGc: true,
|
||||
freshUserDataPerIteration: true,
|
||||
perfCapture: '1',
|
||||
perfWorkerProfiling: '1',
|
||||
traceRendererConsole: '0',
|
||||
},
|
||||
fixture: {
|
||||
bytes: 12_345,
|
||||
catalogSha256: hash('1'),
|
||||
categories: 100,
|
||||
items: 100_000,
|
||||
scenario: 'performance-100k',
|
||||
seed: 91_001,
|
||||
},
|
||||
memoryAccounting: XTREAM_MEMORY_ACCOUNTING,
|
||||
mode: 'formal',
|
||||
runtime: {
|
||||
arch: 'arm64',
|
||||
electronVersion: '38.0.0',
|
||||
harnessNodeVersion: '22.0.0',
|
||||
platform: 'darwin',
|
||||
},
|
||||
source: {
|
||||
gitCommit: '0123456789abcdef0123456789abcdef01234567',
|
||||
gitDiffSha256: hash('2'),
|
||||
gitDirty: false,
|
||||
},
|
||||
suite: 'xtream-performance-100k',
|
||||
variant: 'baseline',
|
||||
};
|
||||
const directories: string[] = [];
|
||||
export async function clearXtreamReportFixtureDirectories(): Promise<void> {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
}
|
||||
|
||||
export async function persist(
|
||||
raw: XtreamRawIterationResult,
|
||||
id: number,
|
||||
afterWrite?: () => void
|
||||
): Promise<XtreamValidatedIterationResult> {
|
||||
return persistWith(raw, async (serialized) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), `iptvnator-xtream-raw-${id}-`)
|
||||
);
|
||||
directories.push(directory);
|
||||
const absolutePath = join(directory, 'raw.json');
|
||||
await writeFile(absolutePath, serialized, 'utf8');
|
||||
afterWrite?.();
|
||||
return {
|
||||
absolutePath,
|
||||
bytes: Buffer.byteLength(serialized),
|
||||
sha256: sha256(serialized),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function persistWith(
|
||||
raw: XtreamRawIterationResult,
|
||||
write: (serialized: string) => Promise<XtreamPersistenceReceipt>
|
||||
): Promise<XtreamValidatedIterationResult> {
|
||||
return persistThenValidateXtreamIteration(raw, SAFETY, write);
|
||||
}
|
||||
|
||||
export function iteration(
|
||||
definition: XtreamIterationDefinition,
|
||||
identity: number
|
||||
): XtreamRawIterationResult {
|
||||
const diagnostic = definition.kind === XTREAM_ITERATION_KIND.DIAGNOSTIC;
|
||||
const background =
|
||||
definition.scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI;
|
||||
const workerBinding = bindFixtureWorkerEvidence(
|
||||
scenarioCapture(definition.scenarioId),
|
||||
workerRequestEvidence(definition.scenarioId)
|
||||
);
|
||||
const cancellationBinding = bindFixtureCancellationEvidence(
|
||||
workerBinding.phaseCapture,
|
||||
workerBinding.requests
|
||||
);
|
||||
const ipcWorkerBinding = bindFixtureIpcWorkerIdentities(
|
||||
cancellationBinding.phaseCapture,
|
||||
workerBinding.requests
|
||||
);
|
||||
const phaseCapture = ipcWorkerBinding.phaseCapture;
|
||||
const phases = attributeXtreamPhases(phaseCapture);
|
||||
const requestEvidence = ipcWorkerBinding.requests;
|
||||
const capturedRequestEvidence = background
|
||||
? invalidateWorkerRequestMetrics(requestEvidence, 3)
|
||||
: requestEvidence;
|
||||
const requestTotalCount = requestEvidence.length;
|
||||
const profile = {
|
||||
cpuProfileDurationMicros: diagnostic ? 2_000 : null,
|
||||
cpuProfileUnavailableReason: diagnostic
|
||||
? null
|
||||
: 'not-captured-outside-diagnostic',
|
||||
} as const;
|
||||
return {
|
||||
...definition,
|
||||
processIdentity: {
|
||||
captureGeneration: 1,
|
||||
electronPid: 10_000 + identity,
|
||||
freshProcess: true,
|
||||
generationIdentitySha256: numberedHash(identity, 3),
|
||||
launchId: `launch-${identity}`,
|
||||
profileDirectorySha256: numberedHash(identity, 4),
|
||||
},
|
||||
phaseCapture,
|
||||
phases,
|
||||
renderer: {
|
||||
...profile,
|
||||
actionLatencyMs: background ? 7 : null,
|
||||
consoleErrors: [],
|
||||
dashboardPaintVisibility: background
|
||||
? 'visible-without-overlay'
|
||||
: null,
|
||||
frameGapMaxMs: 45,
|
||||
frameGapP95Ms: 20,
|
||||
heartbeatMaxMs: 12,
|
||||
heartbeatP95Ms: 5,
|
||||
longTaskCount: 2,
|
||||
longTaskMaxMs: 75,
|
||||
navigationLatencyMs: background ? 8 : null,
|
||||
peakHeapUsedBytes: 300,
|
||||
peakRssBytes: 500,
|
||||
postGcHeapUsedBytes: 200,
|
||||
searchLatencyMs: background ? 9 : null,
|
||||
storeToPaintMs: phases.angularRenderingMs,
|
||||
targetId: `renderer-${identity}`,
|
||||
},
|
||||
main: {
|
||||
...profile,
|
||||
browserWindowId: 1,
|
||||
cpuMetricScope: 'electron-main-thread',
|
||||
cpuSystemMicros: 200,
|
||||
cpuUserMicros: 1_000,
|
||||
eventLoopDelayMaxMs: 8,
|
||||
eventLoopDelayP95Ms: 2,
|
||||
eventLoopDelayP99Ms: 4,
|
||||
eventLoopUtilization: null,
|
||||
eventLoopUtilizationUnavailableReason:
|
||||
'electron-main-embedded-event-loop',
|
||||
peakHeapUsedBytes: 400,
|
||||
peakRssBytes: 1_000,
|
||||
postGcHeapUsedBytes: 250,
|
||||
responsiveEvents: 0,
|
||||
unresponsiveEvents: 0,
|
||||
webContentsId: 2,
|
||||
},
|
||||
databaseWorker: {
|
||||
...profile,
|
||||
cpuMetricScope: 'sum-valid-request-thread-cpu',
|
||||
cpuSystemMicros:
|
||||
capturedRequestEvidence.filter(
|
||||
({ invalidReason }) => invalidReason === null
|
||||
).length * 10,
|
||||
cpuUserMicros:
|
||||
capturedRequestEvidence.filter(
|
||||
({ invalidReason }) => invalidReason === null
|
||||
).length * 20,
|
||||
currentForCapture: true,
|
||||
eventLoopDelayMaxMs: 9,
|
||||
eventLoopDelayMetricScope: 'valid-request-window-proxy',
|
||||
eventLoopDelayP95Ms: 3,
|
||||
eventLoopDelayP99Ms: 5,
|
||||
eventLoopUtilization: 0.6,
|
||||
kind: 'database.worker',
|
||||
ordinal: 1,
|
||||
peakExternalBytes: 40,
|
||||
peakHeapUsedBytes: 250,
|
||||
postGcHeapUsedBytes: 150,
|
||||
requests: {
|
||||
evidence: capturedRequestEvidence,
|
||||
eventLoopDelayMaxMs: 9,
|
||||
eventLoopDelayP95Ms: 3,
|
||||
eventLoopDelayP99Ms: 5,
|
||||
eventLoopUtilization: 0.5,
|
||||
invalidReasons: background
|
||||
? [
|
||||
{
|
||||
count: 3,
|
||||
reason: 'overlapping-database-worker-requests',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
invalidSampleCount: background ? 3 : 0,
|
||||
metricScope: 'valid-request-window-proxy',
|
||||
totalCount: requestTotalCount,
|
||||
validSampleCount: requestTotalCount - (background ? 3 : 0),
|
||||
},
|
||||
rssBytes: null,
|
||||
rssUnavailableReason: 'unavailable-per-worker-thread',
|
||||
wholeWorkerMetricScope: 'whole-worker-operation-window',
|
||||
},
|
||||
playlistRefreshWorker: {
|
||||
instantiated: false,
|
||||
reason: 'not-instantiated-by-xtream-flow',
|
||||
},
|
||||
cancellation: cancellationBinding.cancellation,
|
||||
cancellationEvidence: cancellationBinding.cancellationEvidence,
|
||||
catalogVerification: {
|
||||
databaseStateValid: true,
|
||||
fixtureIdentityValid: true,
|
||||
providerCategoryCount: 100,
|
||||
providerItemCount: 100_000,
|
||||
verifiedAfterCapture: true,
|
||||
},
|
||||
validity: {
|
||||
actionContractValid: true,
|
||||
catalogContractValid: true,
|
||||
databaseWorkerCount: 1,
|
||||
dbPhaseContractValid: true,
|
||||
diagnosticArtifactsValid: diagnostic ? true : null,
|
||||
lateDatabaseRequestCount: 0,
|
||||
playlistWorkerCount: 0,
|
||||
rendererTargetCount: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function withWorkerRequestOverlap(
|
||||
raw: XtreamRawIterationResult,
|
||||
invalidSampleCount: number
|
||||
): XtreamRawIterationResult {
|
||||
return {
|
||||
...raw,
|
||||
databaseWorker: {
|
||||
...raw.databaseWorker,
|
||||
requests: {
|
||||
...raw.databaseWorker.requests,
|
||||
evidence: invalidateWorkerRequestMetrics(
|
||||
raw.databaseWorker.requests.evidence.map(
|
||||
restoreValidRequestMetrics
|
||||
),
|
||||
invalidSampleCount
|
||||
),
|
||||
invalidReasons:
|
||||
invalidSampleCount === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
count: invalidSampleCount,
|
||||
reason: 'overlapping-database-worker-requests',
|
||||
},
|
||||
],
|
||||
invalidSampleCount,
|
||||
validSampleCount:
|
||||
raw.databaseWorker.requests.totalCount - invalidSampleCount,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function restoreValidRequestMetrics(
|
||||
request: WorkerRequestPerformanceMetrics
|
||||
): WorkerRequestPerformanceMetrics {
|
||||
if (
|
||||
request.workEndedEpochMs === null ||
|
||||
request.responsePostedEpochMs === null
|
||||
) {
|
||||
throw new Error('invalid Xtream request fixture');
|
||||
}
|
||||
return {
|
||||
...request,
|
||||
eventLoopDelay: { maxMs: 9, p95Ms: 3, p99Ms: 5 },
|
||||
eventLoopDelayUnavailableReason: null,
|
||||
eventLoopUtilization: 0.5,
|
||||
eventLoopUtilizationUnavailableReason: null,
|
||||
histogramFlushedEpochMs:
|
||||
(request.workEndedEpochMs + request.responsePostedEpochMs) / 2,
|
||||
invalidReason: null,
|
||||
threadCpuSystemMicros: 10,
|
||||
threadCpuUnavailableReason: null,
|
||||
threadCpuUserMicros: 20,
|
||||
};
|
||||
}
|
||||
|
||||
export function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function hash(character: string): string {
|
||||
return character.repeat(64);
|
||||
}
|
||||
|
||||
function numberedHash(value: number, salt: number): string {
|
||||
return sha256(`${salt}:${value}`);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
createXtreamIterationDefinitions,
|
||||
type XtreamBenchmarkManifest,
|
||||
} from './xtream-benchmark-contract';
|
||||
import { createXtreamBenchmarkSummary } from './xtream-benchmark-report';
|
||||
import {
|
||||
MANIFEST,
|
||||
TOKEN,
|
||||
clearXtreamReportFixtureDirectories,
|
||||
iteration,
|
||||
persist,
|
||||
persistWith,
|
||||
sha256,
|
||||
} from './xtream-benchmark-report.fixtures';
|
||||
import {
|
||||
clearXtreamProvenanceFixtureDirectories,
|
||||
formalReportFixture,
|
||||
smokeReportFixture,
|
||||
} from './xtream-benchmark-report-provenance.fixtures';
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all([
|
||||
clearXtreamReportFixtureDirectories(),
|
||||
clearXtreamProvenanceFixtureDirectories(),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Xtream benchmark report contract', () => {
|
||||
it('writes exact raw bytes before semantic validation and verifies a real receipt', async () => {
|
||||
const raw = iteration(
|
||||
itemAt(createXtreamIterationDefinitions(false), 1),
|
||||
1
|
||||
);
|
||||
const invalid = {
|
||||
...raw,
|
||||
main: { ...raw.main, peakRssBytes: -1 },
|
||||
};
|
||||
const order: string[] = [];
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
persist(invalid, 1, () => {
|
||||
order.push('raw-written');
|
||||
}),
|
||||
/invalid Xtream iteration/
|
||||
);
|
||||
order.push('validation-rejected');
|
||||
assert.deepEqual(order, ['raw-written', 'validation-rejected']);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
persistWith(raw, async (serialized) => ({
|
||||
absolutePath: join(tmpdir(), 'does-not-exist.json'),
|
||||
bytes: Buffer.byteLength(serialized),
|
||||
sha256: sha256(serialized),
|
||||
})),
|
||||
/persistence receipt/
|
||||
);
|
||||
});
|
||||
|
||||
it('brands the exact persisted bytes when the caller mutates during write', async () => {
|
||||
const raw = iteration(
|
||||
itemAt(createXtreamIterationDefinitions(false), 1),
|
||||
1
|
||||
);
|
||||
const persistedPeakRss = raw.main.peakRssBytes;
|
||||
const validated = await persist(raw, 2, () => {
|
||||
Object.assign(raw.main, { peakRssBytes: persistedPeakRss + 1 });
|
||||
});
|
||||
|
||||
assert.equal(validated.main.peakRssBytes, persistedPeakRss);
|
||||
assert.notEqual(validated.main.peakRssBytes, raw.main.peakRssBytes);
|
||||
assert.throws(
|
||||
() => Object.assign(validated.main, { peakRssBytes: 1 }),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks exact token and credential-shaped bytes before write but permits suite labels', async () => {
|
||||
const raw = iteration(
|
||||
itemAt(createXtreamIterationDefinitions(false), 1),
|
||||
1
|
||||
);
|
||||
let writes = 0;
|
||||
for (const leaked of [
|
||||
TOKEN,
|
||||
'performance:performance',
|
||||
'http://performance:performance@127.0.0.1:43123',
|
||||
'?username=performance&password=performance',
|
||||
]) {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
persistWith(
|
||||
{
|
||||
...raw,
|
||||
renderer: {
|
||||
...raw.renderer,
|
||||
consoleErrors: [leaked],
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
writes += 1;
|
||||
throw new Error('must not write');
|
||||
}
|
||||
),
|
||||
/credential-safe/
|
||||
);
|
||||
}
|
||||
assert.equal(writes, 0);
|
||||
await assert.doesNotReject(() => persist(raw, 2));
|
||||
});
|
||||
|
||||
it('requires exact clean formal source, runtime, CDP, and environment evidence', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
assert.doesNotThrow(() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
)
|
||||
);
|
||||
|
||||
for (const invalid of [
|
||||
{ ...MANIFEST, source: { ...MANIFEST.source, gitDirty: true } },
|
||||
{
|
||||
...MANIFEST,
|
||||
environment: { ...MANIFEST.environment, cdpPort: 9223 },
|
||||
},
|
||||
{
|
||||
...MANIFEST,
|
||||
environment: {
|
||||
...MANIFEST.environment,
|
||||
perfWorkerProfiling: '0',
|
||||
},
|
||||
},
|
||||
{
|
||||
...MANIFEST,
|
||||
runtime: { ...MANIFEST.runtime, electronVersion: '' },
|
||||
},
|
||||
{ ...MANIFEST, variant: { length: 1 } },
|
||||
]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
invalid as unknown as XtreamBenchmarkManifest,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
),
|
||||
/invalid Xtream benchmark manifest/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('requires ordered definitions and unique process/profile/generation identity', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
const shuffled = [...iterations];
|
||||
[shuffled[0], shuffled[1]] = [itemAt(shuffled, 1), itemAt(shuffled, 0)];
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
shuffled,
|
||||
diagnosticEvidence
|
||||
),
|
||||
/ordered Xtream benchmark schedule/
|
||||
);
|
||||
|
||||
const reused = [...iterations];
|
||||
const first = itemAt(reused, 0);
|
||||
const second = itemAt(reused, 1);
|
||||
reused[1] = {
|
||||
...second,
|
||||
processIdentity: first.processIdentity,
|
||||
};
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
reused,
|
||||
diagnosticEvidence
|
||||
),
|
||||
/fresh process identity/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects forged validated objects and excludes diagnostic CPU profiles from headline metrics', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
const forged = [...iterations];
|
||||
forged[0] = { ...itemAt(forged, 0) };
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
forged,
|
||||
diagnosticEvidence
|
||||
),
|
||||
/validated persistence evidence/
|
||||
);
|
||||
|
||||
const summary = createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
);
|
||||
const background = summary.scenarios.find(
|
||||
({ scenarioId }) => scenarioId === 'xtream-background-ui'
|
||||
);
|
||||
const initial = summary.scenarios.find(
|
||||
({ scenarioId }) => scenarioId === 'xtream-initial-import-large'
|
||||
);
|
||||
assert.ok(background);
|
||||
assert.ok(initial);
|
||||
assert.deepEqual(background.databaseWorkerRequestInvalidReasons, [
|
||||
'overlapping-database-worker-requests',
|
||||
]);
|
||||
assert.equal(
|
||||
background.metrics.databaseWorkerRequestInvalidSampleCount.min,
|
||||
3
|
||||
);
|
||||
assert.equal(
|
||||
background.metrics.databaseWorkerRequestInvalidSampleCount.max,
|
||||
3
|
||||
);
|
||||
assert.deepEqual(initial.databaseWorkerRequestInvalidReasons, [
|
||||
'overlapping-database-worker-requests',
|
||||
]);
|
||||
assert.equal(
|
||||
initial.metrics.databaseWorkerRequestInvalidSampleCount.min,
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
initial.metrics.databaseWorkerRequestInvalidSampleCount.max,
|
||||
2
|
||||
);
|
||||
assert.ok(
|
||||
summary.scenarios.every(
|
||||
({
|
||||
databaseWorkerEventLoopDelayMetricScope,
|
||||
databaseWorkerRequestMetricScope,
|
||||
databaseWorkerWholeWorkerMetricScope,
|
||||
metrics,
|
||||
}) =>
|
||||
databaseWorkerEventLoopDelayMetricScope ===
|
||||
'valid-request-window-proxy' &&
|
||||
databaseWorkerRequestMetricScope ===
|
||||
'valid-request-window-proxy' &&
|
||||
databaseWorkerWholeWorkerMetricScope ===
|
||||
'whole-worker-operation-window' &&
|
||||
metrics.databaseWorkerRequestEventLoopDelayP95Ms.count === 5
|
||||
)
|
||||
);
|
||||
assert.equal(summary.validForComparison, true);
|
||||
assert.ok(Object.isFrozen(summary));
|
||||
assert.ok(Object.isFrozen(summary.manifest));
|
||||
assert.ok(Object.isFrozen(summary.iterations));
|
||||
assert.ok(
|
||||
Object.isFrozen(
|
||||
itemAt(summary.iterations, 0).databaseWorker.requests.evidence
|
||||
)
|
||||
);
|
||||
assert.notEqual(summary.manifest, MANIFEST);
|
||||
assert.notEqual(summary.iterations, iterations);
|
||||
assert.ok(
|
||||
summary.scenarios.every(
|
||||
({
|
||||
diagnosticProfiles,
|
||||
mainEventLoopUtilization,
|
||||
measuredRuns,
|
||||
metrics,
|
||||
phases,
|
||||
}) =>
|
||||
measuredRuns === 5 &&
|
||||
metrics.totalMs.count === 5 &&
|
||||
diagnosticProfiles.mainDurationMicros !== null &&
|
||||
diagnosticProfiles.artifactProvenance.artifacts.length ===
|
||||
7 &&
|
||||
Object.isFrozen(
|
||||
diagnosticProfiles.artifactProvenance.artifacts
|
||||
) &&
|
||||
mainEventLoopUtilization.reason ===
|
||||
'electron-main-embedded-event-loop' &&
|
||||
mainEventLoopUtilization.value === null &&
|
||||
phases.indexes.reason ===
|
||||
'schema-indexes-pre-exist; operation-creates-none' &&
|
||||
phases.restoreUserData.value === null &&
|
||||
phases.structuredCloneAttribution ===
|
||||
'selected-ipc-proxy-includes-queueing-and-scheduling' &&
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
metrics,
|
||||
'mainCpuProfileDurationMicros'
|
||||
)
|
||||
)
|
||||
);
|
||||
assert.equal(
|
||||
initial.phases.restoreUserData.reason,
|
||||
'not-invoked-by-scenario'
|
||||
);
|
||||
assert.equal(background.phases.restoreUserData.reason, 'no-user-data');
|
||||
});
|
||||
|
||||
it('requires branded one-to-one diagnostic evidence bound to each iteration directory', async () => {
|
||||
const { diagnosticEvidence, iterations } = await formalReportFixture();
|
||||
const entries = [...diagnosticEvidence.entries()];
|
||||
const first = itemAt(entries, 0);
|
||||
const second = itemAt(entries, 1);
|
||||
const missing = new Map(entries.slice(1));
|
||||
const swapped = new Map(entries);
|
||||
swapped.set(first[0], second[1]);
|
||||
swapped.set(second[0], first[1]);
|
||||
const extra = new Map(entries);
|
||||
extra.set(itemAt(iterations, 0), first[1]);
|
||||
const forged = new Map(entries);
|
||||
forged.set(first[0], structuredClone(first[1]));
|
||||
const malformed = new Map(entries);
|
||||
malformed.set(first[0], null as unknown as (typeof entries)[number][1]);
|
||||
|
||||
for (const evidence of [missing, swapped, extra, forged, malformed]) {
|
||||
assert.throws(
|
||||
() =>
|
||||
createXtreamBenchmarkSummary(
|
||||
MANIFEST,
|
||||
iterations,
|
||||
evidence
|
||||
),
|
||||
/diagnostic artifact evidence/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps one-run smoke output ineligible after all clean gates pass', async () => {
|
||||
const { diagnosticEvidence, iterations } = await smokeReportFixture();
|
||||
const summary = createXtreamBenchmarkSummary(
|
||||
{ ...MANIFEST, mode: 'smoke' },
|
||||
iterations,
|
||||
diagnosticEvidence
|
||||
);
|
||||
|
||||
assert.equal(summary.validForComparison, false);
|
||||
});
|
||||
});
|
||||
|
||||
function itemAt<T>(items: readonly T[], index: number): T {
|
||||
const item = items[index];
|
||||
assert.ok(item);
|
||||
return item;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
createXtreamIterationDefinitions,
|
||||
listXtreamBenchmarkScenarios,
|
||||
summarizeXtreamNumbers,
|
||||
type XtreamScenarioId,
|
||||
} from './xtream-benchmark-contract';
|
||||
import {
|
||||
serializeXtreamArtifactForPersistence,
|
||||
type XtreamDiagnosticArtifactEvidence,
|
||||
} from './xtream-diagnostic-artifacts';
|
||||
import {
|
||||
bindXtreamDiagnosticEvidence,
|
||||
cloneXtreamSummaryValue,
|
||||
copyXtreamDiagnosticProvenance,
|
||||
} from './xtream-benchmark-summary-evidence';
|
||||
import {
|
||||
assertFreshXtreamProcessIdentities,
|
||||
assertOrderedXtreamSchedule,
|
||||
freezeDeep,
|
||||
isFiniteNonNegative,
|
||||
parseXtreamBenchmarkManifest,
|
||||
snapshotXtreamIterations,
|
||||
toXtreamRawIteration,
|
||||
} from './xtream-exact-schema';
|
||||
import { assertXtreamIterationResult } from './xtream-iteration-validity';
|
||||
import { assertUniqueXtreamPersistencePaths } from './xtream-persistence-schedule';
|
||||
import { verifyXtreamPersistenceReceipt } from './xtream-raw-persistence-receipt';
|
||||
import {
|
||||
XTREAM_NOT_APPLICABLE,
|
||||
XTREAM_RENDERER_VISIBILITY,
|
||||
XTREAM_STRUCTURED_CLONE_ATTRIBUTION,
|
||||
XTREAM_WORKER_REQUEST_METRIC_SCOPE,
|
||||
XTREAM_WORKER_WHOLE_METRIC_SCOPE,
|
||||
type NumericDistribution,
|
||||
type XtreamArtifactSafetyContext,
|
||||
type XtreamBenchmarkManifest,
|
||||
type XtreamBenchmarkSummary,
|
||||
type XtreamPersistedRawEvidence,
|
||||
type XtreamPersistenceReceipt,
|
||||
type XtreamRawIterationResult,
|
||||
type XtreamScenarioMetrics,
|
||||
type XtreamScenarioSummary,
|
||||
type XtreamValidatedIterationResult,
|
||||
} from './xtream-summary-contract';
|
||||
|
||||
const VALIDATED = new WeakSet<object>();
|
||||
|
||||
export async function persistThenValidateXtreamIteration(
|
||||
raw: XtreamRawIterationResult,
|
||||
safety: XtreamArtifactSafetyContext,
|
||||
persistRaw: (serialized: string) => Promise<XtreamPersistenceReceipt>
|
||||
): Promise<XtreamValidatedIterationResult> {
|
||||
const serialized = serializeXtreamArtifactForPersistence(raw, safety);
|
||||
const receipt = await persistRaw(serialized);
|
||||
const evidence = await verifyXtreamPersistenceReceipt(serialized, receipt);
|
||||
const persisted: unknown = JSON.parse(serialized);
|
||||
assertXtreamIterationResult(persisted);
|
||||
return brandValidatedIteration(persisted, evidence);
|
||||
}
|
||||
|
||||
export function createXtreamBenchmarkSummary(
|
||||
manifest: XtreamBenchmarkManifest,
|
||||
iterations: readonly XtreamValidatedIterationResult[],
|
||||
diagnosticEvidence: ReadonlyMap<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>
|
||||
): XtreamBenchmarkSummary {
|
||||
const canonicalManifest = parseXtreamBenchmarkManifest(manifest);
|
||||
const expected = createXtreamIterationDefinitions(
|
||||
canonicalManifest.mode === 'smoke'
|
||||
);
|
||||
const canonicalIterations = snapshotXtreamIterations(iterations);
|
||||
assertOrderedXtreamSchedule(
|
||||
canonicalIterations,
|
||||
expected,
|
||||
canonicalManifest.mode
|
||||
);
|
||||
assertFreshXtreamProcessIdentities(canonicalIterations);
|
||||
for (const iteration of canonicalIterations) {
|
||||
assertValidatedIteration(iteration);
|
||||
if (iteration.validationStatus !== 'valid') invalidIteration();
|
||||
assertXtreamIterationResult(toXtreamRawIteration(iteration));
|
||||
}
|
||||
assertUniqueXtreamPersistencePaths(canonicalIterations);
|
||||
const boundDiagnosticEvidence = bindXtreamDiagnosticEvidence(
|
||||
canonicalIterations,
|
||||
diagnosticEvidence
|
||||
);
|
||||
|
||||
const measuredRunCount = canonicalManifest.mode === 'formal' ? 5 : 1;
|
||||
const comparable =
|
||||
canonicalManifest.mode === 'formal' &&
|
||||
canonicalIterations
|
||||
.filter(({ kind }) => kind === 'measured')
|
||||
.every(({ eligibleForComparison }) => eligibleForComparison);
|
||||
return freezeDeep({
|
||||
iterations: cloneXtreamSummaryValue(canonicalIterations),
|
||||
manifest: cloneXtreamSummaryValue(canonicalManifest),
|
||||
scenarios: listXtreamBenchmarkScenarios().map(({ id }) =>
|
||||
summarizeScenario(
|
||||
canonicalIterations,
|
||||
id,
|
||||
measuredRunCount,
|
||||
boundDiagnosticEvidence
|
||||
)
|
||||
),
|
||||
validForComparison: comparable,
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeScenario(
|
||||
iterations: readonly XtreamValidatedIterationResult[],
|
||||
scenarioId: XtreamScenarioId,
|
||||
measuredRunCount: number,
|
||||
diagnosticEvidence: ReadonlyMap<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>
|
||||
): XtreamScenarioSummary {
|
||||
const measured = iterations.filter(
|
||||
(iteration) =>
|
||||
iteration.scenarioId === scenarioId && iteration.kind === 'measured'
|
||||
);
|
||||
const diagnostic = iterations.find(
|
||||
(iteration) =>
|
||||
iteration.scenarioId === scenarioId &&
|
||||
iteration.kind === 'diagnostic'
|
||||
);
|
||||
if (measured.length !== measuredRunCount || !diagnostic) {
|
||||
scheduleError(measuredRunCount === 5 ? 'formal' : 'smoke');
|
||||
}
|
||||
const provenance = diagnosticEvidence.get(diagnostic);
|
||||
if (!provenance) invalidIteration();
|
||||
const restores =
|
||||
scenarioId === 'xtream-refresh-large' ||
|
||||
scenarioId === 'xtream-background-ui';
|
||||
return {
|
||||
scenarioId,
|
||||
measuredRuns: measuredRunCount,
|
||||
databaseWorkerEventLoopDelayMetricScope:
|
||||
XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY,
|
||||
databaseWorkerRequestInvalidReasons: [
|
||||
...new Set(
|
||||
measured.flatMap((run) =>
|
||||
run.databaseWorker.requests.invalidReasons.map(
|
||||
({ reason }) => reason
|
||||
)
|
||||
)
|
||||
),
|
||||
],
|
||||
databaseWorkerRequestMetricScope:
|
||||
XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY,
|
||||
databaseWorkerWholeWorkerMetricScope:
|
||||
XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW,
|
||||
dashboardPaintVisibility:
|
||||
scenarioId === 'xtream-background-ui'
|
||||
? XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY
|
||||
: null,
|
||||
diagnosticProfiles: {
|
||||
artifactProvenance: copyXtreamDiagnosticProvenance(provenance),
|
||||
databaseWorkerDurationMicros:
|
||||
provenance.profiles.databaseWorker.durationMicros,
|
||||
mainDurationMicros: provenance.profiles.main.durationMicros,
|
||||
rendererDurationMicros: provenance.profiles.renderer.durationMicros,
|
||||
},
|
||||
metrics: summarizeMetrics(measured, scenarioId, measuredRunCount),
|
||||
memory: {
|
||||
databaseWorkerRss: notApplicable(
|
||||
XTREAM_NOT_APPLICABLE.DATABASE_WORKER_RSS
|
||||
),
|
||||
nativeAndSqlite: notApplicable(
|
||||
XTREAM_NOT_APPLICABLE.NATIVE_SQLITE_MEMORY
|
||||
),
|
||||
playlistRefreshWorker: notApplicable(
|
||||
XTREAM_NOT_APPLICABLE.PLAYLIST_REFRESH_WORKER
|
||||
),
|
||||
},
|
||||
mainEventLoopUtilization: notApplicable(
|
||||
XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION
|
||||
),
|
||||
phases: {
|
||||
debugCopy: notApplicable(XTREAM_NOT_APPLICABLE.DEBUG_COPY),
|
||||
indexes: notApplicable(XTREAM_NOT_APPLICABLE.INDEXES_PRE_EXIST),
|
||||
transactionCommit: notApplicable(
|
||||
XTREAM_NOT_APPLICABLE.COMMIT_INCLUDED_IN_SQLITE_WRITE
|
||||
),
|
||||
restoreUserData: notApplicable(
|
||||
restores
|
||||
? XTREAM_NOT_APPLICABLE.RESTORE_NO_USER_DATA
|
||||
: XTREAM_NOT_APPLICABLE.RESTORE_NOT_INVOKED
|
||||
),
|
||||
backgroundSearchMarker:
|
||||
scenarioId === 'xtream-background-ui'
|
||||
? notApplicable(XTREAM_NOT_APPLICABLE.SOURCES_SEARCH_MARKER)
|
||||
: null,
|
||||
structuredCloneAttribution:
|
||||
XTREAM_STRUCTURED_CLONE_ATTRIBUTION.SELECTED_IPC_PROXY,
|
||||
},
|
||||
rendererLongTaskCountTotal: sumValues(
|
||||
measured,
|
||||
(run) => run.renderer.longTaskCount
|
||||
),
|
||||
responsiveEventsTotal: sumValues(
|
||||
measured,
|
||||
(run) => run.main.responsiveEvents
|
||||
),
|
||||
unresponsiveEventsTotal: sumValues(
|
||||
measured,
|
||||
(run) => run.main.unresponsiveEvents
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeMetrics(
|
||||
runs: readonly XtreamValidatedIterationResult[],
|
||||
scenarioId: XtreamScenarioId,
|
||||
count: number
|
||||
): XtreamScenarioMetrics {
|
||||
const metric = (
|
||||
select: (run: XtreamValidatedIterationResult) => number
|
||||
): NumericDistribution => distribution(runs, select, count);
|
||||
const optional = (
|
||||
select: (run: XtreamValidatedIterationResult) => number | null
|
||||
): NumericDistribution =>
|
||||
distribution(runs, (run) => requireNumber(select(run)), count);
|
||||
return {
|
||||
totalMs: metric((run) => run.phases.totalMs),
|
||||
dataAcquisitionMs: metric((run) => run.phases.dataAcquisitionMs),
|
||||
jsonParsingMs: metric((run) => run.phases.jsonParsingMs),
|
||||
normalizationMs: metric((run) => run.phases.normalizationMs),
|
||||
serializationMs: metric((run) => run.phases.serializationMs),
|
||||
deserializationMs: metric((run) => run.phases.deserializationMs),
|
||||
structuredCloneProxyMs: metric(
|
||||
(run) => run.phases.structuredCloneProxyMs
|
||||
),
|
||||
sqliteReadMs: metric((run) => run.phases.sqliteReadMs),
|
||||
sqliteWriteMs: metric((run) => run.phases.sqliteWriteMs),
|
||||
storeUpdateMs: metric((run) => run.phases.storeUpdateMs),
|
||||
angularRenderingMs: metric((run) => run.phases.angularRenderingMs),
|
||||
rendererPeakRssBytes: metric((run) => run.renderer.peakRssBytes),
|
||||
rendererPeakHeapBytes: metric((run) => run.renderer.peakHeapUsedBytes),
|
||||
rendererPostGcHeapBytes: metric(
|
||||
(run) => run.renderer.postGcHeapUsedBytes
|
||||
),
|
||||
rendererLongTaskCount: metric((run) => run.renderer.longTaskCount),
|
||||
rendererLongTaskMaxMs: metric((run) => run.renderer.longTaskMaxMs),
|
||||
rendererFrameGapP95Ms: metric((run) => run.renderer.frameGapP95Ms),
|
||||
rendererFrameGapMaxMs: metric((run) => run.renderer.frameGapMaxMs),
|
||||
rendererHeartbeatP95Ms: metric((run) => run.renderer.heartbeatP95Ms),
|
||||
rendererHeartbeatMaxMs: metric((run) => run.renderer.heartbeatMaxMs),
|
||||
rendererNavigationLatencyMs:
|
||||
scenarioId === 'xtream-background-ui'
|
||||
? optional((run) => run.renderer.navigationLatencyMs)
|
||||
: null,
|
||||
rendererSearchLatencyMs:
|
||||
scenarioId === 'xtream-background-ui'
|
||||
? optional((run) => run.renderer.searchLatencyMs)
|
||||
: null,
|
||||
rendererActionLatencyMs:
|
||||
scenarioId === 'xtream-background-ui'
|
||||
? optional((run) => run.renderer.actionLatencyMs)
|
||||
: null,
|
||||
mainCpuUserMicros: metric((run) => run.main.cpuUserMicros),
|
||||
mainCpuSystemMicros: metric((run) => run.main.cpuSystemMicros),
|
||||
mainPeakRssBytes: metric((run) => run.main.peakRssBytes),
|
||||
mainPeakHeapBytes: metric((run) => run.main.peakHeapUsedBytes),
|
||||
mainPostGcHeapBytes: metric((run) => run.main.postGcHeapUsedBytes),
|
||||
mainEventLoopDelayP95Ms: metric((run) => run.main.eventLoopDelayP95Ms),
|
||||
mainEventLoopDelayP99Ms: metric((run) => run.main.eventLoopDelayP99Ms),
|
||||
mainEventLoopDelayMaxMs: metric((run) => run.main.eventLoopDelayMaxMs),
|
||||
databaseWorkerCpuUserMicros: metric(
|
||||
(run) => run.databaseWorker.cpuUserMicros
|
||||
),
|
||||
databaseWorkerCpuSystemMicros: metric(
|
||||
(run) => run.databaseWorker.cpuSystemMicros
|
||||
),
|
||||
databaseWorkerPeakHeapBytes: metric(
|
||||
(run) => run.databaseWorker.peakHeapUsedBytes
|
||||
),
|
||||
databaseWorkerPostGcHeapBytes: metric(
|
||||
(run) => run.databaseWorker.postGcHeapUsedBytes
|
||||
),
|
||||
databaseWorkerRequestTotalCount: metric(
|
||||
(run) => run.databaseWorker.requests.totalCount
|
||||
),
|
||||
databaseWorkerRequestValidSampleCount: metric(
|
||||
(run) => run.databaseWorker.requests.validSampleCount
|
||||
),
|
||||
databaseWorkerRequestInvalidSampleCount: metric(
|
||||
(run) => run.databaseWorker.requests.invalidSampleCount
|
||||
),
|
||||
databaseWorkerRequestEventLoopDelayP95Ms: metric(
|
||||
(run) => run.databaseWorker.requests.eventLoopDelayP95Ms
|
||||
),
|
||||
databaseWorkerRequestEventLoopDelayP99Ms: metric(
|
||||
(run) => run.databaseWorker.requests.eventLoopDelayP99Ms
|
||||
),
|
||||
databaseWorkerRequestEventLoopDelayMaxMs: metric(
|
||||
(run) => run.databaseWorker.requests.eventLoopDelayMaxMs
|
||||
),
|
||||
databaseWorkerRequestEventLoopUtilization: metric(
|
||||
(run) => run.databaseWorker.requests.eventLoopUtilization
|
||||
),
|
||||
databaseWorkerPeakExternalBytes: metric(
|
||||
(run) => run.databaseWorker.peakExternalBytes
|
||||
),
|
||||
databaseWorkerEventLoopDelayP95Ms: metric(
|
||||
(run) => run.databaseWorker.eventLoopDelayP95Ms
|
||||
),
|
||||
databaseWorkerEventLoopDelayP99Ms: metric(
|
||||
(run) => run.databaseWorker.eventLoopDelayP99Ms
|
||||
),
|
||||
databaseWorkerEventLoopDelayMaxMs: metric(
|
||||
(run) => run.databaseWorker.eventLoopDelayMaxMs
|
||||
),
|
||||
databaseWorkerEventLoopUtilization: metric(
|
||||
(run) => run.databaseWorker.eventLoopUtilization
|
||||
),
|
||||
cancelAcknowledgementLatencyMs:
|
||||
scenarioId === 'xtream-cancel-import'
|
||||
? optional((run) => run.cancellation.acknowledgementLatencyMs)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function distribution(
|
||||
runs: readonly XtreamValidatedIterationResult[],
|
||||
select: (run: XtreamValidatedIterationResult) => number,
|
||||
expectedCount: number
|
||||
): NumericDistribution {
|
||||
const values = runs.map(select);
|
||||
if (
|
||||
values.length !== expectedCount ||
|
||||
values.some((value) => !isFiniteNonNegative(value))
|
||||
) {
|
||||
throw new Error(
|
||||
'Xtream metric requires complete finite measured values'
|
||||
);
|
||||
}
|
||||
return summarizeXtreamNumbers(values);
|
||||
}
|
||||
|
||||
function sumValues(
|
||||
runs: readonly XtreamValidatedIterationResult[],
|
||||
select: (run: XtreamValidatedIterationResult) => number
|
||||
): number {
|
||||
return runs.reduce((total, run) => total + select(run), 0);
|
||||
}
|
||||
|
||||
function requireNumber(value: number | null): number {
|
||||
if (!isFiniteNonNegative(value)) invalidIteration();
|
||||
return value;
|
||||
}
|
||||
|
||||
function notApplicable(reason: string): { reason: string; value: null } {
|
||||
return { reason, value: null };
|
||||
}
|
||||
|
||||
function brandValidatedIteration(
|
||||
raw: XtreamRawIterationResult,
|
||||
persistence: XtreamPersistedRawEvidence
|
||||
): XtreamValidatedIterationResult {
|
||||
const result = freezeDeep({
|
||||
...raw,
|
||||
persistence,
|
||||
validationStatus: 'valid' as const,
|
||||
});
|
||||
VALIDATED.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertValidatedIteration(value: XtreamValidatedIterationResult): void {
|
||||
if (!VALIDATED.has(value)) {
|
||||
throw new Error('Xtream validated persistence evidence is missing');
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleError(mode: 'formal' | 'smoke'): never {
|
||||
throw new Error(
|
||||
`Xtream benchmark requires exactly ${mode === 'formal' ? 'five' : 'one'} measured runs per scenario`
|
||||
);
|
||||
}
|
||||
|
||||
function invalidIteration(): never {
|
||||
throw new Error('invalid Xtream iteration');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamScenarioId,
|
||||
} from './xtream-benchmark-contract';
|
||||
import {
|
||||
deriveXtreamMeasuredPlaylistId,
|
||||
xtreamScenarioRequiresSeed,
|
||||
} from './xtream-benchmark-runtime';
|
||||
|
||||
describe('Xtream benchmark runtime evidence', () => {
|
||||
it('seeds only scenarios that measure an operation on an existing portal', () => {
|
||||
const expected: Record<XtreamScenarioId, boolean> = {
|
||||
[XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: false,
|
||||
[XTREAM_SCENARIO_ID.REFRESH_LARGE]: true,
|
||||
[XTREAM_SCENARIO_ID.DELETE_LARGE]: true,
|
||||
[XTREAM_SCENARIO_ID.CANCEL_IMPORT]: false,
|
||||
[XTREAM_SCENARIO_ID.BACKGROUND_UI]: true,
|
||||
};
|
||||
|
||||
for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) {
|
||||
assert.equal(
|
||||
xtreamScenarioRequiresSeed(scenarioId),
|
||||
expected[scenarioId]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('derives exactly one non-null measured playlist identity', () => {
|
||||
assert.equal(
|
||||
deriveXtreamMeasuredPlaylistId([
|
||||
{ playlistId: null },
|
||||
{ playlistId: 'playlist-1' },
|
||||
{ playlistId: 'playlist-1' },
|
||||
]),
|
||||
'playlist-1'
|
||||
);
|
||||
|
||||
for (const requests of [
|
||||
[{ playlistId: null }],
|
||||
[{ playlistId: 'playlist-1' }, { playlistId: 'playlist-2' }],
|
||||
[{ playlistId: '' }],
|
||||
]) {
|
||||
assert.throws(
|
||||
() => deriveXtreamMeasuredPlaylistId(requests),
|
||||
/xtream-measured-playlist-identity-invalid/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
XTREAM_SCENARIO_ID,
|
||||
type XtreamScenarioId,
|
||||
} from './xtream-benchmark-contract';
|
||||
|
||||
export function xtreamScenarioRequiresSeed(
|
||||
scenarioId: XtreamScenarioId
|
||||
): boolean {
|
||||
return (
|
||||
scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE ||
|
||||
scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE ||
|
||||
scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveXtreamMeasuredPlaylistId(
|
||||
requests: readonly { readonly playlistId: string | null }[]
|
||||
): string {
|
||||
const identities = new Set(
|
||||
requests
|
||||
.map(({ playlistId }) => playlistId)
|
||||
.filter(
|
||||
(playlistId): playlistId is string =>
|
||||
typeof playlistId === 'string' && playlistId.length > 0
|
||||
)
|
||||
);
|
||||
const playlistId = identities.values().next().value as string | undefined;
|
||||
if (identities.size !== 1 || !playlistId) {
|
||||
throw new Error('xtream-measured-playlist-identity-invalid');
|
||||
}
|
||||
return playlistId;
|
||||
}
|
||||
|
||||
export async function disableGenericElectronFixtureProbes(
|
||||
page: Page
|
||||
): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.__portalDebugUnsubscribe?.();
|
||||
window.__portalDebugUnsubscribe = undefined;
|
||||
window.__portalDebugEvents = [];
|
||||
window.__dbOperationUnsubscribe?.();
|
||||
window.__dbOperationUnsubscribe = undefined;
|
||||
window.__dbOperationEvents = [];
|
||||
if (window.__rendererFrameRequestId) {
|
||||
window.cancelAnimationFrame(window.__rendererFrameRequestId);
|
||||
}
|
||||
window.__rendererFrameRequestId = undefined;
|
||||
window.__rendererFrameCount = 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
assertXtreamDiagnosticArtifactEvidence,
|
||||
type XtreamDiagnosticArtifactEvidence,
|
||||
} from './xtream-diagnostic-artifacts';
|
||||
import type {
|
||||
XtreamDiagnosticArtifactProvenance,
|
||||
XtreamValidatedIterationResult,
|
||||
} from './xtream-summary-contract';
|
||||
|
||||
export function bindXtreamDiagnosticEvidence(
|
||||
iterations: readonly XtreamValidatedIterationResult[],
|
||||
evidenceByIteration: ReadonlyMap<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>
|
||||
): ReadonlyMap<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
> {
|
||||
const diagnostic = iterations.filter(({ kind }) => kind === 'diagnostic');
|
||||
const evidenceEntries = snapshotEvidenceEntries(evidenceByIteration);
|
||||
if (evidenceEntries.length !== diagnostic.length) {
|
||||
invalidEvidence();
|
||||
}
|
||||
const diagnosticSet = new Set(diagnostic);
|
||||
const boundEvidence = new Map<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>();
|
||||
for (const [iteration, evidence] of evidenceEntries) {
|
||||
if (!diagnosticSet.has(iteration)) {
|
||||
invalidEvidence();
|
||||
}
|
||||
try {
|
||||
assertXtreamDiagnosticArtifactEvidence(evidence);
|
||||
} catch {
|
||||
invalidEvidence();
|
||||
}
|
||||
if (
|
||||
iteration.processIdentity.profileDirectorySha256 !==
|
||||
evidence.directorySha256 ||
|
||||
iteration.renderer.cpuProfileDurationMicros !==
|
||||
evidence.profiles.renderer.durationMicros ||
|
||||
iteration.main.cpuProfileDurationMicros !==
|
||||
evidence.profiles.main.durationMicros ||
|
||||
iteration.databaseWorker.cpuProfileDurationMicros !==
|
||||
evidence.profiles.databaseWorker.durationMicros ||
|
||||
iteration.databaseWorker.ordinal !==
|
||||
evidence.profiles.databaseWorker.ordinal
|
||||
) {
|
||||
invalidEvidence();
|
||||
}
|
||||
Map.prototype.set.call(boundEvidence, iteration, evidence);
|
||||
}
|
||||
if (
|
||||
diagnostic.some(
|
||||
(iteration) => !Map.prototype.has.call(boundEvidence, iteration)
|
||||
)
|
||||
) {
|
||||
invalidEvidence();
|
||||
}
|
||||
return boundEvidence;
|
||||
}
|
||||
|
||||
export function copyXtreamDiagnosticProvenance(
|
||||
evidence: XtreamDiagnosticArtifactEvidence
|
||||
): XtreamDiagnosticArtifactProvenance {
|
||||
assertXtreamDiagnosticArtifactEvidence(evidence);
|
||||
return {
|
||||
artifacts: evidence.artifacts.map((artifact) => ({ ...artifact })),
|
||||
directorySha256: evidence.directorySha256,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneXtreamSummaryValue<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function snapshotEvidenceEntries(
|
||||
value: ReadonlyMap<
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence
|
||||
>
|
||||
): readonly (readonly [
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence,
|
||||
])[] {
|
||||
try {
|
||||
if (!(value instanceof Map)) invalidEvidence();
|
||||
const sizeGetter = Object.getOwnPropertyDescriptor(
|
||||
Map.prototype,
|
||||
'size'
|
||||
)?.get;
|
||||
if (!sizeGetter) invalidEvidence();
|
||||
const size = Reflect.apply(sizeGetter, value, []) as unknown;
|
||||
const entries: [
|
||||
XtreamValidatedIterationResult,
|
||||
XtreamDiagnosticArtifactEvidence,
|
||||
][] = [];
|
||||
Map.prototype.forEach.call(value, (evidence, iteration) => {
|
||||
entries.push([iteration, evidence]);
|
||||
});
|
||||
if (size !== entries.length) invalidEvidence();
|
||||
return entries;
|
||||
} catch {
|
||||
invalidEvidence();
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEvidence(): never {
|
||||
throw new Error('invalid Xtream diagnostic artifact evidence binding');
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
computeXtreamBuildFilesAggregate,
|
||||
type XtreamBenchmarkBuildIdentity,
|
||||
type XtreamBuildFileIdentity,
|
||||
type XtreamBuildPairIdentity,
|
||||
} from './xtream-build-identity';
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/u;
|
||||
const RENDERER_FILE =
|
||||
/^dist\/apps\/web\/[A-Za-z0-9][A-Za-z0-9._-]{0,254}\.js(?:\.map)?$/u;
|
||||
const BUILD_KEYS = ['electron', 'renderer'] as const;
|
||||
const ELECTRON_KEYS = [
|
||||
'databaseWorker',
|
||||
'main',
|
||||
'playlistRefreshWorker',
|
||||
'preload',
|
||||
] as const;
|
||||
const PAIR_KEYS = ['javascript', 'sourceMap'] as const;
|
||||
const FILE_KEYS = ['bytes', 'path', 'sha256'] as const;
|
||||
const RENDERER_KEYS = [
|
||||
'aggregateSha256',
|
||||
'bytes',
|
||||
'executableJavaScriptCount',
|
||||
'files',
|
||||
'sourceMapCount',
|
||||
] as const;
|
||||
const ELECTRON_PATHS = {
|
||||
databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js',
|
||||
main: 'dist/apps/electron-backend/main.js',
|
||||
playlistRefreshWorker:
|
||||
'dist/apps/electron-backend/workers/playlist-refresh.worker.js',
|
||||
preload: 'dist/apps/electron-backend/main.preload.js',
|
||||
} as const;
|
||||
|
||||
export function parseXtreamBenchmarkBuildIdentity(
|
||||
value: unknown
|
||||
): XtreamBenchmarkBuildIdentity {
|
||||
try {
|
||||
const input = exactRecord(value, BUILD_KEYS);
|
||||
const electronInput = exactRecord(input['electron'], ELECTRON_KEYS);
|
||||
const rendererInput = exactRecord(input['renderer'], RENDERER_KEYS);
|
||||
const electron = Object.freeze({
|
||||
databaseWorker: pair(
|
||||
electronInput['databaseWorker'],
|
||||
ELECTRON_PATHS.databaseWorker
|
||||
),
|
||||
main: pair(electronInput['main'], ELECTRON_PATHS.main),
|
||||
playlistRefreshWorker: pair(
|
||||
electronInput['playlistRefreshWorker'],
|
||||
ELECTRON_PATHS.playlistRefreshWorker
|
||||
),
|
||||
preload: pair(electronInput['preload'], ELECTRON_PATHS.preload),
|
||||
});
|
||||
const renderer = rendererIdentity(rendererInput);
|
||||
return Object.freeze({ electron, renderer });
|
||||
} catch {
|
||||
throw new Error('invalid Xtream benchmark build identity');
|
||||
}
|
||||
}
|
||||
|
||||
function pair(value: unknown, javascriptPath: string): XtreamBuildPairIdentity {
|
||||
const input = exactRecord(value, PAIR_KEYS);
|
||||
return Object.freeze({
|
||||
javascript: fileIdentity(input['javascript'], javascriptPath),
|
||||
sourceMap: fileIdentity(input['sourceMap'], `${javascriptPath}.map`),
|
||||
});
|
||||
}
|
||||
|
||||
function rendererIdentity(
|
||||
input: Record<string, unknown>
|
||||
): XtreamBenchmarkBuildIdentity['renderer'] {
|
||||
const files = exactArray(input['files']).map((value) =>
|
||||
fileIdentity(value)
|
||||
);
|
||||
if (
|
||||
files.length < 3 ||
|
||||
files.length > 10_000 ||
|
||||
!isStrictlySorted(files.map((file) => file.path))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const indexFiles = files.filter(
|
||||
(file) => file.path === 'dist/apps/web/index.html'
|
||||
);
|
||||
const javascript = files.filter((file) => file.path.endsWith('.js'));
|
||||
const sourceMaps = files.filter((file) => file.path.endsWith('.js.map'));
|
||||
if (
|
||||
indexFiles.length !== 1 ||
|
||||
javascript.length === 0 ||
|
||||
files.length !== 1 + javascript.length + sourceMaps.length ||
|
||||
javascript.length !== sourceMaps.length ||
|
||||
javascript.some(
|
||||
(file) =>
|
||||
!sourceMaps.some(
|
||||
(sourceMap) => sourceMap.path === `${file.path}.map`
|
||||
)
|
||||
) ||
|
||||
sourceMaps.some(
|
||||
(sourceMap) =>
|
||||
!javascript.some(
|
||||
(file) => file.path === sourceMap.path.slice(0, -4)
|
||||
)
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
||||
if (
|
||||
!isPositiveSafeInteger(input['bytes']) ||
|
||||
input['bytes'] !== bytes ||
|
||||
input['executableJavaScriptCount'] !== javascript.length ||
|
||||
input['sourceMapCount'] !== sourceMaps.length ||
|
||||
typeof input['aggregateSha256'] !== 'string' ||
|
||||
!SHA256.test(input['aggregateSha256']) ||
|
||||
input['aggregateSha256'] !== computeXtreamBuildFilesAggregate(files)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
aggregateSha256: input['aggregateSha256'],
|
||||
bytes,
|
||||
executableJavaScriptCount: javascript.length,
|
||||
files: Object.freeze(files),
|
||||
sourceMapCount: sourceMaps.length,
|
||||
}) as XtreamBenchmarkBuildIdentity['renderer'];
|
||||
}
|
||||
|
||||
function fileIdentity(
|
||||
value: unknown,
|
||||
expectedPath?: string
|
||||
): XtreamBuildFileIdentity {
|
||||
const input = exactRecord(value, FILE_KEYS);
|
||||
const path = input['path'];
|
||||
const validRendererPath =
|
||||
path === 'dist/apps/web/index.html' ||
|
||||
(typeof path === 'string' && RENDERER_FILE.test(path));
|
||||
if (
|
||||
!isPositiveSafeInteger(input['bytes']) ||
|
||||
typeof path !== 'string' ||
|
||||
(expectedPath === undefined
|
||||
? !validRendererPath
|
||||
: path !== expectedPath) ||
|
||||
typeof input['sha256'] !== 'string' ||
|
||||
!SHA256.test(input['sha256'])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
bytes: input['bytes'],
|
||||
path,
|
||||
sha256: input['sha256'],
|
||||
}) as XtreamBuildFileIdentity;
|
||||
}
|
||||
|
||||
function exactArray(value: unknown): unknown[] {
|
||||
if (!Array.isArray(value)) invalid();
|
||||
const length = Reflect.get(value, 'length') as unknown;
|
||||
if (
|
||||
!Number.isSafeInteger(length) ||
|
||||
Number(length) < 0 ||
|
||||
Number(length) > 10_000
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const expectedKeys = new Set([
|
||||
'length',
|
||||
...Array.from({ length: Number(length) }, (_, index) => String(index)),
|
||||
]);
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (
|
||||
keys.length !== expectedKeys.size ||
|
||||
keys.some((key) => typeof key !== 'string' || !expectedKeys.has(key))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Array.from({ length: Number(length) }, (_, index) =>
|
||||
Reflect.get(value, String(index))
|
||||
);
|
||||
}
|
||||
|
||||
function exactRecord(
|
||||
value: unknown,
|
||||
expectedKeys: readonly string[]
|
||||
): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
invalid();
|
||||
}
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (
|
||||
keys.length !== expectedKeys.length ||
|
||||
keys.some(
|
||||
(key) => typeof key !== 'string' || !expectedKeys.includes(key)
|
||||
)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.fromEntries(
|
||||
expectedKeys.map((key) => [key, Reflect.get(value, key)])
|
||||
);
|
||||
}
|
||||
|
||||
function isStrictlySorted(values: readonly string[]): boolean {
|
||||
return values.every(
|
||||
(value, index) => index === 0 || (values[index - 1] ?? '') < value
|
||||
);
|
||||
}
|
||||
|
||||
function isPositiveSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
link,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
symlink,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
captureXtreamBuildIdentity,
|
||||
computeXtreamBuildFilesAggregate,
|
||||
} from './xtream-build-identity';
|
||||
import { assertXtreamBenchmarkManifest } from './xtream-exact-schema';
|
||||
import { BUILD_IDENTITY, MANIFEST } from './xtream-benchmark-report.fixtures';
|
||||
|
||||
const directories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
directories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true }))
|
||||
);
|
||||
});
|
||||
|
||||
describe('Xtream benchmark build identity', () => {
|
||||
it('hashes exact Electron pairs and sorted top-level renderer code', async () => {
|
||||
const root = await buildFixture();
|
||||
|
||||
const identity = await captureXtreamBuildIdentity(root);
|
||||
|
||||
assert.deepEqual(identity.electron.main.javascript, {
|
||||
bytes: 4,
|
||||
path: 'dist/apps/electron-backend/main.js',
|
||||
sha256: sha256('main'),
|
||||
});
|
||||
assert.deepEqual(identity.electron.preload.sourceMap, {
|
||||
bytes: 11,
|
||||
path: 'dist/apps/electron-backend/main.preload.js.map',
|
||||
sha256: sha256('preload-map'),
|
||||
});
|
||||
assert.deepEqual(
|
||||
identity.renderer.files.map((file) => file.path),
|
||||
[
|
||||
'dist/apps/web/chunk-A.js',
|
||||
'dist/apps/web/chunk-A.js.map',
|
||||
'dist/apps/web/index.html',
|
||||
'dist/apps/web/main-A.js',
|
||||
'dist/apps/web/main-A.js.map',
|
||||
]
|
||||
);
|
||||
assert.equal(identity.renderer.executableJavaScriptCount, 2);
|
||||
assert.equal(identity.renderer.sourceMapCount, 2);
|
||||
assert.equal(
|
||||
identity.renderer.bytes,
|
||||
identity.renderer.files.reduce(
|
||||
(total, file) => total + file.bytes,
|
||||
0
|
||||
)
|
||||
);
|
||||
assert.equal(
|
||||
identity.renderer.aggregateSha256,
|
||||
computeXtreamBuildFilesAggregate(identity.renderer.files)
|
||||
);
|
||||
assert.equal(
|
||||
identity.renderer.files.some((file) =>
|
||||
file.path.includes('app-config.js')
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when an executable or its source map is missing', async () => {
|
||||
const missingElectron = await buildFixture();
|
||||
await unlink(
|
||||
join(
|
||||
missingElectron,
|
||||
'dist/apps/electron-backend/workers/database.worker.js.map'
|
||||
)
|
||||
);
|
||||
await assert.rejects(
|
||||
captureXtreamBuildIdentity(missingElectron),
|
||||
/invalid Xtream performance build/
|
||||
);
|
||||
|
||||
const missingRendererMap = await buildFixture();
|
||||
await unlink(join(missingRendererMap, 'dist/apps/web/chunk-A.js.map'));
|
||||
await assert.rejects(
|
||||
captureXtreamBuildIdentity(missingRendererMap),
|
||||
/invalid Xtream performance build/
|
||||
);
|
||||
|
||||
const orphanRendererMap = await buildFixture();
|
||||
await writeFile(
|
||||
join(orphanRendererMap, 'dist/apps/web/orphan.js.map'),
|
||||
'orphan'
|
||||
);
|
||||
await assert.rejects(
|
||||
captureXtreamBuildIdentity(orphanRendererMap),
|
||||
/invalid Xtream performance build/
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects symlinked and hard-linked build evidence', async () => {
|
||||
const symlinkRoot = await buildFixture();
|
||||
const mainPath = join(
|
||||
symlinkRoot,
|
||||
'dist/apps/electron-backend/main.js'
|
||||
);
|
||||
const external = join(symlinkRoot, 'external-main.js');
|
||||
await writeFile(external, 'main');
|
||||
await unlink(mainPath);
|
||||
await symlink(external, mainPath);
|
||||
await assert.rejects(
|
||||
captureXtreamBuildIdentity(symlinkRoot),
|
||||
/invalid Xtream performance build/
|
||||
);
|
||||
|
||||
const hardLinkRoot = await buildFixture();
|
||||
await link(
|
||||
join(hardLinkRoot, 'dist/apps/web/main-A.js'),
|
||||
join(hardLinkRoot, 'main-alias.js')
|
||||
);
|
||||
await assert.rejects(
|
||||
captureXtreamBuildIdentity(hardLinkRoot),
|
||||
/invalid Xtream performance build/
|
||||
);
|
||||
});
|
||||
|
||||
it('produces a different aggregate for path, byte, or hash drift', () => {
|
||||
const files = [
|
||||
{ bytes: 1, path: 'a.js', sha256: 'a'.repeat(64) },
|
||||
{ bytes: 2, path: 'a.js.map', sha256: 'b'.repeat(64) },
|
||||
];
|
||||
const baseline = computeXtreamBuildFilesAggregate(files);
|
||||
|
||||
for (const changed of [
|
||||
[{ ...files[0], path: 'b.js' }, files[1]],
|
||||
[{ ...files[0], bytes: 2 }, files[1]],
|
||||
[{ ...files[0], sha256: 'c'.repeat(64) }, files[1]],
|
||||
]) {
|
||||
assert.notEqual(
|
||||
computeXtreamBuildFilesAggregate(changed),
|
||||
baseline
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds exact build descriptors into the persisted benchmark manifest', () => {
|
||||
assert.doesNotThrow(() => assertXtreamBenchmarkManifest(MANIFEST));
|
||||
const files = BUILD_IDENTITY.renderer.files;
|
||||
const invalidBuilds = [
|
||||
{
|
||||
...BUILD_IDENTITY,
|
||||
renderer: {
|
||||
...BUILD_IDENTITY.renderer,
|
||||
aggregateSha256: 'f'.repeat(64),
|
||||
},
|
||||
},
|
||||
{
|
||||
...BUILD_IDENTITY,
|
||||
renderer: {
|
||||
...BUILD_IDENTITY.renderer,
|
||||
bytes: BUILD_IDENTITY.renderer.bytes + 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
...BUILD_IDENTITY,
|
||||
renderer: {
|
||||
...BUILD_IDENTITY.renderer,
|
||||
files: [...files].reverse(),
|
||||
},
|
||||
},
|
||||
{
|
||||
...BUILD_IDENTITY,
|
||||
renderer: {
|
||||
...BUILD_IDENTITY.renderer,
|
||||
files: files.slice(0, -1),
|
||||
},
|
||||
},
|
||||
{
|
||||
...BUILD_IDENTITY,
|
||||
electron: {
|
||||
...BUILD_IDENTITY.electron,
|
||||
main: {
|
||||
...BUILD_IDENTITY.electron.main,
|
||||
javascript: {
|
||||
...BUILD_IDENTITY.electron.main.javascript,
|
||||
path: 'dist/apps/electron-backend/other.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const build of invalidBuilds) {
|
||||
assert.throws(
|
||||
() => assertXtreamBenchmarkManifest({ ...MANIFEST, build }),
|
||||
/invalid Xtream benchmark manifest/
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function buildFixture(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'iptvnator-xtream-build-'));
|
||||
directories.push(root);
|
||||
const backend = join(root, 'dist/apps/electron-backend');
|
||||
const workers = join(backend, 'workers');
|
||||
const renderer = join(root, 'dist/apps/web');
|
||||
await Promise.all([
|
||||
mkdir(workers, { recursive: true }),
|
||||
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, 'main.preload.js'), 'preload'),
|
||||
writeFile(join(backend, 'main.preload.js.map'), 'preload-map'),
|
||||
writeFile(join(workers, 'database.worker.js'), 'database'),
|
||||
writeFile(join(workers, 'database.worker.js.map'), 'database-map'),
|
||||
writeFile(join(workers, 'playlist-refresh.worker.js'), 'refresh'),
|
||||
writeFile(
|
||||
join(workers, 'playlist-refresh.worker.js.map'),
|
||||
'refresh-map'
|
||||
),
|
||||
writeFile(join(renderer, 'index.html'), '<app-root/>'),
|
||||
writeFile(join(renderer, 'main-A.js'), 'renderer-main'),
|
||||
writeFile(join(renderer, 'main-A.js.map'), 'renderer-main-map'),
|
||||
writeFile(join(renderer, 'chunk-A.js'), 'renderer-chunk'),
|
||||
writeFile(join(renderer, 'chunk-A.js.map'), 'renderer-chunk-map'),
|
||||
writeFile(join(renderer, 'styles.css'), 'ignored'),
|
||||
writeFile(join(renderer, 'assets/app-config.js'), 'ignored'),
|
||||
]);
|
||||
return root;
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
|
||||
import { readXtreamSafeRegularFile } from './xtream-safe-file-read';
|
||||
|
||||
export interface XtreamBuildFileIdentity {
|
||||
readonly bytes: number;
|
||||
readonly path: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
export interface XtreamBuildPairIdentity {
|
||||
readonly javascript: XtreamBuildFileIdentity;
|
||||
readonly sourceMap: XtreamBuildFileIdentity;
|
||||
}
|
||||
|
||||
export interface XtreamBenchmarkBuildIdentity {
|
||||
readonly electron: {
|
||||
readonly databaseWorker: XtreamBuildPairIdentity;
|
||||
readonly main: XtreamBuildPairIdentity;
|
||||
readonly playlistRefreshWorker: XtreamBuildPairIdentity;
|
||||
readonly preload: XtreamBuildPairIdentity;
|
||||
};
|
||||
readonly renderer: {
|
||||
readonly aggregateSha256: string;
|
||||
readonly bytes: number;
|
||||
readonly executableJavaScriptCount: number;
|
||||
readonly files: readonly XtreamBuildFileIdentity[];
|
||||
readonly sourceMapCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
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`,
|
||||
playlistRefreshWorker: `${BACKEND_ROOT}/workers/playlist-refresh.worker.js`,
|
||||
preload: `${BACKEND_ROOT}/main.preload.js`,
|
||||
} as const;
|
||||
|
||||
export async function captureXtreamBuildIdentity(
|
||||
workspaceRoot: string
|
||||
): 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),
|
||||
]);
|
||||
return Object.freeze({
|
||||
electron: Object.freeze({
|
||||
databaseWorker,
|
||||
main,
|
||||
playlistRefreshWorker,
|
||||
preload,
|
||||
}),
|
||||
renderer,
|
||||
});
|
||||
} catch {
|
||||
throw new Error('invalid Xtream performance build');
|
||||
}
|
||||
}
|
||||
|
||||
export function computeXtreamBuildFilesAggregate(
|
||||
files: readonly XtreamBuildFileIdentity[]
|
||||
): string {
|
||||
try {
|
||||
const length = Reflect.get(files, 'length') as unknown;
|
||||
if (
|
||||
!Number.isSafeInteger(length) ||
|
||||
Number(length) <= 0 ||
|
||||
Number(length) > 10_000
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const snapshot = Array.from({ length: Number(length) }, (_, index) => {
|
||||
const value = Reflect.get(files, String(index)) as
|
||||
XtreamBuildFileIdentity | undefined;
|
||||
if (
|
||||
!value ||
|
||||
!Number.isSafeInteger(value.bytes) ||
|
||||
value.bytes < 0 ||
|
||||
typeof value.path !== 'string' ||
|
||||
value.path.length === 0 ||
|
||||
typeof value.sha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.sha256)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return [value.path, value.bytes, value.sha256] as const;
|
||||
});
|
||||
return sha256(Buffer.from(JSON.stringify(snapshot), 'utf8'));
|
||||
} catch {
|
||||
throw new Error('invalid Xtream build file identity');
|
||||
}
|
||||
}
|
||||
|
||||
async function readPair(
|
||||
workspaceRoot: string,
|
||||
javascriptPath: string
|
||||
): Promise<XtreamBuildPairIdentity> {
|
||||
const [javascript, sourceMap] = await Promise.all([
|
||||
readIdentity(workspaceRoot, javascriptPath),
|
||||
readIdentity(workspaceRoot, `${javascriptPath}.map`),
|
||||
]);
|
||||
return Object.freeze({ javascript, sourceMap });
|
||||
}
|
||||
|
||||
async function readRenderer(
|
||||
workspaceRoot: string
|
||||
): Promise<XtreamBenchmarkBuildIdentity['renderer']> {
|
||||
const rendererDirectory = join(workspaceRoot, RENDERER_ROOT);
|
||||
const namesBefore = await readRendererNames(rendererDirectory);
|
||||
assertRendererPairing(namesBefore);
|
||||
const relativePaths = namesBefore.map((name) => `${RENDERER_ROOT}/${name}`);
|
||||
const files = await Promise.all(
|
||||
relativePaths.map((path) => readIdentity(workspaceRoot, path))
|
||||
);
|
||||
const namesAfter = await readRendererNames(rendererDirectory);
|
||||
if (
|
||||
namesAfter.length !== namesBefore.length ||
|
||||
namesAfter.some((name, index) => name !== namesBefore[index])
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const executableJavaScriptCount = namesBefore.filter((name) =>
|
||||
name.endsWith('.js')
|
||||
).length;
|
||||
const sourceMapCount = namesBefore.filter((name) =>
|
||||
name.endsWith('.js.map')
|
||||
).length;
|
||||
const bytes = files.reduce((total, file) => total + file.bytes, 0);
|
||||
if (!Number.isSafeInteger(bytes) || bytes <= 0) invalid();
|
||||
const frozenFiles = Object.freeze(files);
|
||||
return Object.freeze({
|
||||
aggregateSha256: computeXtreamBuildFilesAggregate(frozenFiles),
|
||||
bytes,
|
||||
executableJavaScriptCount,
|
||||
files: frozenFiles,
|
||||
sourceMapCount,
|
||||
});
|
||||
}
|
||||
|
||||
async function readRendererNames(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const relevant = entries.filter(
|
||||
(entry) =>
|
||||
entry.name === 'index.html' ||
|
||||
entry.name.endsWith('.js') ||
|
||||
entry.name.endsWith('.js.map')
|
||||
);
|
||||
if (
|
||||
relevant.some((entry) => !entry.isFile()) ||
|
||||
relevant.filter((entry) => entry.name === 'index.html').length !== 1
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return relevant.map((entry) => entry.name).sort();
|
||||
}
|
||||
|
||||
function assertRendererPairing(names: readonly string[]): void {
|
||||
const javascript = names.filter((name) => name.endsWith('.js'));
|
||||
const sourceMaps = names.filter((name) => name.endsWith('.js.map'));
|
||||
const javascriptSet = new Set(javascript);
|
||||
const sourceMapSet = new Set(sourceMaps);
|
||||
if (
|
||||
javascript.length === 0 ||
|
||||
javascript.length !== sourceMaps.length ||
|
||||
javascript.some((name) => !sourceMapSet.has(`${name}.map`)) ||
|
||||
sourceMaps.some((name) => !javascriptSet.has(name.slice(0, -4)))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
async function readIdentity(
|
||||
workspaceRoot: string,
|
||||
relativePath: string
|
||||
): Promise<XtreamBuildFileIdentity> {
|
||||
const { content } = await readXtreamSafeRegularFile(
|
||||
join(workspaceRoot, relativePath)
|
||||
);
|
||||
return Object.freeze({
|
||||
bytes: content.byteLength,
|
||||
path: relativePath,
|
||||
sha256: sha256(content),
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(value: Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture';
|
||||
import { snapshotXtreamCancellationEvidence } from './xtream-cancellation-evidence-snapshot';
|
||||
import {
|
||||
isFiniteNonNegative,
|
||||
isPositiveSafeInteger,
|
||||
} from './xtream-exact-schema';
|
||||
import type {
|
||||
XtreamAttributionPhaseEvent,
|
||||
XtreamIpcAttributionSpan,
|
||||
} from './xtream-phase-attribution';
|
||||
import {
|
||||
hasDistinctMonotonicXtreamIpcProducerIdentities,
|
||||
XTREAM_IPC_PRODUCER_NAMESPACE,
|
||||
xtreamIpcProducerNamespaceForWorkerOperation,
|
||||
type XtreamIpcProducerIdentity,
|
||||
} from './xtream-ipc-producer-identity';
|
||||
import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory';
|
||||
import type {
|
||||
XtreamCancellationMetrics,
|
||||
XtreamRawIterationResult,
|
||||
} from './xtream-summary-contract';
|
||||
|
||||
type WorkerRequest =
|
||||
XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number];
|
||||
|
||||
const RECORD_TYPES = [
|
||||
'db-cancel-dispatched',
|
||||
'db-cancel-received',
|
||||
'db-cancel-terminal-received',
|
||||
] as const;
|
||||
|
||||
export function assertXtreamCancellationEvidenceBinding(
|
||||
raw: XtreamRawIterationResult,
|
||||
events: readonly XtreamAttributionPhaseEvent[],
|
||||
ipcSpans: readonly XtreamIpcAttributionSpan[],
|
||||
requests: readonly WorkerRequest[]
|
||||
): void {
|
||||
const failed = requests.filter(
|
||||
({ operation, success }) => operation === 'DB_SAVE_CONTENT' && !success
|
||||
);
|
||||
if (raw.scenarioId !== 'xtream-cancel-import') {
|
||||
if (failed.length !== 0 || raw.cancellationEvidence !== null) invalid();
|
||||
return;
|
||||
}
|
||||
const request = failed[0];
|
||||
if (
|
||||
failed.length !== 1 ||
|
||||
!request ||
|
||||
typeof request.requestId !== 'string' ||
|
||||
typeof request.operationId !== 'string' ||
|
||||
raw.cancellation.failedDatabaseRequestId !== request.requestId ||
|
||||
raw.cancellation.failedOperationId !== request.operationId
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const timeline = cancellationTimeline(raw, request);
|
||||
const databasePreload = ipcPair(ipcSpans, 'dbCancelOperation');
|
||||
assertCancellationProducerIdentity(timeline, databasePreload[0], requests);
|
||||
assertCancellationTimeline(
|
||||
raw.cancellation,
|
||||
request,
|
||||
timeline,
|
||||
phaseSpan(
|
||||
events,
|
||||
PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
|
||||
request.requestId
|
||||
),
|
||||
phaseSpan(events, PHASE.CANCEL_SESSION),
|
||||
phaseSpan(events, PHASE.STORE_IMPORT_TERMINAL),
|
||||
ipcPair(ipcSpans, 'xtreamCancelSession'),
|
||||
databasePreload,
|
||||
raw.phaseCapture.uiPaintEpochMs
|
||||
);
|
||||
}
|
||||
|
||||
interface Span {
|
||||
readonly endEpochMs: number;
|
||||
readonly startEpochMs: number;
|
||||
}
|
||||
|
||||
function assertCancellationTimeline(
|
||||
value: XtreamCancellationMetrics,
|
||||
request: WorkerRequest,
|
||||
timeline: readonly DatabaseWorkerCancelTimelineRecord[],
|
||||
write: Span,
|
||||
networkCancel: Span,
|
||||
terminal: Span,
|
||||
networkPreload: readonly [
|
||||
XtreamIpcAttributionSpan,
|
||||
XtreamIpcAttributionSpan,
|
||||
],
|
||||
databasePreload: readonly [
|
||||
XtreamIpcAttributionSpan,
|
||||
XtreamIpcAttributionSpan,
|
||||
],
|
||||
uiPaintEpochMs: number
|
||||
): void {
|
||||
const [dispatchRecord, receiptRecord, workerTerminalRecord] = timeline;
|
||||
if (!dispatchRecord || !receiptRecord || !workerTerminalRecord) invalid();
|
||||
const click = epoch(value.clickEpochMs);
|
||||
const preloadStart = epoch(value.preloadStartEpochMs);
|
||||
const preloadReturn = epoch(value.preloadReturnEpochMs);
|
||||
const dispatch = epoch(value.databaseDispatchEpochMs);
|
||||
const receipt = epoch(value.workerReceiptEpochMs);
|
||||
const authoritative = epoch(value.authoritativeTerminalEpochMs);
|
||||
const painted = epoch(value.paintedEpochMs);
|
||||
if (
|
||||
request.requestReceivedEpochMs === null ||
|
||||
request.workEndedEpochMs === null ||
|
||||
click < write.startEpochMs ||
|
||||
click < request.requestReceivedEpochMs ||
|
||||
click > write.endEpochMs ||
|
||||
networkPreload[0].startEpochMs < click ||
|
||||
networkPreload[0].endEpochMs > networkCancel.startEpochMs ||
|
||||
networkCancel.endEpochMs > networkPreload[1].startEpochMs ||
|
||||
networkPreload[1].startEpochMs > databasePreload[0].startEpochMs ||
|
||||
preloadStart !== databasePreload[0].startEpochMs ||
|
||||
dispatchRecord.ipcCallId !== databasePreload[0].ipcCallId ||
|
||||
dispatchRecord.sourceEpochMs !== databasePreload[0].sourceEpochMs ||
|
||||
databasePreload[0].endEpochMs > dispatch ||
|
||||
dispatch !== dispatchRecord.epochMs ||
|
||||
dispatch < request.requestReceivedEpochMs ||
|
||||
dispatch > write.endEpochMs ||
|
||||
databasePreload[1].startEpochMs < dispatch ||
|
||||
preloadReturn !== databasePreload[1].endEpochMs ||
|
||||
preloadReturn < dispatch ||
|
||||
receipt !== receiptRecord.epochMs ||
|
||||
receipt < dispatch ||
|
||||
receipt > request.workEndedEpochMs ||
|
||||
receipt > authoritative ||
|
||||
workerTerminalRecord.epochMs < receipt ||
|
||||
workerTerminalRecord.epochMs > authoritative ||
|
||||
authoritative !== request.responseEpochMs ||
|
||||
terminal.startEpochMs < authoritative ||
|
||||
terminal.endEpochMs > painted ||
|
||||
painted !== uiPaintEpochMs
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function cancellationTimeline(
|
||||
raw: XtreamRawIterationResult,
|
||||
request: WorkerRequest
|
||||
): readonly DatabaseWorkerCancelTimelineRecord[] {
|
||||
const evidence = snapshotXtreamCancellationEvidence(
|
||||
raw.cancellationEvidence
|
||||
);
|
||||
if (evidence === null) invalid();
|
||||
const timeline = evidence.timeline;
|
||||
if (timeline.length !== RECORD_TYPES.length) invalid();
|
||||
const first = timeline[0];
|
||||
if (!first) invalid();
|
||||
for (let index = 0; index < timeline.length; index += 1) {
|
||||
const record = timeline[index];
|
||||
if (
|
||||
!record ||
|
||||
record.type !== RECORD_TYPES[index] ||
|
||||
!isFiniteNonNegative(record.epochMs) ||
|
||||
!isFiniteNonNegative(record.sourceEpochMs) ||
|
||||
!isPositiveSafeInteger(record.ipcCallId) ||
|
||||
typeof record.operationId !== 'string' ||
|
||||
record.operationId.length === 0 ||
|
||||
typeof record.requestId !== 'string' ||
|
||||
record.requestId.length === 0 ||
|
||||
record.ipcCallId !== first.ipcCallId ||
|
||||
record.operationId !== first.operationId ||
|
||||
record.requestId !== first.requestId ||
|
||||
record.sourceEpochMs !== first.sourceEpochMs
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
if (
|
||||
first.requestId !== request.requestId ||
|
||||
first.operationId !== request.operationId
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function assertCancellationProducerIdentity(
|
||||
timeline: readonly DatabaseWorkerCancelTimelineRecord[],
|
||||
databasePreload: XtreamIpcAttributionSpan,
|
||||
requests: readonly WorkerRequest[]
|
||||
): void {
|
||||
const dispatch = timeline[0];
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.ipcCallId !== databasePreload.ipcCallId ||
|
||||
dispatch.sourceEpochMs !== databasePreload.sourceEpochMs
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const identities: XtreamIpcProducerIdentity[] = [];
|
||||
for (const { ipcCallId, operation, sourceEpochMs } of requests) {
|
||||
if (ipcCallId === null && sourceEpochMs === null) continue;
|
||||
const namespace = xtreamIpcProducerNamespaceForWorkerOperation(
|
||||
typeof operation === 'string' ? operation : ''
|
||||
);
|
||||
if (
|
||||
namespace === null ||
|
||||
!isPositiveSafeInteger(ipcCallId) ||
|
||||
!isFiniteNonNegative(sourceEpochMs)
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
if (namespace === XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD) {
|
||||
identities.push({ ipcCallId, namespace, sourceEpochMs });
|
||||
}
|
||||
}
|
||||
identities.push({
|
||||
ipcCallId: databasePreload.ipcCallId,
|
||||
namespace: XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD,
|
||||
sourceEpochMs: databasePreload.sourceEpochMs,
|
||||
});
|
||||
if (!hasDistinctMonotonicXtreamIpcProducerIdentities(identities)) {
|
||||
invalid();
|
||||
}
|
||||
}
|
||||
|
||||
function phaseSpan(
|
||||
events: readonly XtreamAttributionPhaseEvent[],
|
||||
phase: string,
|
||||
correlationId?: string
|
||||
): Span {
|
||||
const selected = events.filter(
|
||||
(event) =>
|
||||
event.phase === phase &&
|
||||
(correlationId === undefined ||
|
||||
event.correlationId === correlationId)
|
||||
);
|
||||
const start = selected.find(({ boundary }) => boundary === 'start');
|
||||
const end = selected.find(({ boundary }) => boundary === 'end');
|
||||
if (selected.length !== 2 || !start || !end) invalid();
|
||||
return { endEpochMs: end.epochMs, startEpochMs: start.epochMs };
|
||||
}
|
||||
|
||||
function ipcPair(
|
||||
spans: readonly XtreamIpcAttributionSpan[],
|
||||
method: string
|
||||
): readonly [XtreamIpcAttributionSpan, XtreamIpcAttributionSpan] {
|
||||
const selected = spans.filter((span) => span.method === method);
|
||||
const request = selected.find(({ boundary }) => boundary === 'request');
|
||||
const response = selected.find(({ boundary }) => boundary === 'response');
|
||||
if (selected.length !== 2 || !request || !response) invalid();
|
||||
return [request, response];
|
||||
}
|
||||
|
||||
function epoch(value: number | null): number {
|
||||
if (!isFiniteNonNegative(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('invalid Xtream persisted cancellation evidence');
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture';
|
||||
import type { XtreamCancellationEvidence } from './xtream-summary-contract';
|
||||
|
||||
const EVIDENCE_KEYS = ['timeline'] as const;
|
||||
const RECORD_KEYS = [
|
||||
'epochMs',
|
||||
'ipcCallId',
|
||||
'operationId',
|
||||
'requestId',
|
||||
'sourceEpochMs',
|
||||
'type',
|
||||
] as const;
|
||||
const MAX_TIMELINE_LENGTH = 16;
|
||||
|
||||
export function snapshotXtreamCancellationEvidence(
|
||||
value: unknown
|
||||
): XtreamCancellationEvidence | null {
|
||||
try {
|
||||
const evidence = snapshotExactRecord(value, EVIDENCE_KEYS);
|
||||
const timeline = snapshotExactArray(
|
||||
evidence['timeline'],
|
||||
snapshotTimelineRecord
|
||||
);
|
||||
return Object.freeze({ timeline });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotTimelineRecord(
|
||||
value: unknown
|
||||
): DatabaseWorkerCancelTimelineRecord {
|
||||
return snapshotExactRecord(
|
||||
value,
|
||||
RECORD_KEYS
|
||||
) as unknown as DatabaseWorkerCancelTimelineRecord;
|
||||
}
|
||||
|
||||
function snapshotExactArray<T>(
|
||||
value: unknown,
|
||||
snapshotEntry: (entry: unknown) => T
|
||||
): readonly T[] {
|
||||
if (!Array.isArray(value)) invalid();
|
||||
const length = dataDescriptorValue(
|
||||
Reflect.getOwnPropertyDescriptor(value, 'length')
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(length) ||
|
||||
Number(length) < 0 ||
|
||||
Number(length) > MAX_TIMELINE_LENGTH
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const expectedKeys = new Set<PropertyKey>(['length']);
|
||||
const snapshot: T[] = [];
|
||||
for (let index = 0; index < Number(length); index += 1) {
|
||||
const key = String(index);
|
||||
expectedKeys.add(key);
|
||||
snapshot[index] = snapshotEntry(
|
||||
dataDescriptorValue(Reflect.getOwnPropertyDescriptor(value, key))
|
||||
);
|
||||
}
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (
|
||||
ownKeys.length !== expectedKeys.size ||
|
||||
ownKeys.some((key) => !expectedKeys.has(key))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
}
|
||||
|
||||
function snapshotExactRecord(
|
||||
value: unknown,
|
||||
keys: readonly string[]
|
||||
): Readonly<Record<string, unknown>> {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Reflect.getPrototypeOf(value))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const ownKeys = Reflect.ownKeys(value);
|
||||
if (
|
||||
ownKeys.length !== keys.length ||
|
||||
ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key))
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
const snapshot: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
snapshot[key] = dataDescriptorValue(
|
||||
Reflect.getOwnPropertyDescriptor(value, key)
|
||||
);
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
}
|
||||
|
||||
function dataDescriptorValue(
|
||||
descriptor: PropertyDescriptor | undefined
|
||||
): unknown {
|
||||
if (
|
||||
!descriptor ||
|
||||
!Object.prototype.hasOwnProperty.call(descriptor, 'value')
|
||||
) {
|
||||
invalid();
|
||||
}
|
||||
return descriptor.value;
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('invalid Xtream cancellation evidence snapshot');
|
||||
}
|
||||
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user