diff --git a/AGENTS.md b/AGENTS.md index 78001ad29..b4a8b5223 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,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 request/completion markers plus count-only M3U acquire/parse/normalize 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 phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset + - `IPTVNATOR_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 diff --git a/CLAUDE.md b/CLAUDE.md index f628770f3..5ffa4af4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,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 request/completion markers plus count-only M3U acquire/parse/normalize 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 phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset +- `IPTVNATOR_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 diff --git a/apps/electron-backend-e2e/playwright.xtream-performance.config.ts b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts new file mode 100644 index 000000000..fcf8086e9 --- /dev/null +++ b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts @@ -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, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index ee68cd410..cdf610087 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -39,6 +39,16 @@ "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", diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 87ab9351c..12424acd7 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -77,7 +77,9 @@ type ElectronFixtures = { export type LaunchElectronAppOptions = { args?: readonly string[]; + environmentInheritance?: 'all' | 'runtime-only'; env?: Record; + omitEnvKeys?: readonly string[]; }; export type PortalDebugEvent = { @@ -197,16 +199,7 @@ export async function launchElectronApp( 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); @@ -222,6 +215,86 @@ export async function launchElectronApp( }; } +export function buildElectronLaunchEnvironment( + dataDir: string, + options: LaunchElectronAppOptions = {}, + inheritedEnvironment: NodeJS.ProcessEnv = process.env +): Record { + 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 = { + ...(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 { + const selected: Record = {}; + 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 @@ -1703,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 { +export async function expectNoWorkspaceSearchStatus(page: Page): Promise { await expect( page.locator('app-workspace-shell-header .search-chip--status') ).toHaveCount(0); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts new file mode 100644 index 000000000..88cf4ca91 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts @@ -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 = {}) { + return { + captureGeneration: 3, + ipcCallId: 9, + operationId: 'operation-1', + postedEpochMs: 110, + requestId: 'request-1', + sourceEpochMs: 100, + ...overrides, + }; +} + +function receipt(overrides: Record = {}) { + 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); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts new file mode 100644 index 000000000..0ae253e53 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts @@ -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; + 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(); + 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): 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]), + }); + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts index bcd3ac969..2223e48c1 100644 --- a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts @@ -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'/ diff --git a/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts b/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts new file mode 100644 index 000000000..c6c82239f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts @@ -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; +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts index b162bdafa..ed4b9c13f 100644 --- a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts @@ -73,6 +73,14 @@ export function isCpuProfile(value: unknown): boolean { return elapsed > 0; } +export function readCpuProfileDurationMicros(value: unknown): number | null { + if (!isCpuProfile(value)) { + return null; + } + const profile = value as Record; + return profile['endTime'] - profile['startTime']; +} + export function isHeapSnapshot(value: unknown): boolean { if ( !isRecord(value) || diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts index c153a3581..70e2220d8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts @@ -69,7 +69,10 @@ export async function resolveM3uImportBenchmarkConfiguration(): Promise | null = null; let traceFirstEvent = true; @@ -62,9 +69,7 @@ export function createRendererArtifactCapture(options: { traceOutput.write(']}'); traceOutput.end(); } - if (!traceOutput.writableFinished) { - await once(traceOutput, 'finish'); - } + await finished(traceOutput); })(); return traceOutputFinish; }, @@ -122,7 +127,7 @@ export async function stopRendererDiagnosticCapture( await writeFile( requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'), JSON.stringify(cpuProfile['profile']), - 'utf8' + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); await session.send('Tracing.end'); await artifacts.traceComplete; @@ -145,7 +150,8 @@ export async function takeRendererHeapSnapshot( path: string | null ): Promise { const output = createWriteStream( - requirePath(path, 'renderer heap snapshot') + requirePath(path, 'renderer heap snapshot'), + { flags: 'wx', mode: 0o600 } ); const onChunk = ({ chunk }: { readonly chunk: string }): void => { output.write(chunk); @@ -158,9 +164,43 @@ export async function takeRendererHeapSnapshot( } finally { session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); output.end(); - if (!output.writableFinished) { - await once(output, 'finish'); - } + 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'); } } diff --git a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts index dab027bc9..7f3b61df8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts @@ -82,7 +82,10 @@ async function runScenario( assertSyntheticM3uImportUrl(server.resourceUrl); await server.prime(); await assertPerformanceArtifactCapacity(scenarioDirectory); - await mkdir(scenarioDirectory, { recursive: false }); + await mkdir(scenarioDirectory, { + mode: 0o700, + recursive: false, + }); for (const definition of definitions) { console.log( `[performance] ${config.variant}/${scenario.id}/${definition.runId} starting` @@ -163,7 +166,10 @@ async function runIteration( ): Promise { const iterationDirectory = join(scenarioDirectory, 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-import-performance-') ); @@ -179,6 +185,7 @@ async function runIteration( `--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', @@ -188,10 +195,7 @@ async function runIteration( }, }); app.mainWindow.setDefaultTimeout(120_000); - await assertRendererCdpTarget( - app.mainWindow, - config.rendererCdpPort - ); + await assertRendererCdpTarget(app.mainWindow, config.rendererCdpPort); await installMainCapture(app.electronApp); const rendererWindowIdentity = await resolveRendererWindowIdentity(app); const requestsBefore = server.requestCount(); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index a89f95dc8..619c5cb2c 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,7 +1,7 @@ import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; import type { RendererRssValidity } from './renderer-rss-validity'; import type { - AppPlaylistPerformancePhase, + DatabaseWorkerPerformancePhase, PerformancePhaseEvent, } from '@iptvnator/shared/interfaces'; @@ -57,8 +57,7 @@ export type WorkerPostGcHeapCapture = }; export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = { - EXTERNAL_MEMORY_SAMPLES_MISSING: - 'worker-external-memory-samples-missing', + EXTERNAL_MEMORY_SAMPLES_MISSING: 'worker-external-memory-samples-missing', HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing', } as const; @@ -86,17 +85,23 @@ 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; + readonly itemCount?: number | null; + readonly method?: string; readonly operation?: string; - readonly operationId?: string; + readonly operationId?: string | null; + readonly outcome?: 'error' | 'success' | null; readonly phase?: string; - readonly playlistId?: string; + readonly playlistId?: string | null; readonly requestId?: string; + readonly sessionId?: string | null; readonly success?: boolean; readonly sourceEpochMs?: number; readonly type: string; @@ -114,7 +119,7 @@ export interface WorkerRequestPerformanceMetrics { readonly operationId: string | null; readonly operationIdUnavailableReason: string | null; readonly performanceCaptureUnavailableReason: string | null; - readonly phaseEvents: readonly PerformancePhaseEvent[]; + readonly phaseEvents: readonly PerformancePhaseEvent[]; readonly playlistId: string | null; readonly requestId: string | null; readonly requestReceivedEpochMs: number | null; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index 243803989..09b7f1abf 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -186,7 +186,10 @@ async function runIteration( ): Promise { 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 { 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, diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index 2c4536f8e..b05d01be3 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -3,6 +3,12 @@ import type { ElectronApplication } from '@playwright/test'; import { M3U_IMPORT_PERFORMANCE_PHASE, M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + XtreamCodeActions, } from '@iptvnator/shared/interfaces'; import { @@ -11,6 +17,12 @@ import { type DatabaseRequestIdentity, type DatabaseRequestIdentityCaptureApi, } from './database-request-identity-capture'; +import { + createDatabaseWorkerCancelCapture, + type DatabaseWorkerCancelCapture, + type DatabaseWorkerCancelCaptureSnapshot, + type DatabaseWorkerCancelTimelineRecord, +} from './database-worker-cancel-capture'; import { createDatabaseWorkerPostGcCutoffApi, type DatabaseWorkerPostGcCutoffApi, @@ -31,7 +43,9 @@ import { } from './database-worker-post-gc-selection'; import type { MainCaptureMetrics, + WorkerCaptureMetrics, WorkerPostGcHeapUnavailableReason, + WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { selectMainCaptureGeneration, @@ -52,10 +66,23 @@ import { type RendererWindowRssSessionApi, type RendererWindowRssSessionMetrics, } from './renderer-window-rss-session'; +import { createDiagnosticsPerformancePhaseEventParser } from './performance-phase-events'; +import { + createPerformanceTimelineMergeApi, + type PerformanceTimelineMergeApi, +} from './performance-timeline-merge'; +import { createXtreamIpcMarkerCapture } from './xtream-ipc-marker-events'; +import type { + XtreamIpcMarkerCaptureSnapshot, + XtreamIpcTimelineRecord, +} from './xtream-ipc-marker-events.model'; +import { createXtreamIpcMarkerProtocol } from './xtream-ipc-marker-protocol'; +import { createXtreamMainLifecycleValidator } from './xtream-main-lifecycle'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; export interface MainCaptureStartOptions { + readonly deferMeasurement?: boolean; readonly diagnostic: boolean; readonly outputDirectory: string; readonly rendererWindowIdentity: RendererWindowIdentity; @@ -79,14 +106,100 @@ export interface MainCaptureRolloverResult { readonly nextCaptureUnavailableReason: string | null; } -interface MainCaptureRolloverStatus { +export type XtreamMainCaptureOperationOutcomeKind = + 'error' | 'pending' | 'success'; + +export interface XtreamMainCaptureOperationOutcome { + readonly count: number; + readonly operation: string; + readonly outcome: XtreamMainCaptureOperationOutcomeKind; +} + +export interface XtreamMainCaptureOperationProgress { + readonly current: number | null; + readonly epochMs: number; + readonly phase: string | null; + readonly status: + 'cancelled' | 'completed' | 'error' | 'progress' | 'started'; + readonly total: number | null; +} + +export interface XtreamMainCaptureActiveOperation { + readonly operation: string; + readonly operationId: string | null; + readonly playlistId: string | null; + readonly progress: XtreamMainCaptureOperationProgress | null; + readonly requestId: string; +} + +export interface XtreamMainCaptureInvalidReasons { + readonly capture: readonly string[]; + readonly databaseWorkerCancel: readonly string[]; + readonly xtreamIpc: readonly string[]; +} + +export interface XtreamMainCaptureStatus { + readonly active: boolean; + readonly activeOperations: readonly XtreamMainCaptureActiveOperation[]; + readonly captureGeneration: number; + readonly captureStartedEpochMs: number; + readonly cutoffPhase: 'active' | 'idle' | 'stopping'; + readonly databaseWorkerCount: number; + readonly invalidReasons: readonly string[]; + readonly lateDatabaseRequestCount: number; + readonly lateEventCount: number; + readonly measurementPhase: 'armed' | 'frozen' | 'measuring'; + readonly measurementStartedEpochMs: number | null; + readonly operationOutcomes: readonly XtreamMainCaptureOperationOutcome[]; + readonly playlistRefreshWorkerCount: number; +} + +export interface MainCaptureRolloverStatus { readonly nextCaptureStarted: boolean; readonly nextCaptureUnavailableReason: string | null; } -type MainCaptureStopTransport = MainCaptureGenerationTransport & { +export interface XtreamMainCaptureTransport extends MainCaptureGenerationTransport { readonly rollover: MainCaptureRolloverStatus | null; -}; + readonly xtream: { + readonly cancelCapture: DatabaseWorkerCancelCaptureSnapshot; + readonly captureCutoffEpochMs: number; + readonly captureGeneration: number; + readonly captureStartedEpochMs: number; + readonly captureStoppedEpochMs: number; + readonly databaseWorkerCount: number; + readonly invalidReasons: readonly string[]; + readonly ipcCapture: XtreamIpcMarkerCaptureSnapshot; + readonly lateDatabaseRequestCount: number; + readonly lateEventCount: number; + readonly measurementStartedEpochMs: number; + readonly operationOutcomes: readonly XtreamMainCaptureOperationOutcome[]; + readonly playlistRefreshWorkerCount: number; + }; +} + +export interface XtreamMainCaptureResult { + readonly cancelTimeline: readonly DatabaseWorkerCancelTimelineRecord[]; + readonly capture: MainCaptureMetrics; + readonly captureCutoffEpochMs: number; + readonly captureGeneration: number; + readonly captureStartedEpochMs: number; + readonly captureStoppedEpochMs: number; + readonly databaseWorkerCount: number; + readonly invalidReasons: XtreamMainCaptureInvalidReasons; + readonly ipcTimeline: readonly XtreamIpcTimelineRecord[]; + readonly lateDatabaseRequestCount: number; + readonly lateEventCount: number; + readonly measurementStartedEpochMs: number; + readonly operationOutcomes: readonly XtreamMainCaptureOperationOutcome[]; + readonly playlistRefreshWorkerCount: number; + readonly quarantinedIpcMarkerCount: number; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; + readonly rollover: MainCaptureRolloverStatus | null; + readonly workers: readonly WorkerCaptureMetrics[]; +} + +type MainCaptureStopTransport = XtreamMainCaptureTransport; export async function installMainCapture( electronApp: ElectronApplication @@ -95,6 +208,10 @@ export async function installMainCapture( const captureStateKeys = { databaseRequestIdentityStateKey: DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + databaseWorkerCancelCaptureFactorySource: + createDatabaseWorkerCancelCapture.toString(), + diagnosticsPerformancePhaseEventParserFactorySource: + createDiagnosticsPerformancePhaseEventParser.toString(), databaseWorkerPostGcCutoffApiFactorySource: createDatabaseWorkerPostGcCutoffApi.toString(), databaseWorkerPostGcFinalizationApiFactorySource: @@ -110,11 +227,30 @@ export async function installMainCapture( m3uImportPerformancePhaseEventChannel: M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, m3uImportPerformancePhases: Object.values(M3U_IMPORT_PERFORMANCE_PHASE), + performanceTimelineMergeApiFactorySource: + createPerformanceTimelineMergeApi.toString(), stateKey: MAIN_CAPTURE_STATE_KEY, workerTerminationGenerationApiFactorySource: createWorkerTerminationGenerationApi.toString(), + xtreamIpcMarkerCaptureFactorySource: + createXtreamIpcMarkerCapture.toString(), + xtreamIpcMarkerCaptureOptions: { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + }, + xtreamIpcMarkerProtocolFactorySource: + createXtreamIpcMarkerProtocol.toString(), + xtreamMainPerformancePhaseEventChannel: + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + xtreamMainLifecycleValidatorFactorySource: + createXtreamMainLifecycleValidator.toString(), + xtreamPreloadPerformanceMarkerChannel: + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, }; - await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { + await electronApp.evaluate(async (electron, input) => { + const { app, BrowserWindow, ipcMain } = electron; type JsonRecord = Record; type WorkerTransferable = import('node:worker_threads').Transferable; interface CpuProfileHandle { @@ -190,23 +326,29 @@ export async function installMainCapture( requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; samplePromise: Promise | null; - sampleTimer: NodeJS.Timeout | null; + samplingStarted: boolean; snapshotPath: string | null; terminatedEpochMs: number | null; worker: InstrumentedWorker; } interface TimelineRecord { + 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; + readonly itemCount?: number | null; + readonly method?: string; readonly operation?: string; - readonly operationId?: string; + readonly operationId?: string | null; + readonly outcome?: 'error' | 'success' | null; readonly phase?: string; - readonly playlistId?: string; + readonly playlistId?: string | null; readonly requestId?: string; + readonly sessionId?: string | null; readonly success?: boolean; readonly sourceEpochMs?: number; readonly type: string; @@ -219,16 +361,20 @@ export async function installMainCapture( const databaseRequestIdentityCapture = target[ input.databaseRequestIdentityStateKey ] as DatabaseRequestIdentityCaptureApi; + const restoreFunction = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; const restoreFactory = (source: string): T => { - const factory = new Function( - `"use strict"; return (${source});` - )() as () => T; + const factory = restoreFunction<() => T>(source); return factory(); }; const databaseWorkerPostGcCutoffApi = restoreFactory( input.databaseWorkerPostGcCutoffApiFactorySource ); + const databaseWorkerCancelCapture = + restoreFactory( + input.databaseWorkerCancelCaptureFactorySource + ); const databaseWorkerPostGcFinalizationApi = restoreFactory( input.databaseWorkerPostGcFinalizationApiFactorySource @@ -253,9 +399,31 @@ export async function installMainCapture( restoreFactory( input.workerTerminationGenerationApiFactorySource ); + const diagnosticsPerformancePhaseEventParserFactory = restoreFunction< + typeof createDiagnosticsPerformancePhaseEventParser + >(input.diagnosticsPerformancePhaseEventParserFactorySource); + const performanceTimelineMergeApi = + restoreFactory( + input.performanceTimelineMergeApiFactorySource + ); + const xtreamIpcMarkerProtocolFactory = restoreFunction< + typeof createXtreamIpcMarkerProtocol + >(input.xtreamIpcMarkerProtocolFactorySource); + const xtreamIpcMarkerCaptureFactory = restoreFunction< + typeof createXtreamIpcMarkerCapture + >(input.xtreamIpcMarkerCaptureFactorySource); + const xtreamMainLifecycleValidatorFactory = restoreFunction< + typeof createXtreamMainLifecycleValidator + >(input.xtreamMainLifecycleValidatorFactorySource); + const xtreamIpcMarkerCapture = xtreamIpcMarkerCaptureFactory( + input.xtreamIpcMarkerCaptureOptions, + xtreamIpcMarkerProtocolFactory(input.xtreamIpcMarkerCaptureOptions), + xtreamMainLifecycleValidatorFactory() + ); const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; + threadCpuUsage(previousValue?: NodeJS.CpuUsage): NodeJS.CpuUsage; }; const fs = runtimeProcess.getBuiltinModule( 'node:fs' @@ -290,6 +458,7 @@ export async function installMainCapture( identity: DatabaseRequestIdentity; operation: string; playlistId: string | null; + progress: XtreamMainCaptureOperationProgress | null; record: WorkerRecord; } >(); @@ -297,6 +466,9 @@ export async function installMainCapture( const state = { active: false, captureGeneration: 0, + captureInvalidReasons: [] as string[], + captureOptions: null as MainCaptureStartOptions | null, + captureStartedEpochMs: null as number | null, cpuStart: null as NodeJS.CpuUsage | null, diagnostic: false, eventLoopDelay: null as ReturnType< @@ -310,10 +482,13 @@ export async function installMainCapture( mainPeakRss: 0, mainProfilePath: null as string | null, mainSnapshotPath: null as string | null, + measurementPhase: 'armed' as 'armed' | 'frozen' | 'measuring', + measurementStartedEpochMs: null as number | null, outputDirectory: '', postGcHeap: null as number | null, postGcRss: null as number | null, rendererWindowSession: null as RendererWindowRssSession | null, + lateEventCount: 0, sampleTimer: null as NodeJS.Timeout | null, stopping: false, timeline: [] as TimelineRecord[], @@ -328,60 +503,175 @@ export async function installMainCapture( state.timeline.push({ epochMs: nowEpochMs(), ...record }); } }; - const m3uImportPhases = new Set( - input.m3uImportPerformancePhases - ); - diagnosticsChannel - .channel(input.m3uImportPerformancePhaseEventChannel) - .subscribe((incoming: unknown) => { + const invalidateCapture = (reason: string): void => { + if (!state.captureInvalidReasons.includes(reason)) { + state.captureInvalidReasons.push(reason); + } + }; + const readDatabaseProgress = ( + inputValue: unknown + ): XtreamMainCaptureOperationProgress | null => { + if (typeof inputValue !== 'object' || inputValue === null) { + invalidateCapture('database-operation-progress-invalid'); + return null; + } + const event = inputValue as JsonRecord; + const status = event['status']; + const phase = event['phase']; + const current = event['current']; + const total = event['total']; + if ( + typeof event['operation'] !== 'string' || + !/^[a-z][a-z0-9-]{0,127}$/.test(event['operation']) || + ![ + 'cancelled', + 'completed', + 'error', + 'progress', + 'started', + ].includes(String(status)) || + !( + phase === undefined || + phase === null || + (typeof phase === 'string' && + /^[a-z][a-z0-9-]{0,127}$/.test(phase)) + ) || + !( + current === undefined || + (typeof current === 'number' && + Number.isFinite(current) && + current >= 0) + ) || + !( + total === undefined || + (typeof total === 'number' && + Number.isFinite(total) && + total >= 0) + ) + ) { + invalidateCapture('database-operation-progress-invalid'); + return null; + } + return { + current: typeof current === 'number' ? current : null, + epochMs: nowEpochMs(), + phase: typeof phase === 'string' ? phase : null, + status: status as XtreamMainCaptureOperationProgress['status'], + total: typeof total === 'number' ? total : null, + }; + }; + const readOperationOutcomes = + (): XtreamMainCaptureOperationOutcome[] => { + const counts = new Map< + string, + { error: number; requests: number; success: number } + >(); + for (const entry of state.timeline) { + if ( + entry.type !== 'db-request' && + entry.type !== 'db-response' + ) { + continue; + } + if ( + typeof entry.operation !== 'string' || + !/^DB_[A-Z0-9_]{1,124}$/.test(entry.operation) + ) { + invalidateCapture('database-operation-outcome-invalid'); + continue; + } + const count = counts.get(entry.operation) ?? { + error: 0, + requests: 0, + success: 0, + }; + if (entry.type === 'db-request') { + count.requests += 1; + } else if (entry.success === true) { + count.success += 1; + } else if (entry.success === false) { + count.error += 1; + } else { + invalidateCapture('database-operation-outcome-invalid'); + } + counts.set(entry.operation, count); + } + const outcomes: XtreamMainCaptureOperationOutcome[] = []; + for (const operation of [...counts.keys()].sort()) { + const count = counts.get(operation); + if (!count) continue; + const pending = + count.requests - count.error - count.success; + if (pending < 0) { + invalidateCapture('database-operation-outcome-invalid'); + } + for (const [outcome, value] of [ + ['error', count.error], + ['pending', Math.max(0, pending)], + ['success', count.success], + ] as const) { + if (value > 0) { + outcomes.push({ count: value, operation, outcome }); + } + } + } + return outcomes; + }; + const parseM3uImportPhase = + diagnosticsPerformancePhaseEventParserFactory({ + allowedPhases: input.m3uImportPerformancePhases, + timelineType: 'm3u-import-phase', + }); + const parseXtreamMainPhase = + diagnosticsPerformancePhaseEventParserFactory({ + allowedPhases: input.xtreamIpcMarkerCaptureOptions.mainPhases, + timelineType: 'xtream-main-phase', + }); + const subscribeDiagnosticsPhase = ( + channelName: string, + parse: ReturnType< + typeof createDiagnosticsPerformancePhaseEventParser + >, + accept: (event: NonNullable>) => void + ): void => { + diagnosticsChannel.channel(channelName).subscribe((incoming) => { if (!state.active && !state.stopping) { return; } - if (typeof incoming !== 'object' || incoming === null) { - return; + const event = parse(incoming); + if (event) { + accept(event); } - const event = incoming as JsonRecord; - const boundary = event['boundary']; - const durationMs = event['durationMs']; - const epochMs = event['epochMs']; - const metadata = - typeof event['metadata'] === 'object' && - event['metadata'] !== null - ? (event['metadata'] as JsonRecord) - : null; - const phase = event['phase']; - const requestId = event['requestId']; - if ( - (boundary !== 'start' && boundary !== 'end') || - typeof epochMs !== 'number' || - !Number.isFinite(epochMs) || - typeof phase !== 'string' || - !m3uImportPhases.has(phase) || - typeof requestId !== 'string' || - requestId.length === 0 || - (boundary === 'start' && durationMs !== null) || - (boundary === 'end' && - (typeof durationMs !== 'number' || - !Number.isFinite(durationMs) || - durationMs < 0)) - ) { - return; - } - const byteCount = metadata?.['byteCount']; - const itemCount = metadata?.['itemCount']; - state.timeline.push({ - boundary, - ...(isCount(byteCount) ? { byteCount } : {}), - durationMs: durationMs as number | null, - epochMs, - ...(isCount(itemCount) ? { itemCount } : {}), - phase, - requestId, - type: 'm3u-import-phase', - }); }); - const isCount = (value: unknown): value is number => - Number.isSafeInteger(value) && Number(value) >= 0; + }; + subscribeDiagnosticsPhase( + input.m3uImportPerformancePhaseEventChannel, + parseM3uImportPhase, + (event) => state.timeline.push(event) + ); + subscribeDiagnosticsPhase( + input.xtreamMainPerformancePhaseEventChannel, + parseXtreamMainPhase, + (event) => { + xtreamIpcMarkerCapture.acceptMainPhase(event); + } + ); + ipcMain.on( + input.xtreamPreloadPerformanceMarkerChannel, + (event, marker) => { + let senderWebContentsId = -1; + try { + senderWebContentsId = event.sender.id; + } catch { + // Strict capture rejects an unreadable sender identity. + } + xtreamIpcMarkerCapture.acceptPreload( + senderWebContentsId, + nowEpochMs(), + marker + ); + } + ); const classifyRequest = ( message: JsonRecord ): 'database.worker' | 'playlist-refresh.worker' | null => { @@ -446,7 +736,7 @@ export async function installMainCapture( requestPerformance: [], responseEpochMs: null, samplePromise: null, - sampleTimer: null, + samplingStarted: false, snapshotPath: null, terminatedEpochMs: null, worker, @@ -458,6 +748,17 @@ export async function installMainCapture( return; } const message = incoming as JsonRecord; + if (message['type'] === 'performance-cancel-received') { + if (record.kind === 'database.worker') { + databaseWorkerCancelCapture.acceptReceipt({ + captureGeneration: state.captureGeneration, + message, + receivedEpochMs: nowEpochMs(), + recordGeneration: record.captureGeneration, + }); + } + return; + } if (!isCurrentCaptureRecord(record)) { return; } @@ -498,6 +799,34 @@ export async function installMainCapture( } return; } + if ( + message['type'] === 'event' && + typeof message['requestId'] === 'string' + ) { + const event = message['event']; + const request = dbRequests.get(message['requestId']); + if (request) { + request.progress = readDatabaseProgress(event); + } + if ( + request && + typeof event === 'object' && + event !== null && + (event as JsonRecord)['status'] === 'cancelled' && + typeof (event as JsonRecord)['operationId'] === 'string' + ) { + databaseWorkerCancelCapture.acceptTerminal({ + captureGeneration: state.captureGeneration, + operationId: (event as JsonRecord)[ + 'operationId' + ] as string, + receivedEpochMs: nowEpochMs(), + recordGeneration: record.captureGeneration, + requestId: message['requestId'], + }); + } + return; + } if ( message['type'] === 'response' && typeof message['requestId'] === 'string' @@ -590,9 +919,6 @@ export async function installMainCapture( return record.samplePromise; }; const resetWorkerForCapture = (record: WorkerRecord): void => { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - } record.cancelPostedEpochMs = null; record.captureGeneration = state.captureGeneration; record.cpuFirst = null; @@ -620,28 +946,26 @@ export async function installMainCapture( record.requestPerformance = []; record.responseEpochMs = null; record.samplePromise = null; - record.sampleTimer = null; + record.samplingStarted = false; record.snapshotPath = null; record.terminatedEpochMs = null; }; - const startWorker = (record: WorkerRecord): void => { - if (!state.active) { + const startWorker = async (record: WorkerRecord): Promise => { + if (!state.active || state.measurementPhase !== 'measuring') { return; } if (record.captureGeneration !== state.captureGeneration) { resetWorkerForCapture(record); } - if (record.sampleTimer !== null) { + if (record.samplingStarted) { return; } + record.samplingStarted = true; record.elu = null; record.eluStart = record.worker.performance?.eventLoopUtilization() ?? null; - void sampleWorker(record); - record.sampleTimer = setInterval( - () => void sampleWorker(record), - 20 - ); + const initialSample = sampleWorker(record); + let profileReady: Promise = Promise.resolve(); if ( state.diagnostic && typeof record.worker.startCpuProfile === 'function' @@ -653,7 +977,7 @@ export async function installMainCapture( const profileHandle = record.worker.startCpuProfile(); const profileCaptureKey = record.profileCaptureKey; record.profileHandle = profileHandle; - void profileHandle.then( + profileReady = profileHandle.then( (handle) => { if ( record.profileCaptureKey === profileCaptureKey && @@ -662,15 +986,15 @@ export async function installMainCapture( record.resolvedProfileHandle = handle; } }, - () => undefined + (error: unknown) => { + throw error; + } ); } + await Promise.all([initialSample, profileReady]); }; const stopWorkerSampling = (record: WorkerRecord): void => { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - record.sampleTimer = null; - } + record.samplingStarted = false; }; const joinFinalWorkerSample = async ( record: WorkerRecord @@ -689,6 +1013,9 @@ export async function installMainCapture( if (!profileHandle || !record.profilePath) { return; } + if (record.profileResult !== null) { + return; + } const handle = record.resolvedProfileHandle ?? (waitForHandle ? await profileHandle : null); @@ -715,7 +1042,8 @@ export async function installMainCapture( } fs.writeFileSync( record.profilePath, - JSON.stringify(normalizeWorkerCpuProfile(record.profileResult)) + JSON.stringify(normalizeWorkerCpuProfile(record.profileResult)), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); record.profileHandle = null; record.profileResult = null; @@ -743,7 +1071,10 @@ export async function installMainCapture( const snapshot = await record.worker.getHeapSnapshot(); await streamPromises.pipeline( snapshot, - fs.createWriteStream(record.snapshotPath) + fs.createWriteStream(record.snapshotPath, { + flags: 'wx', + mode: 0o600, + }) ); }; const reportWorkerArtifactFailure = ( @@ -774,7 +1105,7 @@ export async function installMainCapture( record.finalizing ??= databaseWorkerPostGcFinalizationApi .finalize({ finalizationKey: record.finalizationKey, - joinFinalSample: () => joinFinalWorkerSample(record), + joinFinalSample: () => Promise.resolve(), probePostGc: () => selectionUnavailableReason === null ? databaseWorkerPostGcProbeApi.probe({ @@ -798,7 +1129,7 @@ export async function installMainCapture( }), reportAncillaryFailure: (stage, error) => reportWorkerArtifactFailure(record, stage, error), - stopProfile: () => stopWorkerProfile(record), + stopProfile: () => Promise.resolve(), stopSampling: () => stopWorkerSampling(record), ...(state.diagnostic ? { @@ -900,6 +1231,7 @@ export async function installMainCapture( ? databaseWorkerPostGcCutoffApi.observeDatabaseRequest() : null; if (requestDisposition === 'after-cutoff') { + state.lateEventCount += 1; state.timeline.push({ epochMs: nowEpochMs(), operation: @@ -922,7 +1254,27 @@ export async function installMainCapture( return; } const record = createWorkerRecord(this, kind); - startWorker(record); + if ( + state.active && + state.measurementPhase !== 'measuring' + ) { + invalidateCapture( + 'worker-request-before-measurement' + ); + } + if ( + state.active && + state.measurementPhase === 'measuring' && + kind === 'database.worker' && + state.captureOptions?.deferMeasurement === true && + (!isCurrentCaptureRecord(record) || + !record.samplingStarted) + ) { + invalidateCapture('database-worker-not-prearmed'); + } + void startWorker(record).catch(() => + invalidateCapture('worker-measurement-start-failed') + ); if ( isCurrentCaptureRecord(record) && kind === 'playlist-refresh.worker' @@ -954,8 +1306,13 @@ export async function installMainCapture( (payload as JsonRecord)['operationId'] ) : null; + const xtreamIdentity = + xtreamIpcMarkerCapture.matchDatabaseRequest( + value + ); const identity = - payloadOperationId === null + xtreamIdentity ?? + (payloadOperationId === null ? databaseRequestIdentityCapture.matchDatabaseRequest( value ) @@ -964,12 +1321,13 @@ export async function installMainCapture( operationId: payloadOperationId, operationIdUnavailableReason: null, sourceEpochMs: null, - }; + }); record.pendingCount += 1; dbRequests.set(value['requestId'], { identity, operation: value['operation'], playlistId: readDatabasePlaylistId(value), + progress: null, record, }); recordTimeline({ @@ -989,13 +1347,50 @@ export async function installMainCapture( value['type'] === 'cancel' && typeof value['operationId'] === 'string' ) { - const record = operationWorkers.get(value['operationId']); - if (record) { - record.cancelPostedEpochMs = nowEpochMs(); + const playlistRecord = operationWorkers.get( + value['operationId'] + ); + const cancelPostedEpochMs = nowEpochMs(); + if (playlistRecord) { + playlistRecord.cancelPostedEpochMs = + cancelPostedEpochMs; recordTimeline({ operationId: value['operationId'], type: 'playlist-cancel-posted', }); + } else { + const cancelIdentity = + xtreamIpcMarkerCapture.matchDatabaseCancel(value); + const matchingRequests = [ + ...dbRequests.entries(), + ].filter( + ([, request]) => + request.identity.operationId === + value['operationId'] + ); + const match = matchingRequests[0]; + if ( + matchingRequests.length === 1 && + match && + cancelIdentity + ) { + const [requestId, request] = match; + request.record.cancelPostedEpochMs = + cancelPostedEpochMs; + databaseWorkerCancelCapture.acceptDispatch({ + captureGeneration: state.captureGeneration, + ipcCallId: cancelIdentity.ipcCallId, + operationId: cancelIdentity.operationId, + postedEpochMs: cancelPostedEpochMs, + requestId, + sourceEpochMs: cancelIdentity.sourceEpochMs, + }); + } else { + recordTimeline({ + operationId: value['operationId'], + type: 'db-cancel-dispatch-correlation-invalid', + }); + } } } } @@ -1097,13 +1492,32 @@ export async function installMainCapture( state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); state.rendererWindowSession?.sample(); + for (const record of records.values()) { + if (record.samplingStarted && isCurrentCaptureRecord(record)) { + void sampleWorker(record); + } + } }; - const startCapture = async ( - options: MainCaptureStartOptions - ): Promise => { - state.rendererWindowSession?.detach(); - state.rendererWindowSession = null; - const rendererWindowSession = rendererWindowRssSessionApi.create({ + const beginMeasurement = async (): Promise => { + if ( + !state.active || + state.stopping || + state.measurementPhase !== 'armed' || + state.measurementStartedEpochMs !== null || + state.captureOptions === null + ) { + throw new Error('xtream-main-measurement-boundary-unavailable'); + } + const options = state.captureOptions; + const databaseRecords = options.deferMeasurement + ? [...records.values()].filter( + (record) => record.kind === 'database.worker' + ) + : []; + if (options.deferMeasurement && databaseRecords.length !== 1) { + throw new Error('xtream-database-worker-boundary-unavailable'); + } + state.rendererWindowSession = rendererWindowRssSessionApi.create({ browserWindowFromId: (browserWindowId) => BrowserWindow.fromId(browserWindowId), browserWindowId: options.rendererWindowIdentity.browserWindowId, @@ -1111,30 +1525,6 @@ export async function installMainCapture( rendererRssApi: rendererProcessRssApi, webContentsId: options.rendererWindowIdentity.webContentsId, }); - state.stopping = false; - state.captureGeneration += 1; - state.active = true; - databaseRequestIdentityCapture.start(); - dbRequests.clear(); - operationWorkers.clear(); - state.diagnostic = options.diagnostic; - state.outputDirectory = options.outputDirectory; - state.timeline = []; - state.mainPeakHeap = 0; - state.mainPeakRss = 0; - state.mainProfilePath = null; - state.mainSnapshotPath = null; - state.postGcHeap = null; - state.postGcRss = null; - state.rendererWindowSession = rendererWindowSession; - state.cpuStart = process.cpuUsage(); - state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); - state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ - resolution: 1, - }); - state.eventLoopDelay.enable(); - sampleMain(); - state.sampleTimer = setInterval(sampleMain, 20); if (state.diagnostic) { const session = new inspector.Session(); session.connect(); @@ -1150,6 +1540,64 @@ export async function installMainCapture( 'main.heapsnapshot' ); } + if (typeof runtimeProcess.threadCpuUsage !== 'function') { + throw new Error('electron-main-thread-cpu-unavailable'); + } + state.measurementStartedEpochMs = nowEpochMs(); + state.measurementPhase = 'measuring'; + state.cpuStart = runtimeProcess.threadCpuUsage(); + state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + for (const record of databaseRecords) { + await startWorker(record); + } + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + return state.measurementStartedEpochMs; + }; + const startCapture = async ( + options: MainCaptureStartOptions + ): Promise => { + const captureStartedEpochMs = nowEpochMs(); + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + state.stopping = false; + state.captureGeneration += 1; + state.active = true; + state.captureInvalidReasons = []; + state.captureOptions = { ...options }; + state.captureStartedEpochMs = captureStartedEpochMs; + state.measurementPhase = 'armed'; + state.measurementStartedEpochMs = null; + state.lateEventCount = 0; + databaseRequestIdentityCapture.start(); + databaseWorkerCancelCapture.start(state.captureGeneration); + xtreamIpcMarkerCapture.start( + options.rendererWindowIdentity.webContentsId, + captureStartedEpochMs + ); + dbRequests.clear(); + operationWorkers.clear(); + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.mainProfilePath = null; + state.mainSnapshotPath = null; + state.postGcHeap = null; + state.postGcRss = null; + state.cpuStart = null; + state.eventLoopStart = null; + state.eventLoopDelay = null; + if (!options.deferMeasurement) { + await beginMeasurement(); + } }; const api = { @@ -1196,12 +1644,47 @@ export async function installMainCapture( databaseWorkerPostGcCutoffApi.beginCapture(); await startCapture(options); }, + beginMeasurement: async (): Promise => beginMeasurement(), stop: async ( nextOptions?: MainCaptureStartOptions ): Promise => { + const captureCutoffEpochMs = nowEpochMs(); + const completedCaptureGeneration = state.captureGeneration; + const completedCaptureStartedEpochMs = + state.captureStartedEpochMs; + const completedMeasurementStartedEpochMs = + state.measurementStartedEpochMs; + if ( + !Number.isSafeInteger(completedCaptureGeneration) || + completedCaptureGeneration <= 0 || + completedCaptureStartedEpochMs === null || + state.measurementPhase !== 'measuring' || + completedMeasurementStartedEpochMs === null + ) { + throw new Error( + 'xtream-main-capture-generation-unavailable' + ); + } databaseWorkerPostGcCutoffApi.beginStop(); state.stopping = true; state.active = false; + state.measurementPhase = 'frozen'; + const operationOutcomes = readOperationOutcomes(); + const xtreamIpcSnapshot = xtreamIpcMarkerCapture.stop(); + const databaseWorkerCancelSnapshot = + databaseWorkerCancelCapture.stop(); + for (const reason of databaseWorkerCancelSnapshot.invalidReasons) { + state.timeline.push({ + epochMs: nowEpochMs(), + type: `database-worker-cancel-capture-invalid:${reason}`, + }); + } + for (const reason of xtreamIpcSnapshot.invalidReasons) { + state.timeline.push({ + epochMs: nowEpochMs(), + type: `xtream-capture-invalid:${reason}`, + }); + } for (const marker of databaseRequestIdentityCapture.takeSuccessMarkers()) { state.timeline.push({ epochMs: marker.sourceEpochMs, @@ -1223,6 +1706,9 @@ export async function installMainCapture( (record) => record.captureGeneration === state.captureGeneration ); + const currentDatabaseRecords = currentWorkerRecords.filter( + (record) => record.kind === 'database.worker' + ); for (const record of currentWorkerRecords) { stopWorkerSampling(record); } @@ -1235,7 +1721,9 @@ export async function installMainCapture( if (rendererWindow === null) { throw new Error('renderer-window-session-missing'); } - const cpu = process.cpuUsage(state.cpuStart ?? undefined); + const cpu = runtimeProcess.threadCpuUsage( + state.cpuStart ?? undefined + ); const eluEnd = perfHooks.performance.eventLoopUtilization(); const elu = perfHooks.performance.eventLoopUtilization( state.eventLoopStart ?? undefined @@ -1261,9 +1749,20 @@ export async function installMainCapture( ); fs.writeFileSync( state.mainProfilePath, - JSON.stringify(result['profile']) + JSON.stringify(result['profile']), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); } + await Promise.all( + currentDatabaseRecords.map((record) => + joinFinalWorkerSample(record) + ) + ); + await Promise.all( + currentDatabaseRecords.map((record) => + stopWorkerProfile(record) + ) + ); await Promise.all( currentWorkerRecords .filter( @@ -1280,9 +1779,6 @@ export async function installMainCapture( currentWorkerRecords, state.captureGeneration ); - const currentDatabaseRecords = currentWorkerRecords.filter( - (record) => record.kind === 'database.worker' - ); if (databaseSelection.selected) { await finalizeDatabaseWorker( databaseSelection.selected, @@ -1305,7 +1801,10 @@ export async function installMainCapture( state.postGcHeap = postGc.heapUsed; state.postGcRss = postGc.rss; if (state.diagnostic && state.mainSnapshotPath) { - const output = fs.createWriteStream(state.mainSnapshotPath); + const output = fs.createWriteStream( + state.mainSnapshotPath, + { flags: 'wx', mode: 0o600 } + ); const onChunk = (message: { params: { chunk: string } }) => output.write(message.params.chunk); session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); @@ -1397,6 +1896,13 @@ export async function installMainCapture( success: request.success, })), })); + state.timeline = performanceTimelineMergeApi.merge( + state.timeline, + [ + ...xtreamIpcSnapshot.timeline, + ...databaseWorkerCancelSnapshot.timeline, + ] + ); const transport: MainCaptureGenerationTransport = { captureGeneration: state.captureGeneration, metrics: { @@ -1430,6 +1936,25 @@ export async function installMainCapture( }, workers, }; + const captureStoppedEpochMs = nowEpochMs(); + const xtream = { + cancelCapture: databaseWorkerCancelSnapshot, + captureCutoffEpochMs, + captureGeneration: completedCaptureGeneration, + captureStartedEpochMs: completedCaptureStartedEpochMs, + captureStoppedEpochMs, + databaseWorkerCount: currentDatabaseRecords.length, + invalidReasons: [...state.captureInvalidReasons], + ipcCapture: xtreamIpcSnapshot, + lateDatabaseRequestCount: cutoff.lateRequestCount, + lateEventCount: state.lateEventCount, + measurementStartedEpochMs: + completedMeasurementStartedEpochMs, + operationOutcomes, + playlistRefreshWorkerCount: currentWorkerRecords.filter( + ({ kind }) => kind === 'playlist-refresh.worker' + ).length, + }; let rollover: MainCaptureRolloverStatus | null = null; if (nextOptions) { const databaseRecord = @@ -1475,13 +2000,425 @@ export async function installMainCapture( databaseWorkerPostGcCutoffApi.finishStop(); state.stopping = false; } - return { ...transport, rollover }; + return { ...transport, rollover, xtream }; + }, + xtreamStatus: (): XtreamMainCaptureStatus => { + if ( + !Number.isSafeInteger(state.captureGeneration) || + state.captureGeneration <= 0 || + state.captureStartedEpochMs === null + ) { + throw new Error( + 'xtream-main-capture-generation-unavailable' + ); + } + const cutoff = databaseWorkerPostGcCutoffApi.snapshot(); + const currentWorkers = [...records.values()].filter( + (record) => + record.captureGeneration === state.captureGeneration + ); + return { + active: state.active, + activeOperations: [...dbRequests.entries()] + .map(([requestId, request]) => ({ + operation: request.operation, + operationId: request.identity.operationId, + playlistId: request.playlistId, + progress: request.progress + ? { ...request.progress } + : null, + requestId, + })) + .sort( + (left, right) => + left.operation.localeCompare(right.operation) || + left.requestId.localeCompare(right.requestId) + ), + captureGeneration: state.captureGeneration, + captureStartedEpochMs: state.captureStartedEpochMs, + cutoffPhase: cutoff.phase, + databaseWorkerCount: currentWorkers.filter( + ({ kind }) => kind === 'database.worker' + ).length, + invalidReasons: [...state.captureInvalidReasons], + lateDatabaseRequestCount: cutoff.lateRequestCount, + lateEventCount: state.lateEventCount, + measurementPhase: state.measurementPhase, + measurementStartedEpochMs: state.measurementStartedEpochMs, + operationOutcomes: readOperationOutcomes(), + playlistRefreshWorkerCount: currentWorkers.filter( + ({ kind }) => kind === 'playlist-refresh.worker' + ).length, + }; }, }; target[input.stateKey] = api; }, captureStateKeys); } +const XTREAM_CAPTURE_OPERATION_PATTERN = /^DB_[A-Z0-9_]{1,124}$/; +const XTREAM_CAPTURE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const XTREAM_CAPTURE_REASON_PATTERN = /^[a-z0-9][a-z0-9:-]{0,159}$/; +const XTREAM_IPC_TIMELINE_TYPES = new Set([ + 'xtream-main-phase', + 'xtream-preload-marker', +]); +const XTREAM_CANCEL_TIMELINE_TYPES = new Set([ + 'db-cancel-dispatched', + 'db-cancel-received', + 'db-cancel-terminal-received', +]); + +function xtreamCaptureInvalid(kind: 'status' | 'transport'): never { + throw new Error(`xtream-main-capture-${kind}-invalid`); +} + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function snapshotXtreamReasons( + input: readonly string[], + kind: 'status' | 'transport' +): readonly string[] { + if ( + !Array.isArray(input) || + input.some( + (reason) => + typeof reason !== 'string' || + !XTREAM_CAPTURE_REASON_PATTERN.test(reason) + ) + ) { + xtreamCaptureInvalid(kind); + } + return Object.freeze([...input]); +} + +function snapshotXtreamOperationOutcomes( + input: readonly XtreamMainCaptureOperationOutcome[], + kind: 'status' | 'transport' +): readonly XtreamMainCaptureOperationOutcome[] { + if (!Array.isArray(input)) xtreamCaptureInvalid(kind); + const seen = new Set(); + const outcomes = input.map((entry) => { + if ( + typeof entry !== 'object' || + entry === null || + !XTREAM_CAPTURE_OPERATION_PATTERN.test(entry.operation) || + !['error', 'pending', 'success'].includes(entry.outcome) || + !Number.isSafeInteger(entry.count) || + entry.count <= 0 + ) { + xtreamCaptureInvalid(kind); + } + const key = `${entry.operation}:${entry.outcome}`; + if (seen.has(key)) xtreamCaptureInvalid(kind); + seen.add(key); + return Object.freeze({ ...entry }); + }); + return Object.freeze(outcomes); +} + +function snapshotXtreamProgress( + input: XtreamMainCaptureOperationProgress | null, + captureStartedEpochMs: number +): XtreamMainCaptureOperationProgress | null { + if (input === null) return null; + if ( + typeof input !== 'object' || + !isFinitePositive(input.epochMs) || + input.epochMs < captureStartedEpochMs || + !['cancelled', 'completed', 'error', 'progress', 'started'].includes( + input.status + ) || + !( + input.phase === null || /^[a-z][a-z0-9-]{0,127}$/.test(input.phase) + ) || + !( + input.current === null || + (Number.isFinite(input.current) && input.current >= 0) + ) || + !( + input.total === null || + (Number.isFinite(input.total) && input.total >= 0) + ) + ) { + xtreamCaptureInvalid('status'); + } + return Object.freeze({ ...input }); +} + +function snapshotXtreamStatus( + input: XtreamMainCaptureStatus +): XtreamMainCaptureStatus { + if ( + typeof input !== 'object' || + input === null || + typeof input.active !== 'boolean' || + !Number.isSafeInteger(input.captureGeneration) || + input.captureGeneration <= 0 || + !isFinitePositive(input.captureStartedEpochMs) || + !['armed', 'frozen', 'measuring'].includes(input.measurementPhase) || + !( + (input.measurementPhase === 'armed' && + input.measurementStartedEpochMs === null) || + (input.measurementPhase !== 'armed' && + isFinitePositive(input.measurementStartedEpochMs) && + input.measurementStartedEpochMs >= input.captureStartedEpochMs) + ) || + !['active', 'idle', 'stopping'].includes(input.cutoffPhase) || + !isNonNegativeSafeInteger(input.databaseWorkerCount) || + !isNonNegativeSafeInteger(input.playlistRefreshWorkerCount) || + !isNonNegativeSafeInteger(input.lateDatabaseRequestCount) || + !isNonNegativeSafeInteger(input.lateEventCount) || + input.lateEventCount !== input.lateDatabaseRequestCount || + !Array.isArray(input.activeOperations) + ) { + xtreamCaptureInvalid('status'); + } + const activeOperations = input.activeOperations.map((entry) => { + if ( + typeof entry !== 'object' || + entry === null || + !XTREAM_CAPTURE_OPERATION_PATTERN.test(entry.operation) || + !XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(entry.requestId) || + !( + entry.operationId === null || + XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(entry.operationId) + ) || + !( + entry.playlistId === null || + XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(entry.playlistId) + ) + ) { + xtreamCaptureInvalid('status'); + } + return Object.freeze({ + ...entry, + progress: snapshotXtreamProgress( + entry.progress, + input.captureStartedEpochMs + ), + }); + }); + return Object.freeze({ + ...input, + activeOperations: Object.freeze(activeOperations), + invalidReasons: snapshotXtreamReasons(input.invalidReasons, 'status'), + operationOutcomes: snapshotXtreamOperationOutcomes( + input.operationOutcomes, + 'status' + ), + }); +} + +function snapshotXtreamIpcTimeline( + input: XtreamIpcMarkerCaptureSnapshot, + startedEpochMs: number, + cutoffEpochMs: number +): readonly XtreamIpcTimelineRecord[] { + if (!Array.isArray(input.timeline)) xtreamCaptureInvalid('transport'); + return Object.freeze( + input.timeline.map((record) => { + if ( + typeof record !== 'object' || + record === null || + !XTREAM_IPC_TIMELINE_TYPES.has(record.type) || + !['end', 'start'].includes(record.boundary) || + !isFinitePositive(record.epochMs) || + record.epochMs < startedEpochMs || + record.epochMs > cutoffEpochMs + ) { + xtreamCaptureInvalid('transport'); + } + return Object.freeze({ ...record }); + }) + ); +} + +function snapshotXtreamCancelTimeline( + input: DatabaseWorkerCancelCaptureSnapshot, + startedEpochMs: number, + cutoffEpochMs: number +): readonly DatabaseWorkerCancelTimelineRecord[] { + if (!Array.isArray(input.timeline)) xtreamCaptureInvalid('transport'); + return Object.freeze( + input.timeline.map((record) => { + if ( + typeof record !== 'object' || + record === null || + !XTREAM_CANCEL_TIMELINE_TYPES.has(record.type) || + !isFinitePositive(record.epochMs) || + record.epochMs < startedEpochMs || + record.epochMs > cutoffEpochMs || + !Number.isSafeInteger(record.ipcCallId) || + record.ipcCallId <= 0 || + !XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(record.operationId) || + !XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(record.requestId) || + !isFinitePositive(record.sourceEpochMs) || + record.sourceEpochMs > record.epochMs + ) { + xtreamCaptureInvalid('transport'); + } + return Object.freeze({ ...record }); + }) + ); +} + +function snapshotXtreamResult( + input: XtreamMainCaptureTransport +): XtreamMainCaptureResult { + try { + const evidence = input.xtream; + if ( + !Number.isSafeInteger(input.captureGeneration) || + input.captureGeneration <= 0 || + evidence.captureGeneration !== input.captureGeneration || + !isFinitePositive(evidence.captureStartedEpochMs) || + !isFinitePositive(evidence.measurementStartedEpochMs) || + !isFinitePositive(evidence.captureCutoffEpochMs) || + !isFinitePositive(evidence.captureStoppedEpochMs) || + evidence.captureCutoffEpochMs < evidence.captureStartedEpochMs || + evidence.measurementStartedEpochMs < + evidence.captureStartedEpochMs || + evidence.captureCutoffEpochMs < + evidence.measurementStartedEpochMs || + evidence.captureStoppedEpochMs < evidence.captureCutoffEpochMs || + !isNonNegativeSafeInteger(evidence.databaseWorkerCount) || + !isNonNegativeSafeInteger(evidence.playlistRefreshWorkerCount) || + !isNonNegativeSafeInteger( + evidence.ipcCapture.quarantinedKeyCount + ) || + !isNonNegativeSafeInteger(evidence.lateDatabaseRequestCount) || + !isNonNegativeSafeInteger(evidence.lateEventCount) || + evidence.lateEventCount !== evidence.lateDatabaseRequestCount + ) { + xtreamCaptureInvalid('transport'); + } + const capture = selectMainCaptureGeneration(input); + const workers = Object.freeze([...capture.workers]); + const requests = Object.freeze( + workers.flatMap((worker) => worker.requests) + ); + if ( + workers.filter(({ kind }) => kind === 'database.worker').length !== + evidence.databaseWorkerCount || + workers.filter(({ kind }) => kind === 'playlist-refresh.worker') + .length !== evidence.playlistRefreshWorkerCount + ) { + xtreamCaptureInvalid('transport'); + } + const rollover = + input.rollover === null + ? null + : Object.freeze({ ...input.rollover }); + if ( + rollover !== null && + (typeof rollover.nextCaptureStarted !== 'boolean' || + !( + rollover.nextCaptureUnavailableReason === null || + typeof rollover.nextCaptureUnavailableReason === 'string' + )) + ) { + xtreamCaptureInvalid('transport'); + } + const operationOutcomes = snapshotXtreamOperationOutcomes( + evidence.operationOutcomes, + 'transport' + ); + const completedOutcomes = new Map(); + for (const worker of workers) { + if (worker.kind !== 'database.worker') continue; + for (const request of worker.requests) { + if ( + typeof request.operation !== 'string' || + !XTREAM_CAPTURE_OPERATION_PATTERN.test(request.operation) + ) { + xtreamCaptureInvalid('transport'); + } + const key = `${request.operation}:${ + request.success ? 'success' : 'error' + }`; + completedOutcomes.set( + key, + (completedOutcomes.get(key) ?? 0) + 1 + ); + } + } + const capturedCompletedOutcomes = new Map( + operationOutcomes + .filter(({ outcome }) => outcome !== 'pending') + .map(({ count, operation, outcome }) => [ + `${operation}:${outcome}`, + count, + ]) + ); + if ( + completedOutcomes.size !== capturedCompletedOutcomes.size || + [...completedOutcomes].some( + ([key, count]) => capturedCompletedOutcomes.get(key) !== count + ) + ) { + xtreamCaptureInvalid('transport'); + } + const ipcTimeline = snapshotXtreamIpcTimeline( + evidence.ipcCapture, + evidence.captureStartedEpochMs, + evidence.captureCutoffEpochMs + ); + const cancelTimeline = snapshotXtreamCancelTimeline( + evidence.cancelCapture, + evidence.captureStartedEpochMs, + evidence.captureCutoffEpochMs + ); + return Object.freeze({ + cancelTimeline, + capture: Object.freeze({ ...capture, workers }), + captureCutoffEpochMs: evidence.captureCutoffEpochMs, + captureGeneration: evidence.captureGeneration, + captureStartedEpochMs: evidence.captureStartedEpochMs, + captureStoppedEpochMs: evidence.captureStoppedEpochMs, + databaseWorkerCount: evidence.databaseWorkerCount, + invalidReasons: Object.freeze({ + capture: snapshotXtreamReasons( + evidence.invalidReasons, + 'transport' + ), + databaseWorkerCancel: snapshotXtreamReasons( + evidence.cancelCapture.invalidReasons, + 'transport' + ), + xtreamIpc: snapshotXtreamReasons( + evidence.ipcCapture.invalidReasons, + 'transport' + ), + }), + ipcTimeline, + lateDatabaseRequestCount: evidence.lateDatabaseRequestCount, + lateEventCount: evidence.lateEventCount, + measurementStartedEpochMs: evidence.measurementStartedEpochMs, + operationOutcomes, + playlistRefreshWorkerCount: evidence.playlistRefreshWorkerCount, + quarantinedIpcMarkerCount: evidence.ipcCapture.quarantinedKeyCount, + requests, + rollover, + workers, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-main-capture-transport-invalid' + ) { + throw error; + } + return xtreamCaptureInvalid('transport'); + } +} + export async function startMainCapture( electronApp: ElectronApplication, options: MainCaptureStartOptions @@ -1498,6 +2435,18 @@ export async function startMainCapture( ); } +export async function beginXtreamMainMeasurement( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + beginMeasurement(): Promise; + }; + return api.beginMeasurement(); + }, MAIN_CAPTURE_STATE_KEY); +} + export async function readMainCaptureStatus( electronApp: ElectronApplication ): Promise { @@ -1510,6 +2459,19 @@ export async function readMainCaptureStatus( }, MAIN_CAPTURE_STATE_KEY); } +export async function readXtreamMainCaptureStatus( + electronApp: ElectronApplication +): Promise { + const status = await electronApp.evaluate((_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + xtreamStatus(): XtreamMainCaptureStatus; + }; + return api.xtreamStatus(); + }, MAIN_CAPTURE_STATE_KEY); + return snapshotXtreamStatus(status); +} + export async function rolloverMainCapture( electronApp: ElectronApplication, options: MainCaptureStartOptions @@ -1552,3 +2514,46 @@ export async function stopMainCapture( ); return selectMainCaptureGeneration(transport); } + +export async function rolloverXtreamMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + const transport = await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + stop( + nextOptions?: MainCaptureStartOptions + ): Promise; + }; + return api.stop(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); + const result = snapshotXtreamResult(transport); + if (result.rollover === null) { + throw new Error('xtream-main-capture-rollover-status-missing'); + } + return result; +} + +export async function stopXtreamMainCapture( + electronApp: ElectronApplication +): Promise { + const transport = await electronApp.evaluate( + async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, + MAIN_CAPTURE_STATE_KEY + ); + const result = snapshotXtreamResult(transport); + if (result.rollover !== null) { + throw new Error('xtream-main-capture-stop-rollover-unexpected'); + } + return result; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts index a759db490..2c574b3aa 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -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, diff --git a/apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts b/apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts new file mode 100644 index 000000000..38dee2517 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts @@ -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' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts b/apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts new file mode 100644 index 000000000..727c7dc21 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts @@ -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 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts index dd471db9b..d6b32dd5f 100644 --- a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts @@ -2,11 +2,74 @@ 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 = (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([ { diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts index 15468b018..599062d9c 100644 --- a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts @@ -18,6 +18,107 @@ interface ParsedPerformancePhaseEvent { 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 { + 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[] { diff --git a/apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts b/apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts new file mode 100644 index 000000000..d913f8233 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts @@ -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> { + 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 { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-performance-artifact-') + ); + directories.push(directory); + return directory; +} diff --git a/apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts new file mode 100644 index 000000000..158e20ca5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts @@ -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) + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts new file mode 100644 index 000000000..2b13a3cbe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts @@ -0,0 +1,62 @@ +export interface PerformanceTimelineRecord { + readonly epochMs: number; +} + +export interface PerformanceTimelineMergeApi { + merge( + 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( + 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); +} diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts new file mode 100644 index 000000000..4331ac52c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts @@ -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 + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts index 1423da627..319dd71c2 100644 --- a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts @@ -2,9 +2,12 @@ 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: [ @@ -31,8 +34,20 @@ export function parseWorkerPerformancePhaseEvents( operation: string | null, input: unknown, workStartedEpochMs: number, - workEndedEpochMs: number -): readonly PerformancePhaseEvent[] | null { + workEndedEpochMs: number, + requestSucceeded: boolean +): readonly PerformancePhaseEvent[] | 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 || @@ -40,15 +55,11 @@ export function parseWorkerPerformancePhaseEvents( ? [] : null; } - if ( - !Array.isArray(input) || - input.length !== phaseSequence.length * 2 - ) { + if (!Array.isArray(input) || input.length !== phaseSequence.length * 2) { return null; } - const parsed: PerformancePhaseEvent[] = - []; + const parsed: PerformancePhaseEvent[] = []; let lastEpochMs = workStartedEpochMs; for (let index = 0; index < input.length; index += 1) { const raw = input[index]; @@ -81,6 +92,18 @@ export function parseWorkerPerformancePhaseEvents( ) { 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', diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts index 59bc4ecc6..b68b8023d 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -21,10 +21,7 @@ const validCapture = { workStartedEpochMs: 1_010, } as const; -function normalizeForOperation( - operation: string, - performanceCapture: unknown -) { +function normalizeForOperation(operation: string, performanceCapture: unknown) { return normalizeWorkerRequestPerformanceOutcome({ ipcCallId: 2, operation, @@ -40,7 +37,7 @@ function normalizeForOperation( } function normalize(performanceCapture: unknown) { - return normalizeForOperation('DB_GET_CONTENT', performanceCapture); + return normalizeForOperation('DB_HAS_CONTENT', performanceCapture); } function assertClosedCapture( @@ -313,10 +310,7 @@ test('requires the exact operation-specific worker phase sequence', () => { ); } - for (const operation of [ - 'DB_GET_APP_PLAYLIST', - 'DB_UPSERT_APP_PLAYLIST', - ]) { + for (const operation of ['DB_GET_APP_PLAYLIST', 'DB_UPSERT_APP_PLAYLIST']) { assertClosedCapture( normalizeForOperation(operation, validCapture), 'worker-performance-capture-invalid' @@ -325,3 +319,68 @@ test('requires the exact operation-specific worker phase sequence', () => { 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' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts index e5149e442..9a1134a92 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -55,7 +55,7 @@ function requestTransport( ) { return { ipcCallId: 7, - operation: 'DB_GET_CONTENT', + operation: 'DB_HAS_CONTENT', operationId: 'operation-42', operationIdUnavailableReason: null, performanceCapture, @@ -227,7 +227,7 @@ test('main capture retains raw request identity, timestamps, metrics, and reason assert.match(source, /ipcCallId: request\.identity\.ipcCallId/); assert.match(source, /identity: request\.identity/); assert.match(source, /performanceCapture:\s+message\['performance'\]/); - assert.match(source, /sourceEpochMs: request\.identity\.sourceEpochMs/); + assert.match(source, /sourceEpochMs:\s+request\.identity\.sourceEpochMs/); assert.match(source, /captureGeneration: state\.captureGeneration/); assert.doesNotMatch( source, diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts index 4a5cc00ac..cc20f5b2a 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -152,7 +152,8 @@ function parseEventLoopDelay( function parseWorkerPerformanceCapture( input: unknown, - operation: string | null + operation: string | null, + requestSucceeded: boolean ): ParsedWorkerPerformanceCapture | null { if (!isRecord(input)) { return null; @@ -203,7 +204,8 @@ function parseWorkerPerformanceCapture( operation, input['phaseEvents'], workStartedEpochMs, - workEndedEpochMs + workEndedEpochMs, + requestSucceeded ); if (phaseEvents === null) { return null; @@ -318,7 +320,8 @@ export function normalizeWorkerRequestPerformanceOutcome( const performanceCapture = parseWorkerPerformanceCapture( input.performanceCapture, - input.operation + input.operation, + input.success ); if (!performanceCapture) { return unavailable('worker-performance-capture-invalid'); diff --git a/apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts new file mode 100644 index 000000000..220712d6f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts @@ -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> = { + [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> = { + [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 }) + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts new file mode 100644 index 000000000..8770fa25d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts @@ -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 { + 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, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts b/apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts new file mode 100644 index 000000000..1ec8508a3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts @@ -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; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts new file mode 100644 index 000000000..9452f9d07 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts @@ -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 { + const serialized = serializeXtreamArtifactForPersistence(value, safety); + await writeXtreamExclusiveArtifact(absolutePath, serialized); +} + +export async function persistXtreamIteration( + iterationDirectory: string, + raw: XtreamRawIterationResult, + diagnosticEvidence: XtreamDiagnosticArtifactEvidence | null, + safety: XtreamArtifactSafetyContext +): Promise { + const iteration = await persistThenValidateXtreamIteration( + raw, + safety, + (serialized) => + writeXtreamExclusiveArtifact( + join(iterationDirectory, 'result.json'), + serialized + ) + ); + return Object.freeze({ diagnosticEvidence, iteration }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts new file mode 100644 index 000000000..8a2a4566b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts @@ -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>; + } + ), + 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)?.['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 { + 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'), + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts new file mode 100644 index 000000000..2be796140 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts @@ -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; + readonly captureBuildIdentity?: ( + workspaceRoot: string + ) => Promise; + readonly mkdir?: XtreamMkdir; + readonly readElectronVersion?: (workspaceRoot: string) => Promise; + 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 { + 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 = captureXtreamBuildIdentity +): Promise { + 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 { + 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; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts new file mode 100644 index 000000000..ee6a88c72 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts @@ -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() + ), + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts new file mode 100644 index 000000000..d01fa0c69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts @@ -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 +> = 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'; diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts new file mode 100644 index 000000000..c5a22b144 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts @@ -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/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts new file mode 100644 index 000000000..e07236227 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts @@ -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(values: readonly T[], index: number): T { + const value = values[index]; + assert.ok(value); + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts new file mode 100644 index 000000000..2f43572b6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts @@ -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 { + 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 { + 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; + readonly scenarioId: XtreamScenarioId; + readonly terminal: Promise; +} + +export async function assertXtreamSettlementAfterTerminal( + options: AssertXtreamSettlementAfterTerminalOptions +): Promise { + 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 { + readonly driverTerminal: Promise; + readonly rendererTerminal: Promise; + readonly stopMain: () => Promise; + readonly stopRenderer: () => Promise; +} + +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 +> { + 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 { + 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 { + 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 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts new file mode 100644 index 000000000..45d83ea69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts @@ -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; +} +export async function runXtreamBenchmarkIteration( + context: XtreamBenchmarkIterationContext +): Promise { + 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> | null = + null; + let failureStage: XtreamBenchmarkFailureStage = 'capture-setup'; + let stopMainCaptureOnce: + (() => ReturnType) | 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 + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts new file mode 100644 index 000000000..be7038c6d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts @@ -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(); + 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(); + + 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' + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts new file mode 100644 index 000000000..d41d35f49 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts @@ -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 Promise> + >; + readonly persist: (filename: string, value: unknown) => Promise; + 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( + capture: () => Promise +): () => Promise { + let attempt: Promise | null = null; + return () => { + attempt ??= Promise.resolve().then(capture); + return attempt; + }; +} + +export async function persistXtreamIterationFailureEvidence( + options: PersistXtreamIterationFailureEvidenceOptions +): Promise { + const status = {} as Record; + 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 { + try { + await persistXtreamIterationFailureEvidence(options); + } catch (persistenceFailure) { + throw new AggregateError( + [failure, persistenceFailure], + 'Xtream benchmark iteration and failure evidence persistence failed', + { cause: failure } + ); + } + throw failure; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts new file mode 100644 index 000000000..fc2dd445d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts @@ -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 & { + environment: Record; + fixture: Record; + memoryAccounting: Record; + runtime: Record; + source: Record; + } +): 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 { + 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(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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts new file mode 100644 index 000000000..9ceab658f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts @@ -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 { + 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(); + 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); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts new file mode 100644 index 000000000..99b8a3e42 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts @@ -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; + +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 { + 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 { + 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' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts new file mode 100644 index 000000000..ad770f04d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts @@ -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 { + return reportFixture(createXtreamIterationDefinitions(false), 10, true); +} + +export async function smokeReportFixture(): Promise { + return reportFixture(createXtreamIterationDefinitions(true), 100, false); +} + +export async function clearXtreamProvenanceFixtureDirectories(): Promise { + 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 { + 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 { + 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 { + 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)) + ) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts new file mode 100644 index 000000000..19c6c50f5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -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 { + 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 { + 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 +): Promise { + 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}`); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts new file mode 100644 index 000000000..9c1aeb0b8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts @@ -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(items: readonly T[], index: number): T { + const item = items[index]; + assert.ok(item); + return item; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts new file mode 100644 index 000000000..359bcf0d6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts @@ -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(); + +export async function persistThenValidateXtreamIteration( + raw: XtreamRawIterationResult, + safety: XtreamArtifactSafetyContext, + persistRaw: (serialized: string) => Promise +): Promise { + 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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts new file mode 100644 index 000000000..89a9ac283 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts @@ -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 = { + [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/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts new file mode 100644 index 000000000..7b31d227f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts @@ -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 { + 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; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts new file mode 100644 index 000000000..a5525fd67 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts @@ -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(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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts new file mode 100644 index 000000000..3b1b36fa5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts @@ -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 +): 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 { + 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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts new file mode 100644 index 000000000..35985e775 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts @@ -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 { + 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'), ''), + 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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts new file mode 100644 index 000000000..98d779c2e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts @@ -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 { + 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 { + const [javascript, sourceMap] = await Promise.all([ + readIdentity(workspaceRoot, javascriptPath), + readIdentity(workspaceRoot, `${javascriptPath}.map`), + ]); + return Object.freeze({ javascript, sourceMap }); +} + +async function readRenderer( + workspaceRoot: string +): Promise { + 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 { + 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 { + 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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts new file mode 100644 index 000000000..142797fd0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts @@ -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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts new file mode 100644 index 000000000..69b6b9691 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts @@ -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( + 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(['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> { + 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 = {}; + 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'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts new file mode 100644 index 000000000..af5fc6eaf --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts @@ -0,0 +1,283 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture'; +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForMethod, +} from './xtream-ipc-producer-identity'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamCancellationMetrics } from './xtream-summary-contract'; +import type { XtreamCancellationEvidence } from './xtream-summary-contract'; + +export function bindFixtureCancellationEvidence( + input: XtreamPhaseAttributionInput, + requests: readonly WorkerRequestPerformanceMetrics[] +): { + readonly cancellation: XtreamCancellationMetrics; + readonly cancellationEvidence: XtreamCancellationEvidence | null; + readonly phaseCapture: XtreamPhaseAttributionInput; +} { + if (input.scenarioId !== 'xtream-cancel-import') { + return { + cancellation: emptyCancellation(), + cancellationEvidence: null, + phaseCapture: input, + }; + } + const failed = requests.filter( + ({ operation, success }) => operation === 'DB_SAVE_CONTENT' && !success + ); + const request = failed[0]; + if ( + failed.length !== 1 || + !request || + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' || + request.workEndedEpochMs === null + ) { + fixtureError(); + } + const write = phaseSpan( + input.events, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + request.requestId + ); + const cancel = phaseSpan(input.events, PHASE.CANCEL_SESSION); + const clickEpochMs = write.startEpochMs + 0.25; + const networkPreloadStartEpochMs = clickEpochMs + 0.25; + const networkPreloadReturnEpochMs = cancel.endEpochMs + 0.25; + const preloadStartEpochMs = networkPreloadReturnEpochMs + 0.25; + const databaseDispatchEpochMs = preloadStartEpochMs + 0.5; + const preloadReturnEpochMs = databaseDispatchEpochMs + 0.5; + const workerReceiptEpochMs = preloadReturnEpochMs + 0.25; + const workerTerminalEpochMs = workerReceiptEpochMs + 0.25; + if ( + databaseDispatchEpochMs > write.endEpochMs || + workerTerminalEpochMs > request.workEndedEpochMs || + workerTerminalEpochMs > request.responseEpochMs + ) { + fixtureError(); + } + const authoritativeTerminalEpochMs = request.responseEpochMs; + const paintedEpochMs = input.uiPaintEpochMs; + const ipcSpans = resequenceFixtureIpcSpans( + input.ipcSpans.map((span) => { + if (span.method === 'xtreamCancelSession') { + return retime( + span, + networkPreloadStartEpochMs, + cancel.startEpochMs, + cancel.endEpochMs, + networkPreloadReturnEpochMs + ); + } + if (span.method === 'dbCancelOperation') { + return retime( + span, + preloadStartEpochMs, + databaseDispatchEpochMs - 0.25, + databaseDispatchEpochMs + 0.25, + preloadReturnEpochMs + ); + } + return span; + }) + ); + const databasePreload = ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbCancelOperation' + ); + if (!databasePreload) fixtureError(); + const cancelTimeline = cancellationTimeline( + request, + databasePreload, + databaseDispatchEpochMs, + workerReceiptEpochMs, + workerTerminalEpochMs + ); + return { + cancellation: { + acknowledgementLatencyMs: + authoritativeTerminalEpochMs - clickEpochMs, + authoritativeDatabaseAcknowledgementObserved: true, + authoritativeTerminalEpochMs, + clickEpochMs, + clickToDatabaseDispatchMs: databaseDispatchEpochMs - clickEpochMs, + clickToPaintMs: paintedEpochMs - clickEpochMs, + clickToPreloadReturnMs: preloadReturnEpochMs - clickEpochMs, + databaseDispatchEpochMs, + failedDatabaseRequestId: request.requestId, + failedOperationId: request.operationId, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: 'cancel-triggered-after-network-complete', + paintedEpochMs, + preloadReturnEpochMs, + preloadStartEpochMs, + workerReceiptEpochMs, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: workerReceiptEpochMs - clickEpochMs, + }, + cancellationEvidence: { timeline: cancelTimeline }, + phaseCapture: { ...input, ipcSpans }, + }; +} + +function cancellationTimeline( + request: WorkerRequestPerformanceMetrics, + databasePreload: XtreamIpcAttributionSpan, + dispatchEpochMs: number, + receiptEpochMs: number, + terminalEpochMs: number +): readonly DatabaseWorkerCancelTimelineRecord[] { + if ( + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' + ) { + fixtureError(); + } + const identity = { + ipcCallId: databasePreload.ipcCallId, + operationId: request.operationId, + requestId: request.requestId, + sourceEpochMs: databasePreload.sourceEpochMs, + }; + return [ + { + ...identity, + epochMs: dispatchEpochMs, + type: 'db-cancel-dispatched', + }, + { + ...identity, + epochMs: receiptEpochMs, + type: 'db-cancel-received', + }, + { + ...identity, + epochMs: terminalEpochMs, + type: 'db-cancel-terminal-received', + }, + ]; +} + +function emptyCancellation(): XtreamCancellationMetrics { + return { + acknowledgementLatencyMs: null, + authoritativeDatabaseAcknowledgementObserved: false, + authoritativeTerminalEpochMs: null, + clickEpochMs: null, + clickToDatabaseDispatchMs: null, + clickToPaintMs: null, + clickToPreloadReturnMs: null, + databaseDispatchEpochMs: null, + failedDatabaseRequestId: null, + failedOperationId: null, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: null, + paintedEpochMs: null, + preloadReturnEpochMs: null, + preloadStartEpochMs: null, + workerReceiptEpochMs: null, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: null, + }; +} + +function retime( + span: XtreamIpcAttributionSpan, + requestStartEpochMs: number, + requestEndEpochMs: number, + responseStartEpochMs: number, + responseEndEpochMs: number +): XtreamIpcAttributionSpan { + return { + ...span, + endEpochMs: + span.boundary === 'request' + ? requestEndEpochMs + : responseEndEpochMs, + startEpochMs: + span.boundary === 'request' + ? requestStartEpochMs + : responseStartEpochMs, + sourceEpochMs: requestStartEpochMs, + }; +} + +function resequenceFixtureIpcSpans( + spans: readonly XtreamIpcAttributionSpan[] +): readonly XtreamIpcAttributionSpan[] { + const groups = new Map(); + for (const span of spans) { + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + const calls = [...groups.entries()].map(([correlationId, call]) => { + const span = call[0]; + if ( + call.length !== 2 || + !span || + call.some( + (candidate) => + candidate.ipcCallId !== span.ipcCallId || + candidate.sourceEpochMs !== span.sourceEpochMs + ) + ) { + fixtureError(); + } + const namespace = xtreamIpcProducerNamespaceForMethod(span.method); + if (namespace === null) fixtureError(); + return { correlationId, namespace, span }; + }); + const reassigned = new Map(); + for (const namespace of Object.values(XTREAM_IPC_PRODUCER_NAMESPACE)) { + const selected = calls + .filter((call) => call.namespace === namespace) + .sort( + (left, right) => + left.span.sourceEpochMs - right.span.sourceEpochMs || + left.span.ipcCallId - right.span.ipcCallId + ); + const ids = selected + .map(({ span }) => span.ipcCallId) + .sort((left, right) => left - right); + if (new Set(ids).size !== ids.length) fixtureError(); + selected.forEach(({ correlationId }, index) => { + const ipcCallId = ids[index]; + if (!ipcCallId) fixtureError(); + reassigned.set(correlationId, ipcCallId); + }); + } + return spans.map((span) => { + const ipcCallId = reassigned.get(span.correlationId); + if (!ipcCallId) fixtureError(); + return { ...span, ipcCallId }; + }); +} + +function phaseSpan( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + correlationId?: string +): { readonly endEpochMs: number; readonly startEpochMs: number } { + 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) fixtureError(); + return { endEpochMs: end.epochMs, startEpochMs: start.epochMs }; +} + +function fixtureError(): never { + throw new Error('invalid Xtream cancellation fixture evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts new file mode 100644 index 000000000..aedbc2a2a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts @@ -0,0 +1,180 @@ +export interface XtreamCatalogDatabaseQuery { + readonly parameters: readonly (number | string)[]; + readonly sql: string; +} + +const CATEGORY_PARTITIONS = [ + { + categoryType: 'live', + expectedName: 'Performance Live %02d', + idBase: 91_100, + }, + { + categoryType: 'movies', + expectedName: 'Performance VOD %02d', + idBase: 92_100, + }, + { + categoryType: 'series', + expectedName: 'Performance Series %02d', + idBase: 93_100, + }, +] as const; + +const CONTENT_PARTITIONS = [ + { + categoryType: 'live', + contentType: 'live', + expectedTitle: 'Performance Live %06d', + idBase: 999_999, + }, + { + categoryType: 'movies', + contentType: 'movie', + expectedTitle: 'Performance Movie %06d', + idBase: 1_999_999, + }, + { + categoryType: 'series', + contentType: 'series', + expectedTitle: 'Performance Series %06d', + idBase: 2_999_999, + }, +] as const; + +const PLAYLIST_QUERY = ` + SELECT + COUNT(*) AS playlistCount, + COALESCE(SUM(CASE WHEN id = ? THEN 1 ELSE 0 END), 0) + AS matchingPlaylistCount, + MAX(CASE WHEN id = ? THEN type ELSE NULL END) + AS matchingPlaylistType + FROM playlists +`; + +const CATEGORY_COUNT_QUERY = ` + SELECT COUNT(*) AS categoryCount + FROM categories + WHERE playlist_id = ? +`; + +const CONTENT_COUNT_QUERY = ` + SELECT COUNT(*) AS contentCount + FROM content AS item + INNER JOIN categories AS category ON category.id = item.category_id + WHERE category.playlist_id = ? +`; + +const CATEGORY_PARTITION_QUERY = ` + SELECT + COUNT(*) AS count, + COUNT(DISTINCT xtream_id) AS distinctXtreamIdCount, + COALESCE( + SUM(CASE + WHEN name <> printf(?, xtream_id - ?) THEN 1 + ELSE 0 + END), + 0 + ) AS invalidNameCount, + MAX(xtream_id) AS maxXtreamId, + MIN(xtream_id) AS minXtreamId + FROM categories + WHERE playlist_id = ? AND type = ? +`; + +const CONTENT_PARTITION_QUERY = ` + SELECT + COUNT(*) AS count, + COUNT(DISTINCT item.xtream_id) AS distinctXtreamIdCount, + ( + SELECT COUNT(*) + FROM ( + SELECT expected_category.id + FROM categories AS expected_category + LEFT JOIN content AS expected_item + ON expected_item.category_id = expected_category.id + AND expected_item.type = ? + WHERE expected_category.playlist_id = ? + AND expected_category.type = ? + GROUP BY expected_category.id + HAVING COUNT(expected_item.id) <> 1000 + ) + ) AS invalidCategoryCardinalityCount, + COALESCE( + SUM(CASE + WHEN item.title <> printf(?, item.xtream_id - ?) THEN 1 + ELSE 0 + END), + 0 + ) AS invalidTitleCount, + MAX(item.xtream_id) AS maxXtreamId, + MIN(item.xtream_id) AS minXtreamId + FROM content AS item + INNER JOIN categories AS category ON category.id = item.category_id + WHERE category.playlist_id = ? + AND category.type = ? + AND item.type = ? +`; + +const IMPORT_STATUS_QUERY = ` + SELECT + (SELECT value FROM app_state WHERE key = ?) AS live, + (SELECT value FROM app_state WHERE key = ?) AS movie, + (SELECT value FROM app_state WHERE key = ?) AS series +`; + +export function buildXtreamCatalogDatabaseQueries( + playlistId: string +): readonly XtreamCatalogDatabaseQuery[] { + const queries: XtreamCatalogDatabaseQuery[] = [ + descriptor(PLAYLIST_QUERY, [playlistId, playlistId]), + descriptor(CATEGORY_COUNT_QUERY, [playlistId]), + descriptor(CONTENT_COUNT_QUERY, [playlistId]), + ]; + for (const partition of CATEGORY_PARTITIONS) { + queries.push( + descriptor(CATEGORY_PARTITION_QUERY, [ + partition.expectedName, + partition.idBase, + playlistId, + partition.categoryType, + ]) + ); + } + for (const partition of CONTENT_PARTITIONS) { + queries.push( + descriptor(CONTENT_PARTITION_QUERY, [ + partition.contentType, + playlistId, + partition.categoryType, + partition.expectedTitle, + partition.idBase, + playlistId, + partition.categoryType, + partition.contentType, + ]) + ); + } + queries.push( + descriptor(IMPORT_STATUS_QUERY, [ + importStatusKey(playlistId, 'live'), + importStatusKey(playlistId, 'movie'), + importStatusKey(playlistId, 'series'), + ]) + ); + return Object.freeze(queries); +} + +function descriptor( + sql: string, + parameters: readonly (number | string)[] +): XtreamCatalogDatabaseQuery { + return Object.freeze({ + parameters: Object.freeze([...parameters]), + sql, + }); +} + +function importStatusKey(playlistId: string, type: string): string { + return `xtream-import-status:${playlistId}:${type}`; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts new file mode 100644 index 000000000..518663049 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts @@ -0,0 +1,365 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + readXtreamCatalogDatabaseSnapshot, + type XtreamCatalogDatabaseEvaluator, +} from './xtream-catalog-database-reader'; + +interface TestStatement { + get(...parameters: readonly (number | string)[]): unknown; + run(...parameters: readonly (number | string)[]): unknown; +} + +interface TestDatabase { + close(): void; + exec(source: string): void; + prepare(source: string): TestStatement; +} + +interface TestDatabaseConstructor { + new (path: string): TestDatabase; +} + +interface RuntimeProcess { + getBuiltinModule(id: string): unknown; +} + +const { DatabaseSync } = ( + process as unknown as RuntimeProcess +).getBuiltinModule('node:sqlite') as { + readonly DatabaseSync: TestDatabaseConstructor; +}; + +describe('Xtream catalog database reader', () => { + it('reads the scoped catalog and import state after capture stops', async () => { + const database = createDatabase(); + seedPlaylist(database, 'playlist-1'); + seedPartition(database, { + categoryId: 91_101, + categoryName: 'Performance Live 01', + categoryType: 'live', + contentBase: 1_000_000, + contentType: 'live', + playlistId: 'playlist-1', + titleLabel: 'Live', + }); + seedPartition(database, { + categoryId: 92_101, + categoryName: 'Performance VOD 01', + categoryType: 'movies', + contentBase: 2_000_000, + contentType: 'movie', + playlistId: 'playlist-1', + titleLabel: 'Movie', + }); + seedPartition(database, { + categoryId: 93_101, + categoryName: 'Performance Series 01', + categoryType: 'series', + contentBase: 3_000_000, + contentType: 'series', + playlistId: 'playlist-1', + titleLabel: 'Series', + }); + seedStatuses(database, 'playlist-1', 'completed'); + const evaluator = evaluatorFor(database, 2_001); + + const snapshot = await readXtreamCatalogDatabaseSnapshot(evaluator, { + captureStoppedEpochMs: 2_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: '/workspace/dist/apps/electron-backend/main.js', + }); + + assert.deepEqual(snapshot, { + captureStoppedEpochMs: 2_000, + categories: { + live: categoryPartition(1, 91_101), + movies: categoryPartition(1, 92_101), + series: categoryPartition(1, 93_101), + }, + categoryCount: 3, + content: { + live: contentPartition(1_000, 1_000_000), + movie: contentPartition(1_000, 2_000_000), + series: contentPartition(1_000, 3_000_000), + }, + contentCount: 3_000, + databaseReadStartedEpochMs: 2_001, + importStatuses: { + live: 'completed', + movie: 'completed', + series: 'completed', + }, + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + }); + database.close(); + }); + + it('reports malformed fixture rows and incomplete category cardinality', async () => { + const database = createDatabase(); + seedPlaylist(database, 'playlist-1'); + database.exec(` + INSERT INTO categories (playlist_id, name, type, xtream_id) + VALUES ('playlist-1', 'wrong category', 'live', 91101); + INSERT INTO content (category_id, title, xtream_id, type) + VALUES (last_insert_rowid(), 'wrong title', 1000000, 'live'); + `); + seedStatuses(database, 'playlist-1', 'cancelled'); + + const snapshot = await readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 3_001), + { + captureStoppedEpochMs: 3_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: + '/workspace/dist/apps/electron-backend/main.js', + } + ); + + assert.equal(snapshot.categories.live.invalidNameCount, 1); + assert.equal(snapshot.content.live.invalidTitleCount, 1); + assert.equal(snapshot.content.live.invalidCategoryCardinalityCount, 1); + assert.deepEqual(snapshot.importStatuses, { + live: 'cancelled', + movie: 'cancelled', + series: 'cancelled', + }); + database.close(); + }); + + it('returns an empty deleted state without inventing a playlist type', async () => { + const database = createDatabase(); + seedStatuses(database, 'playlist-1', 'completed'); + + const snapshot = await readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 4_001), + { + captureStoppedEpochMs: 4_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: + '/workspace/dist/apps/electron-backend/main.js', + } + ); + + assert.equal(snapshot.categoryCount, 0); + assert.equal(snapshot.contentCount, 0); + assert.equal(snapshot.matchingPlaylistCount, 0); + assert.equal(snapshot.matchingPlaylistType, null); + assert.equal(snapshot.playlistCount, 0); + database.close(); + }); + + it('fails closed for pre-capture reads and malformed main-process results', async () => { + const database = createDatabase(); + const base = { + captureStoppedEpochMs: 5_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: '/workspace/dist/apps/electron-backend/main.js', + }; + + await assert.rejects( + readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 4_999), + base + ), + /invalid Xtream catalog database read/ + ); + await assert.rejects( + readXtreamCatalogDatabaseSnapshot( + { + evaluate: async () => ({ + databaseReadStartedEpochMs: 5_001, + rows: [], + }), + }, + base + ), + /invalid Xtream catalog database read/ + ); + await assert.rejects( + readXtreamCatalogDatabaseSnapshot(evaluatorFor(database, 5_001), { + ...base, + dataDirectory: 'relative', + }), + /invalid Xtream catalog database read/ + ); + database.close(); + }); + + it('accepts high-resolution fractional capture and database epochs', async () => { + const database = createDatabase(); + + const snapshot = await readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 6_000.75), + { + captureStoppedEpochMs: 6_000.5, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: + '/workspace/dist/apps/electron-backend/main.js', + } + ); + + assert.equal(snapshot.captureStoppedEpochMs, 6_000.5); + assert.equal(snapshot.databaseReadStartedEpochMs, 6_000.75); + database.close(); + }); + + it('uses the high-resolution epoch clock inside the main-process read boundary', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/xtream-catalog-database-reader.ts' + ), + 'utf8' + ); + + assert.match(source, /getBuiltinModule\(\s*'node:perf_hooks'\s*\)/u); + assert.match( + source, + /perfHooks\.performance\.timeOrigin\s*\+\s*perfHooks\.performance\.now\(\)/u + ); + assert.doesNotMatch(source, /Date\.now\(\)/u); + }); +}); + +interface QueryDescriptor { + readonly parameters: readonly (number | string)[]; + readonly sql: string; +} + +interface EvaluationInput { + readonly queries: readonly QueryDescriptor[]; +} + +function evaluatorFor( + database: TestDatabase, + databaseReadStartedEpochMs: number +): XtreamCatalogDatabaseEvaluator { + return { + evaluate: async (_pageFunction: unknown, value: unknown) => { + const input = value as unknown as EvaluationInput; + return { + databaseReadStartedEpochMs, + rows: input.queries.map((query) => + database.prepare(query.sql).get(...query.parameters) + ), + }; + }, + } as unknown as XtreamCatalogDatabaseEvaluator; +} + +function createDatabase(): TestDatabase { + const database = new DatabaseSync(':memory:'); + database.exec(` + CREATE TABLE playlists (id TEXT PRIMARY KEY, type TEXT NOT NULL); + CREATE TABLE app_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + xtream_id INTEGER NOT NULL + ); + CREATE TABLE content ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + title TEXT NOT NULL, + xtream_id INTEGER NOT NULL, + type TEXT NOT NULL + ); + `); + return database; +} + +function seedPlaylist(database: TestDatabase, playlistId: string): void { + database + .prepare('INSERT INTO playlists (id, type) VALUES (?, ?)') + .run(playlistId, 'xtream'); +} + +function seedStatuses( + database: TestDatabase, + playlistId: string, + status: string +): void { + const insert = database.prepare( + 'INSERT INTO app_state (key, value) VALUES (?, ?)' + ); + for (const type of ['live', 'movie', 'series']) { + insert.run(`xtream-import-status:${playlistId}:${type}`, status); + } +} + +function seedPartition( + database: TestDatabase, + options: { + readonly categoryId: number; + readonly categoryName: string; + readonly categoryType: string; + readonly contentBase: number; + readonly contentType: string; + readonly playlistId: string; + readonly titleLabel: string; + } +): void { + database + .prepare( + `INSERT INTO categories (playlist_id, name, type, xtream_id) + VALUES (?, ?, ?, ?)` + ) + .run( + options.playlistId, + options.categoryName, + options.categoryType, + options.categoryId + ); + const categoryRow = database + .prepare('SELECT id FROM categories WHERE playlist_id = ? AND type = ?') + .get(options.playlistId, options.categoryType) as { id: number }; + const insert = database.prepare( + `INSERT INTO content (category_id, title, xtream_id, type) + VALUES (?, ?, ?, ?)` + ); + for (let index = 0; index < 1_000; index += 1) { + insert.run( + categoryRow.id, + `Performance ${options.titleLabel} ${String(index + 1).padStart( + 6, + '0' + )}`, + options.contentBase + index, + options.contentType + ); + } +} + +function categoryPartition(count: number, id: number) { + return { + count, + distinctXtreamIdCount: count, + invalidNameCount: 0, + maxXtreamId: id, + minXtreamId: id, + }; +} + +function contentPartition(count: number, id: number) { + return { + count, + distinctXtreamIdCount: count, + invalidCategoryCardinalityCount: 0, + invalidTitleCount: 0, + maxXtreamId: id + count - 1, + minXtreamId: id, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts new file mode 100644 index 000000000..b50658b75 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts @@ -0,0 +1,302 @@ +import type { ElectronApplication } from '@playwright/test'; +import { isAbsolute, join } from 'node:path'; + +import type { XtreamCatalogDatabaseSnapshot } from './xtream-catalog-database-verification'; +import { + buildXtreamCatalogDatabaseQueries, + type XtreamCatalogDatabaseQuery, +} from './xtream-catalog-database-queries'; + +export type XtreamCatalogDatabaseEvaluator = Pick< + ElectronApplication, + 'evaluate' +>; + +export interface XtreamCatalogDatabaseReadOptions { + readonly captureStoppedEpochMs: number; + readonly dataDirectory: string; + readonly playlistId: string; + readonly requireFromPath: string; +} + +interface DatabaseReadInput { + readonly databasePath: string; + readonly queries: readonly XtreamCatalogDatabaseQuery[]; + readonly requireFromPath: string; +} + +interface DatabaseReadTransport { + readonly databaseReadStartedEpochMs: number; + readonly rows: readonly unknown[]; +} + +export async function readXtreamCatalogDatabaseSnapshot( + evaluator: XtreamCatalogDatabaseEvaluator, + options: XtreamCatalogDatabaseReadOptions +): Promise { + try { + assertOptions(options); + const queries = buildXtreamCatalogDatabaseQueries(options.playlistId); + const transport = (await evaluator.evaluate( + async (_electron, input: DatabaseReadInput) => { + interface SqliteStatement { + get(...parameters: readonly (number | string)[]): unknown; + } + interface SqliteDatabase { + close(): void; + pragma(source: string): unknown; + prepare(source: string): SqliteStatement; + } + interface SqliteConstructor { + new ( + path: string, + options: { + readonly: true; + fileMustExist: true; + } + ): SqliteDatabase; + } + interface NodeModuleBuiltin { + createRequire(path: string): (id: string) => unknown; + } + interface NodePerfHooksBuiltin { + readonly performance: { + now(): number; + readonly timeOrigin: number; + }; + } + + const perfHooks = process.getBuiltinModule( + 'node:perf_hooks' + ) as NodePerfHooksBuiltin; + const databaseReadStartedEpochMs = + perfHooks.performance.timeOrigin + + perfHooks.performance.now(); + const nodeModule = process.getBuiltinModule( + 'node:module' + ) as NodeModuleBuiltin; + const requireFromBuild = nodeModule.createRequire( + input.requireFromPath + ); + const Sqlite = requireFromBuild( + 'better-sqlite3' + ) as SqliteConstructor; + const database = new Sqlite(input.databasePath, { + readonly: true, + fileMustExist: true, + }); + try { + database.pragma('query_only = ON'); + return { + databaseReadStartedEpochMs, + rows: input.queries.map((query) => + database.prepare(query.sql).get(...query.parameters) + ), + }; + } finally { + database.close(); + } + }, + { + databasePath: join( + options.dataDirectory, + 'databases', + 'iptvnator.db' + ), + queries, + requireFromPath: options.requireFromPath, + } + )) as DatabaseReadTransport; + return snapshotTransport( + transport, + options.captureStoppedEpochMs, + queries.length + ); + } catch { + throw new Error('invalid Xtream catalog database read'); + } +} + +function snapshotTransport( + value: unknown, + captureStoppedEpochMs: number, + expectedRowCount: number +): XtreamCatalogDatabaseSnapshot { + const transport = exactRecord(value, [ + 'databaseReadStartedEpochMs', + 'rows', + ]); + const databaseReadStartedEpochMs = transport[ + 'databaseReadStartedEpochMs' + ] as unknown; + const rows = transport['rows']; + if ( + !isPositiveFinite(databaseReadStartedEpochMs) || + databaseReadStartedEpochMs < captureStoppedEpochMs || + !Array.isArray(rows) || + rows.length !== expectedRowCount + ) { + invalid(); + } + + const playlist = numericRecord(rows[0], [ + 'playlistCount', + 'matchingPlaylistCount', + 'matchingPlaylistType', + ]); + const categoryCount = singleCount(rows[1], 'categoryCount'); + const contentCount = singleCount(rows[2], 'contentCount'); + const categories = [rows[3], rows[4], rows[5]].map(categoryPartition); + const content = [rows[6], rows[7], rows[8]].map(contentPartition); + const importStatuses = statusRecord(rows[9]); + const matchingPlaylistType = playlist['matchingPlaylistType']; + if ( + matchingPlaylistType !== null && + typeof matchingPlaylistType !== 'string' + ) { + invalid(); + } + + return Object.freeze({ + captureStoppedEpochMs, + categories: Object.freeze({ + live: categories[0], + movies: categories[1], + series: categories[2], + }), + categoryCount, + content: Object.freeze({ + live: content[0], + movie: content[1], + series: content[2], + }), + contentCount, + databaseReadStartedEpochMs, + importStatuses, + matchingPlaylistCount: nonNegativeInteger( + playlist['matchingPlaylistCount'] + ), + matchingPlaylistType, + playlistCount: nonNegativeInteger(playlist['playlistCount']), + }); +} + +function categoryPartition(value: unknown) { + const row = numericRecord(value, [ + 'count', + 'distinctXtreamIdCount', + 'invalidNameCount', + 'maxXtreamId', + 'minXtreamId', + ]); + return Object.freeze({ + count: nonNegativeInteger(row['count']), + distinctXtreamIdCount: nonNegativeInteger(row['distinctXtreamIdCount']), + invalidNameCount: nonNegativeInteger(row['invalidNameCount']), + maxXtreamId: nullableInteger(row['maxXtreamId']), + minXtreamId: nullableInteger(row['minXtreamId']), + }); +} + +function contentPartition(value: unknown) { + const row = numericRecord(value, [ + 'count', + 'distinctXtreamIdCount', + 'invalidCategoryCardinalityCount', + 'invalidTitleCount', + 'maxXtreamId', + 'minXtreamId', + ]); + return Object.freeze({ + count: nonNegativeInteger(row['count']), + distinctXtreamIdCount: nonNegativeInteger(row['distinctXtreamIdCount']), + invalidCategoryCardinalityCount: nonNegativeInteger( + row['invalidCategoryCardinalityCount'] + ), + invalidTitleCount: nonNegativeInteger(row['invalidTitleCount']), + maxXtreamId: nullableInteger(row['maxXtreamId']), + minXtreamId: nullableInteger(row['minXtreamId']), + }); +} + +function singleCount(value: unknown, key: string): number { + return nonNegativeInteger(numericRecord(value, [key])[key]); +} + +function statusRecord(value: unknown) { + const row = exactRecord(value, ['live', 'movie', 'series']); + for (const key of ['live', 'movie', 'series']) { + if (row[key] !== null && typeof row[key] !== 'string') { + invalid(); + } + } + return Object.freeze({ + live: row['live'] as string | null, + movie: row['movie'] as string | null, + series: row['series'] as string | null, + }); +} + +function numericRecord( + value: unknown, + keys: readonly string[] +): Record { + return exactRecord(value, keys); +} + +function exactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record { + 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 nonNegativeInteger(value: unknown): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 + ) { + invalid(); + } + return value; +} + +function nullableInteger(value: unknown): number | null { + if (value === null) return null; + return nonNegativeInteger(value); +} + +function assertOptions(options: XtreamCatalogDatabaseReadOptions): void { + if ( + !isPositiveFinite(options.captureStoppedEpochMs) || + !isAbsolute(options.dataDirectory) || + !isAbsolute(options.requireFromPath) || + options.playlistId.length === 0 || + options.playlistId.length > 256 + ) { + invalid(); + } +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts new file mode 100644 index 000000000..dee01dcc3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { + assertXtreamCatalogDatabaseSnapshot, + createXtreamCatalogVerification, + type XtreamCatalogDatabaseSnapshot, +} from './xtream-catalog-database-verification'; + +describe('Xtream catalog database verification', () => { + it('accepts the exact synthetic catalog after import and refresh scenarios', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] as const) { + const snapshot = importedSnapshot(); + + assert.doesNotThrow(() => + assertXtreamCatalogDatabaseSnapshot( + snapshot, + manifest(), + scenarioId + ) + ); + assert.deepEqual( + createXtreamCatalogVerification( + snapshot, + manifest(), + scenarioId + ), + { + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: 100, + providerItemCount: 100_000, + verifiedAfterCapture: true, + } + ); + } + }); + + it('pins the current cancellation cleanup state and complete source deletion', () => { + assert.doesNotThrow(() => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + statuses: { + live: 'failed', + movie: 'failed', + series: 'failed', + }, + }), + manifest(), + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ) + ); + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + statuses: { + live: 'cancelled', + movie: 'cancelled', + series: 'cancelled', + }, + }), + manifest(), + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + /invalid Xtream database catalog state/ + ); + assert.doesNotThrow(() => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot(), + manifest(), + XTREAM_SCENARIO_ID.DELETE_LARGE + ) + ); + }); + + it('rejects partial, foreign, mis-associated, and non-terminal database state', () => { + const baseline = importedSnapshot(); + const invalid: XtreamCatalogDatabaseSnapshot[] = [ + { ...baseline, playlistCount: 2 }, + { + ...baseline, + categories: { + ...baseline.categories, + live: { + ...baseline.categories.live, + distinctXtreamIdCount: 59, + }, + }, + }, + { + ...baseline, + content: { + ...baseline.content, + movie: { + ...baseline.content.movie, + maxXtreamId: 2_019_998, + }, + }, + }, + { + ...baseline, + content: { + ...baseline.content, + series: { + ...baseline.content.series, + invalidCategoryCardinalityCount: 1, + }, + }, + }, + { + ...baseline, + content: { + ...baseline.content, + live: { + ...baseline.content.live, + invalidTitleCount: 1, + }, + }, + }, + { + ...baseline, + importStatuses: { + ...baseline.importStatuses, + movie: 'importing', + }, + }, + { + ...baseline, + databaseReadStartedEpochMs: baseline.captureStoppedEpochMs - 1, + }, + ]; + + for (const snapshot of invalid) { + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + snapshot, + manifest(), + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + /^Error: invalid Xtream database catalog state$/u + ); + } + }); + + it('rejects cancellation residue and a delete that left the source row behind', () => { + const cancelled = emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + statuses: { + live: 'failed', + movie: 'failed', + series: 'failed', + }, + }); + const cancelWithResidue: XtreamCatalogDatabaseSnapshot = { + ...cancelled, + content: { + ...cancelled.content, + live: { + ...cancelled.content.live, + count: 1, + distinctXtreamIdCount: 1, + maxXtreamId: 1_000_000, + minXtreamId: 1_000_000, + }, + }, + contentCount: 1, + }; + + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + cancelWithResidue, + manifest(), + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + /invalid Xtream database catalog state/ + ); + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + }), + manifest(), + XTREAM_SCENARIO_ID.DELETE_LARGE + ), + /invalid Xtream database catalog state/ + ); + }); + + it('uses one canonical fixture manifest snapshot', () => { + const expectedManifest = manifest(); + const reducedCounts = { + categories: { live: 1, series: 1, total: 3, vod: 1 }, + items: { live: 1, series: 1, total: 3, vod: 1 }, + }; + let countReads = 0; + const changingManifest = { + ...expectedManifest, + get counts() { + countReads += 1; + return countReads === 1 + ? expectedManifest.counts + : reducedCounts; + }, + }; + const reducedSnapshot: XtreamCatalogDatabaseSnapshot = { + ...importedSnapshot(), + categories: { + live: partition(1, 91_101), + movies: partition(1, 92_101), + series: partition(1, 93_101), + }, + categoryCount: 3, + content: { + live: contentPartition(1, 1_000_000), + movie: contentPartition(1, 2_000_000), + series: contentPartition(1, 3_000_000), + }, + contentCount: 3, + }; + + assert.throws( + () => + createXtreamCatalogVerification( + reducedSnapshot, + changingManifest, + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + /invalid Xtream database catalog state/ + ); + assert.equal(countReads, 1); + }); +}); + +function manifest(): XtreamPerformanceManifest { + return { + bytes: 12_345_678, + catalogSha256: 'a'.repeat(64), + counts: { + categories: { live: 60, series: 20, total: 100, vod: 20 }, + items: { + live: 60_000, + series: 20_000, + total: 100_000, + vod: 20_000, + }, + }, + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + }; +} + +function importedSnapshot(): XtreamCatalogDatabaseSnapshot { + return { + captureStoppedEpochMs: 1_000, + categories: { + live: partition(60, 91_101), + movies: partition(20, 92_101), + series: partition(20, 93_101), + }, + categoryCount: 100, + content: { + live: contentPartition(60_000, 1_000_000), + movie: contentPartition(20_000, 2_000_000), + series: contentPartition(20_000, 3_000_000), + }, + contentCount: 100_000, + databaseReadStartedEpochMs: 1_001, + importStatuses: { + live: 'completed', + movie: 'completed', + series: 'completed', + }, + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + }; +} + +function emptySnapshot( + options: { + matchingPlaylistCount?: number; + matchingPlaylistType?: string | null; + playlistCount?: number; + statuses?: XtreamCatalogDatabaseSnapshot['importStatuses']; + } = {} +): XtreamCatalogDatabaseSnapshot { + return { + captureStoppedEpochMs: 1_000, + categories: { + live: partition(0, null), + movies: partition(0, null), + series: partition(0, null), + }, + categoryCount: 0, + content: { + live: contentPartition(0, null), + movie: contentPartition(0, null), + series: contentPartition(0, null), + }, + contentCount: 0, + databaseReadStartedEpochMs: 1_001, + importStatuses: options.statuses ?? { + live: 'completed', + movie: 'completed', + series: 'completed', + }, + matchingPlaylistCount: options.matchingPlaylistCount ?? 0, + matchingPlaylistType: options.matchingPlaylistType ?? null, + playlistCount: options.playlistCount ?? 0, + }; +} + +function partition( + count: number, + minXtreamId: number | null +): XtreamCatalogDatabaseSnapshot['categories']['live'] { + return { + count, + distinctXtreamIdCount: count, + invalidNameCount: 0, + maxXtreamId: minXtreamId === null ? null : minXtreamId + count - 1, + minXtreamId, + }; +} + +function contentPartition( + count: number, + minXtreamId: number | null +): XtreamCatalogDatabaseSnapshot['content']['live'] { + return { + count, + distinctXtreamIdCount: count, + invalidCategoryCardinalityCount: 0, + invalidTitleCount: 0, + maxXtreamId: minXtreamId === null ? null : minXtreamId + count - 1, + minXtreamId, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts new file mode 100644 index 000000000..0467446e0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts @@ -0,0 +1,237 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + XTREAM_SCENARIO_ID, + type XtreamPerformanceManifest, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { parseXtreamManifest } from './xtream-control-state-schema'; + +interface XtreamCatalogPartition { + readonly count: number; + readonly distinctXtreamIdCount: number; + readonly invalidNameCount: number; + readonly maxXtreamId: number | null; + readonly minXtreamId: number | null; +} + +interface XtreamContentPartition { + readonly count: number; + readonly distinctXtreamIdCount: number; + readonly invalidCategoryCardinalityCount: number; + readonly invalidTitleCount: number; + readonly maxXtreamId: number | null; + readonly minXtreamId: number | null; +} + +export interface XtreamCatalogDatabaseSnapshot { + readonly captureStoppedEpochMs: number; + readonly categories: { + readonly live: XtreamCatalogPartition; + readonly movies: XtreamCatalogPartition; + readonly series: XtreamCatalogPartition; + }; + readonly categoryCount: number; + readonly content: { + readonly live: XtreamContentPartition; + readonly movie: XtreamContentPartition; + readonly series: XtreamContentPartition; + }; + readonly contentCount: number; + readonly databaseReadStartedEpochMs: number; + readonly importStatuses: { + readonly live: string | null; + readonly movie: string | null; + readonly series: string | null; + }; + readonly matchingPlaylistCount: number; + readonly matchingPlaylistType: string | null; + readonly playlistCount: number; +} + +export interface XtreamCatalogVerification { + readonly databaseStateValid: true; + readonly fixtureIdentityValid: true; + readonly providerCategoryCount: number; + readonly providerItemCount: number; + readonly verifiedAfterCapture: true; +} + +export function assertXtreamCatalogDatabaseSnapshot( + snapshot: unknown, + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): asserts snapshot is XtreamCatalogDatabaseSnapshot { + try { + const canonicalManifest = parseXtreamManifest(manifest); + assertCatalogSnapshot(snapshot, canonicalManifest, scenarioId); + } catch { + throw new Error('invalid Xtream database catalog state'); + } +} + +export function createXtreamCatalogVerification( + snapshot: unknown, + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): XtreamCatalogVerification { + try { + const canonicalManifest = parseXtreamManifest(manifest); + assertCatalogSnapshot(snapshot, canonicalManifest, scenarioId); + return Object.freeze({ + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: canonicalManifest.counts.categories.total, + providerItemCount: canonicalManifest.counts.items.total, + verifiedAfterCapture: true, + }); + } catch { + throw new Error('invalid Xtream database catalog state'); + } +} + +function assertCatalogSnapshot( + snapshot: unknown, + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): void { + const expected = expectedSnapshot(manifest, scenarioId); + const candidate = snapshotTopLevel(snapshot); + if ( + !isSnapshotTimingValid(candidate) || + !isDeepStrictEqual(withoutSnapshotTiming(candidate), expected) + ) { + invalid(); + } +} + +function expectedSnapshot( + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): XtreamCatalogDatabaseSnapshot { + if (!Object.values(XTREAM_SCENARIO_ID).includes(scenarioId)) { + invalid(); + } + const empty = isEmptyScenario(scenarioId); + const deleted = scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE; + const cancelled = scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT; + const expected = { + captureStoppedEpochMs: 1, + categories: { + live: categoryPartition( + empty ? 0 : manifest.counts.categories.live, + 91_101 + ), + movies: categoryPartition( + empty ? 0 : manifest.counts.categories.vod, + 92_101 + ), + series: categoryPartition( + empty ? 0 : manifest.counts.categories.series, + 93_101 + ), + }, + categoryCount: empty ? 0 : manifest.counts.categories.total, + content: { + live: contentPartition( + empty ? 0 : manifest.counts.items.live, + 1_000_000 + ), + movie: contentPartition( + empty ? 0 : manifest.counts.items.vod, + 2_000_000 + ), + series: contentPartition( + empty ? 0 : manifest.counts.items.series, + 3_000_000 + ), + }, + contentCount: empty ? 0 : manifest.counts.items.total, + databaseReadStartedEpochMs: 1, + importStatuses: { + live: cancelled ? 'failed' : 'completed', + movie: cancelled ? 'failed' : 'completed', + series: cancelled ? 'failed' : 'completed', + }, + matchingPlaylistCount: deleted ? 0 : 1, + matchingPlaylistType: deleted ? null : 'xtream', + playlistCount: deleted ? 0 : 1, + } satisfies XtreamCatalogDatabaseSnapshot; + return { + ...expected, + captureStoppedEpochMs: Number.NaN, + databaseReadStartedEpochMs: Number.NaN, + }; +} + +function snapshotTopLevel(value: unknown): XtreamCatalogDatabaseSnapshot { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(); + } + return { ...value } as XtreamCatalogDatabaseSnapshot; +} + +function isSnapshotTimingValid( + snapshot: XtreamCatalogDatabaseSnapshot +): boolean { + if ( + !isPositiveFinite(snapshot.captureStoppedEpochMs) || + !isPositiveFinite(snapshot.databaseReadStartedEpochMs) || + snapshot.databaseReadStartedEpochMs < snapshot.captureStoppedEpochMs + ) { + return false; + } + return true; +} + +function withoutSnapshotTiming( + snapshot: XtreamCatalogDatabaseSnapshot +): XtreamCatalogDatabaseSnapshot { + return { + ...snapshot, + captureStoppedEpochMs: Number.NaN, + databaseReadStartedEpochMs: Number.NaN, + }; +} + +function isEmptyScenario(scenarioId: XtreamScenarioId): boolean { + return ( + scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT || + scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE + ); +} + +function categoryPartition( + count: number, + firstXtreamId: number +): XtreamCatalogPartition { + return { + count, + distinctXtreamIdCount: count, + invalidNameCount: 0, + maxXtreamId: count === 0 ? null : firstXtreamId + count - 1, + minXtreamId: count === 0 ? null : firstXtreamId, + }; +} + +function contentPartition( + count: number, + firstXtreamId: number +): XtreamContentPartition { + return { + count, + distinctXtreamIdCount: count, + invalidCategoryCardinalityCount: 0, + invalidTitleCount: 0, + maxXtreamId: count === 0 ? null : firstXtreamId + count - 1, + minXtreamId: count === 0 ? null : firstXtreamId, + }; +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts new file mode 100644 index 000000000..e3d60142f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts @@ -0,0 +1,377 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { + XtreamControlState, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { createXtreamControlClient } from './xtream-control-client'; + +const TOKEN = 'never-persist-this-control-token'; +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: 100, + catalogSha256: + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', +}; +const STATE: XtreamControlState = { + epoch: 1, + prepared: MANIFEST, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], +}; + +describe('Xtream performance control client', () => { + it('exposes only fixed prepare, reset, and state operations', async () => { + const calls: RecordedCall[] = []; + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: fakeFetch(calls), + }); + + assert.deepEqual(Object.keys(client).sort(), [ + 'prepare', + 'resetAll', + 'resetObservations', + 'state', + ]); + assert.deepEqual(await client.prepare(), MANIFEST); + await client.resetObservations(); + await client.resetAll(); + assert.deepEqual(await client.state(), STATE); + + assert.deepEqual( + calls.map(({ body, method, path }) => ({ body, method, path })), + [ + { + body: { scenario: 'performance-100k' }, + method: 'POST', + path: '/__control/prepare', + }, + { + body: { mode: 'observations' }, + method: 'POST', + path: '/__control/reset', + }, + { + body: { mode: 'all' }, + method: 'POST', + path: '/__control/reset', + }, + { + body: null, + method: 'GET', + path: '/__control/state', + }, + ] + ); + assert.ok( + calls.every( + ({ headers, redirect }) => + redirect === 'error' && + headers.get('x-iptvnator-performance-token') === TOKEN + ) + ); + assert.equal( + calls.at(-1)?.headers.get('content-type'), + null, + 'GET must not carry a synthetic JSON body' + ); + }); + + it('keeps the token closure-only and redacts transport failures', async () => { + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async () => + new Response( + JSON.stringify({ + error: TOKEN, + password: 'performance', + }), + { status: 500 } + ), + }); + + assert.doesNotMatch(JSON.stringify(client), new RegExp(TOKEN)); + await assert.rejects( + () => client.prepare(), + (error: unknown) => + error instanceof Error && + /prepare failed with HTTP 500/.test(error.message) && + !error.message.includes(TOKEN) && + !error.message.includes('performance') + ); + }); + + it('rejects redirects and a response finalized at any second origin', async () => { + let forwardedToken = false; + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async (_input, init) => { + if (init?.redirect !== 'error') { + forwardedToken = new Headers(init?.headers).has( + 'x-iptvnator-performance-token' + ); + } + return responseAt( + MANIFEST, + 200, + 'http://127.0.0.1:43124/__control/prepare' + ); + }, + }); + + await assert.rejects( + () => client.prepare(), + /redirect|origin|transport failed/ + ); + assert.equal(forwardedToken, false); + }); + + it('validates control responses without echoing their unsafe payloads', async () => { + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async () => + responseAt( + { + username: 'performance', + secret: TOKEN, + }, + 200, + 'http://127.0.0.1:43123/__control/state' + ), + }); + + await assert.rejects( + () => client.state(), + (error: unknown) => + error instanceof Error && + /invalid control response/.test(error.message) && + !error.message.includes(TOKEN) && + !error.message.includes('performance') + ); + }); + + it('sanitizes adversarial response metadata accessors', async () => { + for (const property of ['ok', 'status', 'redirected', 'url'] as const) { + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async (input) => { + const response = { + json: async () => MANIFEST, + ok: property === 'status' ? false : true, + redirected: false, + status: 200, + url: String(input), + }; + Object.defineProperty(response, property, { + get() { + throw new Error(TOKEN); + }, + }); + return response as Response; + }, + }); + + await assert.rejects( + () => client.prepare(), + (error: unknown) => + error instanceof Error && + /invalid response metadata/.test(error.message) && + !error.message.includes(TOKEN) + ); + } + }); + + it('sanitizes reset response Proxy failures', async () => { + const resetBody = new Proxy( + {}, + { + ownKeys() { + throw new Error(TOKEN); + }, + } + ); + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async (input) => + ({ + json: async () => resetBody, + ok: true, + redirected: false, + status: 200, + url: String(input), + }) as Response, + }); + + await assert.rejects( + () => client.resetAll(), + (error: unknown) => + error instanceof Error && + error.message === 'Xtream all reset invalid control response' && + !error.message.includes(TOKEN) + ); + }); + + it('rejects non-loopback origins and empty tokens before issuing I/O', () => { + let calls = 0; + const fetchImpl: typeof fetch = async () => { + calls += 1; + return new Response(); + }; + + assert.throws( + () => + createXtreamControlClient({ + origin: 'https://provider.example', + token: TOKEN, + fetchImpl, + }), + /exact Xtream HTTP loopback origin/ + ); + assert.throws( + () => + createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: '', + fetchImpl, + }), + /non-empty control token/ + ); + assert.equal(calls, 0); + }); + + it('snapshots the validated origin and token before mutable getters can change them', async () => { + const calls: RecordedCall[] = []; + let currentOrigin = 'http://127.0.0.1:43123'; + let currentToken = TOKEN; + let originReads = 0; + let tokenReads = 0; + const client = createXtreamControlClient({ + fetchImpl: fakeFetch(calls), + get origin() { + originReads += 1; + return currentOrigin; + }, + get token() { + tokenReads += 1; + return currentToken; + }, + }); + + currentOrigin = 'https://provider.example'; + currentToken = 'attacker-controlled-token'; + await client.prepare(); + + assert.equal(originReads, 1); + assert.equal(tokenReads, 1); + assert.equal(calls[0]?.path, '/__control/prepare'); + assert.equal( + calls[0]?.headers.get('x-iptvnator-performance-token'), + TOKEN + ); + }); + + it('rejects invalid runtime token values before issuing I/O', () => { + let calls = 0; + const fetchImpl: typeof fetch = async () => { + calls += 1; + return new Response(); + }; + + for (const token of [ + null, + undefined, + 42, + {}, + ' ', + 'line\r\nbreak', + 'x'.repeat(257), + ]) { + assert.throws( + () => + createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token, + fetchImpl, + } as unknown as Parameters< + typeof createXtreamControlClient + >[0]), + /non-empty control token/ + ); + } + assert.equal(calls, 0); + }); +}); + +interface RecordedCall { + readonly body: Record | null; + readonly headers: Headers; + readonly method: string; + readonly path: string; + readonly redirect: RequestRedirect | undefined; +} + +function fakeFetch(calls: RecordedCall[]): typeof fetch { + return async (input, init) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + const method = init?.method ?? 'GET'; + const body = + typeof init?.body === 'string' + ? (JSON.parse(init.body) as Record) + : null; + calls.push({ + body, + headers: new Headers(init?.headers), + method, + path: url.pathname, + redirect: init?.redirect, + }); + if (url.pathname.endsWith('/prepare')) { + return responseAt(MANIFEST, 200, url.href); + } + if (url.pathname.endsWith('/reset')) { + return responseAt( + { + epoch: MANIFEST.epoch, + mode: body?.['mode'], + }, + 200, + url.href + ); + } + return responseAt(STATE, 200, url.href); + }; +} + +function responseAt(value: unknown, status: number, url: string): Response { + const response = Response.json(value, { status }); + Object.defineProperty(response, 'url', { value: url }); + return response; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-client.ts b/apps/electron-backend-e2e/src/performance/xtream-control-client.ts new file mode 100644 index 000000000..72184cdaa --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-client.ts @@ -0,0 +1,273 @@ +import type { + XtreamControlState, + XtreamPerformanceManifest, + XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + assertXtreamArtifactRedacted, + assertXtreamManifest, + assertXtreamManifestIdentity, +} from './xtream-diagnostic-artifacts'; +import { + parseXtreamControlState, + parseXtreamManifest, +} from './xtream-control-state-schema'; +import { assertXtreamScenarioObservations } from './xtream-control-observation-contract'; + +export { + assertXtreamArtifactRedacted, + assertXtreamManifest, + assertXtreamManifestIdentity, + parseXtreamControlState, + parseXtreamManifest, +}; + +export interface XtreamControlClientOptions { + readonly fetchImpl?: typeof fetch; + readonly origin: string; + readonly token: string; +} + +export interface XtreamControlClient { + prepare(): Promise; + resetAll(): Promise; + resetObservations(): Promise; + state(): Promise; +} + +export function createXtreamControlClient( + options: XtreamControlClientOptions +): XtreamControlClient { + let origin: string; + let token: string; + let configuredFetch: typeof fetch | undefined; + try { + origin = options.origin; + token = options.token; + configuredFetch = options.fetchImpl; + } catch { + throw new Error('Xtream benchmark control options are invalid'); + } + assertXtreamMockOrigin(origin); + if (!isValidControlToken(token)) { + throw new Error('Xtream benchmark requires a non-empty control token'); + } + const fetchImpl = configuredFetch ?? globalThis.fetch; + const request = async ( + path: '/__control/prepare' | '/__control/reset' | '/__control/state', + label: string, + body?: Readonly> + ): Promise => { + let response: Response; + const requestUrl = `${origin}${path}`; + try { + response = await fetchImpl(requestUrl, { + method: body ? 'POST' : 'GET', + redirect: 'error', + headers: { + 'x-iptvnator-performance-token': token, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch { + throw new Error(`Xtream control ${label} transport failed`); + } + const metadata = snapshotControlResponse(response, label); + if (!metadata.ok) { + throw new Error( + `Xtream control ${label} failed with HTTP ${metadata.status}` + ); + } + if (metadata.redirected || metadata.url !== requestUrl) { + throw new Error( + `Xtream control ${label} redirect or origin rejected` + ); + } + try { + return (await Reflect.apply( + metadata.json, + response, + [] + )) as unknown; + } catch { + throw new Error(`Xtream ${label} invalid control response`); + } + }; + const reset = async (mode: 'all' | 'observations'): Promise => { + const value = await request('/__control/reset', `${mode} reset`, { + mode, + }); + try { + if (!isRecordWithKeys(value, ['epoch', 'mode'])) { + throw new Error('invalid reset response'); + } + const epoch = Reflect.get(value, 'epoch'); + const responseMode = Reflect.get(value, 'mode'); + if (!isPositiveSafeInteger(epoch) || responseMode !== mode) { + throw new Error('invalid reset response'); + } + } catch { + throw new Error(`Xtream ${mode} reset invalid control response`); + } + }; + return Object.freeze({ + async prepare() { + const value = await request('/__control/prepare', 'prepare', { + scenario: 'performance-100k', + }); + try { + return parseXtreamManifest(value); + } catch { + throw new Error('Xtream prepare invalid control response'); + } + }, + resetAll: () => reset('all'), + resetObservations: () => reset('observations'), + async state() { + const value = await request('/__control/state', 'state'); + try { + return parseXtreamControlState(value); + } catch { + throw new Error('Xtream state invalid control response'); + } + }, + }); +} + +export function assertXtreamMockOrigin(value: unknown): void { + if (typeof value !== 'string') throw invalidOrigin(); + let url: URL; + try { + url = new URL(value); + } catch { + throw invalidOrigin(); + } + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port.length === 0 || + url.username.length > 0 || + url.password.length > 0 || + url.pathname !== '/' || + url.search.length > 0 || + url.hash.length > 0 || + value !== url.origin + ) { + throw invalidOrigin(); + } +} + +function snapshotControlResponse( + response: Response, + label: string +): { + readonly json: () => Promise; + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly url: string; +} { + try { + const json = Reflect.get(response, 'json') as unknown; + const ok = Reflect.get(response, 'ok') as unknown; + const redirected = Reflect.get(response, 'redirected') as unknown; + const status = Reflect.get(response, 'status') as unknown; + const url = Reflect.get(response, 'url') as unknown; + if ( + typeof json !== 'function' || + typeof ok !== 'boolean' || + typeof redirected !== 'boolean' || + !Number.isSafeInteger(status) || + Number(status) < 100 || + Number(status) > 599 || + typeof url !== 'string' + ) { + throw new Error('invalid response metadata'); + } + return { + json: json as () => Promise, + ok, + redirected, + status: Number(status), + url, + }; + } catch { + throw new Error(`Xtream control ${label} invalid response metadata`); + } +} + +export function assertXtreamControlReadyAfterObservationReset( + input: unknown, + expectedManifest: XtreamPerformanceManifest +): asserts input is XtreamControlState { + try { + const state = parseXtreamControlState(input); + assertPreparedState(state, expectedManifest); + if (state.occurrences.length !== 0 || state.ledger.length !== 0) { + throw new Error('observations remain'); + } + } catch { + throw new Error( + 'invalid Xtream mock readiness after observation reset' + ); + } +} + +export function assertXtreamControlStateForScenario( + scenarioId: XtreamScenarioId, + input: unknown, + expectedManifest: XtreamPerformanceManifest +): asserts input is XtreamControlState { + try { + const state = parseXtreamControlState(input); + assertPreparedState(state, expectedManifest); + assertXtreamScenarioObservations(scenarioId, state); + } catch { + throw new Error('invalid Xtream mock observations'); + } +} + +function assertPreparedState( + state: XtreamControlState, + manifest: XtreamPerformanceManifest +): void { + if ( + state.prepared === null || + state.epoch !== manifest.epoch || + state.prepared.epoch !== manifest.epoch || + state.rules.length !== 0 || + state.heldCount !== 0 || + state.heldIds.length !== 0 + ) { + throw new Error('state residue'); + } + assertXtreamManifestIdentity(manifest, state.prepared); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isRecordWithKeys( + value: unknown, + keys: readonly string[] +): value is Record { + return ( + isRecord(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isValidControlToken(value: unknown): value is string { + return typeof value === 'string' && /^[\x21-\x7e]{1,256}$/u.test(value); +} + +function invalidOrigin(): Error { + return new Error('mock must use the exact Xtream HTTP loopback origin'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts new file mode 100644 index 000000000..5953c0e87 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamControlState, + type XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { assertXtreamControlStateForScenario } from './xtream-control-client'; + +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', +}; + +const FULL_ACTIONS = [ + 'get_account_info', + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', +] as const; + +describe('Xtream mock observation contract', () => { + it('models exact account-info multiplicity for every measured window', () => { + assert.doesNotThrow(() => + validate( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + controlState(FULL_ACTIONS, 2) + ) + ); + assert.doesNotThrow(() => + validate( + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + controlState(FULL_ACTIONS.slice(0, 5), 2) + ) + ); + for (const scenarioId of [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] as const) { + assert.doesNotThrow(() => + validate(scenarioId, controlState(FULL_ACTIONS, 2, true)) + ); + } + assert.doesNotThrow(() => + validate(XTREAM_SCENARIO_ID.DELETE_LARGE, controlState([], 0)) + ); + }); + + it('rejects account-info multiplicity from a different scenario window', () => { + for (const [scenarioId, state] of [ + [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + controlState(FULL_ACTIONS, 1), + ], + [ + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + controlState(FULL_ACTIONS.slice(0, 5), 1), + ], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, controlState(FULL_ACTIONS, 1)], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, controlState(FULL_ACTIONS, 1)], + ] as const) { + assert.throws( + () => validate(scenarioId, state), + /invalid Xtream mock observations/ + ); + } + + for (const scenarioId of [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] as const) { + assert.throws( + () => validate(scenarioId, controlState(FULL_ACTIONS, 2)), + /invalid Xtream mock observations/ + ); + } + }); + + it('allows account overlap but requires complete route-account causality', () => { + const exact = controlState(FULL_ACTIONS, 2); + const duplicateArrival = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 2 && + entry.status === 'responded' + ? { ...entry, status: 'arrived' } + : entry + ), + }; + const overlappingAccounts = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 1 && + entry.status === 'responded' + ? { ...entry, atMs: 25 } + : entry + ), + }; + const routeAccountAfterCategories = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 2 && + entry.status === 'responded' + ? { ...entry, atMs: 35 } + : entry + ), + }; + const reversedAccountStarts = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 2 && + entry.status === 'arrived' + ? { ...entry, atMs: 9 } + : entry + ), + }; + + assert.doesNotThrow(() => + validate( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + overlappingAccounts + ) + ); + for (const invalid of [ + duplicateArrival, + reversedAccountStarts, + routeAccountAfterCategories, + ]) { + assert.throws( + () => + validate(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, invalid), + /invalid Xtream mock observations/ + ); + } + }); +}); + +function validate( + scenarioId: Parameters[0], + state: XtreamControlState +): void { + assertXtreamControlStateForScenario(scenarioId, state, MANIFEST); +} + +function controlState( + actions: readonly string[], + accountCount: number, + routeAccountAfterContent = false +): XtreamControlState { + const counts = new Map( + actions.map((action) => [ + action, + action === 'get_account_info' ? accountCount : 1, + ]) + ); + let order = 1; + return { + epoch: MANIFEST.epoch, + prepared: MANIFEST, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [...counts].map(([action, count]) => ({ + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId: 'all', + count, + })), + ledger: [...counts].flatMap(([action, count]) => + Array.from({ length: count }, (_, index) => { + const routeAccount = + routeAccountAfterContent && + action === 'get_account_info' && + index === 1; + const arrived = routeAccount ? 1_000 : order++ * 10; + return [ + { + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId: 'all', + epoch: MANIFEST.epoch, + occurrence: index + 1, + status: 'arrived', + atMs: arrived, + }, + { + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId: 'all', + epoch: MANIFEST.epoch, + occurrence: index + 1, + status: 'responded', + atMs: arrived + 1, + }, + ]; + }).flat() + ), + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts new file mode 100644 index 000000000..5d43a04c3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts @@ -0,0 +1,204 @@ +import { + XTREAM_SCENARIO_ID, + type XtreamControlState, + type XtreamLedgerEntry, + type XtreamOccurrence, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; + +const FULL_ACTIONS = [ + 'get_account_info', + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', +] as const; +const CATEGORY_ACTIONS = FULL_ACTIONS.slice(1, 4); + +interface ExpectedAction { + readonly action: string; + readonly count: number; +} + +interface RequestLifecycle { + readonly arrived: number; + readonly responded: number; +} + +export function assertXtreamScenarioObservations( + scenarioId: XtreamScenarioId, + state: XtreamControlState +): void { + const expected = expectedActions(scenarioId); + if ( + state.occurrences.length !== expected.length || + state.ledger.length !== + expected.reduce((total, entry) => total + entry.count * 2, 0) + ) { + throw new Error('action cardinality'); + } + + const lifecycle = new Map(); + for (const expectedAction of expected) { + const occurrences = state.occurrences.filter( + (entry) => entry.action === expectedAction.action + ); + const entries = state.ledger.filter( + (entry) => entry.action === expectedAction.action + ); + if ( + occurrences.length !== 1 || + !isExpectedOccurrence(occurrences[0], expectedAction.count) || + entries.length !== expectedAction.count * 2 + ) { + throw new Error('action occurrence'); + } + + const requests = Array.from( + { length: expectedAction.count }, + (_, index) => requestLifecycle(entries, state.epoch, index + 1) + ); + assertOccurrenceStartOrder(requests); + lifecycle.set(expectedAction.action, requests); + } + if ( + state.occurrences.some( + (entry) => !expected.some(({ action }) => action === entry.action) + ) || + state.ledger.some( + (entry) => !expected.some(({ action }) => action === entry.action) + ) + ) { + throw new Error('unexpected action'); + } + assertActionOrder(scenarioId, expected, lifecycle); +} + +function expectedActions( + scenarioId: XtreamScenarioId +): readonly ExpectedAction[] { + if (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) return []; + const actions = + scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT + ? FULL_ACTIONS.slice(0, 5) + : FULL_ACTIONS; + return actions.map((action) => ({ + action, + count: action === 'get_account_info' ? 2 : 1, + })); +} + +function requestLifecycle( + entries: readonly XtreamLedgerEntry[], + epoch: number, + occurrence: number +): RequestLifecycle { + const requestEntries = entries.filter( + (entry) => entry.occurrence === occurrence + ); + const arrived = requestEntries.find((entry) => entry.status === 'arrived'); + const responded = requestEntries.find( + (entry) => entry.status === 'responded' + ); + if ( + requestEntries.length !== 2 || + !isExpectedLedger(arrived, epoch, occurrence) || + !isExpectedLedger(responded, epoch, occurrence) || + arrived.status !== 'arrived' || + responded.status !== 'responded' || + arrived.atMs > responded.atMs + ) { + throw new Error('action lifecycle'); + } + return { arrived: arrived.atMs, responded: responded.atMs }; +} + +function assertOccurrenceStartOrder( + requests: readonly RequestLifecycle[] +): void { + for (let index = 1; index < requests.length; index += 1) { + if ( + (requests[index - 1]?.arrived ?? Number.POSITIVE_INFINITY) > + (requests[index]?.arrived ?? Number.NEGATIVE_INFINITY) + ) { + throw new Error('occurrence start order'); + } + } +} + +function assertActionOrder( + scenarioId: XtreamScenarioId, + expected: readonly ExpectedAction[], + lifecycle: ReadonlyMap +): void { + if (expected.length === 0) return; + const accounts = lifecycle.get('get_account_info'); + const routeHydratesAfterImport = + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const account = routeHydratesAfterImport ? accounts?.[0] : accounts?.at(-1); + const liveCategory = lifecycle.get(CATEGORY_ACTIONS[0])?.[0]; + const vodCategory = lifecycle.get(CATEGORY_ACTIONS[1])?.[0]; + const seriesCategory = lifecycle.get(CATEGORY_ACTIONS[2])?.[0]; + const live = lifecycle.get('get_live_streams')?.[0]; + if ( + !account || + !liveCategory || + !vodCategory || + !seriesCategory || + account.responded > + Math.min( + liveCategory.arrived, + vodCategory.arrived, + seriesCategory.arrived + ) || + !live || + Math.max( + liveCategory.responded, + vodCategory.responded, + seriesCategory.responded + ) > live.arrived + ) { + throw new Error('action order'); + } + const vod = lifecycle.get('get_vod_streams')?.[0]; + const series = lifecycle.get('get_series')?.[0]; + if ( + (vod && live.responded > vod.arrived) || + (series && (!vod || vod.responded > series.arrived)) || + (routeHydratesAfterImport && + (!series || + !accounts?.[1] || + series.responded > accounts[1].arrived)) + ) { + throw new Error('content order'); + } +} + +function isExpectedOccurrence( + value: XtreamOccurrence | undefined, + count: number +): boolean { + return ( + value?.scenario === 'performance-100k' && + value.transport === 'direct' && + value.categoryId === 'all' && + value.count === count + ); +} + +function isExpectedLedger( + value: XtreamLedgerEntry | undefined, + epoch: number, + occurrence: number +): value is XtreamLedgerEntry { + return ( + value?.scenario === 'performance-100k' && + value.transport === 'direct' && + value.categoryId === 'all' && + value.epoch === epoch && + value.occurrence === occurrence + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts new file mode 100644 index 000000000..8c2a1ecc9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { + XtreamControlState, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { parseXtreamControlState } from './xtream-control-state-schema'; + +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: 100, + catalogSha256: + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', +}; +const OCCURRENCE = { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_account_info', + categoryId: 'all', + count: 1, +}; +const LEDGER = { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_account_info', + categoryId: 'all', + epoch: 1, + occurrence: 1, + status: 'arrived', + atMs: 1, +}; + +describe('Xtream control-state exact schema', () => { + it('requires exact occurrence and ledger records with known lifecycle states', () => { + const populated = state(); + assert.doesNotThrow(() => parseXtreamControlState(populated)); + + for (const invalid of [ + { + ...populated, + occurrences: [{ ...OCCURRENCE, injected: true }], + }, + { + ...populated, + ledger: [{ ...LEDGER, injected: true }], + }, + { + ...populated, + ledger: [{ ...LEDGER, status: 'completed' }], + }, + ]) { + assert.throws( + () => parseXtreamControlState(invalid), + /^Error: invalid control response$/ + ); + } + }); + + it('returns a stable deep-frozen snapshot of nested accessor values', () => { + let epoch = 1; + let manifestBytes = MANIFEST.bytes; + let occurrenceAction = OCCURRENCE.action; + let ledgerStatus = LEDGER.status; + const input = withAccessor( + { + ...state(), + prepared: withAccessor( + { ...MANIFEST }, + 'bytes', + () => manifestBytes + ), + occurrences: [ + withAccessor( + { ...OCCURRENCE }, + 'action', + () => occurrenceAction + ), + ], + ledger: [ + withAccessor({ ...LEDGER }, 'status', () => ledgerStatus), + ], + }, + 'epoch', + () => epoch + ); + + const parsed = parseXtreamControlState(input); + epoch = 2; + manifestBytes = 0; + occurrenceAction = 'unknown'; + ledgerStatus = 'completed'; + + assert.equal(parsed.epoch, 1); + assert.equal(parsed.prepared?.bytes, MANIFEST.bytes); + assert.equal(parsed.occurrences[0]?.action, 'get_account_info'); + assert.equal(parsed.ledger[0]?.status, 'arrived'); + assert.ok(Object.isFrozen(parsed)); + assert.ok(Object.isFrozen(parsed.prepared)); + assert.ok(Object.isFrozen(parsed.prepared?.counts)); + assert.ok(Object.isFrozen(parsed.occurrences)); + assert.ok(Object.isFrozen(parsed.occurrences[0])); + assert.ok(Object.isFrozen(parsed.ledger)); + assert.ok(Object.isFrozen(parsed.ledger[0])); + }); + + it('sanitizes proxy and nested getter failures', () => { + const secret = 'must-not-escape-control-state'; + const proxy = new Proxy( + {}, + { + ownKeys() { + throw new Error(secret); + }, + } + ); + const nested = state(); + Object.defineProperty(nested.prepared, 'bytes', { + enumerable: true, + get() { + throw new Error(secret); + }, + }); + + for (const invalid of [proxy, nested]) { + assert.throws( + () => parseXtreamControlState(invalid), + (error: unknown) => + error instanceof Error && + error.message === 'invalid control response' && + !error.message.includes(secret) + ); + } + }); +}); + +function state(): XtreamControlState { + return { + epoch: 1, + prepared: structuredClone(MANIFEST), + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [{ ...OCCURRENCE }], + ledger: [{ ...LEDGER }], + }; +} + +function withAccessor( + value: T, + key: K, + get: () => T[K] +): T { + Object.defineProperty(value, key, { enumerable: true, get }); + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts new file mode 100644 index 000000000..4756eb6e6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts @@ -0,0 +1,314 @@ +import type { + XtreamCatalogCounts, + XtreamControlState, + XtreamLedgerEntry, + XtreamOccurrence, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; + +const LIFECYCLE_STATUSES = [ + 'aborted', + 'arrived', + 'blocked', + 'delayed', + 'responded', +] as const; +const STATE_KEYS = [ + 'epoch', + 'prepared', + 'rules', + 'heldIds', + 'heldCount', + 'occurrences', + 'ledger', +] as const; +const MANIFEST_KEYS = [ + 'epoch', + 'scenario', + 'seed', + 'counts', + 'bytes', + 'catalogSha256', +] as const; +const MAX_CONTROL_ARRAY_LENGTH = 4_096; + +export function parseXtreamControlState(value: unknown): XtreamControlState { + try { + const input = snapshotExactRecord(value, STATE_KEYS); + const heldIds = snapshotArray(input['heldIds']); + const occurrences = snapshotArray(input['occurrences']).map( + snapshotOccurrence + ); + const ledger = snapshotArray(input['ledger']).map(snapshotLedger); + const rules = snapshotArray(input['rules']).map((entry) => + snapshotJsonValue(entry, new WeakSet()) + ); + if ( + !isPositiveSafeInteger(input['epoch']) || + !heldIds.every((entry) => typeof entry === 'string') || + !isNonNegativeSafeInteger(input['heldCount']) || + input['heldCount'] !== heldIds.length + ) { + invalid(); + } + const prepared = + input['prepared'] === null + ? null + : snapshotManifest(input['prepared']); + return freezeDeep({ + epoch: input['epoch'], + prepared, + rules, + heldIds: heldIds as string[], + heldCount: input['heldCount'], + occurrences, + ledger, + }) as XtreamControlState; + } catch { + invalid(); + } +} + +export function parseXtreamManifest(value: unknown): XtreamPerformanceManifest { + try { + return freezeDeep(snapshotManifest(value)); + } catch { + throw invalidManifest(); + } +} + +export function assertXtreamManifest( + value: unknown +): asserts value is XtreamPerformanceManifest { + parseXtreamManifest(value); +} + +export function assertXtreamManifestIdentity( + expected: XtreamPerformanceManifest, + actual: unknown +): asserts actual is XtreamPerformanceManifest { + const expectedSnapshot = parseXtreamManifest(expected); + const actualSnapshot = parseXtreamManifest(actual); + const identity = (manifest: XtreamPerformanceManifest) => [ + manifest.scenario, + manifest.seed, + manifest.counts, + manifest.bytes, + manifest.catalogSha256, + ]; + if ( + JSON.stringify(identity(expectedSnapshot)) !== + JSON.stringify(identity(actualSnapshot)) + ) { + throw new Error('Xtream performance fixture identity changed'); + } +} + +function snapshotManifest(value: unknown): XtreamPerformanceManifest { + const input = snapshotExactRecord(value, MANIFEST_KEYS); + const counts = snapshotExactRecord(input['counts'], [ + 'categories', + 'items', + ]); + const categories = snapshotCounts(counts['categories'], [60, 20, 20, 100]); + const items = snapshotCounts( + counts['items'], + [60_000, 20_000, 20_000, 100_000] + ); + if ( + !isPositiveSafeInteger(input['epoch']) || + input['scenario'] !== 'performance-100k' || + input['seed'] !== 91_001 || + !isPositiveSafeInteger(input['bytes']) || + typeof input['catalogSha256'] !== 'string' || + !/^[a-f0-9]{64}$/u.test(input['catalogSha256']) + ) { + throw invalidManifest(); + } + return { + epoch: input['epoch'], + scenario: input['scenario'], + seed: input['seed'], + counts: { categories, items }, + bytes: input['bytes'], + catalogSha256: input['catalogSha256'], + }; +} + +function snapshotCounts( + value: unknown, + expected: readonly number[] +): XtreamCatalogCounts { + const input = snapshotExactRecord(value, [ + 'live', + 'vod', + 'series', + 'total', + ]); + const actual = [ + input['live'], + input['vod'], + input['series'], + input['total'], + ]; + if (actual.some((entry, index) => entry !== expected[index])) { + throw invalidManifest(); + } + return { + live: input['live'] as number, + vod: input['vod'] as number, + series: input['series'] as number, + total: input['total'] as number, + }; +} + +function snapshotOccurrence(value: unknown): XtreamOccurrence { + const input = snapshotExactRecord(value, [ + 'scenario', + 'transport', + 'action', + 'categoryId', + 'count', + ]); + if ( + !['scenario', 'transport', 'action', 'categoryId'].every( + (key) => typeof input[key] === 'string' + ) || + !isPositiveSafeInteger(input['count']) + ) { + invalid(); + } + return input as unknown as XtreamOccurrence; +} + +function snapshotLedger(value: unknown): XtreamLedgerEntry { + const input = snapshotExactRecord(value, [ + 'scenario', + 'transport', + 'action', + 'categoryId', + 'epoch', + 'occurrence', + 'status', + 'atMs', + ]); + if ( + !['scenario', 'transport', 'action', 'categoryId'].every( + (key) => typeof input[key] === 'string' + ) || + typeof input['status'] !== 'string' || + !LIFECYCLE_STATUSES.includes(input['status'] as never) || + !isPositiveSafeInteger(input['epoch']) || + !isPositiveSafeInteger(input['occurrence']) || + !isFiniteNonNegative(input['atMs']) + ) { + invalid(); + } + return input as unknown as XtreamLedgerEntry; +} + +function snapshotExactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record { + 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 snapshotArray(value: unknown): unknown[] { + if (!Array.isArray(value)) invalid(); + const length = Reflect.get(value, 'length') as unknown; + if ( + !isNonNegativeSafeInteger(length) || + length > MAX_CONTROL_ARRAY_LENGTH + ) { + invalid(); + } + const expected = new Set([ + 'length', + ...Array.from({ length }, (_, index) => String(index)), + ]); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expected.size || + keys.some((key) => typeof key !== 'string' || !expected.has(key)) + ) { + invalid(); + } + return Array.from({ length }, (_, index) => + Reflect.get(value, String(index)) + ); +} + +function snapshotJsonValue(value: unknown, stack: WeakSet): unknown { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value; + } + if (typeof value !== 'object') invalid(); + if (stack.has(value)) invalid(); + stack.add(value); + try { + if (Array.isArray(value)) { + return snapshotArray(value).map((entry) => + snapshotJsonValue(entry, stack) + ); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) invalid(); + const input = snapshotExactRecord(value, keys as string[]); + return Object.fromEntries( + (keys as string[]).map((key) => [ + key, + snapshotJsonValue(input[key], stack), + ]) + ); + } finally { + stack.delete(value); + } +} + +function freezeDeep(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): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function invalid(): never { + throw new Error('invalid control response'); +} + +function invalidManifest(): Error { + return new Error('invalid Xtream performance manifest'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts new file mode 100644 index 000000000..1f0a3051c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts @@ -0,0 +1,141 @@ +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 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 playlist delete correlation', () => { + it('correlates the follow-up playlist delete without fabricating an operation ID', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + + assert.equal(capture.acceptPreload(42, 100, deleteMarker()), true); + assert.deepEqual(capture.matchDatabaseRequest(deleteRequest()), { + ipcCallId: 1, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + assert.equal( + capture.acceptPreload( + 42, + 102, + deleteMarker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + true + ); + + const followUp = deleteMarker({ + ipcCallId: 2, + operationId: null, + sourceEpochMs: 103, + }); + assert.equal(capture.acceptPreload(42, 104, followUp), true); + assert.deepEqual( + capture.matchDatabaseRequest( + deleteRequest({ operationId: undefined }) + ), + { + ipcCallId: 2, + operationId: null, + operationIdUnavailableReason: null, + sourceEpochMs: 103, + } + ); + assert.equal( + capture.acceptPreload( + 42, + 106, + deleteMarker({ + ...followUp, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 105, + }) + ), + true + ); + assert.deepEqual(capture.stop().invalidReasons, []); + }); + + it('quarantines overlapping deletes that share the explicit no-operation-ID key', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const first = deleteMarker({ operationId: null }); + + assert.equal(capture.acceptPreload(42, 100, first), true); + assert.equal( + capture.acceptPreload( + 42, + 101, + deleteMarker({ + ipcCallId: 2, + operationId: null, + sourceEpochMs: 101, + }) + ), + false + ); + assert.deepEqual( + capture.matchDatabaseRequest(deleteRequest({ operationId: null })), + { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'xtream-preload-performance-marker-ambiguous', + sourceEpochMs: null, + } + ); + assert.equal(capture.stop().quarantinedKeyCount, 1); + }); +}); + +function deleteMarker(overrides: Record = {}) { + return { + action: null, + boundary: 'start', + categoryType: null, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'dbDeletePlaylist', + operationId: 'operation-1', + outcome: null, + playlistId: 'playlist-1', + schemaVersion: 1, + sessionId: null, + sourceEpochMs: 100, + ...overrides, + }; +} + +function deleteRequest( + payloadOverrides: Record = {} +): Record { + return { + operation: 'DB_DELETE_PLAYLIST', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + ...payloadOverrides, + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts new file mode 100644 index 000000000..788cec191 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { + XtreamCancellationEvidence, + XtreamRawIterationResult, +} from './xtream-summary-contract'; + +describe('Xtream raw descriptor integrity', () => { + it('rejects symbol and non-enumerable top-level extras', () => { + for (const key of [Symbol('extra'), 'hiddenExtra'] as const) { + const raw = createIteration(); + Object.defineProperty(raw, key, { value: true }); + + assert.throws( + () => assertXtreamIterationResult(raw), + /invalid Xtream iteration/ + ); + } + }); + + it('rejects symbol extras on cancellation records', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + Object.defineProperty(evidence.timeline[0], Symbol('extra'), { + value: true, + }); + + assert.throws( + () => assertXtreamIterationResult(raw), + /invalid Xtream iteration/ + ); + }); + + it('rejects a cancellation timeline accessor without invoking it', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + let invocations = 0; + const accessorEvidence = {}; + Object.defineProperty(accessorEvidence, 'timeline', { + enumerable: true, + get() { + invocations += 1; + return evidence.timeline; + }, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: + accessorEvidence as XtreamCancellationEvidence, + }), + /invalid Xtream iteration/ + ); + assert.equal(invocations, 0); + }); + + it('rejects cancellation record accessors without invoking them', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const original = evidence.timeline[0]; + let invocations = 0; + const accessorRecord = { ...original }; + Object.defineProperty(accessorRecord, 'epochMs', { + enumerable: true, + get() { + invocations += 1; + return original.epochMs; + }, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: [ + accessorRecord, + ...evidence.timeline.slice(1), + ], + }, + }), + /invalid Xtream iteration/ + ); + assert.equal(invocations, 0); + }); + + it('rejects every own cancellation timeline key beyond dense indices and length', () => { + for (const extraKey of [ + Symbol('extra'), + 'hiddenExtra', + 'map', + ] as const) { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const timeline = [...evidence.timeline]; + Object.defineProperty(timeline, extraKey, { + value: + extraKey === 'map' + ? Array.prototype.map.bind(timeline) + : true, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { timeline }, + }), + /invalid Xtream iteration/ + ); + } + }); +}); + +function cancellationEvidence( + raw: XtreamRawIterationResult +): XtreamCancellationEvidence { + assert.ok(raw.cancellationEvidence); + return raw.cancellationEvidence; +} + +function createIteration( + scenarioId: XtreamScenarioId = XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE +): XtreamRawIterationResult { + const definition: XtreamIterationDefinition = { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }; + return iteration(definition, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts new file mode 100644 index 000000000..669185815 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts @@ -0,0 +1,392 @@ +import assert from 'node:assert/strict'; +import { + 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 { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +import { + assertXtreamDiagnosticArtifactEvidence, + validateXtreamDiagnosticArtifacts, + type XtreamDiagnosticCapture, +} from './xtream-diagnostic-artifacts'; + +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[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream diagnostic artifacts', () => { + it('accepts one complete main, renderer, and database-worker artifact set', async () => { + const directory = await createDirectory('valid'); + await writeValidArtifacts(directory); + + const evidence = await validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ); + + assertXtreamDiagnosticArtifactEvidence(evidence); + assert.equal(evidence.artifacts.length, 7); + assert.match(evidence.directorySha256, /^[a-f0-9]{64}$/u); + assert.ok( + evidence.artifacts.every( + ({ bytes, filename, label, sha256 }) => + bytes > 0 && + filename.length > 0 && + label.length > 0 && + /^[a-f0-9]{64}$/u.test(sha256) + ) + ); + assert.ok(Object.isFrozen(evidence)); + assert.ok(Object.isFrozen(evidence.artifacts)); + assert.ok(evidence.artifacts.every(Object.isFrozen)); + assert.throws( + () => + assertXtreamDiagnosticArtifactEvidence( + structuredClone(evidence) + ), + /validated Xtream diagnostic artifact evidence/ + ); + }); + + it('fails closed for malformed or missing process artifacts', async () => { + const directory = await createDirectory('invalid'); + await writeValidArtifacts(directory); + await writeFile(join(directory, 'renderer.trace.json'), '{'); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-artifact-invalid:renderer-trace/ + ); + + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ); + const missing: XtreamDiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: null, + }, + }; + await assert.rejects( + () => validateXtreamDiagnosticArtifacts(missing, directory), + /xtream-diagnostic-artifact-missing:main-cpu-profile/ + ); + }); + + it('rejects escaped paths and symlinks', async () => { + const directory = await createDirectory('contained'); + const outside = await createDirectory('outside'); + await writeValidArtifacts(directory); + await writeFile( + join(outside, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + + const escaped: XtreamDiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: join(outside, 'main.cpuprofile'), + }, + }; + await assert.rejects( + () => validateXtreamDiagnosticArtifacts(escaped, directory), + /xtream-diagnostic-artifact-path-invalid:main-cpu-profile/ + ); + + const rendererProfile = join(directory, 'renderer.cpuprofile'); + await unlink(rendererProfile); + await symlink(join(outside, 'main.cpuprofile'), rendererProfile); + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-artifact-path-invalid:renderer-cpu-profile/ + ); + + const symlinkRoot = await createDirectory('directory-symlink'); + const realDirectory = join(symlinkRoot, 'real'); + const symlinkDirectory = join(symlinkRoot, 'alias'); + await mkdir(realDirectory); + await writeValidArtifacts(realDirectory); + await symlink(realDirectory, symlinkDirectory, 'dir'); + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(symlinkDirectory), + symlinkDirectory + ), + /xtream-diagnostic-artifact-path-invalid:iteration-directory/ + ); + }); + + it('requires exactly one database worker and no playlist-refresh worker', async () => { + const directory = await createDirectory('workers'); + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-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( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-playlist-profile-unexpected/ + ); + }); + + it('rejects every rogue top-level profile, snapshot, and trace artifact', async () => { + const filenames = [ + 'main-extra.cpuprofile', + 'main-extra.heapsnapshot', + 'renderer-extra.cpuprofile', + 'renderer-extra.heapsnapshot', + 'renderer-extra.trace.json', + 'rogue.cpuprofile', + 'rogue.heapsnapshot', + 'rogue.trace.json', + 'database.worker-evil.cpuprofile', + 'database.worker-4.trace.json', + 'playlist-refresh.worker-2.trace.json', + 'rogue.CPUPROFILE', + 'rogue.HeapSnapshot', + 'rogue.TRACE.JSON', + ] as const; + + for (const filename of filenames) { + const directory = await createDirectory(filename); + await writeValidArtifacts(directory); + await writeFile( + join(directory, filename), + JSON.stringify( + filename.endsWith('.cpuprofile') + ? CPU_PROFILE + : filename.endsWith('.heapsnapshot') + ? HEAP_SNAPSHOT + : RENDERER_TRACE + ) + ); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-artifact-cardinality-invalid/ + ); + } + }); + + it('rejects mixed-case rogue artifacts nested anywhere in the iteration tree', async () => { + const directory = await createDirectory('nested-rogue'); + const nested = join(directory, 'nested'); + await writeValidArtifacts(directory); + await mkdir(nested); + await writeFile( + join(nested, 'rogue.CpuProfile'), + JSON.stringify(CPU_PROFILE) + ); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /^Error: xtream-diagnostic-artifact-cardinality-invalid$/ + ); + }); + + it('snapshots the validated database worker ordinal before constructing paths', async () => { + const directory = await createDirectory('ordinal-getter'); + await writeValidArtifacts(directory); + let reads = 0; + const worker = { + kind: 'database.worker', + get ordinal(): number { + reads += 1; + return reads === 1 ? 4 : ('../../outside' as unknown as number); + }, + profilePath: join(directory, 'database.worker-4.cpuprofile'), + snapshotPath: join(directory, 'database.worker-4.heapsnapshot'), + }; + const getterCapture: XtreamDiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [worker], + }, + }; + + await assert.doesNotReject(() => + validateXtreamDiagnosticArtifacts(getterCapture, directory) + ); + assert.equal(reads, 1); + }); + + it('sanitizes capture Proxy failures before filesystem access', async () => { + const secret = 'must-not-escape-diagnostic-capture'; + const directory = await createDirectory('proxy-capture'); + const captureProxy = new Proxy( + {}, + { + ownKeys() { + throw new Error(secret); + }, + } + ) as XtreamDiagnosticCapture; + + await assert.rejects( + () => validateXtreamDiagnosticArtifacts(captureProxy, directory), + (error: unknown) => + error instanceof Error && + error.message === 'xtream-diagnostic-capture-invalid' && + !error.message.includes(secret) + ); + }); +}); + +function capture( + directory: string, + databaseOrdinal = 4 +): XtreamDiagnosticCapture { + 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 createDirectory(label: string): Promise { + const directory = await mkdtemp( + join(tmpdir(), `iptvnator-xtream-diagnostic-${label}-`) + ); + directories.push(directory); + return directory; +} + +async function writeValidArtifacts( + directory: string, + databaseOrdinal = 4 +): Promise { + 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) + ), + ]); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts new file mode 100644 index 000000000..a55755bf1 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts @@ -0,0 +1,324 @@ +import { lstat, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { + isCpuProfile, + isHeapSnapshot, + isRendererTrace, + readCpuProfileDurationMicros, +} from './initial-import-diagnostic-artifact-content-validation'; +import { + assertXtreamManifest, + assertXtreamManifestIdentity, +} from './xtream-control-state-schema'; +import { + snapshotXtreamDiagnosticCapture, + type XtreamDiagnosticCapture, + type XtreamDiagnosticWorkerArtifact, +} from './xtream-diagnostic-capture-schema'; +import { + assertXtreamArtifactRedacted, + createXtreamDiagnosticEvidenceAuthority, + serializeXtreamArtifactForPersistence, + type XtreamDiagnosticArtifactEvidence, + type XtreamValidatedArtifactContent, +} from './xtream-diagnostic-evidence'; +import { + isXtreamUnsafeFileError, + readXtreamSafeRegularFile, +} from './xtream-safe-file-read'; + +export { + assertXtreamArtifactRedacted, + assertXtreamManifest, + assertXtreamManifestIdentity, + serializeXtreamArtifactForPersistence, +}; +export type { + XtreamDiagnosticArtifactEvidence, + XtreamDiagnosticArtifactReceipt, +} from './xtream-diagnostic-evidence'; +export type { XtreamDiagnosticCapture } from './xtream-diagnostic-capture-schema'; + +interface ArtifactDescription { + readonly capturesCpuProfileDuration: boolean; + readonly filename: string; + readonly label: string; + readonly path: string | null; + readonly validate: (value: unknown) => boolean; +} + +const EVIDENCE_AUTHORITY = createXtreamDiagnosticEvidenceAuthority(); + +export async function validateXtreamDiagnosticArtifacts( + capture: XtreamDiagnosticCapture, + iterationDirectory: string +): Promise { + const snapshot = snapshotXtreamDiagnosticCapture(capture); + if (typeof iterationDirectory !== 'string') { + throw artifactPathError('iteration-directory'); + } + const directory = resolve(iterationDirectory); + let realDirectory: string; + try { + const stats = await lstat(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('unsafe-iteration-directory'); + } + realDirectory = await realpath(directory); + } catch { + throw artifactPathError('iteration-directory'); + } + + const databaseWorker = requireExactDatabaseWorker(snapshot.main.workers); + const artifacts: readonly ArtifactDescription[] = [ + artifact( + 'main.cpuprofile', + 'main-cpu-profile', + snapshot.main.cpuProfilePath, + isCpuProfile, + true + ), + artifact( + 'renderer.cpuprofile', + 'renderer-cpu-profile', + snapshot.renderer.cpuProfilePath, + isCpuProfile, + true + ), + artifact( + 'renderer.trace.json', + 'renderer-trace', + snapshot.renderer.tracePath, + isRendererTrace + ), + artifact( + 'main.heapsnapshot', + 'main-heap-snapshot', + snapshot.main.heapSnapshotPath, + isHeapSnapshot + ), + artifact( + 'renderer.heapsnapshot', + 'renderer-heap-snapshot', + snapshot.renderer.heapSnapshotPath, + isHeapSnapshot + ), + artifact( + `database.worker-${databaseWorker.ordinal}.cpuprofile`, + 'database-cpu-profile', + databaseWorker.profilePath, + isCpuProfile, + true + ), + artifact( + `database.worker-${databaseWorker.ordinal}.heapsnapshot`, + 'database-heap-snapshot', + databaseWorker.snapshotPath, + isHeapSnapshot + ), + ]; + await assertWorkerArtifactCardinality( + directory, + artifacts.map(({ filename }) => filename) + ); + const validated: XtreamValidatedArtifactContent[] = []; + for (const description of artifacts) { + validated.push( + await validateJsonArtifact(directory, realDirectory, description) + ); + } + await assertWorkerArtifactCardinality( + directory, + artifacts.map(({ filename }) => filename) + ); + return EVIDENCE_AUTHORITY.issue( + realDirectory, + validated, + databaseWorker.ordinal + ); +} + +export function assertXtreamDiagnosticArtifactEvidence( + value: unknown +): asserts value is XtreamDiagnosticArtifactEvidence { + if (!EVIDENCE_AUTHORITY.is(value)) { + throw new Error( + 'expected validated Xtream diagnostic artifact evidence' + ); + } +} + +function requireExactDatabaseWorker( + workers: readonly XtreamDiagnosticWorkerArtifact[] +): XtreamDiagnosticWorkerArtifact { + if (workers.some((worker) => worker.kind === 'playlist-refresh.worker')) { + throw new Error('xtream-diagnostic-playlist-profile-unexpected'); + } + const databaseWorkers = workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const worker = databaseWorkers[0]; + if ( + databaseWorkers.length !== 1 || + workers.length !== 1 || + !worker || + !Number.isSafeInteger(worker.ordinal) || + worker.ordinal <= 0 + ) { + throw new Error( + 'xtream-diagnostic-database-profile-cardinality-invalid' + ); + } + return worker; +} + +async function assertWorkerArtifactCardinality( + directory: string, + expectedArtifacts: readonly string[] +): Promise { + const entries = await listDiagnosticArtifactEntries(directory); + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.(?:cpuprofile|heapsnapshot)$/iu.test( + entry + ) + ) + ) { + throw new Error('xtream-diagnostic-playlist-profile-unexpected'); + } + for (const extension of ['cpuprofile', 'heapsnapshot']) { + if ( + entries.filter((entry) => + new RegExp( + `^database\\.worker-\\d+\\.${extension}$`, + 'iu' + ).test(entry) + ).length !== 1 + ) { + throw new Error( + `xtream-diagnostic-database-${extension === 'cpuprofile' ? 'profile' : 'snapshot'}-cardinality-invalid` + ); + } + } + if ( + entries.length !== expectedArtifacts.length || + entries.some((entry) => !expectedArtifacts.includes(entry)) || + expectedArtifacts.some((entry) => !entries.includes(entry)) + ) { + throw new Error('xtream-diagnostic-artifact-cardinality-invalid'); + } +} + +async function listDiagnosticArtifactEntries( + directory: string +): Promise { + const artifacts: string[] = []; + const walk = async (current: string): Promise => { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const path = join(current, entry.name); + if ( + /(?:\.cpuprofile|\.heapsnapshot|\.trace\.json)$/iu.test( + entry.name + ) + ) { + artifacts.push(relative(directory, path)); + } + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const stats = await lstat(path); + if (stats.isSymbolicLink()) continue; + if (!stats.isDirectory()) { + throw new Error('unsafe-artifact-tree-entry'); + } + await walk(path); + } + }; + try { + await walk(directory); + return artifacts; + } catch { + throw artifactPathError('iteration-directory'); + } +} + +async function validateJsonArtifact( + directory: string, + realDirectory: string, + description: ArtifactDescription +): Promise { + if (description.path === null) { + throw new Error( + `xtream-diagnostic-artifact-missing:${description.label}` + ); + } + const expectedPath = join(directory, description.filename); + if (resolve(description.path) !== expectedPath) { + throw artifactPathError(description.label); + } + + let content: Buffer; + let value: unknown; + try { + const snapshot = await readXtreamSafeRegularFile(expectedPath); + const realArtifactPath = snapshot.realPath; + if (!isContained(realDirectory, realArtifactPath)) { + throw new Error('escaped-artifact'); + } + content = snapshot.content; + value = JSON.parse(content.toString('utf8')) as unknown; + } catch (error) { + if ( + isXtreamUnsafeFileError(error) || + (error instanceof Error && error.message === 'escaped-artifact') + ) { + throw artifactPathError(description.label); + } + throw new Error( + `xtream-diagnostic-artifact-invalid:${description.label}` + ); + } + if (!description.validate(value)) { + throw new Error( + `xtream-diagnostic-artifact-invalid:${description.label}` + ); + } + return { + content, + cpuProfileDurationMicros: description.capturesCpuProfileDuration + ? readCpuProfileDurationMicros(value) + : null, + filename: description.filename, + label: description.label, + }; +} + +function artifact( + filename: string, + label: string, + path: string | null, + validate: (value: unknown) => boolean, + capturesCpuProfileDuration = false +): ArtifactDescription { + return { + capturesCpuProfileDuration, + filename, + label, + path, + validate, + }; +} + +function isContained(directory: string, artifactPath: string): boolean { + const relativePath = relative(directory, artifactPath); + return ( + relativePath.length > 0 && + !isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) + ); +} + +function artifactPathError(label: string): Error { + return new Error(`xtream-diagnostic-artifact-path-invalid:${label}`); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts new file mode 100644 index 000000000..366208c80 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + snapshotXtreamDiagnosticCapture, + type XtreamDiagnosticWorkerArtifact, +} from './xtream-diagnostic-capture-schema'; + +const WORKER: XtreamDiagnosticWorkerArtifact = { + kind: 'database.worker', + ordinal: 4, + profilePath: '/tmp/database.worker-4.cpuprofile', + snapshotPath: '/tmp/database.worker-4.heapsnapshot', +}; + +describe('Xtream diagnostic capture schema', () => { + it('reads a workers accessor once before validating and freezing it', () => { + let reads = 0; + const capture = { + main: { + cpuProfilePath: '/tmp/main.cpuprofile', + heapSnapshotPath: '/tmp/main.heapsnapshot', + get workers() { + reads += 1; + return [{ ...WORKER }]; + }, + }, + renderer: { + cpuProfilePath: '/tmp/renderer.cpuprofile', + heapSnapshotPath: '/tmp/renderer.heapsnapshot', + tracePath: '/tmp/renderer.trace.json', + }, + }; + + const snapshot = snapshotXtreamDiagnosticCapture(capture); + + assert.equal(reads, 1); + assert.deepEqual(snapshot.main.workers, [WORKER]); + assert.ok(Object.isFrozen(snapshot.main.workers)); + assert.ok(Object.isFrozen(snapshot.main.workers[0])); + }); + + it('rejects the producer-impossible zero worker ordinal', () => { + assert.throws( + () => + snapshotXtreamDiagnosticCapture({ + main: { + cpuProfilePath: '/tmp/main.cpuprofile', + heapSnapshotPath: '/tmp/main.heapsnapshot', + workers: [{ ...WORKER, ordinal: 0 }], + }, + renderer: { + cpuProfilePath: '/tmp/renderer.cpuprofile', + heapSnapshotPath: '/tmp/renderer.heapsnapshot', + tracePath: '/tmp/renderer.trace.json', + }, + }), + /xtream-diagnostic-capture-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts new file mode 100644 index 000000000..79b9ad589 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts @@ -0,0 +1,149 @@ +export interface XtreamDiagnosticWorkerArtifact { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; +} + +export interface XtreamDiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly XtreamDiagnosticWorkerArtifact[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +export function snapshotXtreamDiagnosticCapture( + value: unknown +): XtreamDiagnosticCapture { + try { + return snapshotCapture(value); + } catch { + throw new Error('xtream-diagnostic-capture-invalid'); + } +} + +function snapshotCapture(value: unknown): XtreamDiagnosticCapture { + if (!isExactRecord(value, ['main', 'renderer'])) invalid(); + const main = value['main']; + const renderer = value['renderer']; + const workerValues = + typeof main === 'object' && main !== null + ? Reflect.get(main, 'workers') + : null; + if ( + !isExactRecord(main, [ + 'cpuProfilePath', + 'heapSnapshotPath', + 'workers', + ]) || + !isExactRecord(renderer, [ + 'cpuProfilePath', + 'heapSnapshotPath', + 'tracePath', + ]) || + !Array.isArray(workerValues) + ) { + invalid(); + } + const workers = snapshotWorkers(workerValues); + const snapshot = { + main: { + cpuProfilePath: snapshotPath(main['cpuProfilePath']), + heapSnapshotPath: snapshotPath(main['heapSnapshotPath']), + workers: Object.freeze(workers), + }, + renderer: { + cpuProfilePath: snapshotPath(renderer['cpuProfilePath']), + heapSnapshotPath: snapshotPath(renderer['heapSnapshotPath']), + tracePath: snapshotPath(renderer['tracePath']), + }, + }; + Object.freeze(snapshot.main); + Object.freeze(snapshot.renderer); + return Object.freeze(snapshot); +} + +function snapshotWorkers(value: unknown): XtreamDiagnosticWorkerArtifact[] { + if (!Array.isArray(value)) invalid(); + const length = Reflect.get(value, 'length') as unknown; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 64 + ) { + invalid(); + } + const expected = new Set([ + 'length', + ...Array.from({ length: Number(length) }, (_, index) => String(index)), + ]); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expected.size || + keys.some((key) => typeof key !== 'string' || !expected.has(key)) + ) { + invalid(); + } + return Array.from({ length: Number(length) }, (_, index) => + snapshotWorker(Reflect.get(value, String(index))) + ); +} + +function snapshotWorker(value: unknown): XtreamDiagnosticWorkerArtifact { + if ( + !isExactRecord(value, [ + 'kind', + 'ordinal', + 'profilePath', + 'snapshotPath', + ]) + ) { + invalid(); + } + const kind = value['kind']; + const ordinal = value['ordinal']; + const profilePath = snapshotPath(value['profilePath']); + const snapshotPathValue = snapshotPath(value['snapshotPath']); + if ( + typeof kind !== 'string' || + !Number.isSafeInteger(ordinal) || + Number(ordinal) <= 0 + ) { + invalid(); + } + return Object.freeze({ + kind, + ordinal: ordinal as number, + profilePath, + snapshotPath: snapshotPathValue, + }); +} + +function snapshotPath(value: unknown): string | null { + if (value !== null && typeof value !== 'string') invalid(); + return value as string | null; +} + +function isExactRecord( + value: unknown, + keys: readonly string[] +): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) + ); +} + +function invalid(): never { + throw new Error('invalid diagnostic capture'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts new file mode 100644 index 000000000..226102858 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assertXtreamArtifactRedacted, + serializeXtreamArtifactForPersistence, +} from './xtream-diagnostic-artifacts'; +import type { XtreamArtifactSafetyContext } from './xtream-summary-contract'; + +const TOKEN = 'diagnostic-control-token'; +const SAFETY: XtreamArtifactSafetyContext = { + controlToken: TOKEN, + syntheticCredentials: { + password: 'performance', + username: 'performance', + }, +}; +const SAFE_ERROR = 'Xtream artifact must remain credential-safe'; + +describe('Xtream diagnostic persistence safety', () => { + it('rejects single- and double-percent-encoded synthetic credentials', () => { + for (const endpoint of [ + 'http://127.0.0.1/player_api.php?%75sername%3D%70erformance%26%70assword%3D%70erformance', + 'http://127.0.0.1/player_api.php?%2575sername%253D%2570erformance%2526%2570assword%253D%2570erformance', + 'http://%70erformance%3A%70erformance%40127.0.0.1/player_api.php', + 'http://%2570erformance%253A%2570erformance%2540127.0.0.1/player_api.php', + ]) { + assert.throws( + () => + serializeXtreamArtifactForPersistence({ endpoint }, SAFETY), + exactSafeError + ); + } + }); + + it('rejects triple encoding and fails closed beyond the decode cap', () => { + const userInfo = + 'http://performance:performance@127.0.0.1/player_api.php'; + for (const endpoint of [ + encodeTimes(userInfo, 3), + encodeTimes(userInfo, 9), + ]) { + assert.throws( + () => + serializeXtreamArtifactForPersistence({ endpoint }, SAFETY), + exactSafeError + ); + } + }); + + it('preserves benign malformed percent text', () => { + assert.equal( + serializeXtreamArtifactForPersistence( + { label: 'progress is 100% complete' }, + SAFETY + ), + '{"label":"progress is 100% complete"}' + ); + }); + + it('does not let malformed percent text mask encoded credentials', () => { + assert.throws( + () => + serializeXtreamArtifactForPersistence( + { + endpoint: + 'http://%ZZ%70erformance%3A%70erformance%40127.0.0.1/player_api.php', + }, + SAFETY + ), + exactSafeError + ); + }); + + it('does not trust an array own forEach during standalone redaction', () => { + const unsafe = [ + 'http://mock.invalid/player_api.php?username=performance', + ]; + Object.defineProperty(unsafe, 'forEach', { + value: () => undefined, + }); + + assert.throws( + () => assertXtreamArtifactRedacted(unsafe), + exactSafeError + ); + }); + + it('revalidates the serialized JSON emitted by toJSON hooks', () => { + assert.equal( + serializeXtreamArtifactForPersistence( + { + visible: true, + toJSON() { + return { visible: true }; + }, + }, + SAFETY + ), + '{"visible":true}' + ); + + for (const unsafe of [ + { + toJSON() { + return { username: 'injected-after-first-validation' }; + }, + }, + { + toJSON() { + return { + endpoint: + 'http://mock.invalid/player_api.php?token=late', + }; + }, + }, + ]) { + assert.throws( + () => serializeXtreamArtifactForPersistence(unsafe, SAFETY), + exactSafeError + ); + } + }); + + it('sanitizes ownKeys, getter, and revoked Proxy failures', () => { + const ownKeysProxy = new Proxy( + {}, + { + ownKeys() { + throw new Error(TOKEN); + }, + } + ); + const safetyProxy = new Proxy(SAFETY, { + get() { + throw new Error(TOKEN); + }, + }); + const getterValue = Object.defineProperty({}, 'visible', { + enumerable: true, + get() { + throw new Error(TOKEN); + }, + }); + const throwingJson = { + toJSON() { + throw new Error(TOKEN); + }, + }; + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + + assert.throws( + () => assertXtreamArtifactRedacted(ownKeysProxy, [TOKEN]), + exactSafeError + ); + assert.throws( + () => assertXtreamArtifactRedacted(revocable.proxy, [TOKEN]), + exactSafeError + ); + assert.throws( + () => + serializeXtreamArtifactForPersistence( + { visible: true }, + safetyProxy + ), + exactSafeError + ); + assert.throws( + () => serializeXtreamArtifactForPersistence(ownKeysProxy, SAFETY), + exactSafeError + ); + assert.throws( + () => serializeXtreamArtifactForPersistence(getterValue, SAFETY), + exactSafeError + ); + assert.throws( + () => serializeXtreamArtifactForPersistence(throwingJson, SAFETY), + exactSafeError + ); + }); +}); + +function exactSafeError(error: unknown): boolean { + return ( + error instanceof Error && + error.message === SAFE_ERROR && + !error.message.includes(TOKEN) + ); +} + +function encodeTimes(value: string, count: number): string { + let encoded = value; + for (let pass = 0; pass < count; pass += 1) { + encoded = encodeURIComponent(encoded); + } + return encoded; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts new file mode 100644 index 000000000..d55bb2952 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts @@ -0,0 +1,266 @@ +import { createHash } from 'node:crypto'; + +import type { XtreamArtifactSafetyContext } from './xtream-summary-contract'; + +export interface XtreamDiagnosticArtifactReceipt { + readonly bytes: number; + readonly filename: string; + readonly label: string; + readonly sha256: string; +} + +declare const EVIDENCE_BRAND: unique symbol; + +export interface XtreamDiagnosticArtifactEvidence { + readonly [EVIDENCE_BRAND]: true; + readonly artifacts: readonly XtreamDiagnosticArtifactReceipt[]; + readonly directorySha256: string; + readonly profiles: XtreamDiagnosticProfileEvidence; +} + +export interface XtreamDiagnosticProfileEvidence { + readonly databaseWorker: { + readonly durationMicros: number; + readonly ordinal: number; + }; + readonly main: { + readonly durationMicros: number; + }; + readonly renderer: { + readonly durationMicros: number; + }; +} + +export interface XtreamValidatedArtifactContent { + readonly content: Uint8Array; + readonly cpuProfileDurationMicros: number | null; + readonly filename: string; + readonly label: string; +} + +interface XtreamDiagnosticEvidenceAuthority { + is(value: unknown): value is XtreamDiagnosticArtifactEvidence; + issue( + realDirectory: string, + artifacts: readonly XtreamValidatedArtifactContent[], + databaseWorkerOrdinal: number + ): XtreamDiagnosticArtifactEvidence; +} + +const SENSITIVE_KEYS = new Set([ + 'authorization', + 'credential', + 'credentials', + 'password', + 'playlisturl', + 'serverurl', + 'token', + 'username', +]); +const MAX_PERCENT_DECODE_PASSES = 8; + +export function createXtreamDiagnosticEvidenceAuthority(): XtreamDiagnosticEvidenceAuthority { + const validated = new WeakSet(); + return { + is(value): value is XtreamDiagnosticArtifactEvidence { + return ( + typeof value === 'object' && + value !== null && + validated.has(value) + ); + }, + issue(realDirectory, artifacts, databaseWorkerOrdinal) { + const receipts = Object.freeze( + artifacts.map(({ content, filename, label }) => + Object.freeze({ + bytes: content.byteLength, + filename, + label, + sha256: sha256(content), + }) + ) + ); + const duration = (label: string): number => { + const value = artifacts.find( + (artifact) => artifact.label === label + )?.cpuProfileDurationMicros; + if (!Number.isSafeInteger(value) || Number(value) <= 0) { + throw new Error('invalid diagnostic profile evidence'); + } + return Number(value); + }; + if ( + !Number.isSafeInteger(databaseWorkerOrdinal) || + databaseWorkerOrdinal <= 0 + ) { + throw new Error('invalid diagnostic worker identity'); + } + const profiles = Object.freeze({ + databaseWorker: Object.freeze({ + durationMicros: duration('database-cpu-profile'), + ordinal: databaseWorkerOrdinal, + }), + main: Object.freeze({ + durationMicros: duration('main-cpu-profile'), + }), + renderer: Object.freeze({ + durationMicros: duration('renderer-cpu-profile'), + }), + }); + const directorySha256 = sha256( + Buffer.from( + `${realDirectory}\0${JSON.stringify(receipts)}`, + 'utf8' + ) + ); + const evidence = Object.freeze({ + artifacts: receipts, + directorySha256, + profiles, + }) as XtreamDiagnosticArtifactEvidence; + validated.add(evidence); + return evidence; + }, + }; +} + +export function assertXtreamArtifactRedacted( + value: unknown, + forbiddenValues: readonly string[] = [] +): void { + try { + assertArtifactRedacted(value, forbiddenValues); + } catch { + throw unsafeArtifact(); + } +} + +function assertArtifactRedacted( + value: unknown, + forbiddenValues: readonly string[] +): void { + const secrets: string[] = []; + for (let index = 0; index < forbiddenValues.length; index += 1) { + const entry = Reflect.get(forbiddenValues, String(index)) as unknown; + if (typeof entry === 'string' && entry.length > 0) { + secrets.push(entry); + } + } + const visit = (input: unknown): void => { + if (typeof input === 'string') { + for (const candidate of decodedVariants(input)) { + if ( + secrets.some((secret) => candidate.includes(secret)) || + /[?&](?:username|password|token)=/iu.test(candidate) || + /:\/\/[^/\s]+@/u.test(candidate) + ) { + throw unsafeArtifact(); + } + } + return; + } + if (Array.isArray(input)) { + const length = input.length; + for (let index = 0; index < length; index += 1) { + visit(Reflect.get(input, String(index))); + } + return; + } + if (!input || typeof input !== 'object') return; + for (const [key, entry] of Object.entries(input)) { + if (SENSITIVE_KEYS.has(key.replace(/[-_]/gu, '').toLowerCase())) { + throw unsafeArtifact(); + } + visit(entry); + } + }; + visit(value); +} + +function decodedVariants(value: string): readonly string[] { + const variants = [value]; + let current = value; + for (let pass = 0; pass < MAX_PERCENT_DECODE_PASSES; pass += 1) { + const decoded = decodePercentPass(current); + if (decoded === current) break; + variants.push(decoded); + current = decoded; + } + const afterCap = decodePercentPass(current); + if (afterCap !== current) throw unsafeArtifact(); + return variants; +} + +function decodePercentPass(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value.replace(/%([a-f0-9]{2})/giu, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)) + ); + } +} + +export function serializeXtreamArtifactForPersistence( + value: unknown, + safety: XtreamArtifactSafetyContext +): string { + try { + return serializeArtifact(value, safety); + } catch { + throw unsafeArtifact(); + } +} + +function serializeArtifact( + value: unknown, + safety: XtreamArtifactSafetyContext +): string { + const controlToken = safety.controlToken; + const credentials = safety.syntheticCredentials; + const username = credentials?.username; + const password = credentials?.password; + if ( + typeof controlToken !== 'string' || + controlToken.length === 0 || + username !== 'performance' || + password !== 'performance' + ) { + throw unsafeArtifact(); + } + assertXtreamArtifactRedacted(value, [controlToken]); + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + throw unsafeArtifact(); + } + if (typeof serialized !== 'string') throw unsafeArtifact(); + let canonical: unknown; + try { + canonical = JSON.parse(serialized) as unknown; + } catch { + throw unsafeArtifact(); + } + assertXtreamArtifactRedacted(canonical, [controlToken]); + if ( + JSON.stringify(canonical) !== serialized || + serialized.includes(controlToken) || + /performance(?::|%3a)performance/iu.test(serialized) || + /[?&](?:username=performance[^"#]*&password=performance|password=performance[^"#]*&username=performance)/iu.test( + serialized + ) || + /:\/\/performance(?::|%3a)performance@/iu.test(serialized) + ) { + throw unsafeArtifact(); + } + return serialized; +} + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function unsafeArtifact(): Error { + return new Error('Xtream artifact must remain credential-safe'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts new file mode 100644 index 000000000..56d1ffe77 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts @@ -0,0 +1,321 @@ +/* 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 { + createXtreamIterationDefinitions, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; +import { validXtreamWorkerRequestIdentity } from './xtream-worker-request-identity'; + +test('enforces producer-coherent request identities and source-to-worker chronology', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE); + assert.doesNotThrow(() => assertXtreamIterationResult(raw)); + + const correlated = request(raw, 'DB_GET_CATEGORIES'); + const nonApplicable = request(raw, 'DB_GET_APP_STATE'); + const requiredOperationId = request(raw, 'DB_SAVE_CONTENT'); + const receivedEpochMs = requireEpoch(correlated.requestReceivedEpochMs); + + assert.equal( + validXtreamWorkerRequestIdentity({ + ...correlated, + sourceEpochMs: receivedEpochMs + 0.006, + }), + true + ); + assert.equal( + validXtreamWorkerRequestIdentity({ + ...correlated, + sourceEpochMs: receivedEpochMs + 1, + }), + false + ); + + assert.equal(nonApplicable.ipcCallId, null); + assert.equal(nonApplicable.operationId, null); + assert.equal( + nonApplicable.operationIdUnavailableReason, + 'preload-performance-marker-not-applicable' + ); + assert.equal(nonApplicable.sourceEpochMs, null); + + const invalidRequests = [ + replaceRequest(raw, correlated, { + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + sourceEpochMs: null, + }), + replaceRequest(raw, nonApplicable, { + ipcCallId: 900_001, + operationId: 'forged-operation', + operationIdUnavailableReason: null, + sourceEpochMs: + requireEpoch(nonApplicable.requestReceivedEpochMs) - 1, + }), + replaceRequest(raw, requiredOperationId, { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + }), + replaceRequest(raw, correlated, { + operationId: 'forged-operation', + operationIdUnavailableReason: null, + }), + replaceRequest(raw, correlated, { + sourceEpochMs: receivedEpochMs + 1, + }), + ]; + + for (const requests of invalidRequests) { + assert.throws( + () => assertXtreamIterationResult(withRequests(raw, requests)), + /invalid Xtream iteration/ + ); + } +}); + +test('binds populated and empty playlist deletes to their real producer identities', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.DELETE_LARGE); + const deletes = raw.databaseWorker.requests.evidence.filter( + ({ operation }) => operation === 'DB_DELETE_PLAYLIST' + ); + const populated = deletes.find( + (candidate) => + JSON.stringify(requestItemCounts(candidate)) === + JSON.stringify([100_100, 100_101]) + ); + const emptyFollowUp = deletes.find( + (candidate) => + JSON.stringify(requestItemCounts(candidate)) === + JSON.stringify([0, 1]) + ); + assert.ok(populated?.operationId); + assert.ok(emptyFollowUp); + + const producerCoherent = replaceRequest(raw, emptyFollowUp, { + operationId: null, + operationIdUnavailableReason: null, + }); + assert.doesNotThrow(() => + assertXtreamIterationResult(withRequests(raw, producerCoherent)) + ); + + const missingPopulatedIdentity = replaceRequest(raw, populated, { + operationId: null, + operationIdUnavailableReason: null, + }); + assert.throws( + () => + assertXtreamIterationResult( + withRequests(raw, missingPopulatedIdentity) + ), + /invalid Xtream iteration/ + ); + const fabricatedFollowUpIdentity = replaceRequest(raw, emptyFollowUp, { + operationId: 'fabricated-follow-up-operation', + operationIdUnavailableReason: null, + }); + assert.throws( + () => + assertXtreamIterationResult( + withRequests(raw, fabricatedFollowUpIdentity) + ), + /invalid Xtream iteration/ + ); +}); + +test('requires producer IPC IDs to preserve per-producer source chronology', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.DELETE_LARGE); + const [first, second] = raw.databaseWorker.requests.evidence; + assert.ok(first); + assert.ok(second); + assert.ok(first.ipcCallId !== null); + assert.ok(second.ipcCallId !== null); + assert.ok(first.sourceEpochMs !== null); + assert.ok(second.sourceEpochMs !== null); + const laterSourceEpochMs = first.sourceEpochMs + 0.25; + const chronologicalRequests = raw.databaseWorker.requests.evidence.map( + (candidate) => + candidate === second + ? { ...candidate, sourceEpochMs: laterSourceEpochMs } + : candidate + ); + const chronologicalIpcSpans = raw.phaseCapture.ipcSpans.map((span) => + span.ipcCallId === second.ipcCallId + ? { ...span, sourceEpochMs: laterSourceEpochMs } + : span + ); + const swapped = chronologicalRequests.map((candidate) => { + if (candidate === first) { + return { ...candidate, ipcCallId: second.ipcCallId }; + } + return candidate.ipcCallId === second.ipcCallId + ? { ...candidate, ipcCallId: first.ipcCallId } + : candidate; + }); + assert.throws( + () => + assertXtreamIterationResult({ + ...withRequests(raw, swapped), + phaseCapture: { + ...raw.phaseCapture, + ipcSpans: chronologicalIpcSpans, + }, + }), + /invalid Xtream iteration/ + ); + const gapped = raw.databaseWorker.requests.evidence.map( + (candidate, index) => ({ + ...candidate, + ipcCallId: index === 0 ? 10 : 30, + }) + ); + const gappedIpcSpans = raw.phaseCapture.ipcSpans.map((span) => ({ + ...span, + ipcCallId: span.ipcCallId === first.ipcCallId ? 10 : 30, + })); + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...withRequests(raw, gapped), + phaseCapture: { + ...raw.phaseCapture, + ipcSpans: gappedIpcSpans, + }, + }) + ); + + const tied = raw.databaseWorker.requests.evidence.map((candidate) => + candidate === second + ? { ...candidate, sourceEpochMs: first.sourceEpochMs } + : candidate + ); + const tiedIpcSpans = raw.phaseCapture.ipcSpans.map((span) => + span.ipcCallId === second.ipcCallId + ? { ...span, sourceEpochMs: first.sourceEpochMs as number } + : span + ); + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...withRequests(raw, tied), + phaseCapture: { + ...raw.phaseCapture, + ipcSpans: tiedIpcSpans, + }, + }) + ); +}); + +test('does not let caller-owned evidence methods hide a missing populated delete identity', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.DELETE_LARGE); + const pristine = raw.databaseWorker.requests.evidence; + const populated = pristine.find( + (candidate) => + candidate.operation === 'DB_DELETE_PLAYLIST' && + JSON.stringify(requestItemCounts(candidate)) === + JSON.stringify([100_100, 100_101]) + ); + assert.ok(populated); + const forged = replaceRequest(raw, populated, { + operationId: null, + operationIdUnavailableReason: null, + }); + const invoked: string[] = []; + for (const method of ['filter', 'flatMap', 'map', 'some'] as const) { + Object.defineProperty(forged, method, { + configurable: true, + value(...args: unknown[]) { + invoked.push(method); + return Reflect.apply(Array.prototype[method], pristine, args); + }, + }); + } + + assert.throws( + () => assertXtreamIterationResult(withRequests(raw, forged)), + /invalid Xtream iteration/ + ); + assert.deepEqual(invoked, []); +}); + +test('requires the database-worker isolate heap to fit inside main-process RSS only', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakHeapUsedBytes: raw.main.peakRssBytes + 1, + }, + }), + /invalid Xtream iteration/ + ); + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakExternalBytes: raw.main.peakRssBytes + 1, + }, + }) + ); +}); + +function createRaw(scenarioId: XtreamScenarioId): XtreamRawIterationResult { + const definition = createXtreamIterationDefinitions(false).find( + (candidate) => candidate.scenarioId === scenarioId + ); + assert.ok(definition); + return iteration(definition, 1); +} + +function request(raw: XtreamRawIterationResult, operation: string) { + const value = raw.databaseWorker.requests.evidence.find( + (candidate) => candidate.operation === operation + ); + assert.ok(value); + return value; +} + +function replaceRequest( + raw: XtreamRawIterationResult, + target: (typeof raw.databaseWorker.requests.evidence)[number], + replacement: Partial +) { + return raw.databaseWorker.requests.evidence.map((candidate) => + candidate === target ? { ...candidate, ...replacement } : candidate + ); +} + +function withRequests( + raw: XtreamRawIterationResult, + evidence: typeof raw.databaseWorker.requests.evidence +): XtreamRawIterationResult { + return { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { ...raw.databaseWorker.requests, evidence }, + }, + }; +} + +function requireEpoch(value: number | null): number { + assert.ok(value !== null); + return value; +} + +function requestItemCounts( + request: XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number] +): readonly (number | null)[] { + return request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts new file mode 100644 index 000000000..9e2882836 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts @@ -0,0 +1,369 @@ +import { + XTREAM_ITERATION_KIND, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { + XTREAM_IDENTIFIER, + XTREAM_SHA256, +} from './xtream-benchmark-manifest-schema'; +import { + XTREAM_NOT_APPLICABLE, + type XtreamRawIterationResult, + type XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +export { + assertXtreamBenchmarkManifest, + parseXtreamBenchmarkManifest, + XTREAM_IDENTIFIER, + XTREAM_SHA256, +} from './xtream-benchmark-manifest-schema'; + +export const XTREAM_RAW_ITERATION_KEYS = [ + 'cancellation', + 'cancellationEvidence', + 'catalogVerification', + 'databaseWorker', + 'eligibleForComparison', + 'kind', + 'main', + 'phaseCapture', + 'phases', + 'playlistRefreshWorker', + 'processIdentity', + 'renderer', + 'runId', + 'scenarioId', + 'validity', +] as const; + +export const XTREAM_PHASE_NUMBER_KEYS = [ + 'angularRenderingMs', + 'dataAcquisitionMs', + 'deserializationMs', + 'jsonParsingMs', + 'normalizationMs', + 'serializationMs', + 'sqliteReadMs', + 'sqliteWriteMs', + 'storeUpdateMs', + 'structuredCloneProxyMs', + 'totalMs', +] as const; + +export const XTREAM_PHASE_KEYS = [ + ...XTREAM_PHASE_NUMBER_KEYS, + 'backgroundSearchMarkerMs', + 'backgroundSearchMarkerReason', + 'indexesMs', + 'indexesReason', + 'restoreUserDataMs', + 'restoreUserDataReason', + 'structuredCloneAttribution', + 'transactionCommitMs', + 'transactionCommitReason', +] as const; + +export const XTREAM_PROFILE_KEYS = [ + 'cpuProfileDurationMicros', + 'cpuProfileUnavailableReason', +] as const; + +export const XTREAM_RENDERER_NUMBER_KEYS = [ + 'frameGapMaxMs', + 'frameGapP95Ms', + 'heartbeatMaxMs', + 'heartbeatP95Ms', + 'longTaskCount', + 'longTaskMaxMs', + 'peakHeapUsedBytes', + 'peakRssBytes', + 'postGcHeapUsedBytes', + 'storeToPaintMs', +] as const; + +export const XTREAM_MAIN_NUMBER_KEYS = [ + 'browserWindowId', + 'cpuSystemMicros', + 'cpuUserMicros', + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'peakHeapUsedBytes', + 'peakRssBytes', + 'postGcHeapUsedBytes', + 'responsiveEvents', + 'unresponsiveEvents', + 'webContentsId', +] as const; + +export const XTREAM_WORKER_NUMBER_KEYS = [ + 'cpuSystemMicros', + 'cpuUserMicros', + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'eventLoopUtilization', + 'ordinal', + 'peakExternalBytes', + 'peakHeapUsedBytes', + 'postGcHeapUsedBytes', +] as const; + +export const XTREAM_CANCEL_TIME_KEYS = [ + 'acknowledgementLatencyMs', + 'authoritativeTerminalEpochMs', + 'clickEpochMs', + 'clickToDatabaseDispatchMs', + 'clickToPaintMs', + 'clickToPreloadReturnMs', + 'databaseDispatchEpochMs', + 'paintedEpochMs', + 'preloadReturnEpochMs', + 'preloadStartEpochMs', + 'workerReceiptEpochMs', + 'workerReceiptLatencyMs', +] as const; + +export function assertFreshXtreamProcessIdentities( + iterations: readonly XtreamValidatedIterationResult[] +): void { + const unique = (select: (run: XtreamValidatedIterationResult) => unknown) => + new Set(iterations.map(select)).size === iterations.length; + if ( + !unique((run) => run.processIdentity.electronPid) || + !unique((run) => run.processIdentity.launchId) || + !unique((run) => run.processIdentity.profileDirectorySha256) || + !unique((run) => run.processIdentity.generationIdentitySha256) + ) { + throw new Error('Xtream benchmark requires a fresh process identity'); + } +} + +export function snapshotXtreamIterations( + value: unknown +): readonly XtreamValidatedIterationResult[] { + try { + if (!Array.isArray(value)) invalidIterationCollection(); + const lengthDescriptor = Reflect.getOwnPropertyDescriptor( + value, + 'length' + ); + const length = lengthDescriptor?.value; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 128 + ) { + invalidIterationCollection(); + } + const snapshot: XtreamValidatedIterationResult[] = []; + for (let index = 0; index < Number(length); index += 1) { + const descriptor = Reflect.getOwnPropertyDescriptor( + value, + String(index) + ); + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalidIterationCollection(); + } + snapshot.push(descriptor.value as XtreamValidatedIterationResult); + } + return Object.freeze(snapshot); + } catch { + throw new Error('invalid Xtream iteration collection'); + } +} + +export function assertOrderedXtreamSchedule( + iterations: readonly XtreamValidatedIterationResult[], + expected: readonly XtreamIterationDefinition[], + mode: 'formal' | 'smoke' +): void { + if (iterations.length !== expected.length) scheduleError(mode); + for (let index = 0; index < expected.length; index += 1) { + const actual = iterations[index]; + const definition = expected[index]; + assertXtreamSafePathIdentifier(actual?.runId, 'run-id'); + assertXtreamSafePathIdentifier(definition?.runId, 'run-id'); + if ( + !actual || + !definition || + actual.scenarioId !== definition.scenarioId || + actual.runId !== definition.runId || + actual.kind !== definition.kind || + actual.eligibleForComparison !== definition.eligibleForComparison + ) { + throw new Error('invalid ordered Xtream benchmark schedule'); + } + } +} + +export function assertXtreamSafePathIdentifier( + value: unknown, + label: string +): asserts value is string { + if (typeof value !== 'string' || !XTREAM_IDENTIFIER.test(value)) { + throw new Error(`benchmark requires a safe Xtream ${label}`); + } +} + +export function toXtreamRawIteration( + value: XtreamValidatedIterationResult +): XtreamRawIterationResult { + const raw: Record = { ...value }; + delete raw['persistence']; + delete raw['validationStatus']; + return raw as unknown as XtreamRawIterationResult; +} + +export function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + Object.values(value).forEach(freezeDeep); + } + return value; +} + +export function isExactRecord( + value: unknown, + keys: readonly string[] +): value is Record { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + return false; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some( + (key) => typeof key !== 'string' || !keys.includes(key) + ) + ) { + return false; + } + return keys.every((key) => { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + return ( + descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, 'value') + ); + }); + } catch { + return false; + } +} + +export function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function isSafeNonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +export function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +export function isUtilization(value: unknown): boolean { + return isFiniteNonNegative(value) && value <= 1; +} + +export function hasFiniteNonNegativeKeys( + value: Record, + keys: readonly string[] +): boolean { + return keys.every((key) => isFiniteNonNegative(value[key])); +} + +export function hasOrderedEventLoopDelay( + value: Record +): boolean { + return ( + (value['eventLoopDelayP95Ms'] as number) <= + (value['eventLoopDelayP99Ms'] as number) && + (value['eventLoopDelayP99Ms'] as number) <= + (value['eventLoopDelayMaxMs'] as number) + ); +} + +export function hasMatchingEventLoopDelay( + worker: Record +): boolean { + const requests = worker['requests']; + if (typeof requests !== 'object' || requests === null) return false; + return [ + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + ].every( + (key) => worker[key] === (requests as Record)[key] + ); +} + +export function isExactXtreamProcessIdentity(value: unknown): boolean { + const keys = [ + 'captureGeneration', + 'electronPid', + 'freshProcess', + 'generationIdentitySha256', + 'launchId', + 'profileDirectorySha256', + ] as const; + return ( + isExactRecord(value, keys) && + isPositiveSafeInteger(value['captureGeneration']) && + isPositiveSafeInteger(value['electronPid']) && + value['freshProcess'] === true && + typeof value['launchId'] === 'string' && + XTREAM_IDENTIFIER.test(value['launchId']) && + typeof value['generationIdentitySha256'] === 'string' && + XTREAM_SHA256.test(value['generationIdentitySha256']) && + typeof value['profileDirectorySha256'] === 'string' && + XTREAM_SHA256.test(value['profileDirectorySha256']) + ); +} + +export function isXtreamProfile( + value: Record, + kind: unknown +): boolean { + return kind === XTREAM_ITERATION_KIND.DIAGNOSTIC + ? isPositiveSafeInteger(value['cpuProfileDurationMicros']) && + value['cpuProfileUnavailableReason'] === null + : value['cpuProfileDurationMicros'] === null && + value['cpuProfileUnavailableReason'] === + XTREAM_NOT_APPLICABLE.PROFILE_OUTSIDE_DIAGNOSTIC; +} + +export function sameValues( + left: readonly unknown[], + right: readonly unknown[] +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function sameMaps( + left: ReadonlyMap, + right: ReadonlyMap +): boolean { + return sameValues([...left.entries()].sort(), [...right.entries()].sort()); +} + +function scheduleError(mode: 'formal' | 'smoke'): never { + throw new Error( + `Xtream benchmark requires exactly ${mode === 'formal' ? 'five' : 'one'} measured runs per scenario` + ); +} + +function invalidIterationCollection(): never { + throw new Error('invalid iteration collection'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts new file mode 100644 index 000000000..0cf74f4a8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { writeXtreamExclusiveArtifact } from './xtream-exclusive-artifact-writer'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream exclusive artifact writer', () => { + it('durably writes exact bytes and returns their receipt', async () => { + const directory = await createDirectory(); + const path = join(directory, 'raw.json'); + const content = '{"safe":true}'; + + const receipt = await writeXtreamExclusiveArtifact(path, content); + + assert.deepEqual(receipt, { + absolutePath: path, + bytes: Buffer.byteLength(content), + sha256: sha256(content), + }); + assert.equal(await readFile(path, 'utf8'), content); + }); + + it('never truncates or replaces an existing artifact', async () => { + const directory = await createDirectory(); + const path = join(directory, 'raw.json'); + await writeFile(path, 'original', 'utf8'); + + await assert.rejects( + writeXtreamExclusiveArtifact(path, 'replacement'), + /Xtream artifact path is not exclusively writable/ + ); + assert.equal(await readFile(path, 'utf8'), 'original'); + }); + + it('rejects a final-component symlink and relative paths', async () => { + const directory = await createDirectory(); + const target = join(directory, 'target.json'); + const alias = join(directory, 'alias.json'); + await writeFile(target, 'target', 'utf8'); + await symlink(target, alias); + + await assert.rejects( + writeXtreamExclusiveArtifact(alias, 'replacement'), + /Xtream artifact path is not exclusively writable/ + ); + await assert.rejects( + writeXtreamExclusiveArtifact('relative.json', '{}'), + /Xtream artifact path is not exclusively writable/ + ); + assert.equal(await readFile(target, 'utf8'), 'target'); + }); + + it('rejects empty payloads before creating a file', async () => { + const directory = await createDirectory(); + const path = join(directory, 'empty.json'); + + await assert.rejects( + writeXtreamExclusiveArtifact(path, ''), + /Xtream artifact path is not exclusively writable/ + ); + await assert.rejects(readFile(path), { code: 'ENOENT' }); + }); +}); + +async function createDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-exclusive-') + ); + directories.push(directory); + return directory; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts new file mode 100644 index 000000000..9ace12d46 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto'; +import { open } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; + +import type { XtreamPersistenceReceipt } from './xtream-summary-contract'; + +export async function writeXtreamExclusiveArtifact( + absolutePath: string, + serialized: string +): Promise { + try { + if ( + typeof absolutePath !== 'string' || + !isAbsolute(absolutePath) || + typeof serialized !== 'string' || + serialized.length === 0 + ) { + invalid(); + } + const content = Buffer.from(serialized, 'utf8'); + const handle = await open(absolutePath, 'wx', 0o600); + try { + await handle.writeFile(content); + await handle.sync(); + const stats = await handle.stat(); + if ( + !stats.isFile() || + stats.isSymbolicLink() || + stats.nlink !== 1 || + stats.size !== content.byteLength + ) { + invalid(); + } + } finally { + await handle.close(); + } + return Object.freeze({ + absolutePath, + bytes: content.byteLength, + sha256: createHash('sha256').update(content).digest('hex'), + }); + } catch { + throw new Error('Xtream artifact path is not exclusively writable'); + } +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts new file mode 100644 index 000000000..291a0cebb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { link, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +import { + validateXtreamDiagnosticArtifacts, + type XtreamDiagnosticCapture, +} from './xtream-diagnostic-artifacts'; +import { verifyXtreamPersistenceReceipt } from './xtream-raw-persistence-receipt'; + +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[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream filesystem evidence integrity', () => { + it('rejects a raw persistence file with an external hard-link alias', async () => { + const directory = await createDirectory('raw'); + const outside = await createDirectory('raw-outside'); + const serialized = '{"safe":true}'; + const rawPath = join(directory, 'raw.json'); + const externalAlias = join(outside, 'external-alias.json'); + await writeFile(rawPath, serialized, 'utf8'); + await link(rawPath, externalAlias); + + await assert.rejects( + () => + verifyXtreamPersistenceReceipt(serialized, { + absolutePath: rawPath, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + }), + /^Error: invalid Xtream persistence receipt$/ + ); + }); + + it('rejects a diagnostic artifact with an external hard-link alias', async () => { + const directory = await createDirectory('diagnostic'); + const outside = await createDirectory('outside'); + await writeValidArtifacts(directory); + const tracePath = join(directory, 'renderer.trace.json'); + const externalTrace = join(outside, 'renderer.trace.json'); + await unlink(tracePath); + await writeFile(externalTrace, JSON.stringify(RENDERER_TRACE)); + await link(externalTrace, tracePath); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /^Error: xtream-diagnostic-artifact-path-invalid:renderer-trace$/ + ); + }); +}); + +function capture(directory: string): XtreamDiagnosticCapture { + 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 createDirectory(label: string): Promise { + const directory = await mkdtemp( + join(tmpdir(), `iptvnator-xtream-fs-${label}-`) + ); + directories.push(directory); + return directory; +} + +async function writeValidArtifacts(directory: string): Promise { + await Promise.all([ + writeJson(join(directory, 'main.cpuprofile'), CPU_PROFILE), + writeJson(join(directory, 'renderer.cpuprofile'), CPU_PROFILE), + writeJson(join(directory, 'database.worker-4.cpuprofile'), CPU_PROFILE), + writeJson( + join(directory, 'database.worker-4.heapsnapshot'), + HEAP_SNAPSHOT + ), + writeJson(join(directory, 'main.heapsnapshot'), HEAP_SNAPSHOT), + writeJson(join(directory, 'renderer.heapsnapshot'), HEAP_SNAPSHOT), + writeJson(join(directory, 'renderer.trace.json'), RENDERER_TRACE), + ]); +} + +function writeJson(path: string, value: unknown): Promise { + return writeFile(path, JSON.stringify(value)); +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts new file mode 100644 index 000000000..f5d6ac91b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForMethod, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import { attributeXtreamPhases } from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; + +const DB_METHOD_OPERATION = new Map([ + ['dbDeletePlaylist', 'DB_DELETE_PLAYLIST'], + ['dbDeleteXtreamContent', 'DB_DELETE_XTREAM_CONTENT'], + ['dbGetAppPlaylist', 'DB_GET_APP_PLAYLIST'], + ['dbGetCategories', 'DB_GET_CATEGORIES'], + ['dbGetContent', 'DB_GET_CONTENT'], + ['dbRestoreXtreamUserData', 'DB_RESTORE_XTREAM_USER_DATA'], + ['dbSaveCategories', 'DB_SAVE_CATEGORIES'], + ['dbSaveContent', 'DB_SAVE_CONTENT'], + ['dbUpsertAppPlaylist', 'DB_UPSERT_APP_PLAYLIST'], +]); + +describe('Xtream IPC attribution producer identity', () => { + it('requires exact pairs, distinct call IDs, and source chronology', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const requests = initial.ipcSpans + .filter( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetCategories' + ) + .sort((left, right) => left.ipcCallId - right.ipcCallId); + const first = requests[0]; + const second = requests[1]; + assert.ok(first); + assert.ok(second); + const pairDrift = initial.ipcSpans.map((span) => + span.correlationId === first.correlationId && + span.boundary === 'response' + ? { ...span, ipcCallId: span.ipcCallId + 1 } + : span + ); + const sourcePairDrift = initial.ipcSpans.map((span) => + span.correlationId === first.correlationId && + span.boundary === 'response' + ? { ...span, sourceEpochMs: span.sourceEpochMs + 0.01 } + : span + ); + const duplicate = initial.ipcSpans.map((span) => + span.correlationId === second.correlationId + ? { ...span, ipcCallId: first.ipcCallId } + : span + ); + const chronologyRegression = initial.ipcSpans.map((span) => + span.correlationId === first.correlationId + ? { ...span, sourceEpochMs: 100.75 } + : span + ); + + for (const ipcSpans of [ + pairDrift, + sourcePairDrift, + duplicate, + chronologyRegression, + ]) { + assert.throws( + () => attributeXtreamPhases({ ...initial, ipcSpans }), + /invalid Xtream phase capture/ + ); + } + }); + + it('accepts one numeric call ID in each independent preload producer namespace', () => { + const raw = iteration( + { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + }, + 1 + ); + const legacy = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbUpsertAppPlaylist' + ); + const xtream = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetCategories' + ); + assert.ok(legacy); + assert.ok(xtream); + assert.equal(legacy.ipcCallId, xtream.ipcCallId); + const legacySourceEpochMs = legacy.sourceEpochMs + 0.25; + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => { + if ( + xtreamIpcProducerNamespaceForMethod(span.method) === + 'legacy-preload' + ) { + return { ...span, sourceEpochMs: legacySourceEpochMs }; + } + return span.correlationId === xtream.correlationId + ? { ...span, ipcCallId: legacy.ipcCallId } + : span; + }); + const requests = raw.databaseWorker.requests.evidence.map((request) => { + if ( + xtreamIpcProducerNamespaceForWorkerOperation( + request.operation ?? '' + ) === 'legacy-preload' + ) { + return { ...request, sourceEpochMs: legacySourceEpochMs }; + } + return request.ipcCallId === xtream.ipcCallId && + request.operation === 'DB_GET_CATEGORIES' + ? { ...request, ipcCallId: legacy.ipcCallId } + : request; + }); + + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: requests, + }, + }, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }) + ); + }); + + it('does not collide a cancellation call with the legacy preload namespace', () => { + const raw = iteration( + { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.CANCEL_IMPORT, + }, + 1 + ); + const cancellation = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbCancelOperation' + ); + const legacy = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbUpsertAppPlaylist' + ); + assert.ok(cancellation); + assert.ok(legacy); + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => + span.correlationId === legacy.correlationId + ? { ...span, ipcCallId: cancellation.ipcCallId } + : span + ); + const requests = raw.databaseWorker.requests.evidence.map((request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && + request.ipcCallId === legacy.ipcCallId + ? { ...request, ipcCallId: cancellation.ipcCallId } + : request + ); + + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: requests, + }, + }, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }) + ); + }); + + it('shares each database preload identity with its worker request', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const raw = iteration( + { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }, + 1 + ); + const workersByProducerIdentity = new Map( + raw.databaseWorker.requests.evidence.flatMap((request) => + request.ipcCallId === null + ? [] + : [ + [ + workerIdentityKey( + request.operation ?? '', + request.ipcCallId + ), + request, + ] as const, + ] + ) + ); + for (const span of raw.phaseCapture.ipcSpans) { + if (span.boundary !== 'request') continue; + const operation = DB_METHOD_OPERATION.get(span.method); + if (!operation) continue; + const namespace = xtreamIpcProducerNamespaceForMethod( + span.method + ); + assert.ok(namespace); + const worker = workersByProducerIdentity.get( + xtreamIpcProducerIdentityKey(namespace, span.ipcCallId) + ); + assert.ok(worker); + assert.equal(worker.operation, operation); + assert.equal(worker.sourceEpochMs, span.sourceEpochMs); + } + } + }); +}); + +function workerIdentityKey(operation: string, ipcCallId: number): string { + const namespace = xtreamIpcProducerNamespaceForWorkerOperation(operation); + assert.ok(namespace); + return xtreamIpcProducerIdentityKey(namespace, ipcCallId); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts new file mode 100644 index 000000000..8a27b32ef --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts @@ -0,0 +1,67 @@ +export interface XtreamIpcMarkerCaptureOptions { + readonly actions: readonly string[]; + readonly mainPhases: readonly string[]; + readonly methods: readonly string[]; + readonly schemaVersion: number; +} + +export interface XtreamIpcRequestIdentity { + ipcCallId: number | null; + operationId: string | null; + operationIdUnavailableReason: string | null; + sourceEpochMs: number | null; +} + +export interface XtreamIpcCallState { + completionOutcome: 'error' | 'success' | null; + downstreamMatched: boolean; + ended: boolean; + key: string; + marker: import('./xtream-ipc-marker-protocol').SafeXtreamPreloadMarker; + quarantined: boolean; + receivedEpochMs: number; +} + +export interface XtreamMainPhaseState { + endEpochMs: number | null; + startEpochMs: number; +} + +export interface XtreamIpcTimelineRecord { + readonly action?: string | null; + readonly boundary: 'end' | 'start'; + 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 operationId?: string | null; + readonly outcome?: 'error' | 'success' | null; + readonly phase?: string; + readonly playlistId?: string | null; + readonly requestId?: string; + readonly sessionId?: string | null; + readonly sourceEpochMs?: number; + readonly type: 'xtream-main-phase' | 'xtream-preload-marker'; +} + +export interface XtreamIpcMarkerCaptureSnapshot { + readonly invalidReasons: readonly string[]; + readonly quarantinedKeyCount: number; + readonly timeline: readonly XtreamIpcTimelineRecord[]; +} + +export interface XtreamIpcMarkerCapture { + acceptMainPhase(input: unknown): boolean; + acceptPreload( + senderWebContentsId: number, + receivedEpochMs: number, + input: unknown + ): boolean; + matchDatabaseCancel(input: unknown): XtreamIpcRequestIdentity | null; + matchDatabaseRequest(input: unknown): XtreamIpcRequestIdentity | null; + start(expectedWebContentsId: number, captureStartedEpochMs: number): void; + stop(): XtreamIpcMarkerCaptureSnapshot; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts new file mode 100644 index 000000000..443c1be70 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts @@ -0,0 +1,379 @@ +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 XtreamIpcMarkerCaptureOptions, +} from './xtream-ipc-marker-events'; +import { createXtreamIpcMarkerProtocol } from './xtream-ipc-marker-protocol'; +import { createXtreamMainLifecycleValidator } from './xtream-main-lifecycle'; + +const options: XtreamIpcMarkerCaptureOptions = { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: 1, +}; + +function marker(overrides: Record = {}) { + return { + schemaVersion: 1, + sourceEpochMs: 100, + ipcCallId: 1, + method: 'dbSaveContent', + boundary: 'start', + outcome: null, + playlistId: 'playlist-1', + operationId: 'operation-1', + sessionId: null, + action: null, + categoryType: null, + contentType: 'live', + itemCount: 60_000, + ...overrides, + }; +} + +describe('Xtream preload marker capture', () => { + it('restores both factories without module closures for Electron-main injection', () => { + const restore = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; + const protocolFactory = restore( + createXtreamIpcMarkerProtocol.toString() + ); + const captureFactory = restore( + createXtreamIpcMarkerCapture.toString() + ); + const lifecycleFactory = restore< + typeof createXtreamMainLifecycleValidator + >(createXtreamMainLifecycleValidator.toString()); + const capture = captureFactory( + options, + protocolFactory(options), + lifecycleFactory() + ); + + capture.start(42, 90); + assert.equal(capture.acceptPreload(42, 101, marker()), true); + }); + + it('accepts only the exact 13-key safe schema from the bound webContents', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + + assert.equal(capture.acceptPreload(42, 101, marker()), true); + assert.equal( + capture.acceptPreload( + 42, + 102, + marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + true + ); + + const snapshot = capture.stop(); + assert.deepEqual(snapshot.invalidReasons, []); + assert.equal(snapshot.quarantinedKeyCount, 0); + assert.equal(snapshot.timeline.length, 2); + assert.deepEqual(snapshot.timeline[0], { + action: null, + boundary: 'start', + categoryType: null, + contentType: 'live', + epochMs: 101, + ipcCallId: 1, + itemCount: 60_000, + method: 'dbSaveContent', + operationId: 'operation-1', + outcome: null, + playlistId: 'playlist-1', + sessionId: null, + sourceEpochMs: 100, + type: 'xtream-preload-marker', + }); + }); + + it('normalizes sub-millisecond cross-process clock skew and rejects larger reversals', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + + assert.equal( + capture.acceptPreload(42, 100, marker({ sourceEpochMs: 100.006 })), + true + ); + assert.equal( + capture.acceptPreload( + 42, + 101, + marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101.006, + }) + ), + true + ); + const snapshot = capture.stop(); + assert.deepEqual(snapshot.invalidReasons, []); + assert.equal(snapshot.timeline[0]?.epochMs, 100.006); + assert.equal(snapshot.timeline[1]?.epochMs, 101.006); + + const reversed = createXtreamIpcMarkerCapture(options); + reversed.start(42, 90); + assert.equal( + reversed.acceptPreload(42, 100, marker({ sourceEpochMs: 101.001 })), + false + ); + assert.ok( + reversed + .stop() + .invalidReasons.includes('preload-marker-source-after-receipt') + ); + }); + + it('rejects delayed preload markers from a completed capture generation', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + capture.stop(); + capture.start(42, 200); + + assert.equal(capture.acceptPreload(42, 210, marker()), false); + assert.equal( + capture.acceptPreload( + 42, + 211, + marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + false + ); + assert.ok( + capture + .stop() + .invalidReasons.includes('preload-marker-before-capture-start') + ); + }); + + it('rejects another sender, extra or missing keys, malformed IDs, and regressing epochs', () => { + const invalidCases: Array<{ + first?: Record; + input: Record; + receivedEpochMs?: number; + sender?: number; + }> = [ + { input: marker(), sender: 7 }, + { input: { ...marker(), password: 'provider-secret' } }, + { + input: Object.fromEntries( + Object.entries(marker()).filter( + ([key]) => key !== 'itemCount' + ) + ), + }, + { input: marker({ playlistId: ' playlist-1' }) }, + { input: marker({ ipcCallId: 0 }) }, + { input: marker({ outcome: 'success' }) }, + { + first: marker(), + input: marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 99, + }), + receivedEpochMs: 102, + }, + { + input: marker({ sourceEpochMs: 102 }), + receivedEpochMs: 101, + }, + ]; + + for (const testCase of invalidCases) { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + if (testCase.first) { + assert.equal( + capture.acceptPreload(42, 101, testCase.first), + true + ); + } + assert.equal( + capture.acceptPreload( + testCase.sender ?? 42, + testCase.receivedEpochMs ?? 101, + testCase.input + ), + false + ); + assert.notEqual(capture.stop().invalidReasons.length, 0); + } + }); + + it('correlates DB requests FIFO and quarantines overlapping duplicate keys', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal(capture.acceptPreload(42, 100, marker()), true); + + const identity = capture.matchDatabaseRequest({ + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + type: 'live', + }, + }); + assert.deepEqual(identity, { + ipcCallId: 1, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + assert.equal( + capture.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + }), + null + ); + + const duplicateCapture = createXtreamIpcMarkerCapture(options); + duplicateCapture.start(42, 90); + assert.equal(duplicateCapture.acceptPreload(42, 100, marker()), true); + assert.equal( + duplicateCapture.acceptPreload( + 42, + 101, + marker({ ipcCallId: 2, sourceEpochMs: 101 }) + ), + false + ); + assert.deepEqual( + duplicateCapture.matchDatabaseRequest({ + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + type: 'live', + }, + }), + { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'xtream-preload-performance-marker-ambiguous', + sourceEpochMs: null, + } + ); + assert.equal(duplicateCapture.stop().quarantinedKeyCount, 1); + }); + + it('retains a mutable missing identity when a valid preload start arrives after the worker request', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const identity = capture.matchDatabaseRequest({ + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + type: 'live', + }, + }); + assert.deepEqual(identity, { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'xtream-preload-performance-marker-missing', + sourceEpochMs: null, + }); + + assert.equal(capture.acceptPreload(42, 100, marker()), true); + assert.deepEqual(identity, { + ipcCallId: 1, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + }); + + it('correlates read-only content requests without inventing an operation ID', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal( + capture.acceptPreload( + 42, + 100, + marker({ + ipcCallId: 3, + itemCount: null, + method: 'dbGetContent', + operationId: null, + }) + ), + true + ); + + assert.deepEqual( + capture.matchDatabaseRequest({ + operation: 'DB_GET_CONTENT', + payload: { playlistId: 'playlist-1', type: 'live' }, + }), + { + ipcCallId: 3, + operationId: null, + operationIdUnavailableReason: null, + sourceEpochMs: 100, + } + ); + }); + + it('correlates the preload cancel call separately from the active DB request', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const cancelMarker = marker({ + contentType: null, + ipcCallId: 4, + itemCount: null, + method: 'dbCancelOperation', + playlistId: null, + }); + assert.equal(capture.acceptPreload(42, 100, cancelMarker), true); + assert.deepEqual( + capture.matchDatabaseCancel({ + operationId: 'operation-1', + type: 'cancel', + }), + { + ipcCallId: 4, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + } + ); + assert.equal( + capture.acceptPreload( + 42, + 102, + marker({ + ...cancelMarker, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + true + ); + assert.deepEqual(capture.stop().invalidReasons, []); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts new file mode 100644 index 000000000..972377c0b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts @@ -0,0 +1,399 @@ +import { + createXtreamIpcMarkerProtocol, + type XtreamIpcMarkerProtocol, +} from './xtream-ipc-marker-protocol'; +import type { + XtreamIpcCallState, + XtreamIpcMarkerCapture, + XtreamIpcMarkerCaptureOptions, + XtreamIpcRequestIdentity, + XtreamIpcTimelineRecord, + XtreamMainPhaseState, +} from './xtream-ipc-marker-events.model'; +import { + createXtreamMainLifecycleValidator, + type XtreamMainLifecycleValidator, +} from './xtream-main-lifecycle'; +export { deriveXtreamAcquisitionDurationMs } from './xtream-acquisition-duration'; +export type * from './xtream-ipc-marker-events.model'; +export function createXtreamIpcMarkerCapture( + options: XtreamIpcMarkerCaptureOptions, + protocol: XtreamIpcMarkerProtocol = createXtreamIpcMarkerProtocol(options), + mainLifecycle: XtreamMainLifecycleValidator = createXtreamMainLifecycleValidator() +): XtreamIpcMarkerCapture { + let active = false; + let captureStartedEpochMs = 0; + let expectedWebContentsId: number | null = null; + let lastMainEpochMs = 0; + let lastReceivedEpochMs = 0; + let lastSourceEpochMs = 0; + let timeline: XtreamIpcTimelineRecord[] = []; + let invalidReasons: string[] = []; + const preloadSourceReceiptSkewToleranceMs = 1; + const calls = new Map(); + const activeByKey = new Map(); + const pendingRequests = new Map(); + const quarantinedKeys = new Set(); + const mainPhases = new Map(); + const mainRequestCalls = new Map(); + const helpers = { + invalidate(reason: string): false { + invalidReasons.push(reason); + return false; + }, + mainPhasesForRequest( + requestId: string + ): readonly (XtreamMainPhaseState & { readonly phase: string })[] { + const prefix = `${requestId}\u0000`; + return [...mainPhases.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, state]) => ({ + ...state, + phase: key.slice(prefix.length), + })); + }, + matchKey( + key: string | null | undefined + ): XtreamIpcRequestIdentity | null { + if (key === undefined) { + return null; + } + if (key === null || quarantinedKeys.has(key)) { + return protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ); + } + const call = activeByKey.get(key); + if (call && !call.downstreamMatched && !call.quarantined) { + call.downstreamMatched = true; + const identity = protocol.unavailableIdentity( + 'xtream-preload-performance-marker-missing' + ); + protocol.applyMarkerIdentity(identity, call.marker); + return identity; + } + const identity = protocol.unavailableIdentity( + 'xtream-preload-performance-marker-missing' + ); + const waiting = pendingRequests.get(key) ?? []; + waiting.push(identity); + pendingRequests.set(key, waiting); + if (waiting.length > 1) { + helpers.quarantine(key); + } + return identity; + }, + quarantine(key: string): void { + quarantinedKeys.add(key); + const activeCall = activeByKey.get(key); + if (activeCall) { + activeCall.quarantined = true; + activeByKey.delete(key); + } + for (const identity of pendingRequests.get(key) ?? []) { + Object.assign( + identity, + protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ) + ); + } + pendingRequests.delete(key); + }, + }; + return { + acceptMainPhase(input) { + if (!active) return false; + const event = protocol.parseMainPhase(input); + if (!event) { + return helpers.invalidate('main-phase-invalid'); + } + const { boundary, durationMs, epochMs, phase, requestId } = event; + if (epochMs < captureStartedEpochMs) { + return helpers.invalidate('main-phase-before-capture-start'); + } + if (epochMs < lastMainEpochMs) { + return helpers.invalidate('main-phase-invalid'); + } + lastMainEpochMs = epochMs; + const boundaryKey = `${requestId}\u0000${phase}`; + const phaseState = mainPhases.get(boundaryKey); + if (boundary === 'start') { + if (phaseState) { + return helpers.invalidate('main-phase-duplicate-boundary'); + } + mainPhases.set(boundaryKey, { + endEpochMs: null, + startEpochMs: epochMs, + }); + } else { + if ( + !phaseState || + phaseState.endEpochMs !== null || + epochMs < phaseState.startEpochMs + ) { + return helpers.invalidate('main-phase-duplicate-boundary'); + } + phaseState.endEpochMs = epochMs; + } + let call = mainRequestCalls.get(requestId); + if ( + !call && + boundary === 'start' && + (phase === 'xtream.network.total' || + phase === 'xtream.cancel-session') + ) { + const method = + phase === 'xtream.network.total' + ? 'xtreamRequest' + : 'xtreamCancelSession'; + call = [...calls.values()] + .filter( + (candidate) => + !candidate.ended && + !candidate.quarantined && + !candidate.downstreamMatched && + candidate.marker.method === method + ) + .sort( + (left, right) => + left.receivedEpochMs - right.receivedEpochMs || + left.marker.ipcCallId - right.marker.ipcCallId + )[0]; + if (call) { + call.downstreamMatched = true; + mainRequestCalls.set(requestId, call); + } else { + invalidReasons.push( + 'main-phase-preload-performance-marker-missing' + ); + } + } + timeline.push({ + ...(call + ? { + action: call.marker.action, + categoryType: call.marker.categoryType, + contentType: call.marker.contentType, + ipcCallId: call.marker.ipcCallId, + } + : {}), + boundary, + durationMs: durationMs as number | null, + epochMs, + phase, + requestId, + type: 'xtream-main-phase', + }); + return true; + }, + acceptPreload(senderWebContentsId, receivedEpochMs, input) { + if ( + !active || + senderWebContentsId !== expectedWebContentsId || + !protocol.isEpoch(receivedEpochMs) + ) { + return helpers.invalidate( + 'preload-marker-sender-or-epoch-invalid' + ); + } + if (receivedEpochMs < captureStartedEpochMs) { + return helpers.invalidate( + 'preload-marker-before-capture-start' + ); + } + if (receivedEpochMs < lastReceivedEpochMs) { + return helpers.invalidate( + 'preload-marker-sender-or-epoch-invalid' + ); + } + const marker = protocol.parseMarker(input); + if (!marker) { + return helpers.invalidate('preload-marker-invalid-schema'); + } + if (marker.sourceEpochMs < captureStartedEpochMs) { + return helpers.invalidate( + 'preload-marker-before-capture-start' + ); + } + if ( + marker.sourceEpochMs - receivedEpochMs >= + preloadSourceReceiptSkewToleranceMs + ) { + return helpers.invalidate( + 'preload-marker-source-after-receipt' + ); + } + const normalizedReceivedEpochMs = Math.max( + receivedEpochMs, + marker.sourceEpochMs + ); + if (marker.sourceEpochMs < lastSourceEpochMs) { + return helpers.invalidate( + 'preload-marker-source-epoch-regressed' + ); + } + const key = protocol.correlationKey(marker); + if (!key) { + return helpers.invalidate( + 'preload-marker-correlation-key-invalid' + ); + } + lastReceivedEpochMs = receivedEpochMs; + lastSourceEpochMs = marker.sourceEpochMs; + if (marker.boundary === 'start') { + if (calls.has(marker.ipcCallId) || activeByKey.has(key)) { + helpers.quarantine(key); + return helpers.invalidate('preload-marker-duplicate-key'); + } + const call: XtreamIpcCallState = { + completionOutcome: null, + downstreamMatched: false, + ended: false, + key, + marker, + quarantined: false, + receivedEpochMs: normalizedReceivedEpochMs, + }; + calls.set(marker.ipcCallId, call); + activeByKey.set(key, call); + const waiting = pendingRequests.get(key) ?? []; + if (waiting.length === 1 && waiting[0]) { + protocol.applyMarkerIdentity(waiting[0], marker); + call.downstreamMatched = true; + pendingRequests.delete(key); + } else if (waiting.length > 1) { + helpers.quarantine(key); + return helpers.invalidate('preload-marker-duplicate-key'); + } + } else { + const call = calls.get(marker.ipcCallId); + if ( + !call || + call.ended || + call.quarantined || + call.key !== key || + !protocol.sameMarkerIdentity(call.marker, marker) + ) { + return helpers.invalidate('preload-marker-unmatched-end'); + } + call.ended = true; + call.completionOutcome = marker.outcome; + activeByKey.delete(key); + } + timeline.push({ + action: marker.action, + boundary: marker.boundary, + categoryType: marker.categoryType, + contentType: marker.contentType, + epochMs: normalizedReceivedEpochMs, + ipcCallId: marker.ipcCallId, + itemCount: marker.itemCount, + method: marker.method, + operationId: marker.operationId, + outcome: marker.outcome, + playlistId: marker.playlistId, + sessionId: marker.sessionId, + sourceEpochMs: marker.sourceEpochMs, + type: 'xtream-preload-marker', + }); + return true; + }, + matchDatabaseCancel(input) { + if (!active) { + return null; + } + try { + return helpers.matchKey(protocol.readCancelKey(input)); + } catch { + helpers.invalidate('preload-marker-correlation-input-invalid'); + return protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ); + } + }, + matchDatabaseRequest(input) { + if (!active) { + return null; + } + try { + return helpers.matchKey(protocol.readRequestKey(input)); + } catch { + helpers.invalidate('preload-marker-correlation-input-invalid'); + return protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ); + } + }, + start(webContentsId, startedEpochMs) { + const validStart = protocol.isEpoch(startedEpochMs); + active = true; + captureStartedEpochMs = validStart + ? startedEpochMs + : Number.POSITIVE_INFINITY; + expectedWebContentsId = webContentsId; + lastMainEpochMs = captureStartedEpochMs; + lastReceivedEpochMs = captureStartedEpochMs; + lastSourceEpochMs = captureStartedEpochMs; + timeline = []; + invalidReasons = []; + if (!validStart) { + invalidReasons.push('capture-start-epoch-invalid'); + } + calls.clear(); + activeByKey.clear(); + pendingRequests.clear(); + quarantinedKeys.clear(); + mainPhases.clear(); + mainRequestCalls.clear(); + }, + stop() { + for (const call of calls.values()) { + if (!call.ended && !call.quarantined) { + invalidReasons.push('preload-marker-unclosed'); + } + if ( + call.ended && + (call.marker.method === 'xtreamRequest' || + call.marker.method === 'xtreamCancelSession') + ) { + const linkedRequest = [...mainRequestCalls.entries()].find( + ([, candidate]) => candidate === call + ); + if (!linkedRequest) { + invalidReasons.push('preload-main-linkage-missing'); + continue; + } + const lifecycleInvalidReason = mainLifecycle.validate({ + method: call.marker.method, + outcome: call.completionOutcome, + phases: helpers.mainPhasesForRequest(linkedRequest[0]), + }); + if (lifecycleInvalidReason) { + invalidReasons.push(lifecycleInvalidReason); + } + } + } + for (const phase of mainPhases.values()) { + if (phase.endEpochMs === null) { + invalidReasons.push('main-phase-unclosed'); + } + } + for (const key of mainPhases.keys()) { + const requestId = key.slice(0, key.indexOf('\u0000')); + if (!mainRequestCalls.has(requestId)) { + invalidReasons.push( + 'main-phase-preload-performance-marker-missing' + ); + } + } + active = false; + expectedWebContentsId = null; + return Object.freeze({ + invalidReasons: Object.freeze([...invalidReasons]), + quarantinedKeyCount: quarantinedKeys.size, + timeline: Object.freeze([...timeline]), + }); + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts new file mode 100644 index 000000000..09e8998c7 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts @@ -0,0 +1,333 @@ +import type { XtreamIpcMarkerCaptureOptions } from './xtream-ipc-marker-events.model'; +import type { XtreamIpcRequestIdentity } from './xtream-ipc-marker-events.model'; + +export interface SafeXtreamPreloadMarker { + action: string | null; + boundary: 'end' | 'start'; + categoryType: string | null; + contentType: string | null; + ipcCallId: number; + itemCount: number | null; + method: string; + operationId: string | null; + outcome: 'error' | 'success' | null; + playlistId: string | null; + schemaVersion: number; + sessionId: string | null; + sourceEpochMs: number; +} + +export interface SafeXtreamMainPhase { + boundary: 'end' | 'start'; + durationMs: number | null; + epochMs: number; + phase: string; + requestId: string; +} + +export interface XtreamIpcMarkerProtocol { + applyMarkerIdentity( + identity: XtreamIpcRequestIdentity, + marker: SafeXtreamPreloadMarker + ): void; + correlationKey(marker: SafeXtreamPreloadMarker): string | null; + isEpoch(value: unknown): value is number; + parseMainPhase(input: unknown): SafeXtreamMainPhase | null; + parseMarker(input: unknown): SafeXtreamPreloadMarker | null; + readCancelKey(input: unknown): string | null | undefined; + readRequestKey(input: unknown): string | null | undefined; + sameMarkerIdentity( + start: SafeXtreamPreloadMarker, + end: SafeXtreamPreloadMarker + ): boolean; + unavailableIdentity(reason: string): XtreamIpcRequestIdentity; +} + +export function createXtreamIpcMarkerProtocol( + options: XtreamIpcMarkerCaptureOptions +): XtreamIpcMarkerProtocol { + type JsonRecord = Record; + const markerKeys = new Set([ + 'schemaVersion', + 'sourceEpochMs', + 'ipcCallId', + 'method', + 'boundary', + 'outcome', + 'playlistId', + 'operationId', + 'sessionId', + 'action', + 'categoryType', + 'contentType', + 'itemCount', + ]); + const actions = new Set(options.actions); + const mainPhases = new Set(options.mainPhases); + const methods = new Set(options.methods); + const categoryTypes = new Set(['live', 'movies', 'series']); + const contentTypes = new Set(['live', 'movie', 'series']); + const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + const rendererRequestId = /^xtream-[0-9a-z]+-[1-9][0-9]*$/; + const internalRequestId = + /^xtream-main-(?:fallback-[1-9][0-9]*|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/; + const operationToMethod: Readonly> = { + DB_DELETE_PLAYLIST: 'dbDeletePlaylist', + DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent', + DB_GET_CATEGORIES: 'dbGetCategories', + DB_GET_CONTENT: 'dbGetContent', + DB_GLOBAL_SEARCH: 'dbGlobalSearch', + DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData', + DB_SAVE_CATEGORIES: 'dbSaveCategories', + DB_SAVE_CONTENT: 'dbSaveContent', + DB_SEARCH_CONTENT: 'dbSearchContent', + }; + const helpers = { + correlationKey(marker: SafeXtreamPreloadMarker): string | null { + const values: Array = [marker.method]; + switch (marker.method) { + case 'xtreamRequest': + if (marker.action === null) return null; + values.push(marker.ipcCallId); + break; + case 'xtreamCancelSession': + values.push(marker.sessionId); + break; + case 'dbGetCategories': + case 'dbSaveCategories': + values.push(marker.playlistId, marker.categoryType); + break; + case 'dbGetContent': + values.push(marker.playlistId, marker.contentType); + break; + case 'dbSaveContent': + values.push( + marker.playlistId, + marker.contentType, + marker.operationId + ); + break; + case 'dbDeletePlaylist': + values.push( + marker.playlistId, + marker.operationId ?? '' + ); + break; + case 'dbDeleteXtreamContent': + case 'dbRestoreXtreamUserData': + values.push(marker.playlistId, marker.operationId); + break; + case 'dbSearchContent': + values.push(marker.playlistId); + break; + case 'dbGlobalSearch': + break; + case 'dbCancelOperation': + values.push(marker.operationId); + break; + default: + return null; + } + return values.some((value) => value === null) + ? null + : JSON.stringify(values); + }, + isEpoch(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + }, + isIdentifier(value: unknown): value is string { + return typeof value === 'string' && identifierPattern.test(value); + }, + isNullableIdentifier(value: unknown): value is string | null { + return value === null || helpers.isIdentifier(value); + }, + isNullableMember( + value: unknown, + allowed: ReadonlySet + ): value is string | null { + return ( + value === null || + (typeof value === 'string' && allowed.has(value)) + ); + }, + isRecord(value: unknown): value is JsonRecord { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); + }, + }; + return { + applyMarkerIdentity(identity, marker): void { + identity.ipcCallId = marker.ipcCallId; + identity.operationId = marker.operationId; + identity.operationIdUnavailableReason = null; + identity.sourceEpochMs = marker.sourceEpochMs; + }, + correlationKey(marker): string | null { + return helpers.correlationKey(marker); + }, + isEpoch(value): value is number { + return helpers.isEpoch(value); + }, + parseMainPhase(input): SafeXtreamMainPhase | null { + try { + if (!helpers.isRecord(input)) { + return null; + } + const boundary = input['boundary']; + const durationMs = input['durationMs']; + const epochMs = input['epochMs']; + const phase = input['phase']; + const requestId = input['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + !helpers.isEpoch(epochMs) || + typeof phase !== 'string' || + !mainPhases.has(phase) || + typeof requestId !== 'string' || + requestId.length > 64 || + (!rendererRequestId.test(requestId) && + !internalRequestId.test(requestId)) || + (boundary === 'start' + ? durationMs !== null + : typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs < 0) + ) { + return null; + } + return { + boundary, + durationMs: durationMs as number | null, + epochMs, + phase, + requestId, + }; + } catch { + return null; + } + }, + parseMarker(input): SafeXtreamPreloadMarker | null { + try { + if ( + !helpers.isRecord(input) || + Reflect.ownKeys(input).length !== markerKeys.size || + Reflect.ownKeys(input).some( + (key) => typeof key !== 'string' || !markerKeys.has(key) + ) + ) { + return null; + } + const boundary = input['boundary']; + const outcome = input['outcome']; + const itemCount = input['itemCount']; + if ( + input['schemaVersion'] !== options.schemaVersion || + !helpers.isEpoch(input['sourceEpochMs']) || + !Number.isSafeInteger(input['ipcCallId']) || + Number(input['ipcCallId']) < 1 || + typeof input['method'] !== 'string' || + !methods.has(input['method']) || + (boundary !== 'start' && boundary !== 'end') || + (boundary === 'start' + ? outcome !== null + : outcome !== 'success' && outcome !== 'error') || + !helpers.isNullableIdentifier(input['playlistId']) || + !helpers.isNullableIdentifier(input['operationId']) || + !helpers.isNullableIdentifier(input['sessionId']) || + !helpers.isNullableMember(input['action'], actions) || + !helpers.isNullableMember( + input['categoryType'], + categoryTypes + ) || + !helpers.isNullableMember( + input['contentType'], + contentTypes + ) || + (itemCount !== null && + (!Number.isSafeInteger(itemCount) || + Number(itemCount) < 0 || + Number(itemCount) > 1_000_000)) + ) { + return null; + } + return input as unknown as SafeXtreamPreloadMarker; + } catch { + return null; + } + }, + readCancelKey(input): string | null | undefined { + if (!helpers.isRecord(input) || input['type'] !== 'cancel') { + return undefined; + } + return helpers.correlationKey({ + method: 'dbCancelOperation', + operationId: + typeof input['operationId'] === 'string' + ? input['operationId'] + : null, + } as SafeXtreamPreloadMarker); + }, + readRequestKey(input): string | null | undefined { + if ( + !helpers.isRecord(input) || + typeof input['operation'] !== 'string' + ) { + return undefined; + } + const method = operationToMethod[input['operation']]; + if (!method) { + return undefined; + } + const payload = helpers.isRecord(input['payload']) + ? input['payload'] + : {}; + return helpers.correlationKey({ + method, + playlistId: + typeof payload['playlistId'] === 'string' + ? payload['playlistId'] + : null, + operationId: + typeof payload['operationId'] === 'string' + ? payload['operationId'] + : null, + categoryType: + typeof payload['type'] === 'string' && + categoryTypes.has(payload['type']) + ? payload['type'] + : null, + contentType: + typeof payload['type'] === 'string' && + contentTypes.has(payload['type']) + ? payload['type'] + : null, + } as SafeXtreamPreloadMarker); + }, + sameMarkerIdentity(start, end): boolean { + return ( + start.ipcCallId === end.ipcCallId && + start.method === end.method && + start.playlistId === end.playlistId && + start.operationId === end.operationId && + start.sessionId === end.sessionId && + start.action === end.action && + start.categoryType === end.categoryType && + start.contentType === end.contentType && + start.itemCount === end.itemCount + ); + }, + unavailableIdentity(reason): XtreamIpcRequestIdentity { + return { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: reason, + sourceEpochMs: null, + }; + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts new file mode 100644 index 000000000..7572c02fd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts @@ -0,0 +1,96 @@ +import { + XTREAM_WORKER_REQUEST_IDENTITY_KIND, + xtreamWorkerRequestIdentityKind, +} from './xtream-worker-request-identity'; + +export const XTREAM_IPC_PRODUCER_NAMESPACE = { + LEGACY_PRELOAD: 'legacy-preload', + XTREAM_PRELOAD: 'xtream-preload', +} as const; + +export type XtreamIpcProducerNamespace = + (typeof XTREAM_IPC_PRODUCER_NAMESPACE)[keyof typeof XTREAM_IPC_PRODUCER_NAMESPACE]; + +export interface XtreamIpcProducerIdentity { + readonly ipcCallId: number; + readonly namespace: XtreamIpcProducerNamespace; + readonly sourceEpochMs: number; +} + +const LEGACY_PRELOAD_METHODS = new Set([ + 'dbGetAppPlaylist', + 'dbUpsertAppPlaylist', +]); +const XTREAM_PRELOAD_METHODS = new Set([ + 'dbCancelOperation', + 'dbDeletePlaylist', + 'dbDeleteXtreamContent', + 'dbGetCategories', + 'dbGetContent', + 'dbGlobalSearch', + 'dbRestoreXtreamUserData', + 'dbSaveCategories', + 'dbSaveContent', + 'dbSearchContent', + 'xtreamCancelSession', + 'xtreamRequest', +]); + +export function xtreamIpcProducerNamespaceForMethod( + method: string +): XtreamIpcProducerNamespace | null { + if (LEGACY_PRELOAD_METHODS.has(method)) { + return XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD; + } + if (XTREAM_PRELOAD_METHODS.has(method)) { + return XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD; + } + return null; +} + +export function xtreamIpcProducerNamespaceForWorkerOperation( + operation: string +): XtreamIpcProducerNamespace | null { + const kind = xtreamWorkerRequestIdentityKind(operation); + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD) { + return XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD; + } + if ( + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_IPC || + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION + ) { + return XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD; + } + return null; +} + +export function xtreamIpcProducerIdentityKey( + namespace: XtreamIpcProducerNamespace, + ipcCallId: number +): string { + return `${namespace}\0${ipcCallId}`; +} + +export function hasDistinctMonotonicXtreamIpcProducerIdentities( + identities: readonly XtreamIpcProducerIdentity[] +): boolean { + const keys = identities.map(({ ipcCallId, namespace }) => + xtreamIpcProducerIdentityKey(namespace, ipcCallId) + ); + if (new Set(keys).size !== keys.length) return false; + for (const namespace of Object.values(XTREAM_IPC_PRODUCER_NAMESPACE)) { + const ordered = identities + .filter((identity) => identity.namespace === namespace) + .sort((left, right) => left.ipcCallId - right.ipcCallId); + if ( + ordered.some( + ({ sourceEpochMs }, index) => + index > 0 && + sourceEpochMs < Number(ordered[index - 1]?.sourceEpochMs) + ) + ) { + return false; + } + } + return true; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts new file mode 100644 index 000000000..6291794f3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts @@ -0,0 +1,78 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { XtreamIpcAttributionSpan } from './xtream-phase-attribution'; +import { + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForMethod, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; + +const DB_METHOD_OPERATION = Object.freeze({ + dbDeletePlaylist: 'DB_DELETE_PLAYLIST', + dbDeleteXtreamContent: 'DB_DELETE_XTREAM_CONTENT', + dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST', + dbGetCategories: 'DB_GET_CATEGORIES', + dbGetContent: 'DB_GET_CONTENT', + dbRestoreXtreamUserData: 'DB_RESTORE_XTREAM_USER_DATA', + dbSaveCategories: 'DB_SAVE_CATEGORIES', + dbSaveContent: 'DB_SAVE_CONTENT', + dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST', +}); +const WORKER_OPERATIONS = new Set(Object.values(DB_METHOD_OPERATION)); + +export function xtreamWorkerOperationForIpcMethod( + method: string +): string | null { + return ( + DB_METHOD_OPERATION[method as keyof typeof DB_METHOD_OPERATION] ?? null + ); +} + +export function assertXtreamIpcWorkerIdentityBinding( + spans: readonly XtreamIpcAttributionSpan[], + requests: readonly WorkerRequestPerformanceMetrics[] +): void { + const workerByIdentity = new Map(); + for (const request of requests) { + if (request.ipcCallId === null) continue; + const namespace = xtreamIpcProducerNamespaceForWorkerOperation( + typeof request.operation === 'string' ? request.operation : '' + ); + if (namespace === null) invalid(); + const key = xtreamIpcProducerIdentityKey(namespace, request.ipcCallId); + if (workerByIdentity.has(key)) invalid(); + workerByIdentity.set(key, request); + } + const boundWorkerIdentities = new Set(); + for (const span of spans) { + if (span.boundary !== 'request') continue; + const operation = xtreamWorkerOperationForIpcMethod(span.method); + if (operation === null) continue; + const namespace = xtreamIpcProducerNamespaceForMethod(span.method); + if (namespace === null) invalid(); + const key = xtreamIpcProducerIdentityKey(namespace, span.ipcCallId); + const worker = workerByIdentity.get(key); + if ( + !worker || + worker.operation !== operation || + worker.sourceEpochMs !== span.sourceEpochMs + ) { + invalid(); + } + boundWorkerIdentities.add(key); + } + if ( + boundWorkerIdentities.size !== workerByIdentity.size || + [...workerByIdentity].some( + ([key, request]) => + !boundWorkerIdentities.has(key) || + typeof request.operation !== 'string' || + !WORKER_OPERATIONS.has(request.operation) + ) + ) { + invalid(); + } +} + +function invalid(): never { + throw new Error('invalid Xtream IPC worker identity binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts new file mode 100644 index 000000000..fc685c982 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts @@ -0,0 +1,85 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { xtreamWorkerOperationForIpcMethod } from './xtream-ipc-worker-identity-binding'; + +interface ProducerIdentity { + readonly ipcCallId: number; + readonly sourceEpochMs: number; +} + +export function bindFixtureIpcWorkerIdentities( + input: XtreamPhaseAttributionInput, + requests: readonly WorkerRequestPerformanceMetrics[] +): { + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; +} { + const identities = new Map< + WorkerRequestPerformanceMetrics, + ProducerIdentity + >(); + const methods = new Set( + input.ipcSpans + .filter(({ boundary }) => boundary === 'request') + .map(({ method }) => method) + .filter( + (method) => xtreamWorkerOperationForIpcMethod(method) !== null + ) + ); + for (const method of methods) { + const operation = xtreamWorkerOperationForIpcMethod(method); + if (operation === null) fixtureError(); + const calls = requestSpans(input.ipcSpans, method); + const workers = requests + .filter((request) => request.operation === operation) + .sort( + (left, right) => + Number(left.requestReceivedEpochMs) - + Number(right.requestReceivedEpochMs) || + String(left.requestId).localeCompare( + String(right.requestId) + ) + ); + if (calls.length !== workers.length) fixtureError(); + calls.forEach((call, index) => { + const worker = workers[index]; + if ( + !worker || + call.outcome !== (worker.success ? 'success' : 'error') || + worker.requestReceivedEpochMs === null || + call.sourceEpochMs > worker.requestReceivedEpochMs + ) { + fixtureError(); + } + identities.set(worker, { + ipcCallId: call.ipcCallId, + sourceEpochMs: call.sourceEpochMs, + }); + }); + } + const bound = requests.map((request) => { + const identity = identities.get(request); + if (identity) return { ...request, ...identity }; + if (request.ipcCallId !== null || request.sourceEpochMs !== null) { + fixtureError(); + } + return request; + }); + return { phaseCapture: input, requests: bound }; +} + +function requestSpans( + spans: readonly XtreamIpcAttributionSpan[], + method: string +): readonly XtreamIpcAttributionSpan[] { + return spans + .filter((span) => span.boundary === 'request' && span.method === method) + .sort((left, right) => left.ipcCallId - right.ipcCallId); +} + +function fixtureError(): never { + throw new Error('invalid Xtream IPC worker identity fixture'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts new file mode 100644 index 000000000..e31ee1fe8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { createXtreamAssemblerFixture } from './xtream-iteration-assembler.fixture'; +import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; +import { summarizeXtreamUiActionSamples } from './xtream-ui-action-probe'; + +describe('Xtream raw iteration assembler background UI evidence', () => { + it('binds samples to the full refresh lifecycle after the delete prelude', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const request = input.mainCapture.requests.find( + ({ operation }) => operation === 'DB_DELETE_XTREAM_CONTENT' + ); + const background = input.terminal.backgroundProbe; + const operation = input.terminal.operation; + const storeTerminalEpochMs = + input.rendererCapture.probe.storeTerminalEpochMs; + assert.ok(request?.workStartedEpochMs); + assert.ok(request.workEndedEpochMs); + assert.ok(background); + assert.ok(operation); + assert.ok(storeTerminalEpochMs); + const step = (storeTerminalEpochMs - request.workEndedEpochMs) / 12; + assert.ok(step > 0); + const samples = background.result.samples.map((sample, index) => { + const startEpochMs = + Number(request.workEndedEpochMs) + step * (index * 2 + 1); + const endEpochMs = startEpochMs + step; + return { + ...sample, + endEpochMs, + latencyMs: step, + startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + completedEpochMs: samples.at(-1)?.endEpochMs ?? 0, + result: { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }, + startedEpochMs: request.workStartedEpochMs, + }, + operation: { + ...operation, + terminalEpochMs: request.workEndedEpochMs, + }, + }, + }) + ); + }); + + it('accepts samples that finish before the delete prelude completes', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const request = input.mainCapture.requests.find( + ({ operation }) => operation === 'DB_DELETE_XTREAM_CONTENT' + ); + const background = input.terminal.backgroundProbe; + const operation = input.terminal.operation; + assert.ok(request?.workStartedEpochMs); + assert.ok(request.workEndedEpochMs); + assert.ok(background); + assert.ok(operation); + const step = + (request.workEndedEpochMs - background.startedEpochMs) / 12; + assert.ok(step > 0); + const samples = background.result.samples.map((sample, index) => { + const startEpochMs = + background.startedEpochMs + step * (index * 2 + 1); + const endEpochMs = startEpochMs + step; + return { + ...sample, + endEpochMs, + latencyMs: step, + startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + assert.ok( + Number(samples.at(-1)?.endEpochMs) < request.workEndedEpochMs + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + completedEpochMs: samples.at(-1)?.endEpochMs ?? 0, + result: { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }, + }, + operation: { + ...operation, + terminalEpochMs: request.workEndedEpochMs, + }, + }, + }) + ); + }); + + it('rejects overlapping samples', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const background = input.terminal.backgroundProbe; + assert.ok(background); + const samples = background.result.samples.map((sample, index, all) => { + if (index !== 1) return sample; + const previous = all[0]; + assert.ok(previous); + const startEpochMs = previous.endEpochMs - 0.1; + return { + ...sample, + latencyMs: sample.endEpochMs - startEpochMs, + startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + result: { + ...summary, + dashboardPaintVisibility: + 'visible-without-overlay', + samples, + }, + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('rejects a sample after the final refresh store terminal', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const background = input.terminal.backgroundProbe; + const storeTerminalEpochMs = + input.rendererCapture.probe.storeTerminalEpochMs; + assert.ok(background); + assert.ok(storeTerminalEpochMs); + const samples = background.result.samples.map((sample, index, all) => { + if (index !== all.length - 1) return sample; + const endEpochMs = storeTerminalEpochMs + 0.1; + return { + ...sample, + endEpochMs, + latencyMs: endEpochMs - sample.startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + completedEpochMs: samples.at(-1)?.endEpochMs ?? 0, + result: { + ...summary, + dashboardPaintVisibility: + 'visible-without-overlay', + samples, + }, + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts new file mode 100644 index 000000000..a86c9d8e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts @@ -0,0 +1,319 @@ +import type { RendererPerformancePhaseEvent } from '@iptvnator/shared/logging'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; +import type { + XtreamBackgroundProbeEvidence, + XtreamScenarioTerminalEvidence, +} from './xtream-scenario-driver'; +import { + summarizeXtreamUiActionSamples, + type XtreamUiActionProbeResult, + type XtreamUiActionSample, +} from './xtream-ui-action-probe'; + +const STORE_PREFIX = 'store.'; + +export function createLegacyTimeline( + requests: readonly WorkerRequestPerformanceMetrics[] +): MainTimelineRecord[] { + const legacy = requests.filter( + (request) => + xtreamIpcProducerNamespaceForWorkerOperation( + request.operation ?? '' + ) === XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD + ); + return legacy.flatMap((request) => { + const source = requireFixtureValue(request.sourceEpochMs); + const received = requireFixtureValue(request.requestReceivedEpochMs); + const response = request.responseEpochMs; + const completion = response + 0.1; + const shared = { + ipcCallId: requireFixtureValue(request.ipcCallId), + operation: requireFixtureValue(request.operation), + playlistId: requireFixtureValue(request.playlistId), + }; + return [ + { + ...shared, + epochMs: (source + received) / 2, + requestId: requireFixtureValue(request.requestId), + sourceEpochMs: source, + type: 'db-request', + }, + { + ...shared, + epochMs: response, + requestId: requireFixtureValue(request.requestId), + success: true, + type: 'db-response', + }, + { + ...shared, + epochMs: completion, + sourceEpochMs: completion, + success: true, + type: 'preload-performance-success', + }, + ]; + }); +} + +export function createRendererCapture( + raw: XtreamRawIterationResult, + background: XtreamUiActionProbeResult | undefined +) { + const storeEvents = raw.phaseCapture.events.filter(({ phase }) => + phase.startsWith(STORE_PREFIX) + ); + const itemsByPhaseId = new Map( + storeEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ correlationId, itemCount }) => [correlationId, itemCount]) + ); + const performancePhaseEvents = storeEvents.map( + (event): RendererPerformancePhaseEvent => { + const itemCount = itemsByPhaseId.get(event.correlationId) ?? null; + return { + boundary: event.boundary, + epochMs: event.epochMs, + ...(itemCount === null + ? {} + : { metadata: { items: itemCount } }), + monotonicMs: event.epochMs, + ...(event.boundary === 'end' ? { outcome: 'success' } : {}), + phase: event.phase as RendererPerformancePhaseEvent['phase'], + phaseId: event.correlationId, + schemaVersion: 1, + }; + } + ); + const probe = { + cancelEligibleProgressEpochMs: + raw.cancellation.clickEpochMs === null + ? null + : raw.cancellation.clickEpochMs - 0.1, + cancellationClickEpochMs: raw.cancellation.clickEpochMs, + dbCancellationTerminalEpochMs: + raw.cancellation.authoritativeTerminalEpochMs, + dbOperationEvents: [], + domReadyEpochMs: raw.phaseCapture.uiPaintEpochMs - 0.1, + frameGapsMs: [20, 45], + heartbeatDelaysMs: [5, 12], + invalidReason: null, + longTasksMs: [30, 75], + operationStartEpochMs: raw.phaseCapture.operationStartEpochMs, + performancePhaseEvents, + routeReadyEpochMs: raw.phaseCapture.operationStartEpochMs + 0.1, + scenarioId: raw.scenarioId, + storeTerminalEpochMs: Math.max( + ...storeEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ epochMs }) => epochMs) + ), + terminalEpochMs: raw.phaseCapture.terminalEpochMs, + uiPaintedEpochMs: raw.phaseCapture.uiPaintEpochMs, + }; + return { + captureCutoffEpochMs: raw.phaseCapture.terminalEpochMs + 0.5, + consoleErrorBreakdown: + raw.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT + ? { + databaseSaveCancellation: 1, + fetchContentCancellation: 1, + initializationCancellation: 1, + unexpected: 0, + } + : { + databaseSaveCancellation: 0, + fetchContentCancellation: 0, + initializationCancellation: 0, + unexpected: 0, + }, + consoleErrorCount: + raw.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT ? 3 : 0, + cpuProfilePath: null, + frameGap: { + count: 2, + max: 45, + mean: 32.5, + median: 32.5, + min: 20, + p95: 43.75, + p99: 44.75, + }, + heapSnapshotPath: null, + heartbeatDelay: { + count: 2, + max: 12, + mean: 8.5, + median: 8.5, + min: 5, + p95: 11.65, + p99: 11.93, + }, + longTask: { + count: 2, + max: 75, + mean: 52.5, + median: 52.5, + min: 30, + p95: 72.75, + p99: 74.55, + }, + measurementStartEpochMs: raw.phaseCapture.operationStartEpochMs, + pageErrorCount: 0, + peakHeapUsedBytes: raw.renderer.peakHeapUsedBytes, + postGcHeapUsedBytes: raw.renderer.postGcHeapUsedBytes, + probe, + storeToPaintMs: + raw.phaseCapture.uiPaintEpochMs - probe.storeTerminalEpochMs, + targetId: raw.renderer.targetId, + tracePath: null, + ...(background ? { background } : {}), + }; +} + +export function createBackgroundEvidence( + raw: XtreamRawIterationResult, + requests: readonly WorkerRequestPerformanceMetrics[] +): XtreamBackgroundProbeEvidence | null { + if (raw.scenarioId !== XTREAM_SCENARIO_ID.BACKGROUND_UI) return null; + const request = requireFixtureValue( + requests.find( + ({ operation }) => operation === 'DB_DELETE_XTREAM_CONTENT' + ) + ); + const start = requireFixtureValue(request.workStartedEpochMs); + const deleteEnd = requireFixtureValue(request.workEndedEpochMs); + const refreshEnd = Math.max( + ...raw.phaseCapture.events + .filter( + ({ boundary, phase }) => + boundary === 'end' && + phase === 'store.xtream-import-terminal' + ) + .map(({ epochMs }) => epochMs) + ); + const step = (refreshEnd - deleteEnd) / 12; + const categories = [ + 'action', + 'navigation', + 'navigation', + 'action', + 'search', + ] as const; + const kinds = [ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', + ] as const; + const samples: XtreamUiActionSample[] = kinds.map((kind, index) => ({ + activeAfter: true, + activeBefore: true, + category: requireFixtureValue(categories[index]), + endEpochMs: deleteEnd + step * (index * 2 + 2), + kind, + latencyMs: step, + startEpochMs: deleteEnd + step * (index * 2 + 1), + })); + const summary = summarizeXtreamUiActionSamples(samples); + return { + completedEpochMs: requireFixtureValue(samples.at(-1)).endEpochMs, + operation: 'delete-xtream-content', + operationId: requireFixtureValue(request.operationId), + playlistId: requireFixtureValue(request.playlistId), + result: { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }, + startedEpochMs: start, + }; +} + +export function createTerminal( + raw: XtreamRawIterationResult, + background: XtreamBackgroundProbeEvidence | null +): XtreamScenarioTerminalEvidence { + const cancellation = + raw.cancellation.clickEpochMs === null + ? null + : { + armedEpochMs: raw.phaseCapture.operationStartEpochMs - 0.5, + clickEpochMs: raw.cancellation.clickEpochMs, + current: 6_000, + operationId: requireFixtureValue( + raw.cancellation.failedOperationId + ), + progressEpochMs: raw.cancellation.clickEpochMs - 0.1, + threshold: 6_000, + total: 60_000, + }; + const operation = + background === null + ? null + : { + active: false, + operation: 'delete-xtream-content' as const, + operationId: background.operationId, + playlistId: background.playlistId, + startedEpochMs: background.startedEpochMs - 0.1, + terminalEpochMs: + requireFixtureValue(background.result.samples[0]) + .startEpochMs - + requireFixtureValue(background.result.samples[0]) + .latencyMs, + terminalStatus: 'completed' as const, + }; + return { + backgroundProbe: background, + cancellation, + operation, + scenarioId: raw.scenarioId, + terminalEpochMs: raw.phaseCapture.terminalEpochMs - 0.1, + }; +} + +export function operationOutcomes( + requests: readonly WorkerRequestPerformanceMetrics[] +) { + const counts = new Map(); + for (const request of requests) { + const outcome = request.success ? 'success' : 'error'; + const key = `${request.operation}\0${outcome}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [operation, outcome] = key.split('\0'); + return { + count, + operation: requireFixtureValue(operation), + outcome: outcome as 'error' | 'success', + }; + }); +} + +export function scenarioOrdinal(scenarioId: XtreamScenarioId): number { + return Object.values(XTREAM_SCENARIO_ID).indexOf(scenarioId) + 1; +} + +function requireFixtureValue(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('invalid Xtream assembler fixture'); + } + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts new file mode 100644 index 000000000..360fcf8f7 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts @@ -0,0 +1,337 @@ +import { + XTREAM_ITERATION_KIND, + type XtreamIterationDefinition, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import type { + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForMethod, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import type { XtreamIterationAssemblyInput } from './xtream-iteration-assembler'; +import { + createBackgroundEvidence, + createLegacyTimeline, + createRendererCapture, + createTerminal, + operationOutcomes, + scenarioOrdinal, +} from './xtream-iteration-assembler-fixture-runtime'; +import type { XtreamIpcTimelineRecord } from './xtream-ipc-marker-events.model'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +const MAIN_PHASES = new Set([ + PHASE.CANCEL_SESSION, + PHASE.JSON_TRANSFORM, + PHASE.NETWORK_TOTAL, + PHASE.RESPONSE_READY, +]); + +export function createXtreamAssemblerFixture( + scenarioId: XtreamScenarioId +): XtreamIterationAssemblyInput { + const definition: XtreamIterationDefinition = { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }; + const raw = iteration(definition, scenarioOrdinal(scenarioId)); + const worker = createWorker(raw); + const background = createBackgroundEvidence(raw, worker.requests); + const captureStartedEpochMs = raw.phaseCapture.operationStartEpochMs - 1; + const captureCutoffEpochMs = raw.phaseCapture.terminalEpochMs + 1; + const rendererCapture = createRendererCapture(raw, background?.result); + return { + catalogVerification: { + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: + raw.catalogVerification.providerCategoryCount, + providerItemCount: raw.catalogVerification.providerItemCount, + verifiedAfterCapture: true, + }, + definition, + diagnosticArtifacts: null, + mainCapture: { + cancelTimeline: + raw.cancellationEvidence?.timeline.map((entry) => ({ + ...entry, + })) ?? [], + capture: { + cpuProfilePath: null, + cpuSystemMicros: raw.main.cpuSystemMicros, + cpuUserMicros: raw.main.cpuUserMicros, + eventLoopDelay: { + maxMs: raw.main.eventLoopDelayMaxMs, + p95Ms: raw.main.eventLoopDelayP95Ms, + p99Ms: raw.main.eventLoopDelayP99Ms, + }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + raw.main.eventLoopUtilizationUnavailableReason, + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: raw.main.peakHeapUsedBytes, + peakRssBytes: raw.main.peakRssBytes, + postGcHeapUsedBytes: raw.main.postGcHeapUsedBytes, + postGcRssBytes: raw.main.peakRssBytes - 1, + }, + rendererWindow: { + responsiveEvents: raw.main.responsiveEvents, + rss: { + identity: { creationTime: 1, pid: 42 }, + missingSampleCount: 0, + peakRssBytes: raw.renderer.peakRssBytes, + unavailableReason: null, + validSampleCount: 2, + }, + unresponsiveEvents: raw.main.unresponsiveEvents, + windowIdentity: { + browserWindowId: raw.main.browserWindowId, + webContentsId: raw.main.webContentsId, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: createLegacyTimeline(worker.requests), + workers: [worker], + }, + captureCutoffEpochMs, + captureGeneration: 1, + captureStartedEpochMs, + captureStoppedEpochMs: captureCutoffEpochMs + 1, + databaseWorkerCount: 1, + invalidReasons: { + capture: [], + databaseWorkerCancel: [], + xtreamIpc: [], + }, + ipcTimeline: createXtreamTimeline(raw), + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementStartedEpochMs: + raw.phaseCapture.operationStartEpochMs - 0.5, + operationOutcomes: operationOutcomes(worker.requests), + playlistRefreshWorkerCount: 0, + quarantinedIpcMarkerCount: 0, + requests: worker.requests, + rollover: null, + workers: [worker], + }, + process: { + electronPid: 20_000 + scenarioOrdinal(scenarioId), + freshProcessVerified: true, + launchId: `launch-${scenarioOrdinal(scenarioId)}`, + profileDirectorySha256: String(scenarioOrdinal(scenarioId)).repeat( + 64 + ), + }, + rendererCapture, + terminal: createTerminal(raw, background), + trigger: { + scenarioId, + triggerEpochMs: raw.phaseCapture.operationStartEpochMs + 0.1, + }, + }; +} + +function createWorker(raw: XtreamRawIterationResult): WorkerCaptureMetrics { + const metric = raw.databaseWorker; + return { + cancelPostedEpochMs: raw.cancellation.databaseDispatchEpochMs, + cpuSystemMicros: metric.cpuSystemMicros, + cpuUserMicros: metric.cpuUserMicros, + eventLoopDelay: { + maxMs: metric.eventLoopDelayMaxMs, + p95Ms: metric.eventLoopDelayP95Ms, + p99Ms: metric.eventLoopDelayP99Ms, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: metric.eventLoopUtilization, + externalMemorySampleCount: 2, + heapUsedSampleCount: 2, + kind: 'database.worker', + operationId: null, + ordinal: metric.ordinal, + peakExternalBytes: metric.peakExternalBytes, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: metric.peakHeapUsedBytes, + peakHeapUsedUnavailableReason: null, + playlistId: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: metric.postGcHeapUsedBytes, + profilePath: null, + requests: metric.requests.evidence.map(cloneRequest), + responseEpochMs: null, + snapshotPath: null, + terminatedEpochMs: null, + }; +} + +function cloneRequest( + request: WorkerRequestPerformanceMetrics +): WorkerRequestPerformanceMetrics { + return { + ...request, + eventLoopDelay: + request.eventLoopDelay === null + ? null + : { ...request.eventLoopDelay }, + phaseEvents: request.phaseEvents.map((event) => ({ + ...event, + ...(event.metadata ? { metadata: { ...event.metadata } } : {}), + })), + }; +} + +function createXtreamTimeline( + raw: XtreamRawIterationResult +): XtreamIpcTimelineRecord[] { + const main = raw.phaseCapture.events + .filter(({ phase }) => MAIN_PHASES.has(phase)) + .map((event): XtreamIpcTimelineRecord => ({ + action: event.providerAction, + boundary: event.boundary, + categoryType: event.categoryType, + contentType: event.contentType, + durationMs: event.durationMs, + epochMs: event.epochMs, + phase: event.phase, + requestId: event.correlationId, + type: 'xtream-main-phase', + })); + const markers = xtreamCalls(raw).flatMap(({ request, response }) => [ + marker(raw, request, 'start'), + marker(raw, response, 'end'), + ]); + return [...main, ...markers].sort((left, right) => { + return left.epochMs - right.epochMs; + }); +} + +function marker( + raw: XtreamRawIterationResult, + span: XtreamIpcAttributionSpan, + boundary: 'end' | 'start' +): XtreamIpcTimelineRecord { + const semantic = markerSemantic(raw, span); + return { + action: semantic.providerAction, + boundary, + categoryType: semantic.categoryType, + contentType: semantic.contentType, + epochMs: span.endEpochMs, + ipcCallId: span.ipcCallId, + itemCount: null, + method: span.method, + operationId: + span.method === 'dbCancelOperation' + ? raw.cancellation.failedOperationId + : semantic.operationId, + outcome: boundary === 'start' ? null : span.outcome, + playlistId: semantic.playlistId, + sessionId: span.method.startsWith('xtream') ? 'session-1' : null, + sourceEpochMs: + boundary === 'start' ? span.sourceEpochMs : span.startEpochMs, + type: 'xtream-preload-marker', + }; +} + +function markerSemantic( + raw: XtreamRawIterationResult, + span: XtreamIpcAttributionSpan +) { + if (span.method === 'xtreamRequest') { + const providers = providerGroups(raw.phaseCapture); + const calls = xtreamCalls(raw).filter( + ({ request }) => request.method === 'xtreamRequest' + ); + const index = calls.findIndex( + ({ request }) => request.ipcCallId === span.ipcCallId + ); + const provider = providers[index]?.[0]; + return { + categoryType: null, + contentType: null, + operationId: null, + playlistId: null, + providerAction: provider?.providerAction ?? null, + }; + } + const request = raw.databaseWorker.requests.evidence.find( + (candidate) => + candidate.ipcCallId === span.ipcCallId && + xtreamIpcProducerNamespaceForWorkerOperation( + candidate.operation ?? '' + ) === XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD + ); + const event = raw.phaseCapture.events.find( + (candidate) => + candidate.correlationId === request?.requestId && + candidate.boundary === 'start' + ); + return { + categoryType: event?.categoryType ?? null, + contentType: event?.contentType ?? null, + operationId: request?.operationId ?? null, + playlistId: request?.playlistId ?? null, + providerAction: null, + }; +} + +function xtreamCalls(raw: XtreamRawIterationResult) { + return pairIpc(raw.phaseCapture.ipcSpans).filter( + ({ request }) => + xtreamIpcProducerNamespaceForMethod(request.method) === + XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD + ); +} + +function pairIpc(spans: readonly XtreamIpcAttributionSpan[]) { + const groups = new Map(); + for (const span of spans) { + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + return [...groups.values()].map((group) => { + const request = group.find(({ boundary }) => boundary === 'request'); + const response = group.find(({ boundary }) => boundary === 'response'); + if (!request || !response) { + throw new Error('invalid Xtream assembler IPC fixture'); + } + return { request, response }; + }); +} + +function providerGroups(capture: XtreamPhaseAttributionInput) { + const groups = new Map(); + for (const event of capture.events.filter( + ({ phase }) => phase === PHASE.NETWORK_TOTAL + )) { + groups.set(event.correlationId, [ + ...(groups.get(event.correlationId) ?? []), + event, + ]); + } + return [...groups.values()].sort((left, right) => { + const leftStart = left[0]; + const rightStart = right[0]; + if (!leftStart || !rightStart) { + throw new Error('invalid Xtream assembler provider fixture'); + } + return leftStart.epochMs - rightStart.epochMs; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts new file mode 100644 index 000000000..fc529d740 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { createXtreamAssemblerFixture } from './xtream-iteration-assembler.fixture'; +import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; + +describe('Xtream raw iteration assembler', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + it(`assembles measured ${scenarioId} evidence without inventing fields`, () => { + const input = createXtreamAssemblerFixture( + scenarioId as XtreamScenarioId + ); + + const result = assembleXtreamRawIteration(input); + + assert.doesNotThrow(() => assertXtreamIterationResult(result)); + assert.equal( + result.processIdentity.captureGeneration, + input.mainCapture.captureGeneration + ); + assert.equal( + result.databaseWorker.requests.evidence.length, + input.mainCapture.requests.length + ); + assert.equal(result.validity.databaseWorkerCount, 1); + assert.equal(result.validity.playlistWorkerCount, 0); + assert.equal(result.main.cpuMetricScope, 'electron-main-thread'); + assert.equal( + result.databaseWorker.cpuMetricScope, + 'sum-valid-request-thread-cpu' + ); + assert.equal( + result.databaseWorker.cpuUserMicros, + result.databaseWorker.requests.evidence.reduce( + (total, request) => + total + + (request.invalidReason === null + ? Number(request.threadCpuUserMicros) + : 0), + 0 + ) + ); + assert.equal( + result.cancellationEvidence === null, + scenarioId !== XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + }); + } + + it('fails closed on capture invalidation instead of manufacturing a valid result', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + mainCapture: { + ...input.mainCapture, + invalidReasons: { + ...input.mainCapture.invalidReasons, + capture: ['database-worker-activity-after-cutoff'], + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('accepts a coarse trigger clock from the same millisecond as renderer start', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const operationStartEpochMs = + input.rendererCapture.probe.operationStartEpochMs + 0.05; + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + measurementStartEpochMs: operationStartEpochMs, + probe: { + ...input.rendererCapture.probe, + operationStartEpochMs, + }, + }, + trigger: { + ...input.trigger, + triggerEpochMs: Math.floor(operationStartEpochMs), + }, + }) + ); + }); + + it('rejects a coarse trigger clock from the millisecond before renderer start', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const operationStartEpochMs = + input.rendererCapture.probe.operationStartEpochMs + 0.05; + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + operationStartEpochMs, + }, + }, + trigger: { + ...input.trigger, + triggerEpochMs: Math.floor(operationStartEpochMs) - 1, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('accepts a late delete driver observation after renderer and main capture cutoffs', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.DELETE_LARGE + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + terminalEpochMs: + input.mainCapture.captureCutoffEpochMs + 100, + }, + }) + ); + }); + + it('accepts renderer cancellation delivery after the authoritative database response', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const authoritativeTerminalEpochMs = input.mainCapture.requests.find( + ({ operation, success }) => + operation === 'DB_SAVE_CONTENT' && !success + )?.responseEpochMs; + assert.equal(typeof authoritativeTerminalEpochMs, 'number'); + const dbCancellationTerminalEpochMs = + Number(authoritativeTerminalEpochMs) + 0.25; + assert.ok( + dbCancellationTerminalEpochMs < + Number(input.rendererCapture.probe.uiPaintedEpochMs) + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + dbCancellationTerminalEpochMs, + }, + }, + }) + ); + }); + + it('accepts main observing a network cancel response after the next renderer IPC starts', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const databaseCancelStart = input.mainCapture.ipcTimeline.find( + ({ boundary, method }) => + boundary === 'start' && method === 'dbCancelOperation' + ); + assert.ok(databaseCancelStart); + assert.equal(typeof databaseCancelStart.sourceEpochMs, 'number'); + const rendererHandoffEpochMs = Number( + databaseCancelStart.sourceEpochMs + ); + const delayedMainObservationEpochMs = rendererHandoffEpochMs + 0.1; + assert.ok(delayedMainObservationEpochMs < databaseCancelStart.epochMs); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + mainCapture: { + ...input.mainCapture, + ipcTimeline: input.mainCapture.ipcTimeline.map((record) => + record.boundary === 'end' && + record.method === 'xtreamCancelSession' + ? { + ...record, + epochMs: delayedMainObservationEpochMs, + sourceEpochMs: rendererHandoffEpochMs, + } + : record + ), + }, + }) + ); + }); + + it('accepts the non-authoritative cancel IPC returning after the database acknowledgement', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const authoritativeTerminalEpochMs = input.mainCapture.requests.find( + ({ operation, success }) => + operation === 'DB_SAVE_CONTENT' && !success + )?.responseEpochMs; + assert.equal(typeof authoritativeTerminalEpochMs, 'number'); + const responseSourceEpochMs = + Number(authoritativeTerminalEpochMs) + 0.1; + const responseObservedEpochMs = responseSourceEpochMs + 0.1; + assert.ok( + responseObservedEpochMs < + Number(input.rendererCapture.probe.uiPaintedEpochMs) + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + mainCapture: { + ...input.mainCapture, + ipcTimeline: input.mainCapture.ipcTimeline + .map((record) => + record.boundary === 'end' && + record.method === 'dbCancelOperation' + ? { + ...record, + epochMs: responseObservedEpochMs, + sourceEpochMs: responseSourceEpochMs, + } + : record + ) + .sort((left, right) => left.epochMs - right.epochMs), + }, + }) + ); + }); + + it('binds store-to-paint duration to the renderer terminal and paint epochs', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + + assert.doesNotThrow(() => assembleXtreamRawIteration(input)); + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + storeToPaintMs: + input.rendererCapture.storeToPaintMs + 0.1, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('includes trailing delete database and IPC evidence after the visible paint', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.DELETE_LARGE + ); + const rendererTerminalEpochMs = + input.rendererCapture.probe.uiPaintedEpochMs; + + const result = assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + terminalEpochMs: rendererTerminalEpochMs, + }, + }, + }); + + assert.ok( + result.phaseCapture.terminalEpochMs > rendererTerminalEpochMs + ); + assert.equal( + result.phases.totalMs, + result.phaseCapture.terminalEpochMs - + result.phaseCapture.operationStartEpochMs + ); + }); + + it('binds diagnostic durations and worker ordinal only from validated artifact evidence', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + definition: { + ...input.definition, + eligibleForComparison: false, + kind: 'diagnostic', + runId: 'diagnostic', + }, + diagnosticArtifacts: { + artifacts: [], + directorySha256: '0'.repeat(64), + profiles: { + databaseWorker: { + durationMicros: 1, + ordinal: 1, + }, + main: { durationMicros: 1 }, + renderer: { durationMicros: 1 }, + }, + } as never, + }), + /xtream-iteration-assembly-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts new file mode 100644 index 000000000..e2c6ddb53 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts @@ -0,0 +1,259 @@ +import { createHash } from 'node:crypto'; + +import type { XtreamIterationDefinition } from './xtream-benchmark-contract'; +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import type { XtreamCatalogVerification } from './xtream-catalog-database-verification'; +import { + assertXtreamDiagnosticArtifactEvidence, + type XtreamDiagnosticArtifactEvidence, +} from './xtream-diagnostic-artifacts'; +import { freezeDeep } from './xtream-exact-schema'; +import { + assertXtreamMainEvidenceGates, + createXtreamDatabaseWorkerMetrics, + createXtreamMainMetrics, + createXtreamRendererMetrics, +} from './xtream-iteration-assembly-metrics'; +import { deriveXtreamPhaseCapture } from './xtream-iteration-assembly-phases'; +import { assembleXtreamScenarioEvidence } from './xtream-iteration-assembly-scenarios'; +import { assertXtreamIterationEvidenceBinding } from './xtream-iteration-evidence-binding'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamMainCaptureResult } from './m3u-refresh-main-capture'; +import { attributeXtreamPhases } from './xtream-phase-attribution'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import { validXtreamRendererConsoleErrorEvidence } from './xtream-renderer-console-errors'; +import { assertCompleteXtreamRendererProbe } from './xtream-renderer-probe'; +import { + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT, + type XtreamScenarioTerminalEvidence, + type XtreamScenarioTriggerEvidence, +} from './xtream-scenario-driver'; +import { + XTREAM_NOT_APPLICABLE, + type XtreamCatalogVerification as XtreamSummaryCatalogVerification, + type XtreamIterationProcessIdentity, + type XtreamRawIterationResult, +} from './xtream-summary-contract'; + +export interface XtreamIterationProcessEvidence { + readonly electronPid: number; + readonly freshProcessVerified: true; + readonly launchId: string; + readonly profileDirectorySha256: string; +} + +export interface XtreamIterationAssemblyInput { + readonly catalogVerification: XtreamCatalogVerification; + readonly definition: XtreamIterationDefinition; + readonly diagnosticArtifacts: XtreamDiagnosticArtifactEvidence | null; + readonly mainCapture: XtreamMainCaptureResult; + readonly process: XtreamIterationProcessEvidence; + readonly rendererCapture: XtreamRendererCaptureMetrics; + readonly terminal: XtreamScenarioTerminalEvidence; + readonly trigger: XtreamScenarioTriggerEvidence; +} + +export function assembleXtreamRawIteration( + input: XtreamIterationAssemblyInput +): XtreamRawIterationResult { + try { + return assembleStrict(input); + } catch { + throw new Error('xtream-iteration-assembly-invalid'); + } +} + +function assembleStrict( + input: XtreamIterationAssemblyInput +): XtreamRawIterationResult { + assertInputBoundary(input); + const diagnostic = validateDiagnosticEvidence(input); + assertXtreamMainEvidenceGates(input.mainCapture); + assertCompleteXtreamRendererProbe(input.rendererCapture.probe); + const phaseCapture = deriveXtreamPhaseCapture( + input.definition.scenarioId, + input.mainCapture, + input.rendererCapture + ); + const phases = attributeXtreamPhases(phaseCapture); + const scenario = assembleXtreamScenarioEvidence({ + main: input.mainCapture, + phaseCapture, + renderer: input.rendererCapture, + scenarioId: input.definition.scenarioId, + terminal: input.terminal, + trigger: input.trigger, + }); + const context = { + diagnostic, + kind: input.definition.kind, + }; + const raw: XtreamRawIterationResult = { + ...input.definition, + cancellation: scenario.cancellation, + cancellationEvidence: scenario.cancellationEvidence, + catalogVerification: copyCatalogVerification(input.catalogVerification), + databaseWorker: createXtreamDatabaseWorkerMetrics( + input.mainCapture, + context + ), + main: createXtreamMainMetrics(input.mainCapture, context), + phaseCapture, + phases, + playlistRefreshWorker: { + instantiated: false, + reason: XTREAM_NOT_APPLICABLE.PLAYLIST_REFRESH_WORKER, + }, + processIdentity: createProcessIdentity(input), + renderer: createXtreamRendererMetrics( + input.mainCapture, + input.rendererCapture, + context, + scenario.backgroundMetrics + ), + validity: { + actionContractValid: true, + catalogContractValid: true, + databaseWorkerCount: input.mainCapture.databaseWorkerCount, + dbPhaseContractValid: true, + diagnosticArtifactsValid: + input.definition.kind === XTREAM_ITERATION_KIND.DIAGNOSTIC + ? true + : null, + lateDatabaseRequestCount: + input.mainCapture.lateDatabaseRequestCount, + playlistWorkerCount: input.mainCapture.playlistRefreshWorkerCount, + rendererTargetCount: 1, + }, + }; + assertXtreamIterationEvidenceBinding(raw); + assertXtreamIterationResult(raw); + return freezeDeep(raw); +} + +function assertInputBoundary(input: XtreamIterationAssemblyInput): void { + const scenarioId = input.definition.scenarioId; + if ( + !Object.values(XTREAM_SCENARIO_ID).includes(scenarioId) || + input.mainCapture.requests.length !== + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT[scenarioId] || + !validXtreamRendererConsoleErrorEvidence( + input.rendererCapture.consoleErrorCount, + input.rendererCapture.consoleErrorBreakdown, + scenarioId + ) || + input.rendererCapture.pageErrorCount !== 0 || + input.rendererCapture.storeToPaintMs !== + requireEpoch(input.rendererCapture.probe.uiPaintedEpochMs) - + requireEpoch(input.rendererCapture.probe.storeTerminalEpochMs) + ) { + invalid(); + } + const catalog = input.catalogVerification; + if ( + catalog.databaseStateValid !== true || + catalog.fixtureIdentityValid !== true || + catalog.verifiedAfterCapture !== true || + catalog.providerCategoryCount !== 100 || + catalog.providerItemCount !== 100_000 + ) { + invalid(); + } +} + +function copyCatalogVerification( + catalog: XtreamCatalogVerification +): XtreamSummaryCatalogVerification { + return { + databaseStateValid: catalog.databaseStateValid, + fixtureIdentityValid: catalog.fixtureIdentityValid, + providerCategoryCount: catalog.providerCategoryCount as 100, + providerItemCount: catalog.providerItemCount as 100_000, + verifiedAfterCapture: catalog.verifiedAfterCapture, + }; +} + +function validateDiagnosticEvidence( + input: XtreamIterationAssemblyInput +): XtreamDiagnosticArtifactEvidence | null { + if (input.definition.kind !== XTREAM_ITERATION_KIND.DIAGNOSTIC) { + if ( + input.diagnosticArtifacts !== null || + input.mainCapture.capture.cpuProfilePath !== null || + input.mainCapture.capture.heapSnapshotPath !== null || + input.rendererCapture.cpuProfilePath !== null || + input.rendererCapture.heapSnapshotPath !== null || + input.rendererCapture.tracePath !== null || + input.mainCapture.workers.some( + ({ profilePath, snapshotPath }) => + profilePath !== null || snapshotPath !== null + ) + ) { + invalid(); + } + return null; + } + assertXtreamDiagnosticArtifactEvidence(input.diagnosticArtifacts); + if ( + input.process.profileDirectorySha256 !== + input.diagnosticArtifacts.directorySha256 + ) { + invalid(); + } + return input.diagnosticArtifacts; +} + +function createProcessIdentity( + input: XtreamIterationAssemblyInput +): XtreamIterationProcessIdentity { + const process = input.process; + if ( + process.freshProcessVerified !== true || + !Number.isSafeInteger(process.electronPid) || + process.electronPid <= 0 || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(process.launchId) || + !/^[a-f0-9]{64}$/.test(process.profileDirectorySha256) + ) { + invalid(); + } + const captureGeneration = input.mainCapture.captureGeneration; + if (!Number.isSafeInteger(captureGeneration) || captureGeneration <= 0) { + invalid(); + } + const identity = { + captureGeneration, + electronPid: process.electronPid, + launchId: process.launchId, + profileDirectorySha256: process.profileDirectorySha256, + rendererTargetId: input.rendererCapture.targetId, + browserWindowId: + input.mainCapture.capture.rendererWindow.windowIdentity + .browserWindowId, + webContentsId: + input.mainCapture.capture.rendererWindow.windowIdentity + .webContentsId, + }; + return { + captureGeneration, + electronPid: process.electronPid, + freshProcess: true, + generationIdentitySha256: createHash('sha256') + .update(JSON.stringify(identity)) + .digest('hex'), + launchId: process.launchId, + profileDirectorySha256: process.profileDirectorySha256, + }; +} + +function requireEpoch(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream iteration assembly input'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts new file mode 100644 index 000000000..77c1efb25 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts @@ -0,0 +1,332 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { + XtreamMainCaptureResult, + XtreamMainCaptureOperationOutcome, +} from './m3u-refresh-main-capture'; +import { + XTREAM_ITERATION_KIND, + type XtreamIterationKind, +} from './xtream-benchmark-contract'; +import type { XtreamDiagnosticArtifactEvidence } from './xtream-diagnostic-evidence'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import { + XTREAM_NOT_APPLICABLE, + XTREAM_MAIN_CPU_METRIC_SCOPE, + XTREAM_WORKER_CPU_METRIC_SCOPE, + XTREAM_WORKER_REQUEST_INVALID_REASON, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type XtreamDatabaseWorkerMetrics, + type XtreamMainMetrics, + type XtreamRendererMetrics, + type XtreamWorkerRequestAccounting, +} from './xtream-summary-contract'; +import { deriveXtreamWorkerRequestWindowProxy } from './xtream-worker-request-proxy'; + +interface MetricContext { + readonly diagnostic: XtreamDiagnosticArtifactEvidence | null; + readonly kind: XtreamIterationKind; +} + +export function createXtreamMainMetrics( + main: XtreamMainCaptureResult, + context: MetricContext +): XtreamMainMetrics { + const memory = main.capture.memory; + const window = main.capture.rendererWindow; + return { + ...profile(context, 'main'), + browserWindowId: window.windowIdentity.browserWindowId, + cpuMetricScope: XTREAM_MAIN_CPU_METRIC_SCOPE.ELECTRON_MAIN_THREAD, + cpuSystemMicros: main.capture.cpuSystemMicros, + cpuUserMicros: main.capture.cpuUserMicros, + eventLoopDelayMaxMs: main.capture.eventLoopDelay.maxMs, + eventLoopDelayP95Ms: main.capture.eventLoopDelay.p95Ms, + eventLoopDelayP99Ms: main.capture.eventLoopDelay.p99Ms, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION, + peakHeapUsedBytes: memory.peakHeapUsedBytes, + peakRssBytes: memory.peakRssBytes, + postGcHeapUsedBytes: requireNumber(memory.postGcHeapUsedBytes), + responsiveEvents: window.responsiveEvents, + unresponsiveEvents: window.unresponsiveEvents, + webContentsId: window.windowIdentity.webContentsId, + }; +} + +export function createXtreamRendererMetrics( + main: XtreamMainCaptureResult, + renderer: XtreamRendererCaptureMetrics, + context: MetricContext, + background: { + readonly actionLatencyMs: number; + readonly dashboardPaintVisibility: 'visible-without-overlay'; + readonly navigationLatencyMs: number; + readonly searchLatencyMs: number; + } | null +): XtreamRendererMetrics { + const rss = main.capture.rendererWindow.rss; + if ( + rss.unavailableReason !== null || + rss.missingSampleCount !== 0 || + rss.validSampleCount <= 0 + ) { + invalid(); + } + return { + ...profile(context, 'renderer'), + actionLatencyMs: background?.actionLatencyMs ?? null, + consoleErrors: [], + dashboardPaintVisibility: background?.dashboardPaintVisibility ?? null, + frameGapMaxMs: requireDistribution(renderer.frameGap, 'max', true), + frameGapP95Ms: requireDistribution(renderer.frameGap, 'p95', true), + heartbeatMaxMs: requireDistribution( + renderer.heartbeatDelay, + 'max', + true + ), + heartbeatP95Ms: requireDistribution( + renderer.heartbeatDelay, + 'p95', + true + ), + longTaskCount: renderer.longTask.count, + longTaskMaxMs: + renderer.longTask.count === 0 + ? 0 + : requireDistribution(renderer.longTask, 'max', true), + navigationLatencyMs: background?.navigationLatencyMs ?? null, + peakHeapUsedBytes: renderer.peakHeapUsedBytes, + peakRssBytes: requireNumber(rss.peakRssBytes), + postGcHeapUsedBytes: renderer.postGcHeapUsedBytes, + searchLatencyMs: background?.searchLatencyMs ?? null, + storeToPaintMs: renderer.storeToPaintMs, + targetId: renderer.targetId, + }; +} + +export function createXtreamDatabaseWorkerMetrics( + main: XtreamMainCaptureResult, + context: MetricContext +): XtreamDatabaseWorkerMetrics { + const worker = main.workers[0]; + if ( + main.workers.length !== 1 || + !worker || + worker.kind !== 'database.worker' || + worker.heapUsedSampleCount <= 0 || + worker.externalMemorySampleCount <= 0 || + worker.peakHeapUsedUnavailableReason !== null || + worker.peakExternalUnavailableReason !== null || + worker.postGcHeapUnavailableReason !== null + ) { + invalid(); + } + const requests = createRequestAccounting(worker.requests); + const threadCpu = sumXtreamRequestThreadCpu(requests.evidence); + if ( + context.diagnostic && + context.diagnostic.profiles.databaseWorker.ordinal !== worker.ordinal + ) { + invalid(); + } + return { + ...profile(context, 'databaseWorker'), + cpuMetricScope: XTREAM_WORKER_CPU_METRIC_SCOPE.SUM_VALID_REQUEST_WORK, + cpuSystemMicros: threadCpu.system, + cpuUserMicros: threadCpu.user, + currentForCapture: true, + eventLoopDelayMaxMs: requests.eventLoopDelayMaxMs, + eventLoopDelayMetricScope: + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY, + eventLoopDelayP95Ms: requests.eventLoopDelayP95Ms, + eventLoopDelayP99Ms: requests.eventLoopDelayP99Ms, + eventLoopUtilization: requireNumber(worker.eventLoopUtilization), + kind: 'database.worker', + ordinal: worker.ordinal, + peakExternalBytes: requireNumber(worker.peakExternalBytes), + peakHeapUsedBytes: requireNumber(worker.peakHeapUsedBytes), + postGcHeapUsedBytes: requireNumber(worker.postGcHeapUsedBytes), + requests, + rssBytes: null, + rssUnavailableReason: XTREAM_NOT_APPLICABLE.DATABASE_WORKER_RSS, + wholeWorkerMetricScope: + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW, + }; +} + +function sumXtreamRequestThreadCpu( + requests: readonly WorkerRequestPerformanceMetrics[] +): { readonly system: number; readonly user: number } { + let system = 0; + let user = 0; + for (const request of requests) { + if ( + request.invalidReason === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ) { + continue; + } + if ( + request.invalidReason !== null || + request.threadCpuUnavailableReason !== null || + request.threadCpuSystemMicros === null || + request.threadCpuUserMicros === null + ) { + invalid(); + } + system += request.threadCpuSystemMicros; + user += request.threadCpuUserMicros; + } + if ( + !Number.isSafeInteger(system) || + system < 0 || + !Number.isSafeInteger(user) || + user < 0 + ) { + invalid(); + } + return Object.freeze({ system, user }); +} + +export function assertXtreamMainEvidenceGates( + main: XtreamMainCaptureResult +): void { + if ( + main.databaseWorkerCount !== 1 || + main.playlistRefreshWorkerCount !== 0 || + main.workers.length !== 1 || + main.workers[0]?.kind !== 'database.worker' || + main.lateDatabaseRequestCount !== 0 || + main.lateEventCount !== 0 || + main.quarantinedIpcMarkerCount !== 0 || + main.rollover !== null || + Object.values(main.invalidReasons).some( + (reasons) => reasons.length !== 0 + ) || + main.capture.workers.length !== main.workers.length || + main.capture.workers[0]?.ordinal !== main.workers[0]?.ordinal || + main.requests.length !== main.workers[0]?.requests.length || + !sameOutcomes(main.operationOutcomes, main.requests) + ) { + invalid(); + } +} + +function createRequestAccounting( + evidence: readonly WorkerRequestPerformanceMetrics[] +): XtreamWorkerRequestAccounting { + const snapshot = evidence.map(cloneRequest); + const proxy = deriveXtreamWorkerRequestWindowProxy(snapshot); + if (!proxy) invalid(); + const invalidSampleCount = snapshot.filter( + ({ invalidReason }) => + invalidReason === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ).length; + return { + evidence: Object.freeze(snapshot), + ...proxy, + invalidReasons: + invalidSampleCount === 0 + ? [] + : [ + { + count: invalidSampleCount, + reason: XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS, + }, + ], + invalidSampleCount, + metricScope: + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY, + totalCount: snapshot.length, + validSampleCount: snapshot.length - invalidSampleCount, + }; +} + +function cloneRequest( + request: WorkerRequestPerformanceMetrics +): WorkerRequestPerformanceMetrics { + return { + ...request, + eventLoopDelay: + request.eventLoopDelay === null + ? null + : { ...request.eventLoopDelay }, + phaseEvents: request.phaseEvents.map((event) => ({ + ...event, + ...(event.metadata ? { metadata: { ...event.metadata } } : {}), + })), + }; +} + +function sameOutcomes( + actual: readonly XtreamMainCaptureOperationOutcome[], + requests: readonly WorkerRequestPerformanceMetrics[] +): boolean { + const expected = new Map(); + for (const request of requests) { + const key = `${request.operation}\0${ + request.success ? 'success' : 'error' + }`; + expected.set(key, (expected.get(key) ?? 0) + 1); + } + const observed = new Map( + actual.map(({ count, operation, outcome }) => [ + `${operation}\0${outcome}`, + count, + ]) + ); + return ( + actual.every(({ outcome }) => outcome !== 'pending') && + expected.size === observed.size && + [...expected].every(([key, count]) => observed.get(key) === count) + ); +} + +function profile( + context: MetricContext, + process: 'databaseWorker' | 'main' | 'renderer' +) { + if (context.kind !== XTREAM_ITERATION_KIND.DIAGNOSTIC) { + if (context.diagnostic !== null) invalid(); + return { + cpuProfileDurationMicros: null, + cpuProfileUnavailableReason: + XTREAM_NOT_APPLICABLE.PROFILE_OUTSIDE_DIAGNOSTIC, + } as const; + } + if (!context.diagnostic) invalid(); + const duration = + process === 'databaseWorker' + ? context.diagnostic.profiles.databaseWorker.durationMicros + : context.diagnostic.profiles[process].durationMicros; + return { + cpuProfileDurationMicros: duration, + cpuProfileUnavailableReason: null, + } as const; +} + +function requireDistribution( + value: { + readonly count: number; + readonly max: number | null; + readonly p95: number | null; + }, + key: 'max' | 'p95', + requireSamples: boolean +): number { + if (requireSamples && value.count <= 0) invalid(); + return requireNumber(value[key]); +} + +function requireNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream assembly metrics evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts new file mode 100644 index 000000000..80191208b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts @@ -0,0 +1,376 @@ +import { pairRendererPerformancePhaseEvents } from './renderer-performance-phase-events'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { XtreamMainCaptureResult } from './m3u-refresh-main-capture'; +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import { + semanticTypeFromXtreamPhaseSource, + xtreamDataSemanticSource, + type XtreamPhaseSemanticSource, +} from './xtream-phase-semantic-source'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import { xtreamWorkerOperationForIpcMethod } from './xtream-ipc-worker-identity-binding'; +import type { XtreamIpcTimelineRecord } from './xtream-ipc-marker-events.model'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; + +const MAIN_PHASES = new Set([ + PHASE.CANCEL_SESSION, + PHASE.JSON_TRANSFORM, + PHASE.NETWORK_TOTAL, + PHASE.RESPONSE_READY, +]); +const LEGACY_METHODS = Object.freeze({ + DB_GET_APP_PLAYLIST: 'dbGetAppPlaylist', + DB_UPSERT_APP_PLAYLIST: 'dbUpsertAppPlaylist', +}); + +export function deriveXtreamPhaseCapture( + scenarioId: XtreamPhaseAttributionInput['scenarioId'], + main: XtreamMainCaptureResult, + renderer: XtreamRendererCaptureMetrics +): XtreamPhaseAttributionInput { + const operationStartEpochMs = renderer.probe.operationStartEpochMs; + const rendererTerminalEpochMs = requireEpoch( + renderer.probe.terminalEpochMs + ); + const uiPaintEpochMs = requireEpoch(renderer.probe.uiPaintedEpochMs); + const events = [ + ...mainPhaseEvents(main), + ...workerPhaseEvents(main.requests, main), + ...rendererPhaseEvents(renderer), + ]; + const ipc = ipcSpans(main); + const terminalEpochMs = Math.max( + rendererTerminalEpochMs, + ...events.map(({ epochMs }) => epochMs), + ...ipc.map(({ endEpochMs }) => endEpochMs) + ); + return Object.freeze({ + backgroundSearchMarkerObserved: events.some( + ({ phase }) => phase === PHASE.STORE_SEARCH_RESULTS + ), + events: Object.freeze(events), + ipcSpans: Object.freeze(ipc), + operationStartEpochMs, + scenarioId, + terminalEpochMs, + uiPaintEpochMs, + }); +} + +function mainPhaseEvents( + main: XtreamMainCaptureResult +): XtreamAttributionPhaseEvent[] { + return main.ipcTimeline + .filter( + ({ phase, type }) => + type === 'xtream-main-phase' && + typeof phase === 'string' && + MAIN_PHASES.has(phase) + ) + .map((record) => { + const source = { + categoryType: record.categoryType ?? null, + contentType: record.contentType ?? null, + providerAction: record.action ?? null, + } as XtreamPhaseSemanticSource; + return phaseEvent({ + boundary: record.boundary, + correlationId: requireText(record.requestId), + durationMs: record.durationMs ?? null, + epochMs: record.epochMs, + itemCount: null, + phase: requireText(record.phase), + source, + }); + }); +} + +function workerPhaseEvents( + requests: readonly WorkerRequestPerformanceMetrics[], + main: XtreamMainCaptureResult +): XtreamAttributionPhaseEvent[] { + return requests.flatMap((request) => { + const correlationId = requireText(request.requestId); + const source = workerSemanticSource(request, main); + return request.phaseEvents.map((event) => + phaseEvent({ + boundary: event.boundary, + correlationId, + durationMs: event.durationMs, + epochMs: event.epochMs, + itemCount: + event.boundary === 'end' + ? (event.metadata?.itemCount ?? null) + : null, + phase: event.phase, + source, + }) + ); + }); +} + +function workerSemanticSource( + request: WorkerRequestPerformanceMetrics, + main: XtreamMainCaptureResult +): XtreamPhaseSemanticSource { + const operation = requireText(request.operation); + const namespace = xtreamIpcProducerNamespaceForWorkerOperation(operation); + if (namespace !== XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD) { + return xtreamDataSemanticSource(null); + } + const marker = main.ipcTimeline.filter( + (record) => + record.type === 'xtream-preload-marker' && + record.boundary === 'start' && + record.ipcCallId === request.ipcCallId && + record.sourceEpochMs === request.sourceEpochMs && + xtreamWorkerOperationForIpcMethod(record.method ?? '') === operation + ); + if (marker.length !== 1 || !marker[0]) invalid(); + return { + categoryType: marker[0].categoryType ?? null, + contentType: marker[0].contentType ?? null, + providerAction: null, + } as XtreamPhaseSemanticSource; +} + +function rendererPhaseEvents( + renderer: XtreamRendererCaptureMetrics +): XtreamAttributionPhaseEvent[] { + return pairRendererPerformancePhaseEvents( + renderer.probe.performancePhaseEvents + ).flatMap((span) => { + const source = rendererSemanticSource(span.phase); + const itemCount = span.metadata?.items ?? null; + return [ + phaseEvent({ + boundary: 'start', + correlationId: span.phaseId, + durationMs: null, + epochMs: span.startedEpochMs, + itemCount: null, + phase: span.phase, + source, + }), + phaseEvent({ + boundary: 'end', + correlationId: span.phaseId, + durationMs: span.durationMs, + epochMs: span.endedEpochMs, + itemCount, + phase: span.phase, + source, + }), + ]; + }); +} + +function rendererSemanticSource(phase: string): XtreamPhaseSemanticSource { + const semantic: XtreamPhaseSemanticType | null = + phase === PHASE.STORE_PUBLISH_LIVE + ? SEMANTIC.LIVE_CONTENT + : phase === PHASE.STORE_PUBLISH_VOD + ? SEMANTIC.VOD_CONTENT + : phase === PHASE.STORE_PUBLISH_SERIES + ? SEMANTIC.SERIES_CONTENT + : null; + return xtreamDataSemanticSource(semantic); +} + +function ipcSpans(main: XtreamMainCaptureResult): XtreamIpcAttributionSpan[] { + const spans = xtreamIpcSpans(main); + for (const request of main.requests) { + const operation = request.operation ?? ''; + const method = LEGACY_METHODS[operation as keyof typeof LEGACY_METHODS]; + if (method) spans.push(...legacyIpcPair(main, request, method)); + } + return spans; +} + +function xtreamIpcSpans( + main: XtreamMainCaptureResult +): XtreamIpcAttributionSpan[] { + const groups = new Map(); + for (const marker of main.ipcTimeline) { + if ( + marker.type !== 'xtream-preload-marker' || + !Number.isSafeInteger(marker.ipcCallId) || + typeof marker.method !== 'string' + ) { + continue; + } + const key = xtreamIpcProducerIdentityKey( + XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD, + Number(marker.ipcCallId) + ); + groups.set(key, [...(groups.get(key) ?? []), marker]); + } + return [...groups.entries()].flatMap(([correlationId, markers]) => { + const start = markers.find(({ boundary }) => boundary === 'start'); + const end = markers.find(({ boundary }) => boundary === 'end'); + if ( + markers.length !== 2 || + !start || + !end || + start.method !== end.method || + start.outcome !== null || + !['error', 'success'].includes(String(end.outcome)) + ) { + invalid(); + } + const sourceEpochMs = requireEpoch(start.sourceEpochMs); + return [ + ipcSpan( + 'request', + correlationId, + start, + sourceEpochMs, + sourceEpochMs, + end.outcome as 'error' | 'success' + ), + ipcSpan( + 'response', + correlationId, + end, + requireEpoch(end.sourceEpochMs), + sourceEpochMs, + end.outcome as 'error' | 'success' + ), + ]; + }); +} + +function legacyIpcPair( + main: XtreamMainCaptureResult, + request: WorkerRequestPerformanceMetrics, + method: string +): XtreamIpcAttributionSpan[] { + const ipcCallId = requirePositiveInteger(request.ipcCallId); + const sourceEpochMs = requireEpoch(request.sourceEpochMs); + const requestTimeline = oneTimeline(main, request, 'db-request'); + const success = oneTimeline(main, request, 'preload-performance-success'); + const responseStartEpochMs = requireEpoch(request.responseEpochMs); + const correlationId = xtreamIpcProducerIdentityKey( + XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD, + ipcCallId + ); + return [ + { + boundary: 'request', + correlationId, + endEpochMs: requestTimeline.epochMs, + ipcCallId, + method, + outcome: 'success', + sourceEpochMs, + startEpochMs: sourceEpochMs, + }, + { + boundary: 'response', + correlationId, + endEpochMs: requireEpoch(success.sourceEpochMs), + ipcCallId, + method, + outcome: 'success', + sourceEpochMs, + startEpochMs: responseStartEpochMs, + }, + ]; +} + +function oneTimeline( + main: XtreamMainCaptureResult, + request: WorkerRequestPerformanceMetrics, + type: 'db-request' | 'preload-performance-success' +) { + const matches = main.capture.timeline.filter( + (record) => + record.type === type && + record.ipcCallId === request.ipcCallId && + record.operation === request.operation && + record.playlistId === request.playlistId && + (type !== 'db-request' || + (record.requestId === request.requestId && + record.sourceEpochMs === request.sourceEpochMs)) + ); + if (matches.length !== 1 || !matches[0]) invalid(); + return matches[0]; +} + +function ipcSpan( + boundary: 'request' | 'response', + correlationId: string, + marker: XtreamIpcTimelineRecord, + startEpochMs: number, + sourceEpochMs: number, + outcome: 'error' | 'success' +): XtreamIpcAttributionSpan { + return { + boundary, + correlationId, + endEpochMs: marker.epochMs, + ipcCallId: requirePositiveInteger(marker.ipcCallId), + method: requireText(marker.method), + outcome, + sourceEpochMs, + startEpochMs, + }; +} + +function phaseEvent(input: { + boundary: 'end' | 'start'; + correlationId: string; + durationMs: number | null; + epochMs: number; + itemCount: number | null; + phase: string; + source: XtreamPhaseSemanticSource; +}): XtreamAttributionPhaseEvent { + const semanticType = semanticTypeFromXtreamPhaseSource(input.source); + if (semanticType === undefined) invalid(); + return { + boundary: input.boundary, + categoryType: input.source.categoryType, + contentType: input.source.contentType, + correlationId: input.correlationId, + durationMs: input.durationMs, + epochMs: input.epochMs, + itemCount: input.itemCount, + phase: input.phase, + providerAction: input.source.providerAction, + semanticType, + }; +} + +function requireEpoch(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + invalid(); + return value; +} + +function requirePositiveInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || Number(value) <= 0) invalid(); + return Number(value); +} + +function requireText(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream assembly phase evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts new file mode 100644 index 000000000..e07ce6dd9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts @@ -0,0 +1,363 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { XtreamMainCaptureResult } from './m3u-refresh-main-capture'; +import type { + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import type { + XtreamScenarioTerminalEvidence, + XtreamScenarioTriggerEvidence, +} from './xtream-scenario-driver'; +import { + XTREAM_CANCEL_NETWORK_ABORT_REASON, + type XtreamCancellationEvidence, + type XtreamCancellationMetrics, +} from './xtream-summary-contract'; +import { + summarizeXtreamUiActionSamples, + XTREAM_UI_ACTION_ORDER, + type XtreamUiActionProbeResult, + type XtreamUiActionSample, +} from './xtream-ui-action-probe'; + +export interface XtreamScenarioAssembly { + readonly backgroundMetrics: { + readonly actionLatencyMs: number; + readonly dashboardPaintVisibility: 'visible-without-overlay'; + readonly navigationLatencyMs: number; + readonly searchLatencyMs: number; + } | null; + readonly cancellation: XtreamCancellationMetrics; + readonly cancellationEvidence: XtreamCancellationEvidence | null; +} + +export function assembleXtreamScenarioEvidence(input: { + readonly main: XtreamMainCaptureResult; + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly renderer: XtreamRendererCaptureMetrics; + readonly scenarioId: XtreamScenarioId; + readonly terminal: XtreamScenarioTerminalEvidence; + readonly trigger: XtreamScenarioTriggerEvidence; +}): XtreamScenarioAssembly { + assertEnvelope(input); + return { + backgroundMetrics: backgroundMetrics(input), + ...cancellationEvidence(input), + }; +} + +function assertEnvelope(input: { + readonly main: XtreamMainCaptureResult; + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly renderer: XtreamRendererCaptureMetrics; + readonly scenarioId: XtreamScenarioId; + readonly terminal: XtreamScenarioTerminalEvidence; + readonly trigger: XtreamScenarioTriggerEvidence; +}): void { + const start = input.phaseCapture.operationStartEpochMs; + const terminal = input.phaseCapture.terminalEpochMs; + if ( + input.trigger.scenarioId !== input.scenarioId || + input.terminal.scenarioId !== input.scenarioId || + input.renderer.probe.scenarioId !== input.scenarioId || + input.trigger.triggerEpochMs < Math.floor(start) || + input.trigger.triggerEpochMs > input.terminal.terminalEpochMs || + input.main.captureStartedEpochMs > + input.main.measurementStartedEpochMs || + input.main.measurementStartedEpochMs > start || + input.renderer.measurementStartEpochMs < start || + input.renderer.probe.terminalEpochMs === null || + input.renderer.probe.terminalEpochMs > + input.renderer.captureCutoffEpochMs || + input.renderer.captureCutoffEpochMs > input.main.captureCutoffEpochMs || + terminal > input.main.captureCutoffEpochMs || + input.main.captureCutoffEpochMs > input.main.captureStoppedEpochMs + ) { + invalid(); + } +} + +function backgroundMetrics( + input: Parameters[0] +): XtreamScenarioAssembly['backgroundMetrics'] { + const background = input.terminal.backgroundProbe; + const operation = input.terminal.operation; + if (input.scenarioId !== XTREAM_SCENARIO_ID.BACKGROUND_UI) { + if (background !== null || operation !== null) invalid(); + return null; + } + if ( + !background || + !operation || + operation.active || + operation.terminalStatus !== 'completed' || + operation.terminalEpochMs === null || + background.operation !== 'delete-xtream-content' || + operation.operation !== background.operation || + operation.operationId !== background.operationId || + operation.playlistId !== background.playlistId || + operation.startedEpochMs > background.startedEpochMs || + operation.terminalEpochMs > input.terminal.terminalEpochMs || + background.completedEpochMs > input.terminal.terminalEpochMs || + background.completedEpochMs >= input.renderer.probe.uiPaintedEpochMs + ) { + invalid(); + } + const requests = input.main.requests.filter( + ({ operation: candidate }) => candidate === 'DB_DELETE_XTREAM_CONTENT' + ); + const request = requests[0]; + if ( + requests.length !== 1 || + !request || + request.operationId !== background.operationId || + request.playlistId !== background.playlistId || + request.workStartedEpochMs === null || + request.workEndedEpochMs === null || + background.startedEpochMs < request.workStartedEpochMs || + background.startedEpochMs > request.workEndedEpochMs || + request.workEndedEpochMs > input.terminal.terminalEpochMs + ) { + invalid(); + } + const result = readUiResult(background.result); + const firstSample = result.samples[0]; + const lastSample = result.samples.at(-1); + const dashboardSample = result.samples.find( + ({ kind }) => kind === 'dashboard' + ); + const storeTerminalEpochMs = input.renderer.probe.storeTerminalEpochMs; + if ( + !firstSample || + !lastSample || + !dashboardSample || + storeTerminalEpochMs === null || + firstSample.startEpochMs < background.startedEpochMs || + lastSample.endEpochMs > background.completedEpochMs || + lastSample.endEpochMs > storeTerminalEpochMs || + operation.terminalEpochMs > storeTerminalEpochMs || + request.workEndedEpochMs > storeTerminalEpochMs + ) { + invalid(); + } + return { + actionLatencyMs: requireNumber(result.action.max), + dashboardPaintVisibility: result.dashboardPaintVisibility, + navigationLatencyMs: requireNumber(result.navigation.max), + searchLatencyMs: requireNumber(result.search.max), + }; +} + +function readUiResult(value: unknown): XtreamUiActionProbeResult { + if (!isRecord(value)) invalid(); + const samplesValue = value['samples']; + if ( + value['dashboardPaintVisibility'] !== 'visible-without-overlay' || + !Array.isArray(samplesValue) || + samplesValue.length !== XTREAM_UI_ACTION_ORDER.length + ) { + invalid(); + } + const samples = samplesValue.map((entry, index) => + readSample(entry, index) + ); + if ( + samples.some((sample, index) => { + const previous = samples[index - 1]; + return ( + previous !== undefined && + previous.endEpochMs > sample.startEpochMs + ); + }) + ) { + invalid(); + } + const summary = summarizeXtreamUiActionSamples(samples); + if ( + !isDeepStrictEqual(summary.action, value['action']) || + !isDeepStrictEqual(summary.navigation, value['navigation']) || + !isDeepStrictEqual(summary.search, value['search']) + ) { + invalid(); + } + return { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }; +} + +function readSample(value: unknown, index: number): XtreamUiActionSample { + if ( + !isRecord(value) || + value['activeAfter'] !== true || + value['activeBefore'] !== true || + value['kind'] !== XTREAM_UI_ACTION_ORDER[index] || + !['action', 'navigation', 'search'].includes(String(value['category'])) + ) { + invalid(); + } + const startEpochMs = requireNumber(value['startEpochMs']); + const endEpochMs = requireNumber(value['endEpochMs']); + const latencyMs = requireNumber(value['latencyMs']); + if ( + endEpochMs < startEpochMs || + Math.abs(latencyMs - (endEpochMs - startEpochMs)) > 0.001 + ) { + invalid(); + } + return { + activeAfter: true, + activeBefore: true, + category: value['category'] as XtreamUiActionSample['category'], + endEpochMs, + kind: value['kind'] as XtreamUiActionSample['kind'], + latencyMs, + startEpochMs, + }; +} + +function cancellationEvidence( + input: Parameters[0] +): Pick { + const driver = input.terminal.cancellation; + if (input.scenarioId !== XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + if (driver !== null) invalid(); + return { + cancellation: emptyCancellation(), + cancellationEvidence: null, + }; + } + const failed = input.main.requests.filter( + ({ operation, success }) => operation === 'DB_SAVE_CONTENT' && !success + ); + const request = failed[0]; + const timeline = input.main.cancelTimeline; + const renderer = input.renderer.probe; + if ( + failed.length !== 1 || + !request || + !driver || + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' || + driver.operationId !== request.operationId || + driver.armedEpochMs > driver.progressEpochMs || + driver.progressEpochMs > driver.clickEpochMs || + driver.threshold !== Math.max(100, Math.floor(driver.total * 0.1)) || + driver.current < driver.threshold || + renderer.cancellationClickEpochMs === null || + renderer.cancellationClickEpochMs < driver.clickEpochMs || + renderer.dbCancellationTerminalEpochMs === null || + timeline.length !== 3 + ) { + invalid(); + } + const dispatch = timeline[0]; + const receipt = timeline[1]; + const workerTerminal = timeline[2]; + const preload = ipcPair(input.phaseCapture.ipcSpans, 'dbCancelOperation'); + const authoritative = request.responseEpochMs; + const painted = requireNumber(renderer.uiPaintedEpochMs); + if ( + !dispatch || + !receipt || + !workerTerminal || + dispatch.type !== 'db-cancel-dispatched' || + receipt.type !== 'db-cancel-received' || + workerTerminal.type !== 'db-cancel-terminal-received' || + renderer.dbCancellationTerminalEpochMs < workerTerminal.epochMs || + renderer.dbCancellationTerminalEpochMs > painted || + authoritative > painted + ) { + invalid(); + } + const click = driver.clickEpochMs; + const cancellation: XtreamCancellationMetrics = { + acknowledgementLatencyMs: authoritative - click, + authoritativeDatabaseAcknowledgementObserved: true, + authoritativeTerminalEpochMs: authoritative, + clickEpochMs: click, + clickToDatabaseDispatchMs: dispatch.epochMs - click, + clickToPaintMs: painted - click, + clickToPreloadReturnMs: preload[1].endEpochMs - click, + databaseDispatchEpochMs: dispatch.epochMs, + failedDatabaseRequestId: request.requestId, + failedOperationId: request.operationId, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: + XTREAM_CANCEL_NETWORK_ABORT_REASON.AFTER_NETWORK_COMPLETE, + paintedEpochMs: painted, + preloadReturnEpochMs: preload[1].endEpochMs, + preloadStartEpochMs: preload[0].startEpochMs, + workerReceiptEpochMs: receipt.epochMs, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: receipt.epochMs - click, + }; + if ( + Object.values(cancellation).some( + (entry) => typeof entry === 'number' && entry < 0 + ) + ) { + invalid(); + } + return { + cancellation, + cancellationEvidence: { + timeline: timeline.map((entry) => ({ ...entry })), + }, + }; +} + +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 emptyCancellation(): XtreamCancellationMetrics { + return { + acknowledgementLatencyMs: null, + authoritativeDatabaseAcknowledgementObserved: false, + authoritativeTerminalEpochMs: null, + clickEpochMs: null, + clickToDatabaseDispatchMs: null, + clickToPaintMs: null, + clickToPreloadReturnMs: null, + databaseDispatchEpochMs: null, + failedDatabaseRequestId: null, + failedOperationId: null, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: null, + paintedEpochMs: null, + preloadReturnEpochMs: null, + preloadStartEpochMs: null, + workerReceiptEpochMs: null, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: null, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream scenario assembly evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts new file mode 100644 index 000000000..026215095 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts @@ -0,0 +1,389 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +describe('Xtream persisted iteration evidence binding', () => { + it('rejects an independently edited phase headline', () => { + const raw = createIteration(); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phases: { + ...raw.phases, + sqliteWriteMs: 99, + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects worker phase evidence that no longer backs the phase headline', () => { + const raw = createIteration(); + const requests = raw.databaseWorker.requests.evidence.map((request) => { + if (request.operation !== 'DB_SAVE_CONTENT') return request; + const phaseEvents = request.phaseEvents.map((event, index) => + index === 1 + ? { + ...event, + durationMs: 1.5, + epochMs: event.epochMs + 0.5, + } + : event + ); + return { ...request, phaseEvents }; + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: requests, + }, + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects a self-consistent cancel headline outside the failed request', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const cancellation = { + ...raw.cancellation, + authoritativeTerminalEpochMs: + Number(raw.cancellation.authoritativeTerminalEpochMs) + 5_000, + clickEpochMs: Number(raw.cancellation.clickEpochMs) + 5_000, + databaseDispatchEpochMs: + Number(raw.cancellation.databaseDispatchEpochMs) + 5_000, + paintedEpochMs: Number(raw.cancellation.paintedEpochMs) + 5_000, + preloadReturnEpochMs: + Number(raw.cancellation.preloadReturnEpochMs) + 5_000, + preloadStartEpochMs: + Number(raw.cancellation.preloadStartEpochMs) + 5_000, + workerReceiptEpochMs: + Number(raw.cancellation.workerReceiptEpochMs) + 5_000, + }; + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellation, + }), + /invalid Xtream iteration/ + ); + }); + + it('binds the dedicated cancel receipt and failed-request identities exactly', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const receiptDrift = { + ...raw.cancellation, + workerReceiptEpochMs: + Number(raw.cancellation.workerReceiptEpochMs) + 0.5, + workerReceiptLatencyMs: + Number(raw.cancellation.workerReceiptLatencyMs) + 0.5, + }; + const wrongRequest = { + ...raw.cancellation, + failedDatabaseRequestId: 'another-request', + }; + const wrongOperation = { + ...raw.cancellation, + failedOperationId: 'another-operation', + }; + + for (const cancellation of [ + receiptDrift, + wrongRequest, + wrongOperation, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellation, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('accepts either ordering of worker receipt and preload return after dispatch', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const dispatch = Number(raw.cancellation.databaseDispatchEpochMs); + const preloadReturn = Number(raw.cancellation.preloadReturnEpochMs); + const workerTerminal = Number( + raw.cancellationEvidence?.timeline.find( + ({ type }) => type === 'db-cancel-terminal-received' + )?.epochMs + ); + const beforeReturn = dispatch + (preloadReturn - dispatch) / 2; + const afterReturn = + preloadReturn + (workerTerminal - preloadReturn) / 2; + + assert.notEqual( + raw.cancellation.workerReceiptEpochMs, + preloadReturn, + 'fixture receipt must be independent from the preload response' + ); + for (const receiptEpochMs of [beforeReturn, afterReturn]) { + assert.doesNotThrow(() => + assertXtreamIterationResult(withReceipt(raw, receiptEpochMs)) + ); + } + }); + + it('rejects cancel-receipt identity drift and receipt after the authoritative terminal', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const receiptIndex = evidence.timeline.findIndex( + ({ type }) => type === 'db-cancel-received' + ); + assert.notEqual(receiptIndex, -1); + + for (const receipt of [ + { requestId: 'another-request' }, + { operationId: 'another-operation' }, + { ipcCallId: 999 }, + { sourceEpochMs: 999 }, + { + epochMs: + Number(raw.cancellation.authoritativeTerminalEpochMs) + 1, + }, + ]) { + const timeline = evidence.timeline.map((record, index) => + index === receiptIndex ? { ...record, ...receipt } : record + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { timeline }, + }), + /invalid Xtream iteration/ + ); + } + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => ({ + ...record, + sourceEpochMs: record.sourceEpochMs + 1, + })), + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects reuse of a worker request IPC identity for cancellation', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const failedRequest = raw.databaseWorker.requests.evidence.find( + ({ operation, success }) => + operation === 'DB_SAVE_CONTENT' && !success + ); + assert.ok(failedRequest?.ipcCallId); + + for (const ipcCallId of [failedRequest.ipcCallId, 999]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => ({ + ...record, + ipcCallId: ipcCallId as number, + })), + }, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('persists producer identity on the database cancel IPC pair', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const databaseCancel = raw.phaseCapture.ipcSpans.filter( + ({ method }) => method === 'dbCancelOperation' + ) as readonly ((typeof raw.phaseCapture.ipcSpans)[number] & { + readonly ipcCallId?: number; + readonly sourceEpochMs?: number; + })[]; + + assert.equal(databaseCancel.length, 2); + assert.equal(typeof databaseCancel[0]?.ipcCallId, 'number'); + assert.equal( + databaseCancel[0]?.ipcCallId, + databaseCancel[1]?.ipcCallId + ); + assert.equal(typeof databaseCancel[0]?.sourceEpochMs, 'number'); + assert.equal( + databaseCancel[0]?.sourceEpochMs, + databaseCancel[1]?.sourceEpochMs + ); + }); + + it('rejects database preload identities swapped across worker operations', () => { + const raw = createIteration(); + const contentRead = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetContent' + ); + const contentWrite = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbSaveContent' + ); + assert.ok(contentRead); + assert.ok(contentWrite); + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => { + if (span.correlationId === contentRead.correlationId) { + return { + ...span, + ipcCallId: contentWrite.ipcCallId, + sourceEpochMs: contentWrite.sourceEpochMs, + }; + } + return span.correlationId === contentWrite.correlationId + ? { + ...span, + ipcCallId: contentRead.ipcCallId, + sourceEpochMs: contentRead.sourceEpochMs, + } + : span; + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects cancellation source chronology before an earlier worker call', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const lastWorkerCall = raw.databaseWorker.requests.evidence + .filter( + (request) => + request.ipcCallId !== null && request.sourceEpochMs !== null + ) + .sort( + (left, right) => + Number(right.ipcCallId) - Number(left.ipcCallId) + )[0]; + assert.ok(lastWorkerCall?.sourceEpochMs); + const databaseSourceEpochMs = + Number(lastWorkerCall.sourceEpochMs) - 0.25; + const networkSourceEpochMs = databaseSourceEpochMs - 0.25; + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => { + if (span.method === 'xtreamCancelSession') { + return { ...span, sourceEpochMs: networkSourceEpochMs }; + } + return span.method === 'dbCancelOperation' + ? { ...span, sourceEpochMs: databaseSourceEpochMs } + : span; + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => ({ + ...record, + sourceEpochMs: databaseSourceEpochMs, + })), + }, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }), + /invalid Xtream iteration/ + ); + }); + + it('ignores caller-owned phase array methods when checking evidence', () => { + const raw = createIteration(); + const events = [ + ...raw.phaseCapture.events, + ] as unknown as typeof raw.phaseCapture.events & { + filter: () => readonly never[]; + map: () => readonly never[]; + }; + Object.defineProperties(events, { + filter: { value: () => [] }, + map: { value: () => [] }, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phaseCapture: { ...raw.phaseCapture, events }, + phases: { ...raw.phases, sqliteWriteMs: 99 }, + }), + /invalid Xtream iteration/ + ); + }); +}); + +function withReceipt( + raw: XtreamRawIterationResult, + receiptEpochMs: number +): XtreamRawIterationResult { + const click = Number(raw.cancellation.clickEpochMs); + const evidence = cancellationEvidence(raw); + return { + ...raw, + cancellation: { + ...raw.cancellation, + workerReceiptEpochMs: receiptEpochMs, + workerReceiptLatencyMs: receiptEpochMs - click, + }, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => + record.type === 'db-cancel-received' + ? { ...record, epochMs: receiptEpochMs } + : record + ), + }, + }; +} + +function cancellationEvidence( + raw: XtreamRawIterationResult +): NonNullable { + const evidence = raw.cancellationEvidence; + assert.ok(evidence); + return evidence; +} + +function createIteration( + scenarioId: XtreamScenarioId = XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE +): XtreamRawIterationResult { + const definition: XtreamIterationDefinition = { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }; + return iteration(definition, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts new file mode 100644 index 000000000..880487d07 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts @@ -0,0 +1,154 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + attributeXtreamPhases, + type XtreamAttributionPhaseEvent, +} from './xtream-phase-attribution'; +import { assertXtreamCancellationEvidenceBinding } from './xtream-cancellation-evidence-binding'; +import { isExactRecord } from './xtream-exact-schema'; +import { assertXtreamIpcWorkerIdentityBinding } from './xtream-ipc-worker-identity-binding'; +import { snapshotXtreamPhaseInput } from './xtream-own-array-snapshot'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +const PHASE_CAPTURE_KEYS = [ + 'backgroundSearchMarkerObserved', + 'events', + 'ipcSpans', + 'operationStartEpochMs', + 'scenarioId', + 'terminalEpochMs', + 'uiPaintEpochMs', +] as const; +const WORKER_PHASES = new Set([ + PHASE.DESERIALIZE_PLAYLIST, + PHASE.NORMALIZE_CATEGORIES, + PHASE.NORMALIZE_CONTENT, + PHASE.NORMALIZE_SEARCH_RANK, + PHASE.SERIALIZE_PLAYLIST, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + PHASE.SQLITE_GENERIC_READ, + PHASE.SQLITE_GENERIC_WRITE, + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + PHASE.SQLITE_SEARCH_QUERY, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, +]); +type WorkerBindablePhaseEvent = Omit< + XtreamAttributionPhaseEvent, + 'categoryType' | 'contentType' | 'providerAction' | 'semanticType' +>; + +export function assertXtreamIterationEvidenceBinding( + raw: XtreamRawIterationResult +): void { + if ( + !isExactRecord(raw.phaseCapture, PHASE_CAPTURE_KEYS) || + raw.phaseCapture.scenarioId !== raw.scenarioId + ) { + invalid(); + } + const canonicalCapture = snapshotXtreamPhaseInput(raw.phaseCapture); + const events = canonicalCapture.events; + const ipcSpans = canonicalCapture.ipcSpans; + const requests = snapshotOwnArray< + XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number] + >(raw.databaseWorker.requests.evidence); + const attributed = attributeXtreamPhases(canonicalCapture); + if (!isDeepStrictEqual(attributed, raw.phases)) invalid(); + assertWorkerPhaseBinding(events, requests); + assertXtreamIpcWorkerIdentityBinding(ipcSpans, requests); + assertXtreamCancellationEvidenceBinding(raw, events, ipcSpans, requests); +} + +function assertWorkerPhaseBinding( + events: readonly XtreamAttributionPhaseEvent[], + requests: readonly XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number][] +): void { + const captured = events + .filter(({ phase }) => WORKER_PHASES.has(phase)) + .map(copyCaptureEvent) + .sort(compareEvents); + const projected = requests + .flatMap((request) => { + if (typeof request.requestId !== 'string') invalid(); + return snapshotOwnArray<(typeof request.phaseEvents)[number]>( + request.phaseEvents + ).map((event): WorkerBindablePhaseEvent => ({ + boundary: event.boundary, + correlationId: request.requestId as string, + durationMs: event.durationMs, + epochMs: event.epochMs, + itemCount: + event.boundary === 'end' + ? (event.metadata?.itemCount ?? null) + : null, + phase: event.phase, + })); + }) + .sort(compareEvents); + if (!isDeepStrictEqual(captured, projected)) invalid(); +} + +function copyCaptureEvent( + event: XtreamAttributionPhaseEvent +): WorkerBindablePhaseEvent { + return { + boundary: event.boundary, + correlationId: event.correlationId, + durationMs: event.durationMs, + epochMs: event.epochMs, + itemCount: event.itemCount, + phase: event.phase, + }; +} + +function compareEvents( + left: WorkerBindablePhaseEvent, + right: WorkerBindablePhaseEvent +): number { + return eventKey(left).localeCompare(eventKey(right)); +} + +function eventKey(event: WorkerBindablePhaseEvent): string { + return [ + event.correlationId, + event.phase, + event.boundary, + event.epochMs, + event.durationMs, + event.itemCount, + ].join('\0'); +} + +function snapshotOwnArray(value: unknown): readonly T[] { + if (!Array.isArray(value)) invalid(); + const descriptor = Reflect.getOwnPropertyDescriptor(value, 'length'); + const length = descriptor?.value; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 512 + ) { + invalid(); + } + const result: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const entry = Reflect.getOwnPropertyDescriptor(value, String(index)); + if (!entry || !Object.prototype.hasOwnProperty.call(entry, 'value')) { + invalid(); + } + result[index] = entry.value as T; + } + return Object.freeze(result); +} + +function invalid(): never { + throw new Error('invalid Xtream persisted evidence binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts new file mode 100644 index 000000000..32cf93b9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts @@ -0,0 +1,53 @@ +import { XTREAM_ITERATION_KIND } from './xtream-benchmark-contract'; +import { isExactRecord } from './xtream-exact-schema'; +import { XTREAM_NOT_APPLICABLE } from './xtream-summary-contract'; + +export function assertXtreamFixedIterationContracts( + raw: Record +): void { + const playlist = raw['playlistRefreshWorker']; + const catalog = raw['catalogVerification']; + const validity = raw['validity']; + if ( + !isExactRecord(playlist, ['instantiated', 'reason']) || + playlist['instantiated'] !== false || + playlist['reason'] !== XTREAM_NOT_APPLICABLE.PLAYLIST_REFRESH_WORKER || + !isExactRecord(catalog, [ + 'databaseStateValid', + 'fixtureIdentityValid', + 'providerCategoryCount', + 'providerItemCount', + 'verifiedAfterCapture', + ]) || + ![ + catalog['databaseStateValid'], + catalog['fixtureIdentityValid'], + catalog['verifiedAfterCapture'], + ].every((entry) => entry === true) || + catalog['providerCategoryCount'] !== 100 || + catalog['providerItemCount'] !== 100_000 || + !isExactRecord(validity, [ + 'actionContractValid', + 'catalogContractValid', + 'databaseWorkerCount', + 'dbPhaseContractValid', + 'diagnosticArtifactsValid', + 'lateDatabaseRequestCount', + 'playlistWorkerCount', + 'rendererTargetCount', + ]) || + ![ + validity['actionContractValid'], + validity['catalogContractValid'], + validity['dbPhaseContractValid'], + ].every((entry) => entry === true) || + validity['databaseWorkerCount'] !== 1 || + validity['playlistWorkerCount'] !== 0 || + validity['rendererTargetCount'] !== 1 || + validity['lateDatabaseRequestCount'] !== 0 || + validity['diagnosticArtifactsValid'] !== + (raw['kind'] === XTREAM_ITERATION_KIND.DIAGNOSTIC ? true : null) + ) { + throw new Error('invalid'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts new file mode 100644 index 000000000..2321f8a91 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { iteration as createIteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +describe('Xtream iteration semantic evidence', () => { + it('labels only the selected IPC proxy boundary', () => { + const raw = iteration(); + + assert.equal( + raw.phases.structuredCloneAttribution, + 'selected-ipc-proxy-includes-queueing-and-scheduling' + ); + assert.equal( + raw.phases.indexesReason, + 'schema-indexes-pre-exist; operation-creates-none' + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phases: { + ...raw.phases, + structuredCloneAttribution: + 'proxy-includes-queueing-and-scheduling', + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('preserves Electron main ELU as an explicit unavailable pair', () => { + const raw = iteration(); + + assert.equal(raw.main.eventLoopUtilization, null); + assert.equal( + raw.main.eventLoopUtilizationUnavailableReason, + 'electron-main-embedded-event-loop' + ); + for (const main of [ + { + ...raw.main, + eventLoopUtilization: 0, + eventLoopUtilizationUnavailableReason: null, + }, + { + ...raw.main, + eventLoopUtilizationUnavailableReason: null, + }, + ]) { + assert.throws( + () => assertXtreamIterationResult({ ...raw, main }), + /invalid Xtream iteration/ + ); + } + }); + + it('requires physically possible process memory, phase bounds, and long-task pairs', () => { + const raw = iteration(); + const invalid = [ + { + ...raw, + renderer: { ...raw.renderer, peakHeapUsedBytes: 0 }, + }, + { + ...raw, + renderer: { + ...raw.renderer, + postGcHeapUsedBytes: raw.renderer.peakHeapUsedBytes + 1, + }, + }, + { + ...raw, + main: { ...raw.main, peakRssBytes: 0 }, + }, + { + ...raw, + main: { + ...raw.main, + peakHeapUsedBytes: raw.main.peakRssBytes + 1, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakHeapUsedBytes: 0, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + postGcHeapUsedBytes: + raw.databaseWorker.peakHeapUsedBytes + 1, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + ordinal: 0, + }, + }, + { + ...raw, + phases: { + ...raw.phases, + storeUpdateMs: raw.phases.totalMs + 1, + }, + }, + { + ...raw, + renderer: { + ...raw.renderer, + longTaskCount: 0, + longTaskMaxMs: 75, + }, + }, + { + ...raw, + renderer: { + ...raw.renderer, + longTaskCount: 2, + longTaskMaxMs: 0, + }, + }, + ]; + + for (const candidate of invalid) { + assert.throws( + () => + assertXtreamIterationResult( + candidate as XtreamRawIterationResult + ), + /invalid Xtream iteration/ + ); + } + }); + + it('requires positive safe diagnostic CPU-profile durations', () => { + const diagnostic = iteration({ + ...definition(), + eligibleForComparison: false, + kind: XTREAM_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }); + const mutate = ( + process: 'databaseWorker' | 'main' | 'renderer', + cpuProfileDurationMicros: number + ): XtreamRawIterationResult => ({ + ...diagnostic, + [process]: { + ...diagnostic[process], + cpuProfileDurationMicros, + }, + }); + + for (const process of ['databaseWorker', 'main', 'renderer'] as const) { + for (const duration of [0, 1.5]) { + assert.throws( + () => + assertXtreamIterationResult(mutate(process, duration)), + /invalid Xtream iteration/ + ); + } + } + }); + + it('requires distinct worker request and producer preload identities', () => { + const raw = iteration(); + const evidence = raw.databaseWorker.requests.evidence; + const first = evidence[0]; + const second = evidence[1]; + const correlated = evidence.filter( + ({ operation }) => operation === 'DB_GET_CATEGORIES' + ); + const operationIdentities = evidence.filter( + ({ operation }) => operation === 'DB_SAVE_CONTENT' + ); + const firstCorrelated = correlated[0]; + const secondCorrelated = correlated[1]; + const firstOperation = operationIdentities[0]; + const secondOperation = operationIdentities[1]; + assert.ok(first?.requestId); + assert.ok(second); + assert.ok(firstCorrelated?.ipcCallId !== null); + assert.ok(secondCorrelated); + assert.ok(firstOperation?.operationId); + assert.ok(secondOperation); + + const duplicateRequestId = evidence.map((request, index) => + index === 1 + ? { + ...request, + requestId: first.requestId, + phaseEvents: request.phaseEvents.map((event) => ({ + ...event, + requestId: first.requestId, + })), + } + : request + ); + const duplicateIpcCallId = evidence.map((request) => + request === secondCorrelated + ? { + ...request, + ipcCallId: firstCorrelated.ipcCallId, + sourceEpochMs: firstCorrelated.sourceEpochMs, + } + : request + ); + const duplicateOperationId = evidence.map((request) => + request === secondOperation + ? { + ...request, + operationId: firstOperation.operationId, + operationIdUnavailableReason: null, + } + : request + ); + const zeroSourceEpoch = evidence.map((request) => + request === firstCorrelated + ? { ...request, sourceEpochMs: 0 } + : request + ); + const missingProducerIdentity = evidence.map((request) => + request === firstCorrelated + ? { + ...request, + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + sourceEpochMs: null, + } + : request + ); + + for (const candidate of [ + duplicateRequestId, + duplicateIpcCallId, + duplicateOperationId, + zeroSourceEpoch, + missingProducerIdentity, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: candidate, + }, + }, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('requires exact per-request operation, outcome, phase, and accounting evidence', () => { + const raw = iteration(); + const evidence = raw.databaseWorker.requests.evidence; + assert.ok(evidence.length > 1); + + const mutations = [ + { + ...raw.databaseWorker.requests, + totalCount: 1, + validSampleCount: 1, + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.slice(1), + totalCount: evidence.length - 1, + validSampleCount: evidence.length - 1, + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.map((request, index) => + index === 0 + ? { ...request, operation: 'DB_GET_CONTENT' } + : request + ), + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.map((request, index) => + index === 0 ? { ...request, success: false } : request + ), + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.map((request, index) => + index === 0 ? { ...request, phaseEvents: [] } : request + ), + }, + ]; + + for (const requests of mutations) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests, + }, + } as XtreamRawIterationResult), + /invalid Xtream iteration/ + ); + } + }); +}); + +function definition(): XtreamIterationDefinition { + return { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + }; +} + +function iteration( + input: XtreamIterationDefinition = definition() +): XtreamRawIterationResult { + return createIteration(input, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts new file mode 100644 index 000000000..759cc3b06 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts @@ -0,0 +1,373 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import { + iteration as createIteration, + withWorkerRequestOverlap, +} from './xtream-benchmark-report.fixtures'; +import { + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type XtreamRawIterationResult, +} from './xtream-summary-contract'; + +describe('Xtream raw iteration validity', () => { + it('requires exact finite nonnegative numeric keys for every process and phase', () => { + const raw = iteration(); + const candidates = [ + { + ...raw, + phases: { ...raw.phases, totalMs: '100' }, + }, + { + ...raw, + renderer: { ...raw.renderer, peakRssBytes: undefined }, + }, + { + ...raw, + main: { ...raw.main, cpuUserMicros: null }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakExternalBytes: Number.NaN, + }, + }, + { + ...raw, + main: { ...raw.main, inventedMetric: 1 }, + }, + ]; + + assert.doesNotThrow(() => assertXtreamIterationResult(raw)); + for (const candidate of candidates) { + assert.throws( + () => + assertXtreamIterationResult( + candidate as unknown as XtreamRawIterationResult + ), + /invalid Xtream iteration/ + ); + } + }); + + it('uses explicit diagnostic-only CPU-profile availability pairs', () => { + const measured = iteration(); + const diagnostic = iteration({ + ...definition(), + kind: XTREAM_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + eligibleForComparison: false, + }); + + assert.doesNotThrow(() => assertXtreamIterationResult(measured)); + assert.doesNotThrow(() => assertXtreamIterationResult(diagnostic)); + assert.throws( + () => + assertXtreamIterationResult({ + ...measured, + main: { + ...measured.main, + cpuProfileDurationMicros: 100, + cpuProfileUnavailableReason: null, + }, + }), + /invalid Xtream iteration/ + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...diagnostic, + renderer: { + ...diagnostic.renderer, + cpuProfileDurationMicros: null, + cpuProfileUnavailableReason: + 'not-captured-outside-diagnostic', + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('requires one exact store-to-paint value and a fresh process identity', () => { + const raw = iteration(); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + renderer: { ...raw.renderer, storeToPaintMs: 12.999 }, + }), + /invalid Xtream iteration/ + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + processIdentity: { + ...raw.processIdentity, + freshProcess: false, + }, + }), + /invalid Xtream iteration/ + ); + const { processIdentity: _removed, ...missingIdentity } = raw; + void _removed; + assert.throws( + () => + assertXtreamIterationResult( + missingIdentity as XtreamRawIterationResult + ), + /invalid Xtream iteration/ + ); + }); + + it('requires every cancel receipt and validates the authoritative timeline', () => { + const cancel = iteration({ + ...definition(), + scenarioId: XTREAM_SCENARIO_ID.CANCEL_IMPORT, + }); + + assert.doesNotThrow(() => assertXtreamIterationResult(cancel)); + for (const cancellation of [ + { + ...cancel.cancellation, + clickToPreloadReturnMs: + Number(cancel.cancellation.clickToPreloadReturnMs) - 0.5, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadReturnEpochMs) - 0.5, + }, + { + ...cancel.cancellation, + clickToPreloadReturnMs: + Number(cancel.cancellation.clickToPreloadReturnMs) + 0.5, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadReturnEpochMs) + 0.5, + }, + { ...cancel.cancellation, workerReceiptLatencyMs: null }, + { + ...cancel.cancellation, + workerReceiptEpochMs: + Number(cancel.cancellation.databaseDispatchEpochMs) - 0.5, + }, + { + ...cancel.cancellation, + clickToPaintMs: + Number(cancel.cancellation.clickToPaintMs) + 0.1, + }, + { + ...cancel.cancellation, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadStartEpochMs) - 0.1, + }, + { + ...cancel.cancellation, + clickToPreloadReturnMs: + Number(cancel.cancellation.clickToPreloadReturnMs) + 1, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadReturnEpochMs) + 1, + }, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...cancel, + cancellation, + }), + /invalid Xtream iteration|authoritative database cancellation/ + ); + } + }); + + it('requires all non-cancel timeline values to be exact nulls', () => { + const raw = iteration(); + + for (const cancellation of [ + { ...raw.cancellation, clickEpochMs: 100 }, + { + ...raw.cancellation, + failedDatabaseRequestId: 'unexpected-request', + }, + { + ...raw.cancellation, + failedOperationId: 'unexpected-operation', + }, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellation, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('keeps the interactive background refresh visible without an overlay', () => { + const background = iteration({ + ...definition(), + scenarioId: XTREAM_SCENARIO_ID.BACKGROUND_UI, + }); + + assert.equal( + background.renderer.dashboardPaintVisibility, + 'visible-without-overlay' + ); + assert.doesNotThrow(() => assertXtreamIterationResult(background)); + assert.throws( + () => + assertXtreamIterationResult({ + ...background, + renderer: { + ...background.renderer, + dashboardPaintVisibility: 'occluded-by-overlay', + }, + } as unknown as XtreamRawIterationResult), + /invalid Xtream iteration/ + ); + }); + + it('accounts for scheduler-dependent overlap without filtering samples', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ]) { + const overlapped = withWorkerRequestOverlap( + iteration({ ...definition(), scenarioId }), + 2 + ); + assert.doesNotThrow(() => assertXtreamIterationResult(overlapped)); + } + + const overlapped = withWorkerRequestOverlap(iteration(), 2); + for (const requests of [ + { + ...overlapped.databaseWorker.requests, + invalidReasons: [{ count: 2, reason: 'missing-event-loop' }], + }, + { + ...overlapped.databaseWorker.requests, + invalidReasons: [ + { + count: 1, + reason: 'overlapping-database-worker-requests', + }, + ], + }, + { + ...overlapped.databaseWorker.requests, + eventLoopDelayP95Ms: null, + }, + { + ...overlapped.databaseWorker.requests, + metricScope: 'whole-worker-operation', + }, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...overlapped, + databaseWorker: { + ...overlapped.databaseWorker, + requests, + }, + } as unknown as XtreamRawIterationResult), + /invalid Xtream iteration/ + ); + } + const deletion = withWorkerRequestOverlap( + iteration({ + ...definition(), + scenarioId: XTREAM_SCENARIO_ID.DELETE_LARGE, + }), + 1 + ); + assert.throws( + () => assertXtreamIterationResult(deletion), + /invalid Xtream iteration/ + ); + }); + + it('derives request-window headline delay without relabelling whole-worker ELU', () => { + const raw = iteration(); + assert.equal( + raw.databaseWorker.eventLoopDelayMetricScope, + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY + ); + assert.equal( + raw.databaseWorker.requests.metricScope, + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY + ); + assert.equal( + raw.databaseWorker.wholeWorkerMetricScope, + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW + ); + assert.notEqual( + raw.databaseWorker.eventLoopUtilization, + raw.databaseWorker.requests.eventLoopUtilization + ); + assert.doesNotThrow(() => assertXtreamIterationResult(raw)); + const arbitraryRequestProxy = { + eventLoopDelayMaxMs: 4, + eventLoopDelayP95Ms: 1, + eventLoopDelayP99Ms: 2, + eventLoopUtilization: 0.9, + }; + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + ...arbitraryRequestProxy, + eventLoopUtilization: + raw.databaseWorker.eventLoopUtilization, + requests: { + ...raw.databaseWorker.requests, + ...arbitraryRequestProxy, + }, + }, + }), + /invalid Xtream iteration/ + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + eventLoopDelayP95Ms: 2.5, + }, + }, + }), + /invalid Xtream iteration/ + ); + }); +}); + +function definition(): XtreamIterationDefinition { + return { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + }; +} + +function iteration( + input: XtreamIterationDefinition = definition() +): XtreamRawIterationResult { + return createIteration(input, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts new file mode 100644 index 000000000..4ff857463 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts @@ -0,0 +1,361 @@ +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + XTREAM_CANCEL_NETWORK_ABORT_REASON, + XTREAM_MAIN_CPU_METRIC_SCOPE, + XTREAM_NOT_APPLICABLE, + XTREAM_RENDERER_VISIBILITY, + XTREAM_STRUCTURED_CLONE_ATTRIBUTION, + XTREAM_WORKER_CPU_METRIC_SCOPE, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type XtreamRawIterationResult, +} from './xtream-summary-contract'; +import { + hasFiniteNonNegativeKeys, + hasMatchingEventLoopDelay, + hasOrderedEventLoopDelay, + assertXtreamSafePathIdentifier, + isExactXtreamProcessIdentity, + isExactRecord, + isFiniteNonNegative, + isPositiveSafeInteger, + isSafeNonNegativeInteger, + isUtilization, + isXtreamProfile, + XTREAM_CANCEL_TIME_KEYS, + XTREAM_MAIN_NUMBER_KEYS, + XTREAM_PHASE_KEYS, + XTREAM_PHASE_NUMBER_KEYS, + XTREAM_PROFILE_KEYS, + XTREAM_RAW_ITERATION_KEYS, + XTREAM_RENDERER_NUMBER_KEYS, + XTREAM_WORKER_NUMBER_KEYS, +} from './xtream-exact-schema'; +import { assertXtreamWorkerHeapWithinMainRss } from './xtream-memory-validity'; +import { assertXtreamIterationEvidenceBinding } from './xtream-iteration-evidence-binding'; +import { assertXtreamFixedIterationContracts } from './xtream-iteration-fixed-contracts'; +import { validXtreamWorkerRequestAccounting } from './xtream-worker-request-evidence'; + +export function assertXtreamIterationResult( + input: unknown +): asserts input is XtreamRawIterationResult { + try { + if (!isExactRecord(input, XTREAM_RAW_ITERATION_KEYS)) invalid(); + assertDefinition(input); + assertPhases(input['phases'], input['scenarioId']); + assertRenderer(input['renderer'], input); + assertMain(input['main'], input['kind']); + assertWorker( + input['databaseWorker'], + input['kind'], + input['scenarioId'] as XtreamScenarioId + ); + assertXtreamWorkerHeapWithinMainRss( + input['databaseWorker'], + input['main'] + ); + assertProcessIdentity(input['processIdentity']); + assertCancellation(input['cancellation'], input['scenarioId']); + assertXtreamFixedIterationContracts(input); + assertXtreamIterationEvidenceBinding( + input as unknown as XtreamRawIterationResult + ); + } catch { + throw new Error('invalid Xtream iteration'); + } +} + +function assertDefinition(value: Record): void { + if ( + !Object.values(XTREAM_SCENARIO_ID).includes( + value['scenarioId'] as never + ) || + !Object.values(XTREAM_ITERATION_KIND).includes( + value['kind'] as never + ) || + typeof value['eligibleForComparison'] !== 'boolean' || + (value['kind'] !== XTREAM_ITERATION_KIND.MEASURED && + value['eligibleForComparison']) + ) { + invalid(); + } + assertXtreamSafePathIdentifier(value['runId'], 'run-id'); +} + +function assertPhases(value: unknown, scenarioId: unknown): void { + if ( + !isExactRecord(value, XTREAM_PHASE_KEYS) || + !hasFiniteNonNegativeKeys(value, XTREAM_PHASE_NUMBER_KEYS) + ) { + invalid(); + } + const background = scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const restores = + background || scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE; + if ( + (value['totalMs'] as number) <= 0 || + (value['angularRenderingMs'] as number) <= 0 || + value['indexesMs'] !== null || + value['indexesReason'] !== XTREAM_NOT_APPLICABLE.INDEXES_PRE_EXIST || + value['transactionCommitMs'] !== null || + value['transactionCommitReason'] !== + XTREAM_NOT_APPLICABLE.COMMIT_INCLUDED_IN_SQLITE_WRITE || + value['structuredCloneAttribution'] !== + XTREAM_STRUCTURED_CLONE_ATTRIBUTION.SELECTED_IPC_PROXY || + value['restoreUserDataMs'] !== null || + value['restoreUserDataReason'] !== + (restores + ? XTREAM_NOT_APPLICABLE.RESTORE_NO_USER_DATA + : XTREAM_NOT_APPLICABLE.RESTORE_NOT_INVOKED) || + value['backgroundSearchMarkerMs'] !== null || + value['backgroundSearchMarkerReason'] !== + (background ? XTREAM_NOT_APPLICABLE.SOURCES_SEARCH_MARKER : null) || + XTREAM_PHASE_NUMBER_KEYS.some( + (key) => (value[key] as number) > (value['totalMs'] as number) + ) || + (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE && + (value['dataAcquisitionMs'] !== 0 || + value['jsonParsingMs'] !== 0 || + value['normalizationMs'] !== 0 || + value['deserializationMs'] !== 0)) + ) { + invalid(); + } +} + +function assertRenderer(value: unknown, raw: Record): void { + const keys = [ + ...XTREAM_PROFILE_KEYS, + ...XTREAM_RENDERER_NUMBER_KEYS, + 'actionLatencyMs', + 'consoleErrors', + 'dashboardPaintVisibility', + 'navigationLatencyMs', + 'searchLatencyMs', + 'targetId', + ] as const; + if ( + !isExactRecord(value, keys) || + !hasFiniteNonNegativeKeys(value, XTREAM_RENDERER_NUMBER_KEYS) || + !isXtreamProfile(value, raw['kind']) || + !isSafeNonNegativeInteger(value['longTaskCount']) || + typeof value['targetId'] !== 'string' || + value['targetId'].length === 0 || + !Array.isArray(value['consoleErrors']) || + value['consoleErrors'].length !== 0 || + value['storeToPaintMs'] !== + (raw['phases'] as Record)['angularRenderingMs'] || + (value['frameGapP95Ms'] as number) > + (value['frameGapMaxMs'] as number) || + (value['heartbeatP95Ms'] as number) > + (value['heartbeatMaxMs'] as number) || + (value['peakHeapUsedBytes'] as number) <= 0 || + (value['peakRssBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) > + (value['peakHeapUsedBytes'] as number) || + (value['peakHeapUsedBytes'] as number) > + (value['peakRssBytes'] as number) || + (value['longTaskCount'] === 0) !== (value['longTaskMaxMs'] === 0) + ) { + invalid(); + } + const background = raw['scenarioId'] === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const optional = [ + value['navigationLatencyMs'], + value['searchLatencyMs'], + value['actionLatencyMs'], + ]; + if ( + (background && + (value['dashboardPaintVisibility'] !== + XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY || + !optional.every(isFiniteNonNegative))) || + (!background && + (value['dashboardPaintVisibility'] !== null || + optional.some((entry) => entry !== null))) + ) { + invalid(); + } +} + +function assertMain(value: unknown, kind: unknown): void { + const keys = [ + ...XTREAM_PROFILE_KEYS, + ...XTREAM_MAIN_NUMBER_KEYS, + 'cpuMetricScope', + 'eventLoopUtilization', + 'eventLoopUtilizationUnavailableReason', + ] as const; + if ( + !isExactRecord(value, keys) || + !hasFiniteNonNegativeKeys(value, XTREAM_MAIN_NUMBER_KEYS) || + !isXtreamProfile(value, kind) || + !isPositiveSafeInteger(value['browserWindowId']) || + !isPositiveSafeInteger(value['webContentsId']) || + !isSafeNonNegativeInteger(value['responsiveEvents']) || + !isSafeNonNegativeInteger(value['unresponsiveEvents']) || + value['eventLoopUtilization'] !== null || + value['eventLoopUtilizationUnavailableReason'] !== + XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION || + value['cpuMetricScope'] !== + XTREAM_MAIN_CPU_METRIC_SCOPE.ELECTRON_MAIN_THREAD || + !hasOrderedEventLoopDelay(value) || + (value['peakHeapUsedBytes'] as number) <= 0 || + (value['peakRssBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) > + (value['peakHeapUsedBytes'] as number) || + (value['peakHeapUsedBytes'] as number) > + (value['peakRssBytes'] as number) + ) { + invalid(); + } +} + +function assertWorker( + value: unknown, + kind: unknown, + scenarioId: XtreamScenarioId +): void { + const keys = [ + ...XTREAM_PROFILE_KEYS, + ...XTREAM_WORKER_NUMBER_KEYS, + 'cpuMetricScope', + 'currentForCapture', + 'eventLoopDelayMetricScope', + 'kind', + 'requests', + 'rssBytes', + 'rssUnavailableReason', + 'wholeWorkerMetricScope', + ] as const; + if ( + !isExactRecord(value, keys) || + !hasFiniteNonNegativeKeys(value, XTREAM_WORKER_NUMBER_KEYS) || + !isXtreamProfile(value, kind) || + value['kind'] !== 'database.worker' || + value['cpuMetricScope'] !== + XTREAM_WORKER_CPU_METRIC_SCOPE.SUM_VALID_REQUEST_WORK || + value['currentForCapture'] !== true || + !isPositiveSafeInteger(value['ordinal']) || + !isUtilization(value['eventLoopUtilization']) || + !hasOrderedEventLoopDelay(value) || + !validXtreamWorkerRequestAccounting(value['requests'], scenarioId) || + !hasMatchingEventLoopDelay(value) || + value['eventLoopDelayMetricScope'] !== + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY || + value['wholeWorkerMetricScope'] !== + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW || + value['rssBytes'] !== null || + value['rssUnavailableReason'] !== + XTREAM_NOT_APPLICABLE.DATABASE_WORKER_RSS || + (value['peakHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) > + (value['peakHeapUsedBytes'] as number) + ) { + invalid(); + } +} + +function assertProcessIdentity(value: unknown): void { + if (!isExactXtreamProcessIdentity(value)) invalid(); +} + +function assertCancellation(value: unknown, scenarioId: unknown): void { + const keys = [ + ...XTREAM_CANCEL_TIME_KEYS, + 'authoritativeDatabaseAcknowledgementObserved', + 'failedDatabaseRequestId', + 'failedOperationId', + 'mainNetworkAbortObserved', + 'mainNetworkAbortReason', + 'workerReceiptIsAuthoritative', + ] as const; + if (!isExactRecord(value, keys)) invalid(); + const cancel = scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT; + if (!cancel) { + if ( + XTREAM_CANCEL_TIME_KEYS.some((key) => value[key] !== null) || + value['authoritativeDatabaseAcknowledgementObserved'] !== false || + value['failedDatabaseRequestId'] !== null || + value['failedOperationId'] !== null || + value['mainNetworkAbortObserved'] !== false || + value['mainNetworkAbortReason'] !== null || + value['workerReceiptIsAuthoritative'] !== false + ) { + invalid(); + } + return; + } + if ( + !XTREAM_CANCEL_TIME_KEYS.every((key) => + isFiniteNonNegative(value[key]) + ) || + value['authoritativeDatabaseAcknowledgementObserved'] !== true || + typeof value['failedDatabaseRequestId'] !== 'string' || + value['failedDatabaseRequestId'].length === 0 || + typeof value['failedOperationId'] !== 'string' || + value['failedOperationId'].length === 0 || + value['mainNetworkAbortObserved'] !== false || + value['mainNetworkAbortReason'] !== + XTREAM_CANCEL_NETWORK_ABORT_REASON.AFTER_NETWORK_COMPLETE || + value['workerReceiptIsAuthoritative'] !== false + ) { + invalid(); + } + const click = value['clickEpochMs'] as number; + const preloadStart = value['preloadStartEpochMs'] as number; + const databaseDispatch = value['databaseDispatchEpochMs'] as number; + const preloadReturn = value['preloadReturnEpochMs'] as number; + const workerReceipt = value['workerReceiptEpochMs'] as number; + const authoritativeTerminal = value[ + 'authoritativeTerminalEpochMs' + ] as number; + const painted = value['paintedEpochMs'] as number; + if ( + click > preloadStart || + preloadStart > databaseDispatch || + databaseDispatch > preloadReturn || + databaseDispatch > workerReceipt || + workerReceipt > authoritativeTerminal || + authoritativeTerminal > painted || + !derived( + value, + 'clickToDatabaseDispatchMs', + 'databaseDispatchEpochMs' + ) || + !derived(value, 'clickToPreloadReturnMs', 'preloadReturnEpochMs') || + !derived(value, 'workerReceiptLatencyMs', 'workerReceiptEpochMs') || + !derived( + value, + 'acknowledgementLatencyMs', + 'authoritativeTerminalEpochMs' + ) || + !derived(value, 'clickToPaintMs', 'paintedEpochMs') + ) { + invalid(); + } +} + +function derived( + value: Record, + latency: string, + epoch: string +): boolean { + return ( + Math.abs( + (value[latency] as number) - + ((value[epoch] as number) - (value['clickEpochMs'] as number)) + ) <= 0.001 + ); +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts new file mode 100644 index 000000000..8037eecda --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import type { ElectronApplication } from '@playwright/test'; + +import { + readXtreamMainCaptureStatus, + rolloverXtreamMainCapture, + stopXtreamMainCapture, + type XtreamMainCaptureTransport, +} from './m3u-refresh-main-capture'; + +const STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; +const target = globalThis as unknown as Record; + +function electronApp(): ElectronApplication { + return { + evaluate: async ( + callback: (...args: never[]) => unknown, + input: never + ) => callback({} as never, input), + } as unknown as ElectronApplication; +} + +function electronAppRejectingAsyncEvaluation(): ElectronApplication { + return { + evaluate: (callback: (...args: never[]) => unknown, input: never) => { + const result = callback({} as never, input); + if (result instanceof Promise) { + return Promise.reject( + new Error('async-main-evaluator-not-allowed') + ); + } + return Promise.resolve(result); + }, + } as unknown as ElectronApplication; +} + +function transport( + overrides: Partial = {} +): XtreamMainCaptureTransport { + return { + captureGeneration: 7, + metrics: { + cpuProfilePath: null, + cpuSystemMicros: 11, + cpuUserMicros: 13, + eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'electron-main-embedded-event-loop', + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: 100, + peakRssBytes: 200, + postGcHeapUsedBytes: 80, + postGcRssBytes: 160, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { creationTime: 1_000, pid: 44 }, + missingSampleCount: 0, + peakRssBytes: 90, + unavailableReason: null, + validSampleCount: 1, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 2, + webContentsId: 3, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: [], + }, + rollover: null, + workers: [ + { + captureGeneration: 7, + metrics: { + cancelPostedEpochMs: null, + cpuSystemMicros: 5, + cpuUserMicros: 8, + eventLoopUtilization: 0.2, + externalMemorySampleCount: 1, + heapUsedSampleCount: 1, + kind: 'database.worker', + operationId: null, + ordinal: 1, + peakExternalBytes: 20, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 40, + peakHeapUsedUnavailableReason: null, + playlistId: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: 30, + profilePath: null, + responseEpochMs: null, + snapshotPath: null, + terminatedEpochMs: null, + }, + requests: [ + { + ipcCallId: 5, + operation: 'DB_DELETE_XTREAM_CONTENT', + operationId: 'operation-1', + operationIdUnavailableReason: null, + performanceCapture: null, + playlistId: 'playlist-1', + requestId: 'request-1', + responseEpochMs: 180, + sourceEpochMs: 110, + success: true, + }, + ], + }, + ], + xtream: { + cancelCapture: { + invalidReasons: [], + timeline: [], + }, + captureCutoffEpochMs: 190, + captureGeneration: 7, + captureStartedEpochMs: 100, + captureStoppedEpochMs: 220, + databaseWorkerCount: 1, + invalidReasons: [], + ipcCapture: { + invalidReasons: [], + quarantinedKeyCount: 0, + timeline: [], + }, + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementStartedEpochMs: 105, + operationOutcomes: [ + { + count: 1, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'success', + }, + ], + playlistRefreshWorkerCount: 0, + }, + ...overrides, + }; +} + +afterEach(() => delete target[STATE_KEY]); + +describe('Xtream main capture API', () => { + it('reads live status through a synchronous main evaluator', async () => { + target[STATE_KEY] = { + xtreamStatus: () => ({ + active: true, + activeOperations: [], + captureGeneration: 7, + captureStartedEpochMs: 100, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 105, + operationOutcomes: [], + playlistRefreshWorkerCount: 0, + }), + }; + + const status = await readXtreamMainCaptureStatus( + electronAppRejectingAsyncEvaluation() + ); + + assert.equal(status.captureGeneration, 7); + }); + + it('returns live generation, exact active DB operation, and progress', async () => { + target[STATE_KEY] = { + xtreamStatus: () => ({ + active: true, + activeOperations: [ + { + operation: 'DB_DELETE_XTREAM_CONTENT', + operationId: 'operation-1', + playlistId: 'playlist-1', + progress: { + current: 250, + epochMs: 150, + phase: 'deleting-content', + status: 'progress', + total: 1_000, + }, + requestId: 'request-1', + }, + ], + captureGeneration: 7, + captureStartedEpochMs: 100, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 105, + operationOutcomes: [ + { + count: 1, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'pending', + }, + ], + playlistRefreshWorkerCount: 0, + }), + }; + + const status = await readXtreamMainCaptureStatus(electronApp()); + + assert.equal(status.captureGeneration, 7); + assert.equal( + status.activeOperations[0]?.operation, + 'DB_DELETE_XTREAM_CONTENT' + ); + assert.equal(status.activeOperations[0]?.progress?.current, 250); + assert.deepEqual(status.operationOutcomes, [ + { + count: 1, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'pending', + }, + ]); + }); + + it('keeps completed generation and cutoff evidence across rollover', async () => { + target[STATE_KEY] = { + stop: () => + transport({ + rollover: { + nextCaptureStarted: true, + nextCaptureUnavailableReason: null, + }, + }), + }; + + const result = await rolloverXtreamMainCapture(electronApp(), { + diagnostic: false, + outputDirectory: '/tmp/synthetic-performance', + rendererWindowIdentity: { + browserWindowId: 2, + webContentsId: 3, + }, + }); + + assert.equal(result.captureGeneration, 7); + assert.equal(result.captureStartedEpochMs, 100); + assert.equal(result.captureCutoffEpochMs, 190); + assert.equal(result.captureStoppedEpochMs, 220); + assert.equal(result.rollover?.nextCaptureStarted, true); + assert.equal(result.databaseWorkerCount, 1); + assert.equal(result.playlistRefreshWorkerCount, 0); + assert.equal(result.quarantinedIpcMarkerCount, 0); + assert.equal(result.workers.length, 1); + assert.equal(result.requests.length, 1); + }); + + it('fails closed when the stop generation is missing or inconsistent', async () => { + for (const invalid of [ + transport({ captureGeneration: 0 }), + transport({ + xtream: { + ...transport().xtream, + captureGeneration: 8, + }, + }), + ]) { + target[STATE_KEY] = { stop: () => invalid }; + await assert.rejects( + stopXtreamMainCapture(electronApp()), + /xtream-main-capture-transport-invalid/ + ); + } + }); + + it('fails closed on unknown IPC and cancel timeline labels', async () => { + for (const invalid of [ + transport({ + xtream: { + ...transport().xtream, + ipcCapture: { + invalidReasons: [], + quarantinedKeyCount: 0, + timeline: [{ epochMs: 120, type: 'unknown-ipc-label' }], + } as never, + }, + }), + transport({ + xtream: { + ...transport().xtream, + cancelCapture: { + invalidReasons: [], + timeline: [ + { epochMs: 130, type: 'unknown-cancel-label' }, + ], + } as never, + }, + }), + ]) { + target[STATE_KEY] = { stop: () => invalid }; + await assert.rejects( + stopXtreamMainCapture(electronApp()), + /xtream-main-capture-transport-invalid/ + ); + } + }); + + it('fails closed when operation outcomes disagree with request evidence', async () => { + target[STATE_KEY] = { + stop: () => + transport({ + xtream: { + ...transport().xtream, + operationOutcomes: [ + { + count: 2, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'success', + }, + ], + }, + }), + }; + + await assert.rejects( + stopXtreamMainCapture(electronApp()), + /xtream-main-capture-transport-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts new file mode 100644 index 000000000..a5f7701ce --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const source = readFileSync( + join(import.meta.dirname, 'm3u-refresh-main-capture.ts'), + 'utf8' +); + +describe('Xtream main capture integration', () => { + it('installs the strict preload and diagnostics correlators in the existing capture generation', () => { + for (const required of [ + 'createDatabaseWorkerCancelCapture', + 'createDiagnosticsPerformancePhaseEventParser', + 'createPerformanceTimelineMergeApi', + 'createXtreamIpcMarkerCapture', + 'createXtreamIpcMarkerProtocol', + 'createXtreamMainLifecycleValidator', + 'XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL', + 'XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL', + 'xtreamIpcMarkerCapture.acceptPreload', + 'xtreamIpcMarkerCapture.acceptMainPhase', + 'xtreamIpcMarkerCapture.start', + 'xtreamIpcMarkerCapture.stop', + 'databaseWorkerCancelCapture.acceptReceipt', + 'databaseWorkerCancelCapture.acceptTerminal', + 'databaseWorkerCancelCapture.start', + 'databaseWorkerCancelCapture.stop', + ]) { + assert.ok(source.includes(required), `missing ${required}`); + } + }); + + it('restores argument-taking capture functions without invoking them', () => { + assert.match( + source, + /const restoreFunction = \(source: string\): T =>/ + ); + assert.match( + source, + /const factory = restoreFunction<\(\) => T>\(source\);\s*return factory\(\);/ + ); + + for (const factoryName of [ + 'diagnosticsPerformancePhaseEventParserFactory', + 'xtreamIpcMarkerProtocolFactory', + 'xtreamIpcMarkerCaptureFactory', + 'xtreamMainLifecycleValidatorFactory', + ]) { + assert.match( + source, + new RegExp( + `const ${factoryName} = restoreFunction<[\\s\\S]*?>\\(` + ), + `${factoryName} must be restored without an eager zero-argument call` + ); + } + }); + + it('fences Xtream events with an epoch captured before activation', () => { + const captureStart = source.indexOf( + 'const captureStartedEpochMs = nowEpochMs()' + ); + const active = source.indexOf('state.active = true', captureStart); + assert.ok(captureStart >= 0); + assert.ok(active > captureStart); + assert.match( + source, + /xtreamIpcMarkerCapture\.start\(\s*options\.rendererWindowIdentity\.webContentsId,\s*captureStartedEpochMs\s*\)/ + ); + }); + + it('arms attribution before starting deferred operation-window metrics exactly once', () => { + const measurement = section( + 'const beginMeasurement = async', + 'const startCapture = async' + ); + const arm = section('const startCapture = async', 'const api ='); + + assert.doesNotMatch(arm, /threadCpuUsage|monitorEventLoopDelay/); + assert.doesNotMatch(arm, /setInterval\(sampleMain/); + assert.doesNotMatch(arm, /'Profiler\.start'/); + assert.match(measurement, /state\.measurementStartedEpochMs/); + assert.match(measurement, /runtimeProcess\.threadCpuUsage\(\)/); + assert.match(measurement, /monitorEventLoopDelay/); + assert.match(measurement, /setInterval\(sampleMain, 20\)/); + assert.match(measurement, /'Profiler\.start'/); + assert.match( + source, + /if \(!options\.deferMeasurement\) \{\s*await beginMeasurement\(\);\s*\}/ + ); + assert.match( + source, + /beginMeasurement: async \(\): Promise =>\s*beginMeasurement\(\)/ + ); + assert.match( + source, + /export async function beginXtreamMainMeasurement/ + ); + }); + + it('uses Electron main-thread CPU accounting instead of process-wide CPU', () => { + assert.match( + source, + /runtimeProcess\.threadCpuUsage\(\s*state\.cpuStart \?\? undefined\s*\)/ + ); + assert.doesNotMatch(source, /\bprocess\.cpuUsage\(/); + assert.doesNotMatch(source, /\bruntimeProcess\.cpuUsage\(/); + }); + + it('correlates DB cancel dispatch before recognizing the worker receipt', () => { + const dispatch = source.indexOf( + 'xtreamIpcMarkerCapture.matchDatabaseCancel' + ); + const receipt = source.indexOf( + 'databaseWorkerCancelCapture.acceptReceipt' + ); + const response = source.indexOf("message['type'] === 'response'"); + assert.ok(dispatch >= 0); + assert.ok(receipt >= 0); + assert.ok(response >= 0); + assert.ok(receipt < response); + assert.match( + source, + /message\['type'\]\s*===\s*'performance-cancel-received'/ + ); + }); + + it('routes Xtream DB request attribution before the legacy M3U identity branch', () => { + const xtreamMatch = source.indexOf( + 'xtreamIpcMarkerCapture.matchDatabaseRequest' + ); + const m3uMatch = source.indexOf( + 'databaseRequestIdentityCapture.matchDatabaseRequest' + ); + assert.ok(xtreamMatch >= 0); + assert.ok(m3uMatch >= 0); + assert.ok(xtreamMatch < m3uMatch); + }); + + it('merges stop-buffered Xtream events before timeline serialization', () => { + const merge = source.indexOf('performanceTimelineMergeApi.merge'); + const serialize = source.indexOf('timeline: state.timeline'); + assert.ok(merge >= 0); + assert.ok(serialize >= 0); + assert.ok(merge < serialize); + assert.equal( + source.includes( + 'state.timeline.push(...xtreamIpcSnapshot.timeline)' + ), + false + ); + }); + + it('adds Xtream status gates without replacing the existing M3U status API', () => { + for (const required of [ + 'status: (): MainCaptureStatus', + 'xtreamStatus: (): XtreamMainCaptureStatus', + 'activeOperations:', + 'captureGeneration: state.captureGeneration', + 'lateDatabaseRequestCount: cutoff.lateRequestCount', + 'operationOutcomes: readOperationOutcomes()', + "kind === 'database.worker'", + "kind === 'playlist-refresh.worker'", + ]) { + assert.ok(source.includes(required), `missing ${required}`); + } + assert.match( + source, + /progress:\s*request\.progress\s*\?\s*\{ \.\.\.request\.progress \}\s*:\s*null/ + ); + }); + + it('snapshots the completed generation and cutoff before atomically starting the next capture', () => { + const cutoff = source.indexOf( + 'const captureCutoffEpochMs = nowEpochMs()' + ); + const beginStop = source.indexOf( + 'databaseWorkerPostGcCutoffApi.beginStop()', + cutoff + ); + const stopped = source.indexOf( + 'const captureStoppedEpochMs = nowEpochMs()', + beginStop + ); + const rollover = source.indexOf( + 'databaseWorkerPostGcCutoffApi.rolloverCapture()', + stopped + ); + const nextStart = source.indexOf( + 'await startCapture(nextOptions)', + rollover + ); + const completedReturn = source.indexOf( + 'return { ...transport, rollover, xtream }', + nextStart + ); + + assert.ok(cutoff >= 0); + assert.ok(cutoff < beginStop); + assert.ok(beginStop < stopped); + assert.ok(stopped < rollover); + assert.ok(rollover < nextStart); + assert.ok(nextStart < completedReturn); + assert.match(source, /captureGeneration: completedCaptureGeneration/); + assert.match( + source, + /captureStartedEpochMs: completedCaptureStartedEpochMs/ + ); + }); + + it('strictly allowlists Xtream IPC and cancel timeline labels at the public boundary', () => { + assert.match( + source, + /XTREAM_IPC_TIMELINE_TYPES = new Set\(\[\s*'xtream-main-phase',\s*'xtream-preload-marker'/ + ); + assert.match( + source, + /XTREAM_CANCEL_TIMELINE_TYPES = new Set\(\[\s*'db-cancel-dispatched',\s*'db-cancel-received',\s*'db-cancel-terminal-received'/ + ); + assert.match( + source, + /throw new Error\(`xtream-main-capture-\$\{kind\}-invalid`\)/ + ); + }); +}); + +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); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts new file mode 100644 index 000000000..6ee28c826 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createXtreamMainLifecycleValidator } from './xtream-main-lifecycle'; + +describe('Xtream request-scoped main lifecycle validator', () => { + it('reconstructs without module closures and accepts legal producer paths', () => { + const restore = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; + const factory = restore( + createXtreamMainLifecycleValidator.toString() + ); + const validate = factory().validate; + const network = phase('xtream.network.total', 100, 120); + const json = phase('xtream.json.transform', 105, 110); + const response = phase('xtream.response.ready', 121, 122); + + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'success', + phases: [network, json, response], + }), + null + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'error', + phases: [network], + }), + null + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'error', + phases: [network, json], + }), + null + ); + assert.equal( + validate({ + method: 'xtreamCancelSession', + outcome: 'success', + phases: [phase('xtream.cancel-session', 100, 101)], + }), + null + ); + }); + + it('rejects incomplete and impossible request lifecycles', () => { + const validate = createXtreamMainLifecycleValidator().validate; + const network = phase('xtream.network.total', 100, 120); + const json = phase('xtream.json.transform', 105, 110); + + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'success', + phases: [network, json], + }), + 'main-phase-inventory-incomplete' + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'success', + phases: [ + network, + json, + phase('xtream.response.ready', 119, 121), + ], + }), + 'main-phase-topology-invalid' + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'error', + phases: [network, phase('xtream.response.ready', 121, 122)], + }), + 'main-phase-topology-invalid' + ); + }); +}); + +function phase(phaseName: string, startEpochMs: number, endEpochMs: number) { + return { + endEpochMs, + phase: phaseName, + startEpochMs, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts new file mode 100644 index 000000000..49b1fde6d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts @@ -0,0 +1,111 @@ +export interface XtreamMainLifecyclePhase { + readonly endEpochMs: number | null; + readonly phase: string; + readonly startEpochMs: number; +} + +export interface XtreamMainLifecycleInput { + readonly method: string; + readonly outcome: 'error' | 'success' | null; + readonly phases: readonly XtreamMainLifecyclePhase[]; +} + +export interface XtreamMainLifecycleValidator { + validate(input: XtreamMainLifecycleInput): string | null; +} + +export function createXtreamMainLifecycleValidator(): XtreamMainLifecycleValidator { + const helpers = { + closedPhase( + phases: readonly XtreamMainLifecyclePhase[], + phase: string + ): XtreamMainLifecyclePhase | null { + const matches = phases.filter( + (candidate) => candidate.phase === phase + ); + const match = matches[0]; + return matches.length === 1 && match && match.endEpochMs !== null + ? match + : null; + }, + isEpoch(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + }, + }; + return { + validate(input): string | null { + const phaseNames = input.phases.map(({ phase }) => phase); + if ( + new Set(phaseNames).size !== phaseNames.length || + input.phases.some( + ({ endEpochMs, startEpochMs }) => + !helpers.isEpoch(startEpochMs) || + (endEpochMs !== null && + (!helpers.isEpoch(endEpochMs) || + endEpochMs < startEpochMs)) + ) + ) { + return 'main-phase-inventory-incomplete'; + } + if (input.method === 'xtreamCancelSession') { + return helpers.closedPhase( + input.phases, + 'xtream.cancel-session' + ) && + phaseNames.every( + (phase) => phase === 'xtream.cancel-session' + ) + ? null + : 'main-phase-inventory-incomplete'; + } + if ( + input.method !== 'xtreamRequest' || + (input.outcome !== 'success' && input.outcome !== 'error') + ) { + return 'main-phase-inventory-incomplete'; + } + + const network = helpers.closedPhase( + input.phases, + 'xtream.network.total' + ); + const json = helpers.closedPhase( + input.phases, + 'xtream.json.transform' + ); + const response = helpers.closedPhase( + input.phases, + 'xtream.response.ready' + ); + if ( + !network || + phaseNames.some( + (phase) => + phase !== 'xtream.network.total' && + phase !== 'xtream.json.transform' && + phase !== 'xtream.response.ready' + ) || + (phaseNames.includes('xtream.json.transform') && !json) || + (input.outcome === 'success' && (!json || !response)) + ) { + return 'main-phase-inventory-incomplete'; + } + if ( + (json && + (json.startEpochMs < network.startEpochMs || + Number(json.endEpochMs) > + Number(network.endEpochMs))) || + (input.outcome === 'success' && + response !== null && + response.startEpochMs < Number(network.endEpochMs)) || + (input.outcome === 'error' && + phaseNames.includes('xtream.response.ready')) + ) { + return 'main-phase-topology-invalid'; + } + return null; + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts new file mode 100644 index 000000000..fcd71b3eb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts @@ -0,0 +1,71 @@ +/* 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 { XTREAM_MAIN_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { pairXtreamPhaseEvents } from './xtream-phase-event-pairing'; +import type { + XtreamAttributionPhaseEvent, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; + +test('accepts producer-shaped main output when separately read clocks disagree on the phase span', () => { + const events = [event('start', null, 101), event('end', 1.6115, 102)]; + const paired = pairXtreamPhaseEvents(input(events)); + assert.equal(paired[0]?.durationMs, 1.6115); + assert.equal( + Number(paired[0]?.endEpochMs) - Number(paired[0]?.startEpochMs), + 1 + ); +}); + +test('rejects a main phase duration outside the operation envelope', () => { + const events: XtreamAttributionPhaseEvent[] = [ + event('start', null, 101), + event('end', 999, 102), + ]; + + assert.throws( + () => pairXtreamPhaseEvents(input(events)), + /invalid Xtream phase event pairing/ + ); +}); + +function event( + boundary: 'end' | 'start', + durationMs: number | null, + epochMs: number +): XtreamAttributionPhaseEvent { + return { + boundary, + categoryType: null, + contentType: null, + correlationId: 'xtream-mabc1000-1', + durationMs, + epochMs, + itemCount: null, + phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + providerAction: null, + semanticType: null, + }; +} + +function input( + events: readonly XtreamAttributionPhaseEvent[], + scenarioId: XtreamScenarioId = XTREAM_SCENARIO_ID.CANCEL_IMPORT +): XtreamPhaseAttributionInput { + return { + backgroundSearchMarkerObserved: false, + events, + ipcSpans: [], + operationStartEpochMs: 100, + scenarioId, + terminalEpochMs: 120, + uiPaintEpochMs: 120, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts new file mode 100644 index 000000000..39bd02f15 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts @@ -0,0 +1,378 @@ +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, + deriveXtreamAcquisitionDurationMs, + 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, +}; + +function marker(overrides: Record = {}) { + return { + schemaVersion: 1, + sourceEpochMs: 100, + ipcCallId: 1, + method: 'dbSaveContent', + boundary: 'start', + outcome: null, + playlistId: 'playlist-1', + operationId: 'operation-1', + sessionId: null, + action: null, + categoryType: null, + contentType: 'live', + itemCount: 60_000, + ...overrides, + }; +} + +function mainPhase(overrides: Record = {}) { + return { + boundary: 'start', + durationMs: null, + epochMs: 110, + phase: 'xtream.network.total', + requestId: 'xtream-mock1-1', + ...overrides, + }; +} + +describe('Xtream main phase correlation', () => { + it('binds network phases to preload calls in FIFO order without inventing handler entry time', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal( + capture.acceptPreload( + 42, + 100, + marker({ + action: XtreamCodeActions.GetLiveCategories, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'xtreamRequest', + operationId: null, + playlistId: null, + sessionId: 'session-1', + }) + ), + true + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ epochMs: 101, requestId: 'xtream-mock1-1' }) + ), + true + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary: 'end', + durationMs: 9, + epochMs: 110, + requestId: 'xtream-mock1-1', + }) + ), + true + ); + + const mainEvents = capture + .stop() + .timeline.filter(({ type }) => type === 'xtream-main-phase'); + assert.deepEqual( + mainEvents.map(({ action, ipcCallId, requestId }) => ({ + action, + ipcCallId, + requestId, + })), + [ + { + action: XtreamCodeActions.GetLiveCategories, + ipcCallId: 1, + requestId: 'xtream-mock1-1', + }, + { + action: XtreamCodeActions.GetLiveCategories, + ipcCallId: 1, + requestId: 'xtream-mock1-1', + }, + ] + ); + assert.equal( + JSON.stringify(mainEvents).includes('handler-entry'), + false + ); + }); + + it('invalidates a successful preload request without its required main linkage and phase inventory', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const start = marker({ + action: XtreamCodeActions.GetLiveCategories, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'xtreamRequest', + operationId: null, + playlistId: null, + sessionId: 'session-1', + }); + assert.equal(capture.acceptPreload(42, 100, start), true); + assert.equal( + capture.acceptPreload( + 42, + 120, + marker({ + ...start, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 119, + }) + ), + true + ); + + assert.ok( + capture + .stop() + .invalidReasons.includes('preload-main-linkage-missing') + ); + }); + + it('invalidates an errored preload request when no main lifecycle was captured', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const start = xtreamRequestMarker(); + assert.equal(capture.acceptPreload(42, 100, start), true); + assert.equal( + capture.acceptPreload( + 42, + 120, + marker({ + ...start, + boundary: 'end', + outcome: 'error', + sourceEpochMs: 119, + }) + ), + true + ); + + assert.ok( + capture + .stop() + .invalidReasons.includes('preload-main-linkage-missing') + ); + }); + + it('rejects response-ready before network completion for one request', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const start = xtreamRequestMarker(); + assert.equal(capture.acceptPreload(42, 100, start), true); + acceptMainBoundary(capture, 'xtream.network.total', 'start', 101); + acceptMainPair(capture, 'xtream.json.transform', 102, 104); + acceptMainPair(capture, 'xtream.response.ready', 105, 106); + acceptMainBoundary(capture, 'xtream.network.total', 'end', 110, 9); + assert.equal( + capture.acceptPreload( + 42, + 120, + marker({ + ...start, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 119, + }) + ), + true + ); + + assert.ok( + capture + .stop() + .invalidReasons.includes('main-phase-topology-invalid') + ); + }); + + it('accepts the legal success and error main lifecycles', () => { + const success = createXtreamIpcMarkerCapture(options); + success.start(42, 90); + const successStart = xtreamRequestMarker(); + assert.equal(success.acceptPreload(42, 100, successStart), true); + acceptMainBoundary(success, 'xtream.network.total', 'start', 101); + acceptMainPair(success, 'xtream.json.transform', 103, 106); + acceptMainBoundary(success, 'xtream.network.total', 'end', 110, 9); + acceptMainPair(success, 'xtream.response.ready', 111, 112); + assert.equal( + success.acceptPreload( + 42, + 120, + marker({ + ...successStart, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 119, + }) + ), + true + ); + assert.deepEqual(success.stop().invalidReasons, []); + + const error = createXtreamIpcMarkerCapture(options); + error.start(42, 90); + const errorStart = xtreamRequestMarker(); + assert.equal(error.acceptPreload(42, 100, errorStart), true); + acceptMainPair(error, 'xtream.network.total', 101, 110); + assert.equal( + error.acceptPreload( + 42, + 120, + marker({ + ...errorStart, + boundary: 'end', + outcome: 'error', + sourceEpochMs: 119, + }) + ), + true + ); + assert.deepEqual(error.stop().invalidReasons, []); + }); + + it('rejects duplicate, unclosed, or non-monotonic main phase boundaries', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal(capture.acceptMainPhase(mainPhase()), true); + assert.equal(capture.acceptMainPhase(mainPhase()), false); + const snapshot = capture.stop(); + assert.ok( + snapshot.invalidReasons.includes('main-phase-duplicate-boundary') + ); + assert.ok(snapshot.invalidReasons.includes('main-phase-unclosed')); + }); + + it('rejects delayed main phases from a completed capture generation', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + capture.stop(); + capture.start(42, 200); + + assert.equal( + capture.acceptMainPhase(mainPhase({ epochMs: 150 })), + false + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary: 'end', + durationMs: 10, + epochMs: 160, + }) + ), + false + ); + assert.ok( + capture + .stop() + .invalidReasons.includes('main-phase-before-capture-start') + ); + }); + + it('fails neutrally when a phase or correlation payload is a revoked proxy', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + + assert.doesNotThrow(() => capture.acceptMainPhase(revoked.proxy)); + assert.equal(capture.acceptMainPhase(revoked.proxy), false); + assert.doesNotThrow(() => capture.matchDatabaseCancel(revoked.proxy)); + assert.doesNotThrow(() => capture.matchDatabaseRequest(revoked.proxy)); + }); + + it('subtracts JSON transform from network total and rejects negative acquisition', () => { + assert.equal(deriveXtreamAcquisitionDurationMs(150, 40), 110); + assert.throws( + () => deriveXtreamAcquisitionDurationMs(40, 41), + /xtream-acquisition-duration-invalid/ + ); + assert.throws( + () => deriveXtreamAcquisitionDurationMs(-1, 0), + /xtream-acquisition-duration-invalid/ + ); + }); +}); + +function xtreamRequestMarker() { + return marker({ + action: XtreamCodeActions.GetLiveCategories, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'xtreamRequest', + operationId: null, + playlistId: null, + sessionId: 'session-1', + }); +} + +function acceptMainPair( + capture: ReturnType, + phase: string, + startedEpochMs: number, + endedEpochMs: number +): void { + assert.equal( + capture.acceptMainPhase( + mainPhase({ + epochMs: startedEpochMs, + phase, + }) + ), + true + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary: 'end', + durationMs: endedEpochMs - startedEpochMs, + epochMs: endedEpochMs, + phase, + }) + ), + true + ); +} + +function acceptMainBoundary( + capture: ReturnType, + phase: string, + boundary: 'end' | 'start', + epochMs: number, + durationMs: number | null = null +): void { + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary, + durationMs, + epochMs, + phase, + }) + ), + true + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts new file mode 100644 index 000000000..50891055a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { XtreamMainCaptureStatus } from './m3u-refresh-main-capture'; +import { isXtreamMainCaptureSettled } from './xtream-main-settlement'; + +describe('Xtream main capture settlement', () => { + for (const [scenarioId, expectedCount] of [ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, 30], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, 51], + [XTREAM_SCENARIO_ID.DELETE_LARGE, 2], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT, 24], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, 54], + ] as const) { + it(`accepts the exact terminal inventory for ${scenarioId}`, () => { + const status = settledStatus(scenarioId); + + assert.equal( + status.operationOutcomes.reduce( + (total, entry) => total + entry.count, + 0 + ), + expectedCount + ); + assert.equal(isXtreamMainCaptureSettled(status, scenarioId), true); + }); + } + + it('reports unsettled while a known request remains pending', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE); + const first = status.operationOutcomes[0]; + assert.ok(first); + + assert.equal( + isXtreamMainCaptureSettled( + { + ...status, + activeOperations: [ + { + operation: first.operation, + operationId: 'operation-1', + playlistId: 'playlist-1', + progress: null, + requestId: 'request-1', + }, + ], + operationOutcomes: [ + ...status.operationOutcomes.slice(1), + { + count: 1, + operation: first.operation, + outcome: 'pending', + }, + ...(first.count > 1 + ? [{ ...first, count: first.count - 1 }] + : []), + ], + }, + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + false + ); + }); + + it('reports unsettled through the measured refresh route-content tail', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.REFRESH_LARGE); + const content = status.operationOutcomes.find( + ({ operation }) => operation === 'DB_GET_CONTENT' + ); + assert.deepEqual(content, { + count: 9, + operation: 'DB_GET_CONTENT', + outcome: 'success', + }); + + assert.equal( + isXtreamMainCaptureSettled( + { + ...status, + activeOperations: [ + { + operation: 'DB_GET_CONTENT', + operationId: null, + playlistId: 'playlist-1', + progress: null, + requestId: 'route-live-read', + }, + ], + operationOutcomes: [ + ...status.operationOutcomes.filter( + (entry) => entry !== content + ), + { + count: 6, + operation: 'DB_GET_CONTENT', + outcome: 'success', + }, + { + count: 1, + operation: 'DB_GET_CONTENT', + outcome: 'pending', + }, + ], + }, + XTREAM_SCENARIO_ID.REFRESH_LARGE + ), + false + ); + }); + + it('fails closed on unexpected operations, excess counts, workers, and invalid capture evidence', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.REFRESH_LARGE); + const invalid: XtreamMainCaptureStatus[] = [ + { + ...status, + operationOutcomes: [ + ...status.operationOutcomes, + { + count: 1, + operation: 'DB_FOREIGN_OPERATION', + outcome: 'success', + }, + ], + }, + { + ...status, + databaseWorkerCount: 2, + }, + { + ...status, + playlistRefreshWorkerCount: 1, + }, + { + ...status, + invalidReasons: ['database-capture-invalid'], + }, + { + ...status, + lateDatabaseRequestCount: 1, + lateEventCount: 1, + }, + ]; + + for (const candidate of invalid) { + assert.throws( + () => + isXtreamMainCaptureSettled( + candidate, + XTREAM_SCENARIO_ID.REFRESH_LARGE + ), + /xtream-main-settlement-invalid/ + ); + } + }); + + it('requires the cancellation save-content request to fail and every other request to succeed', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const failed = status.operationOutcomes.find( + ({ operation, outcome }) => + operation === 'DB_SAVE_CONTENT' && outcome === 'error' + ); + assert.deepEqual(failed, { + count: 1, + operation: 'DB_SAVE_CONTENT', + outcome: 'error', + }); + + assert.throws( + () => + isXtreamMainCaptureSettled( + { + ...status, + operationOutcomes: status.operationOutcomes.map( + (entry) => + entry === failed + ? { ...entry, outcome: 'success' } + : entry + ), + }, + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + /xtream-main-settlement-invalid/ + ); + }); +}); + +function settledStatus(scenarioId: XtreamScenarioId): XtreamMainCaptureStatus { + const expected = expectedOutcomes(scenarioId); + return { + active: true, + activeOperations: [], + captureGeneration: 2, + captureStartedEpochMs: 1_000, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 1_001, + operationOutcomes: expected, + playlistRefreshWorkerCount: 0, + }; +} + +function expectedOutcomes( + scenarioId: XtreamScenarioId +): XtreamMainCaptureStatus['operationOutcomes'] { + const inventory = { + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: [ + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_GET_APP_STATE', 6], + ['DB_GET_CATEGORIES', 6], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 6], + ['DB_SAVE_CONTENT', 3], + ['DB_SET_APP_STATE', 3], + ], + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: [ + ['DB_DELETE_XTREAM_CONTENT', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_UPDATE_PLAYLIST', 1], + ['DB_GET_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 2], + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_APP_STATE', 12], + ['DB_GET_CATEGORIES', 9], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 9], + ['DB_SAVE_CONTENT', 3], + ['DB_SET_APP_STATE', 6], + ['DB_RESTORE_XTREAM_USER_DATA', 1], + ['DB_CLEAR_ALL_PLAYBACK_POSITIONS', 1], + ], + [XTREAM_SCENARIO_ID.DELETE_LARGE]: [['DB_DELETE_PLAYLIST', 2]], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: [ + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_GET_APP_STATE', 4], + ['DB_GET_CATEGORIES', 6], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 1], + ['DB_SAVE_CONTENT', 1], + ['DB_SET_APP_STATE', 3], + ['DB_CLEAR_XTREAM_IMPORT_CACHE', 3], + ], + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: [ + ['DB_DELETE_XTREAM_CONTENT', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_UPDATE_PLAYLIST', 1], + ['DB_GET_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 2], + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_APP_STATE', 12], + ['DB_GET_CATEGORIES', 9], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 9], + ['DB_SAVE_CONTENT', 3], + ['DB_SET_APP_STATE', 6], + ['DB_RESTORE_XTREAM_USER_DATA', 1], + ['DB_CLEAR_ALL_PLAYBACK_POSITIONS', 1], + ['DB_GET_RECENTLY_VIEWED', 1], + ['DB_GET_ALL_GLOBAL_FAVORITES', 1], + ['DB_GET_GLOBAL_RECENTLY_ADDED', 1], + ], + } as const; + return inventory[scenarioId] + .map(([operation, count]) => ({ + count, + operation, + outcome: + scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT && + operation === 'DB_SAVE_CONTENT' + ? ('error' as const) + : ('success' as const), + })) + .sort((left, right) => left.operation.localeCompare(right.operation)); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts new file mode 100644 index 000000000..d0744a87c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts @@ -0,0 +1,114 @@ +import { + expectedXtreamWorkerRequestInventory, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { + XtreamMainCaptureOperationOutcomeKind, + XtreamMainCaptureStatus, +} from './m3u-refresh-main-capture'; + +interface ExpectedOutcome { + readonly count: number; + readonly outcome: Exclude; +} + +export function isXtreamMainCaptureSettled( + status: XtreamMainCaptureStatus, + scenarioId: XtreamScenarioId +): boolean { + try { + assertCaptureBoundary(status); + const expected = expectedOutcomes(scenarioId); + const observed = new Map< + string, + { error: number; pending: number; success: number } + >(); + for (const entry of status.operationOutcomes) { + const expectation = expected.get(entry.operation); + if (!expectation) invalid(); + const counts = observed.get(entry.operation) ?? { + error: 0, + pending: 0, + success: 0, + }; + if (counts[entry.outcome] !== 0) invalid(); + counts[entry.outcome] = entry.count; + if ( + counts.error + counts.pending + counts.success > + expectation.count || + (expectation.outcome === 'success' && counts.error > 0) || + (expectation.outcome === 'error' && counts.success > 0) + ) { + invalid(); + } + observed.set(entry.operation, counts); + } + for (const active of status.activeOperations) { + if (!expected.has(active.operation)) invalid(); + } + if ( + status.databaseWorkerCount === 0 || + status.activeOperations.length > 0 + ) { + return false; + } + for (const [operation, expectation] of expected) { + const counts = observed.get(operation); + if ( + !counts || + counts.pending !== 0 || + counts[expectation.outcome] !== expectation.count || + counts.error + counts.success !== expectation.count + ) { + return false; + } + } + return observed.size === expected.size; + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-main-settlement-invalid' + ) { + throw error; + } + invalid(); + } +} + +function expectedOutcomes( + scenarioId: XtreamScenarioId +): ReadonlyMap { + const expected = new Map(); + for (const entry of expectedXtreamWorkerRequestInventory(scenarioId)) { + if (expected.has(entry.operation)) invalid(); + expected.set(entry.operation, { + count: entry.count, + outcome: entry.success ? 'success' : 'error', + }); + } + return expected; +} + +function assertCaptureBoundary(status: XtreamMainCaptureStatus): void { + if ( + !status.active || + status.cutoffPhase !== 'active' || + status.measurementPhase !== 'measuring' || + typeof status.measurementStartedEpochMs !== 'number' || + !Number.isFinite(status.measurementStartedEpochMs) || + status.measurementStartedEpochMs < status.captureStartedEpochMs || + status.invalidReasons.length !== 0 || + status.lateDatabaseRequestCount !== 0 || + status.lateEventCount !== 0 || + status.databaseWorkerCount > 1 || + status.playlistRefreshWorkerCount !== 0 || + !Number.isSafeInteger(status.captureGeneration) || + status.captureGeneration <= 0 + ) { + invalid(); + } +} + +function invalid(): never { + throw new Error('xtream-main-settlement-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts b/apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts new file mode 100644 index 000000000..624650851 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts @@ -0,0 +1,13 @@ +export function assertXtreamWorkerHeapWithinMainRss( + worker: unknown, + main: unknown +): void { + const workerMetrics = worker as Record; + const mainMetrics = main as Record; + if ( + (workerMetrics['peakHeapUsedBytes'] as number) > + (mainMetrics['peakRssBytes'] as number) + ) { + throw new Error('database-worker heap exceeds Electron main RSS'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts b/apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts new file mode 100644 index 000000000..2674bf419 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts @@ -0,0 +1,119 @@ +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; + +const MAX_CAPTURE_ARRAY_LENGTH = 512; +const EVENT_KEYS = [ + 'boundary', + 'categoryType', + 'contentType', + 'correlationId', + 'durationMs', + 'epochMs', + 'itemCount', + 'phase', + 'providerAction', + 'semanticType', +] as const; +const IPC_SPAN_KEYS = [ + 'boundary', + 'correlationId', + 'endEpochMs', + 'ipcCallId', + 'method', + 'outcome', + 'sourceEpochMs', + 'startEpochMs', +] as const; + +export function snapshotXtreamPhaseInput( + input: XtreamPhaseAttributionInput +): XtreamPhaseAttributionInput { + return Object.freeze({ + backgroundSearchMarkerObserved: input.backgroundSearchMarkerObserved, + events: snapshotArray(input.events, (event) => + snapshotRecord(event, EVENT_KEYS) + ), + ipcSpans: snapshotArray(input.ipcSpans, (span) => + snapshotRecord(span, IPC_SPAN_KEYS) + ), + operationStartEpochMs: input.operationStartEpochMs, + scenarioId: input.scenarioId, + terminalEpochMs: input.terminalEpochMs, + uiPaintEpochMs: input.uiPaintEpochMs, + }); +} + +function snapshotArray( + value: unknown, + snapshotElement: (element: unknown) => T +): readonly T[] { + if (!Array.isArray(value)) invalid(); + const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, 'length'); + const length = lengthDescriptor?.value; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > MAX_CAPTURE_ARRAY_LENGTH + ) { + invalid(); + } + const expectedKeys = new Set(['length']); + const snapshot: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const key = String(index); + expectedKeys.add(key); + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalid(); + } + snapshot[index] = snapshotElement(descriptor.value); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== expectedKeys.size || + ownKeys.some((key) => !expectedKeys.has(key)) + ) { + invalid(); + } + return Object.freeze(snapshot); +} + +function snapshotRecord(value: unknown, keys: readonly string[]): T { + 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 = {}; + for (const key of keys) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalid(); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot) as T; +} + +function invalid(): never { + throw new Error('invalid Xtream capture array'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts new file mode 100644 index 000000000..7c5a9e190 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts @@ -0,0 +1,95 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +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 { afterEach, test } from 'node:test'; + +import { + createXtreamBenchmarkSummary, + persistThenValidateXtreamIteration, +} from './xtream-benchmark-report'; +import { MANIFEST, TOKEN, sha256 } from './xtream-benchmark-report.fixtures'; +import { + clearXtreamProvenanceFixtureDirectories, + smokeReportFixture, +} from './xtream-benchmark-report-provenance.fixtures'; +import { toXtreamRawIteration } from './xtream-exact-schema'; +import type { + XtreamRawIterationResult, + XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all([ + ...directories + .splice(0) + .map((directory) => + rm(directory, { force: true, recursive: true }) + ), + clearXtreamProvenanceFixtureDirectories(), + ]); +}); + +test('rejects a schedule whose validated raw iterations reuse one persistence path', async () => { + const { diagnosticEvidence, iterations } = await smokeReportFixture(); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-shared-raw-path-') + ); + directories.push(directory); + const absolutePath = join(directory, 'raw.json'); + const first = await persistAt( + toXtreamRawIteration(required(iterations, 0)), + absolutePath + ); + const second = await persistAt( + toXtreamRawIteration(required(iterations, 1)), + absolutePath + ); + const reused = [...iterations]; + reused[0] = first; + reused[1] = second; + + assert.equal(first.persistence.pathSha256, second.persistence.pathSha256); + assert.throws( + () => + createXtreamBenchmarkSummary( + { ...MANIFEST, mode: 'smoke' }, + reused, + diagnosticEvidence + ), + /persistence path/ + ); +}); + +async function persistAt( + raw: XtreamRawIterationResult, + absolutePath: string +): Promise { + return persistThenValidateXtreamIteration( + raw, + { + controlToken: TOKEN, + syntheticCredentials: { + password: 'performance', + username: 'performance', + }, + }, + async (serialized) => { + await writeFile(absolutePath, serialized); + return { + absolutePath, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + }; + } + ); +} + +function required(items: readonly T[], index: number): T { + const item = items[index]; + assert.ok(item); + return item; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts b/apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts new file mode 100644 index 000000000..bbd4dcf4d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts @@ -0,0 +1,16 @@ +import type { XtreamValidatedIterationResult } from './xtream-summary-contract'; + +export function assertUniqueXtreamPersistencePaths( + iterations: readonly XtreamValidatedIterationResult[] +): void { + const paths = new Set(); + for (const iteration of iterations) { + const path = iteration.persistence.pathSha256; + if (paths.has(path)) { + throw new Error( + 'Xtream benchmark requires a unique raw persistence path per iteration' + ); + } + paths.add(path); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts new file mode 100644 index 000000000..b7e8c907e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts @@ -0,0 +1,189 @@ +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import { + semanticTypeFromXtreamPhaseSource, + xtreamDataSemanticSource, + xtreamProviderSemanticSource, + type XtreamPhaseSemanticSource, +} from './xtream-phase-semantic-source'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForMethod, +} from './xtream-ipc-producer-identity'; + +export interface XtreamFixtureInventoryBuilder { + readonly cursor: number; + add( + phase: string, + id: string, + itemCount: number | null, + semanticType?: XtreamPhaseSemanticType + ): void; + addAt( + phase: string, + id: string, + startEpochMs: number, + durationMs: number, + itemCount: number | null, + semanticType?: XtreamPhaseSemanticType + ): void; + addProvider(id: string, semanticType: XtreamPhaseSemanticType): void; + capture( + scenarioId: XtreamScenarioId, + ipcSpans: readonly XtreamIpcAttributionSpan[], + uiPaintEpochMs?: number + ): XtreamPhaseAttributionInput; +} + +export function createXtreamFixtureInventoryBuilder(): XtreamFixtureInventoryBuilder { + let cursor = 104; + const events: XtreamAttributionPhaseEvent[] = []; + return { + get cursor() { + return cursor; + }, + add(phase, id, itemCount, semanticType) { + events.push( + ...pair( + phase, + id, + cursor, + 1, + itemCount, + xtreamDataSemanticSource(semanticType ?? null) + ) + ); + cursor += 2; + }, + addAt(phase, id, startEpochMs, durationMs, itemCount, semanticType) { + events.push( + ...pair( + phase, + id, + startEpochMs, + durationMs, + itemCount, + xtreamDataSemanticSource(semanticType ?? null) + ) + ); + cursor = Math.max(cursor, startEpochMs + durationMs + 1); + }, + addProvider(id, semanticType) { + const source = xtreamProviderSemanticSource(semanticType); + events.push(...pair(PHASE.NETWORK_TOTAL, id, cursor, 4, 1, source)); + events.push( + ...pair(PHASE.JSON_TRANSFORM, id, cursor + 1, 1, 1, source) + ); + events.push( + ...pair(PHASE.RESPONSE_READY, id, cursor + 5, 1, 1, source) + ); + cursor += 7; + }, + capture(scenarioId, ipcSpans, paint = cursor + 2) { + return { + backgroundSearchMarkerObserved: false, + events, + ipcSpans, + operationStartEpochMs: 100, + scenarioId, + terminalEpochMs: cursor + 5, + uiPaintEpochMs: paint, + }; + }, + }; +} + +export function createXtreamFixtureIpcInventory( + counts: Readonly> +): XtreamIpcAttributionSpan[] { + const spans: XtreamIpcAttributionSpan[] = []; + const nextIpcCallId = new Map( + Object.values(XTREAM_IPC_PRODUCER_NAMESPACE).map((namespace) => [ + namespace, + 100, + ]) + ); + for (const [method, count] of Object.entries(counts)) { + const namespace = xtreamIpcProducerNamespaceForMethod(method); + if (namespace === null) { + throw new Error('invalid fixture IPC producer method'); + } + for (let index = 0; index < Math.abs(count); index += 1) { + const ipcCallId = Number(nextIpcCallId.get(namespace)) + 1; + nextIpcCallId.set(namespace, ipcCallId); + const correlationId = `${method}-${index}`; + const outcome = count < 0 ? 'error' : 'success'; + spans.push( + { + boundary: 'request', + correlationId, + endEpochMs: 101.25, + ipcCallId, + method, + outcome, + sourceEpochMs: 100.5, + startEpochMs: 101, + }, + { + boundary: 'response', + correlationId, + endEpochMs: 101.75, + ipcCallId, + method, + outcome, + sourceEpochMs: 100.5, + startEpochMs: 101.5, + } + ); + } + } + return spans; +} + +export function pair( + phase: string, + correlationId: string, + startEpochMs: number, + durationMs: number, + itemCount: number | null, + source: XtreamPhaseSemanticSource = xtreamDataSemanticSource(null) +): XtreamAttributionPhaseEvent[] { + const semanticType = semanticTypeFromXtreamPhaseSource(source); + if (semanticType === undefined) { + throw new Error('invalid fixture semantic source'); + } + return [ + { + boundary: 'start', + categoryType: source.categoryType, + contentType: source.contentType, + correlationId, + durationMs: null, + epochMs: startEpochMs, + itemCount: null, + phase, + providerAction: source.providerAction, + semanticType, + }, + { + boundary: 'end', + categoryType: source.categoryType, + contentType: source.contentType, + correlationId, + durationMs, + epochMs: startEpochMs + durationMs, + itemCount, + phase, + providerAction: source.providerAction, + semanticType, + }, + ]; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts new file mode 100644 index 000000000..5360a0e64 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + attributeXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; + +describe('Xtream phase attribution topology', () => { + it('rejects impossible store, refresh, cancel-cleanup, and double-delete topology', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const refresh = scenarioCapture(XTREAM_SCENARIO_ID.REFRESH_LARGE); + const cancel = scenarioCapture(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const deletion = scenarioCapture(XTREAM_SCENARIO_ID.DELETE_LARGE); + const phaseStart = ( + capture: typeof initial, + phase: string, + correlationId: string + ) => + Number( + capture.events.find( + (event) => + event.boundary === 'start' && + event.phase === phase && + event.correlationId === correlationId + )?.epochMs + ); + const candidates = [ + { + ...initial, + events: movePair( + initial.events, + PHASE.STORE_IMPORT_TERMINAL, + 'store-terminal', + 102 + ), + }, + { + ...initial, + events: movePair( + initial.events, + PHASE.STORE_PUBLISH_LIVE, + 'store-live', + phaseStart(initial, PHASE.STORE_PUBLISH_VOD, 'store-vod') + + 2 + ), + }, + { + ...refresh, + events: movePair( + refresh.events, + PHASE.STORE_REFRESH_META, + 'refresh-meta', + refresh.terminalEpochMs - 3 + ), + uiPaintEpochMs: refresh.terminalEpochMs - 1, + }, + { + ...refresh, + events: movePair( + refresh.events, + PHASE.STORE_REFRESH_META, + 'refresh-meta', + phaseStart( + refresh, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + 'refresh-delete' + ) + ), + }, + { + ...refresh, + events: movePair( + refresh.events, + PHASE.STORE_PUBLISH_CATEGORIES, + 'store-categories-route', + phaseStart( + refresh, + PHASE.STORE_IMPORT_TERMINAL, + 'store-terminal' + ) - 2 + ), + }, + { + ...cancel, + events: movePair( + cancel.events, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-live', + 102 + ), + }, + { + ...cancel, + events: movePair( + cancel.events, + PHASE.CANCEL_SESSION, + 'cancel-session', + phaseStart( + cancel, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + 'content-save-live' + ) - 2 + ), + }, + { + ...deletion, + events: movePair( + deletion.events, + PHASE.STORE_DELETE_ROW, + 'delete-store', + 102 + ), + uiPaintEpochMs: deletion.terminalEpochMs - 1, + }, + ]; + + for (const candidate of candidates) { + assert.throws( + () => attributeXtreamPhases(candidate), + /invalid Xtream phase capture/ + ); + } + }); +}); + +function movePair( + events: ReturnType['events'], + phase: string, + correlationId: string, + startEpochMs: number +) { + return events.map((event) => + event.phase === phase && event.correlationId === correlationId + ? { + ...event, + epochMs: + startEpochMs + + (event.boundary === 'end' ? Number(event.durationMs) : 0), + } + : event + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts new file mode 100644 index 000000000..70ffc40de --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts @@ -0,0 +1,351 @@ +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + type XtreamAttributionPhaseEvent, + type XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { + createXtreamFixtureInventoryBuilder as createInventoryBuilder, + createXtreamFixtureIpcInventory as ipcInventory, + type XtreamFixtureInventoryBuilder as InventoryBuilder, +} from './xtream-phase-attribution-fixture-builder'; +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC } from './xtream-phase-inventory'; + +export { pair } from './xtream-phase-attribution-fixture-builder'; + +const CATEGORY_COUNTS = [60, 20, 20] as const; +const CONTENT_COUNTS = [60_000, 20_000, 20_000] as const; +const CATEGORY_SEMANTICS = [ + SEMANTIC.LIVE_CATEGORIES, + SEMANTIC.VOD_CATEGORIES, + SEMANTIC.SERIES_CATEGORIES, +] as const; +const CONTENT_SEMANTICS = [ + SEMANTIC.LIVE_CONTENT, + SEMANTIC.VOD_CONTENT, + SEMANTIC.SERIES_CONTENT, +] as const; + +export function scenarioCapture( + scenarioId: XtreamScenarioId +): XtreamPhaseAttributionInput { + const builder = createInventoryBuilder(); + + if (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + 'delete-request-1', + 100_100 + ); + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + 'delete-request-1', + 100_101 + ); + builder.add(PHASE.STORE_DELETE_ROW, 'delete-store', 1); + const paint = builder.cursor + 2; + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + 'delete-request-2', + 0 + ); + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + 'delete-request-2', + 1 + ); + return builder.capture( + scenarioId, + ipcInventory({ dbDeletePlaylist: 2 }), + paint + ); + } + + if ( + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ) { + builder.add( + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + 'refresh-delete', + 100_100 + ); + builder.add( + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + 'refresh-delete', + 100_100 + ); + builder.add(PHASE.STORE_REFRESH_META, 'refresh-meta', 1); + addPlaylistRead(builder, 'refresh-effect'); + addPlaylistUpsert(builder); + builder.addProvider('provider-account', SEMANTIC.ACCOUNT); + addImportFlow(builder); + addRouteHydration(builder); + return builder.capture( + scenarioId, + ipcInventory({ + dbDeleteXtreamContent: 1, + dbGetAppPlaylist: 1, + dbGetCategories: 9, + dbGetContent: 9, + dbRestoreXtreamUserData: 1, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + }) + ); + } + + addPlaylistUpsert(builder); + builder.addProvider('provider-account', SEMANTIC.ACCOUNT); + builder.addProvider('provider-account-route', SEMANTIC.ACCOUNT); + addCategories(builder); + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories', 100); + builder.add( + PHASE.SQLITE_CONTENT_READ, + 'content-read-live-pre', + 0, + SEMANTIC.LIVE_CONTENT + ); + builder.addProvider('provider-content-0', SEMANTIC.LIVE_CONTENT); + builder.add( + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + 'content-save-live', + 60, + SEMANTIC.LIVE_CONTENT + ); + builder.add( + PHASE.NORMALIZE_CONTENT, + 'content-save-live', + 60_000, + SEMANTIC.LIVE_CONTENT + ); + const liveWriteStart = builder.cursor; + builder.addAt( + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + 'content-save-live', + liveWriteStart, + 6, + null, + SEMANTIC.LIVE_CONTENT + ); + builder.addAt( + PHASE.CANCEL_SESSION, + 'cancel-session', + liveWriteStart + 2, + 1, + 0 + ); + builder.add( + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-live', + 6_060 + ); + builder.add( + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-movie', + 20 + ); + builder.add( + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-series', + 20 + ); + builder.add(PHASE.STORE_IMPORT_TERMINAL, 'store-terminal', 0); + return builder.capture( + scenarioId, + ipcInventory({ + dbCancelOperation: 1, + dbGetCategories: 6, + dbGetContent: 1, + dbSaveCategories: 3, + dbSaveContent: -1, + dbUpsertAppPlaylist: 1, + xtreamCancelSession: 1, + xtreamRequest: 6, + }) + ); + } + + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories', 100); + addContentAndStores(builder); + return builder.capture( + scenarioId, + ipcInventory({ + dbGetCategories: 6, + dbGetContent: 6, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + }) + ); +} + +function addImportFlow(builder: InventoryBuilder): void { + addCategories(builder); + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories', 100); + addContentAndStores(builder); +} + +function addRouteHydration(builder: InventoryBuilder): void { + builder.addProvider('provider-account-route', SEMANTIC.ACCOUNT); + CATEGORY_COUNTS.forEach((count, index) => { + builder.add( + PHASE.SQLITE_CATEGORIES_READ, + `category-read-${index}-route`, + count, + CATEGORY_SEMANTICS[index] + ); + }); + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories-route', 100); + const storePhases = [ + PHASE.STORE_PUBLISH_LIVE, + PHASE.STORE_PUBLISH_VOD, + PHASE.STORE_PUBLISH_SERIES, + ] as const; + CONTENT_COUNTS.forEach((count, index) => { + builder.add( + PHASE.SQLITE_CONTENT_READ, + `content-read-${index}-route`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + storePhases[index], + `store-${CONTENT_SEMANTICS[index]}-route`, + count, + CONTENT_SEMANTICS[index] + ); + }); + builder.add(PHASE.STORE_IMPORT_TERMINAL, 'store-terminal-route', 100_000); +} + +function addPlaylistRead(builder: InventoryBuilder, id: string): void { + const requestId = `playlist-get-${id}`; + builder.add(PHASE.SQLITE_GENERIC_READ, requestId, 1); + builder.add(PHASE.DESERIALIZE_PLAYLIST, requestId, 1); +} + +function addPlaylistUpsert(builder: InventoryBuilder): void { + builder.add(PHASE.SERIALIZE_PLAYLIST, 'playlist-upsert', 1); + builder.add(PHASE.SQLITE_GENERIC_WRITE, 'playlist-upsert', 1); +} + +function addCategories(builder: InventoryBuilder): void { + CATEGORY_COUNTS.forEach((_count, index) => { + builder.add( + PHASE.SQLITE_CATEGORIES_READ, + `category-read-${index}-pre`, + 0, + CATEGORY_SEMANTICS[index] + ); + }); + CATEGORY_COUNTS.forEach((_count, index) => { + builder.addProvider( + `provider-category-${index}`, + CATEGORY_SEMANTICS[index] + ); + }); + CATEGORY_COUNTS.forEach((count, index) => { + builder.add( + PHASE.NORMALIZE_CATEGORIES, + `category-save-${index}`, + count, + CATEGORY_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + `category-save-${index}`, + count, + CATEGORY_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CATEGORIES_READ, + `category-read-${index}-post`, + count, + CATEGORY_SEMANTICS[index] + ); + }); +} + +function addContentAndStores(builder: InventoryBuilder): void { + const storePhases = [ + PHASE.STORE_PUBLISH_LIVE, + PHASE.STORE_PUBLISH_VOD, + PHASE.STORE_PUBLISH_SERIES, + ] as const; + const storeIds = ['store-live', 'store-vod', 'store-series'] as const; + CONTENT_COUNTS.forEach((count, index) => { + builder.add( + PHASE.SQLITE_CONTENT_READ, + `content-read-${index}-pre`, + 0, + CONTENT_SEMANTICS[index] + ); + builder.addProvider( + `provider-content-${index}`, + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + `content-save-${index}`, + CATEGORY_COUNTS[index], + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.NORMALIZE_CONTENT, + `content-save-${index}`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + `content-save-${index}`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CONTENT_READ, + `content-read-${index}-post`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + storePhases[index], + storeIds[index], + count, + CONTENT_SEMANTICS[index] + ); + }); + builder.add(PHASE.STORE_IMPORT_TERMINAL, 'store-terminal', 100_000); +} + +export function removePair( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + correlationId: string +): XtreamAttributionPhaseEvent[] { + return events.filter( + (event) => + event.phase !== phase || event.correlationId !== correlationId + ); +} + +export function lastStoreEnd( + events: readonly XtreamAttributionPhaseEvent[] +): number { + return Math.max( + ...events + .filter( + (event) => + event.boundary === 'end' && event.phase.startsWith('store.') + ) + .map((event) => event.epochMs) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts new file mode 100644 index 000000000..b2e284896 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + attributeXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { + lastStoreEnd, + pair, + removePair, + scenarioCapture, +} from './xtream-phase-attribution.fixtures'; +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; + +describe('Xtream phase attribution', () => { + it('requires the exact scenario-specific worker, store, and IPC inventory', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const input = scenarioCapture(scenarioId); + const result = attributeXtreamPhases(input); + + assert.equal( + result.structuredCloneAttribution, + 'selected-ipc-proxy-includes-queueing-and-scheduling' + ); + assert.equal( + result.angularRenderingMs, + input.uiPaintEpochMs - lastStoreEnd(input.events) + ); + assert.ok(result.totalMs > 0); + if (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + assert.equal(result.dataAcquisitionMs, 0); + assert.equal(result.jsonParsingMs, 0); + assert.equal(result.normalizationMs, 0); + } else { + assert.ok(result.dataAcquisitionMs >= 0); + assert.ok(result.jsonParsingMs >= 0); + assert.ok(result.normalizationMs >= 0); + } + if ( + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ) { + assert.equal( + input.events.filter( + ({ boundary, phase }) => + boundary === 'end' && + phase === PHASE.SQLITE_CONTENT_READ + ).length, + 9 + ); + for (const phase of [ + PHASE.STORE_PUBLISH_LIVE, + PHASE.STORE_PUBLISH_VOD, + PHASE.STORE_PUBLISH_SERIES, + ]) { + assert.equal( + input.events.filter( + (event) => + event.boundary === 'end' && + event.phase === phase + ).length, + 2 + ); + } + } + } + }); + + it('accepts parallel category completion as a multiset rather than temporal type order', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const originalStarts = new Map( + initial.events + .filter(({ boundary }) => boundary === 'start') + .map(({ correlationId, epochMs, phase }) => [ + `${phase}\0${correlationId}`, + epochMs, + ]) + ); + const swappedRequests = new Map([ + ['category-save-0', 'category-save-1'], + ['category-save-1', 'category-save-0'], + ['category-read-0-post', 'category-read-1-post'], + ['category-read-1-post', 'category-read-0-post'], + ]); + const events = initial.events.map((event) => { + const swappedRequest = swappedRequests.get(event.correlationId); + const start = + swappedRequest === undefined + ? undefined + : originalStarts.get(`${event.phase}\0${swappedRequest}`); + return start === undefined + ? event + : { + ...event, + epochMs: + start + + (event.boundary === 'end' + ? Number(event.durationMs) + : 0), + }; + }); + + assert.doesNotThrow(() => + attributeXtreamPhases({ ...initial, events }) + ); + }); + + it('includes only performance-marked app-playlist reads in IPC inventory', () => { + const expectedReads = new Map([ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, 0], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, 1], + [XTREAM_SCENARIO_ID.DELETE_LARGE, 0], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT, 0], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, 1], + ]); + for (const [scenarioId, expected] of expectedReads) { + const requests = scenarioCapture(scenarioId).ipcSpans.filter( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetAppPlaylist' + ); + assert.equal(requests.length, expected); + } + }); + + it('rejects missing, extra, or mislabeled phases and IPC pairs', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const missingNormalization = removePair( + initial.events, + PHASE.NORMALIZE_CONTENT, + 'content-save-2' + ); + const missingIpcResponse = initial.ipcSpans.slice(0, -1); + const wrongIpcMethod = initial.ipcSpans.map((span, index) => + index === 0 ? { ...span, method: 'dbSearchContent' } : span + ); + const extraStore = [ + ...initial.events, + ...pair(PHASE.STORE_PUBLISH_LIVE, 'extra-live', 900, 1, 1), + ]; + const numericPhaseCorrelation = initial.events.map((event) => + event.phase === PHASE.NORMALIZE_CONTENT && + event.correlationId === 'content-save-2' + ? { ...event, correlationId: 7 as unknown as string } + : event + ); + const numericIpcCorrelation = initial.ipcSpans.map((span) => + span.correlationId === 'dbGetCategories-0' + ? { ...span, correlationId: 7 as unknown as string } + : span + ); + + for (const candidate of [ + { ...initial, events: missingNormalization }, + { ...initial, ipcSpans: missingIpcResponse }, + { ...initial, ipcSpans: wrongIpcMethod }, + { ...initial, events: extraStore }, + { ...initial, events: numericPhaseCorrelation }, + { ...initial, ipcSpans: numericIpcCorrelation }, + { + ...initial, + uiPaintEpochMs: initial.terminalEpochMs + 1, + }, + { + ...initial, + scenarioId: 'xtream-unknown' as typeof initial.scenarioId, + }, + { + ...initial, + backgroundSearchMarkerObserved: 'false' as unknown as boolean, + }, + ]) { + assert.throws( + () => attributeXtreamPhases(candidate), + /invalid Xtream phase capture/ + ); + } + }); + + it('requires the exact cancel prefix, cleanup, and unsuccessful live write', () => { + const cancel = scenarioCapture(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const liveWrite = cancel.events.findIndex( + (event) => + event.phase === PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS && + event.boundary === 'end' + ); + const withSuccessfulWrite = cancel.events.map((event, index) => + index === liveWrite ? { ...event, itemCount: 60_000 } : event + ); + const missingCacheClear = removePair( + cancel.events, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-live' + ); + const misorderedCacheClear = cancel.events.map((event) => { + if (event.boundary !== 'end') return event; + if (event.correlationId === 'cache-clear-live') { + return { ...event, itemCount: 20 }; + } + if (event.correlationId === 'cache-clear-movie') { + return { ...event, itemCount: 6_060 }; + } + return event; + }); + + assert.throws( + () => + attributeXtreamPhases({ + ...cancel, + events: withSuccessfulWrite, + }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...cancel, + events: missingCacheClear, + }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...cancel, + events: misorderedCacheClear, + }), + /invalid Xtream phase capture/ + ); + }); + + it('keeps first visible store-to-paint separate from later delete settlement', () => { + const deletion = scenarioCapture(XTREAM_SCENARIO_ID.DELETE_LARGE); + + assert.ok(deletion.uiPaintEpochMs < deletion.terminalEpochMs); + const result = attributeXtreamPhases(deletion); + assert.equal( + result.angularRenderingMs, + deletion.uiPaintEpochMs - lastStoreEnd(deletion.events) + ); + assert.ok(result.totalMs > result.angularRenderingMs); + }); + + it('fails closed on unexpected Sources search instrumentation', () => { + const background = scenarioCapture(XTREAM_SCENARIO_ID.BACKGROUND_UI); + + assert.throws( + () => + attributeXtreamPhases({ + ...background, + backgroundSearchMarkerObserved: true, + }), + /unexpected background search marker/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...background, + events: [ + ...background.events, + ...pair( + PHASE.SQLITE_SEARCH_QUERY, + 'unexpected-search', + 900, + 1, + 1 + ), + ], + }), + /invalid Xtream phase capture/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts new file mode 100644 index 000000000..a4570cf9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts @@ -0,0 +1,376 @@ +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + isExactRecord, + isFiniteNonNegative, + isPositiveSafeInteger, + sameMaps, + sameValues, +} from './xtream-exact-schema'; +import { pairXtreamPhaseEvents } from './xtream-phase-event-pairing'; +import { + expectedXtreamIpc, + expectedXtreamPhases, + matchesXtreamPhaseItemCounts, + XTREAM_ATTRIBUTION_PHASE, + XTREAM_NORMALIZATION_PHASES, + XTREAM_SQLITE_READ_PHASES, + XTREAM_SQLITE_WRITE_PHASES, + type XtreamInventoryExpectation, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import type { + XtreamPhaseCategoryType, + XtreamPhaseContentType, + XtreamPhaseProviderAction, +} from './xtream-phase-semantic-source'; +import { snapshotXtreamPhaseInput } from './xtream-own-array-snapshot'; +import { + assertXtreamPhaseTopology, + type XtreamTopologyPhaseSpan, +} from './xtream-phase-topology'; +import { + hasDistinctMonotonicXtreamIpcProducerIdentities, + xtreamIpcProducerNamespaceForMethod, +} from './xtream-ipc-producer-identity'; + +export { XTREAM_ATTRIBUTION_PHASE } from './xtream-phase-inventory'; + +export const XTREAM_PHASE_NA_REASON = { + COMMIT_INCLUDED_IN_WRITE: 'included-in-sqlite-write-transaction-spans', + NO_USER_DATA: 'no-user-data', + NOT_INVOKED: 'not-invoked-by-scenario', + SCHEMA_INDEXES_PRE_EXIST: + 'schema-indexes-pre-exist; operation-creates-none', + SOURCES_SEARCH_HAS_NO_XTREAM_MARKER: + 'sources-search-does-not-publish-xtream-search-results', +} as const; + +export interface XtreamAttributionPhaseEvent { + readonly boundary: 'end' | 'start'; + readonly categoryType: XtreamPhaseCategoryType | null; + readonly contentType: XtreamPhaseContentType | null; + readonly correlationId: string; + readonly durationMs: number | null; + readonly epochMs: number; + readonly itemCount: number | null; + readonly phase: string; + readonly providerAction: XtreamPhaseProviderAction | null; + readonly semanticType: XtreamPhaseSemanticType | null; +} + +export interface XtreamIpcAttributionSpan { + readonly boundary: 'request' | 'response'; + readonly correlationId: string; + readonly endEpochMs: number; + readonly ipcCallId: number; + readonly method: string; + readonly outcome: 'error' | 'success'; + readonly sourceEpochMs: number; + readonly startEpochMs: number; +} + +export interface XtreamPhaseAttributionInput { + readonly backgroundSearchMarkerObserved: boolean; + readonly events: readonly XtreamAttributionPhaseEvent[]; + readonly ipcSpans: readonly XtreamIpcAttributionSpan[]; + readonly operationStartEpochMs: number; + readonly scenarioId: XtreamScenarioId; + readonly terminalEpochMs: number; + readonly uiPaintEpochMs: number; +} + +export interface XtreamPhaseAttribution { + readonly angularRenderingMs: number; + readonly backgroundSearchMarkerMs: null; + readonly backgroundSearchMarkerReason: + | typeof XTREAM_PHASE_NA_REASON.SOURCES_SEARCH_HAS_NO_XTREAM_MARKER + | null; + readonly dataAcquisitionMs: number; + readonly deserializationMs: number; + readonly indexesMs: null; + readonly indexesReason: typeof XTREAM_PHASE_NA_REASON.SCHEMA_INDEXES_PRE_EXIST; + readonly jsonParsingMs: number; + readonly normalizationMs: number; + readonly restoreUserDataMs: null; + readonly restoreUserDataReason: + | typeof XTREAM_PHASE_NA_REASON.NO_USER_DATA + | typeof XTREAM_PHASE_NA_REASON.NOT_INVOKED; + readonly serializationMs: number; + readonly sqliteReadMs: number; + readonly sqliteWriteMs: number; + readonly storeUpdateMs: number; + readonly structuredCloneAttribution: 'selected-ipc-proxy-includes-queueing-and-scheduling'; + readonly structuredCloneProxyMs: number; + readonly totalMs: number; + readonly transactionCommitMs: null; + readonly transactionCommitReason: typeof XTREAM_PHASE_NA_REASON.COMMIT_INCLUDED_IN_WRITE; +} + +const PHASE = XTREAM_ATTRIBUTION_PHASE; +type PhaseSpan = XtreamTopologyPhaseSpan; + +export function attributeXtreamPhases( + input: XtreamPhaseAttributionInput +): XtreamPhaseAttribution { + try { + input = snapshotXtreamPhaseInput(input); + assertEnvelope(input); + const spans = pairXtreamPhaseEvents(input); + const expected = expectedXtreamPhases(input.scenarioId); + assertInventory(spans, expected, input.scenarioId); + assertMainPhases(spans); + assertXtreamPhaseTopology(input.scenarioId, spans); + const store = spans.filter(({ phase }) => phase.startsWith('store.')); + const storeEnd = Math.max(...store.map(({ endEpochMs }) => endEpochMs)); + if (input.uiPaintEpochMs < storeEnd) invalid(); + if ( + input.scenarioId === 'xtream-background-ui' && + input.backgroundSearchMarkerObserved + ) { + throw new Error('unexpected background search marker'); + } + const network = select(spans, PHASE.NETWORK_TOTAL); + const json = select(spans, PHASE.JSON_TRANSFORM); + const jsonById = new Map( + json.map((span) => [span.correlationId, span]) + ); + const restores = + input.scenarioId === 'xtream-refresh-large' || + input.scenarioId === 'xtream-background-ui'; + return { + totalMs: input.terminalEpochMs - input.operationStartEpochMs, + dataAcquisitionMs: + sum(network) - + sum( + network.map(({ correlationId }) => { + const nested = jsonById.get(correlationId); + if (!nested) invalid(); + return nested; + }) + ), + jsonParsingMs: sum(json), + normalizationMs: sum(filter(spans, XTREAM_NORMALIZATION_PHASES)), + serializationMs: sum(select(spans, PHASE.SERIALIZE_PLAYLIST)), + deserializationMs: sum(select(spans, PHASE.DESERIALIZE_PLAYLIST)), + structuredCloneProxyMs: assertIpcInventory(input), + sqliteReadMs: sum(filter(spans, XTREAM_SQLITE_READ_PHASES)), + sqliteWriteMs: sum(filter(spans, XTREAM_SQLITE_WRITE_PHASES)), + storeUpdateMs: sum(store), + angularRenderingMs: input.uiPaintEpochMs - storeEnd, + indexesMs: null, + indexesReason: XTREAM_PHASE_NA_REASON.SCHEMA_INDEXES_PRE_EXIST, + transactionCommitMs: null, + transactionCommitReason: + XTREAM_PHASE_NA_REASON.COMMIT_INCLUDED_IN_WRITE, + structuredCloneAttribution: + 'selected-ipc-proxy-includes-queueing-and-scheduling', + restoreUserDataMs: null, + restoreUserDataReason: restores + ? XTREAM_PHASE_NA_REASON.NO_USER_DATA + : XTREAM_PHASE_NA_REASON.NOT_INVOKED, + backgroundSearchMarkerMs: null, + backgroundSearchMarkerReason: + input.scenarioId === 'xtream-background-ui' + ? XTREAM_PHASE_NA_REASON.SOURCES_SEARCH_HAS_NO_XTREAM_MARKER + : null, + }; + } catch (error) { + if ( + error instanceof Error && + error.message === 'unexpected background search marker' + ) { + throw error; + } + throw new Error('invalid Xtream phase capture'); + } +} + +function assertInventory( + spans: readonly PhaseSpan[], + expected: ReadonlyMap, + scenarioId: XtreamScenarioId +): void { + if (spans.length !== sumExpectations(expected)) invalid(); + for (const [phase, expectation] of expected) { + const actual = select(spans, phase).sort( + (left, right) => left.startEpochMs - right.startEpochMs + ); + if (actual.length !== expectation.occurrences) invalid(); + if ( + expectation.itemCounts && + !matchesXtreamPhaseItemCounts( + phase, + actual.map(({ itemCount }) => itemCount), + expectation.itemCounts + ) + ) { + invalid(); + } + } + if (spans.some(({ phase }) => !expected.has(phase))) invalid(); + if (scenarioId === 'xtream-cancel-import') { + const counts = select( + spans, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS + ) + .sort((left, right) => left.startEpochMs - right.startEpochMs) + .map(({ itemCount }) => itemCount); + if ( + !isFiniteNonNegative(counts[0]) || + counts[0] < 6_060 || + counts[0] >= 60_060 || + !sameValues(counts.slice(1), [20, 20]) + ) { + invalid(); + } + } +} + +function assertMainPhases(spans: readonly PhaseSpan[]): void { + const network = select(spans, PHASE.NETWORK_TOTAL); + const json = new Map( + select(spans, PHASE.JSON_TRANSFORM).map((span) => [ + span.correlationId, + span, + ]) + ); + const response = new Map( + select(spans, PHASE.RESPONSE_READY).map((span) => [ + span.correlationId, + span, + ]) + ); + for (const span of network) { + const nested = json.get(span.correlationId); + const ready = response.get(span.correlationId); + if ( + !nested || + !ready || + nested.semanticType !== span.semanticType || + ready.semanticType !== span.semanticType || + nested.startEpochMs < span.startEpochMs || + nested.endEpochMs > span.endEpochMs || + ready.startEpochMs < span.endEpochMs + ) { + invalid(); + } + } +} + +function assertIpcInventory(input: XtreamPhaseAttributionInput): number { + const expected = expectedXtreamIpc(input.scenarioId); + const groups = new Map(); + let total = 0; + for (const span of input.ipcSpans) { + if ( + !isExactRecord(span, [ + 'boundary', + 'correlationId', + 'endEpochMs', + 'ipcCallId', + 'method', + 'outcome', + 'sourceEpochMs', + 'startEpochMs', + ]) || + !['request', 'response'].includes(span.boundary) || + typeof span.correlationId !== 'string' || + span.correlationId.length === 0 || + typeof span.method !== 'string' || + span.method.length === 0 || + !['error', 'success'].includes(span.outcome) || + !isFiniteNonNegative(span.startEpochMs) || + !isFiniteNonNegative(span.endEpochMs) || + !isPositiveSafeInteger(span.ipcCallId) || + !isFiniteNonNegative(span.sourceEpochMs) || + span.sourceEpochMs < input.operationStartEpochMs || + span.sourceEpochMs > span.startEpochMs || + span.startEpochMs < input.operationStartEpochMs || + span.endEpochMs < span.startEpochMs || + span.endEpochMs > input.terminalEpochMs + ) { + invalid(); + } + total += span.endEpochMs - span.startEpochMs; + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + const actual = new Map(); + const producerCalls: XtreamIpcAttributionSpan[] = []; + for (const spans of groups.values()) { + const request = spans.find(({ boundary }) => boundary === 'request'); + const response = spans.find(({ boundary }) => boundary === 'response'); + if ( + spans.length !== 2 || + !request || + !response || + request.method !== response.method || + request.outcome !== response.outcome || + request.ipcCallId !== response.ipcCallId || + request.sourceEpochMs !== response.sourceEpochMs || + request.endEpochMs > response.startEpochMs + ) { + invalid(); + } + producerCalls.push(request); + const counts = actual.get(request.method) ?? { error: 0, success: 0 }; + counts[request.outcome] += 1; + actual.set(request.method, counts); + } + const producerIdentities = producerCalls.map( + ({ ipcCallId, method, sourceEpochMs }) => { + const namespace = xtreamIpcProducerNamespaceForMethod(method); + if (namespace === null) invalid(); + return { ipcCallId, namespace, sourceEpochMs }; + } + ); + if (!hasDistinctMonotonicXtreamIpcProducerIdentities(producerIdentities)) + invalid(); + if (!sameMaps(actual, expected)) invalid(); + return total; +} + +function assertEnvelope(input: XtreamPhaseAttributionInput): void { + if ( + !isFiniteNonNegative(input.operationStartEpochMs) || + !isFiniteNonNegative(input.terminalEpochMs) || + !isFiniteNonNegative(input.uiPaintEpochMs) || + !Object.values(XTREAM_SCENARIO_ID).includes(input.scenarioId) || + typeof input.backgroundSearchMarkerObserved !== 'boolean' || + input.terminalEpochMs < input.operationStartEpochMs || + input.uiPaintEpochMs < input.operationStartEpochMs || + input.uiPaintEpochMs > input.terminalEpochMs + ) { + invalid(); + } +} + +function filter(spans: readonly PhaseSpan[], phases: ReadonlySet) { + return spans.filter(({ phase }) => phases.has(phase)); +} + +function select(spans: readonly PhaseSpan[], phase: string): PhaseSpan[] { + return spans.filter((span) => span.phase === phase); +} + +function sum(spans: readonly PhaseSpan[]): number { + return spans.reduce((total, span) => total + span.durationMs, 0); +} + +function sumExpectations( + values: ReadonlyMap +) { + return [...values.values()].reduce( + (total, { occurrences }) => total + occurrences, + 0 + ); +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts new file mode 100644 index 000000000..32c824803 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts @@ -0,0 +1,100 @@ +import { + isExactRecord, + isFiniteNonNegative, + isSafeNonNegativeInteger, +} from './xtream-exact-schema'; +import type { + XtreamAttributionPhaseEvent, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { fitsPerformanceDurationEnvelope } from './performance-phase-duration-envelope'; +import { XTREAM_ATTRIBUTION_PHASE } from './xtream-phase-inventory'; +import { semanticTypeFromXtreamPhaseSource } from './xtream-phase-semantic-source'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const PHASES = new Set(Object.values(XTREAM_ATTRIBUTION_PHASE)); + +export function pairXtreamPhaseEvents( + input: XtreamPhaseAttributionInput +): readonly XtreamTopologyPhaseSpan[] { + const groups = new Map(); + for (const event of input.events) { + assertCanonicalEvent(event); + const key = `${event.phase}\0${event.correlationId}`; + groups.set(key, [...(groups.get(key) ?? []), event]); + } + return [...groups.values()].map((events) => pairGroup(input, events)); +} + +function assertCanonicalEvent(event: XtreamAttributionPhaseEvent): void { + if ( + !isExactRecord(event, [ + 'boundary', + 'categoryType', + 'contentType', + 'correlationId', + 'durationMs', + 'epochMs', + 'itemCount', + 'phase', + 'providerAction', + 'semanticType', + ]) || + !PHASES.has(event.phase) || + !['start', 'end'].includes(event.boundary) || + typeof event.correlationId !== 'string' || + event.correlationId.length === 0 || + semanticTypeFromXtreamPhaseSource(event) !== event.semanticType || + !isFiniteNonNegative(event.epochMs) + ) { + invalid(); + } +} + +function pairGroup( + input: XtreamPhaseAttributionInput, + events: readonly XtreamAttributionPhaseEvent[] +): XtreamTopologyPhaseSpan { + const [start, end] = events; + if ( + events.length !== 2 || + !start || + !end || + start.boundary !== 'start' || + end.boundary !== 'end' || + start.durationMs !== null || + start.itemCount !== null || + start.providerAction !== end.providerAction || + start.categoryType !== end.categoryType || + start.contentType !== end.contentType || + start.semanticType !== end.semanticType || + !isFiniteNonNegative(end.durationMs) || + (end.itemCount !== null && !isSafeNonNegativeInteger(end.itemCount)) || + start.epochMs < input.operationStartEpochMs || + end.epochMs < start.epochMs || + end.epochMs > input.terminalEpochMs || + !fitsPerformanceDurationEnvelope( + end.durationMs, + input.operationStartEpochMs, + input.terminalEpochMs + ) + ) { + invalid(); + } + return { + categoryType: start.categoryType, + contentType: start.contentType, + correlationId: start.correlationId, + durationMs: end.durationMs, + endEpochMs: end.epochMs, + itemCount: end.itemCount, + phase: start.phase, + providerAction: start.providerAction, + semanticType: start.semanticType, + startEpochMs: start.epochMs, + }; +} + +function invalid(): never { + throw new Error('invalid Xtream phase event pairing'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts new file mode 100644 index 000000000..9ba722cbe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts @@ -0,0 +1,285 @@ +import type { XtreamScenarioId } from './xtream-benchmark-contract'; + +export const XTREAM_ATTRIBUTION_PHASE = { + CANCEL_SESSION: 'xtream.cancel-session', + DESERIALIZE_PLAYLIST: 'deserialize.playlist', + JSON_TRANSFORM: 'xtream.json.transform', + NETWORK_TOTAL: 'xtream.network.total', + NORMALIZE_CATEGORIES: 'normalize.categories', + NORMALIZE_CONTENT: 'normalize.content', + NORMALIZE_SEARCH_RANK: 'normalize.search-rank', + RESPONSE_READY: 'xtream.response.ready', + SERIALIZE_PLAYLIST: 'serialize.playlist', + SQLITE_CATEGORIES_READ: 'sqlite.categories.read', + SQLITE_CATEGORIES_WRITE_TRANSACTIONS: + 'sqlite.categories.write-transactions', + SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read', + SQLITE_CONTENT_READ: 'sqlite.content.read', + SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions', + SQLITE_GENERIC_READ: 'sqlite.read', + SQLITE_GENERIC_WRITE: 'sqlite.write', + SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids', + SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS: + 'sqlite.playlist-delete.write-transactions', + SQLITE_SEARCH_QUERY: 'sqlite.search.query', + SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS: + 'sqlite.xtream-cache-clear.write-transactions', + SQLITE_XTREAM_DELETE_COLLECT_USER_DATA: + 'sqlite.xtream-delete.collect-user-data', + SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS: + 'sqlite.xtream-delete.write-transactions', + STORE_DELETE_ROW: 'store.xtream-delete-row', + STORE_IMPORT_TERMINAL: 'store.xtream-import-terminal', + STORE_PUBLISH_CATEGORIES: 'store.xtream-publish-categories', + STORE_PUBLISH_LIVE: 'store.xtream-publish-live', + STORE_PUBLISH_SERIES: 'store.xtream-publish-series', + STORE_PUBLISH_VOD: 'store.xtream-publish-vod', + STORE_REFRESH_META: 'store.xtream-refresh-meta', + STORE_SEARCH_RESULTS: 'store.xtream-search-results', +} as const; + +export const XTREAM_PHASE_SEMANTIC_TYPE = { + ACCOUNT: 'account', + LIVE_CATEGORIES: 'live-categories', + LIVE_CONTENT: 'live-content', + SERIES_CATEGORIES: 'series-categories', + SERIES_CONTENT: 'series-content', + VOD_CATEGORIES: 'vod-categories', + VOD_CONTENT: 'vod-content', +} as const; + +export type XtreamPhaseSemanticType = + (typeof XTREAM_PHASE_SEMANTIC_TYPE)[keyof typeof XTREAM_PHASE_SEMANTIC_TYPE]; + +export interface XtreamInventoryExpectation { + readonly itemCounts?: readonly (number | null)[]; + readonly occurrences: number; +} + +export interface XtreamIpcOutcomeCounts { + readonly error: number; + readonly success: number; +} + +type AddExpectation = ( + phase: string, + occurrences: number, + itemCounts?: readonly (number | null)[] +) => void; + +const PHASE = XTREAM_ATTRIBUTION_PHASE; + +export const XTREAM_NORMALIZATION_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.NORMALIZE_CONTENT, + PHASE.NORMALIZE_SEARCH_RANK, +]); +export const XTREAM_SQLITE_READ_PHASES = new Set([ + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_GENERIC_READ, + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_SEARCH_QUERY, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, +]); +export const XTREAM_SQLITE_WRITE_PHASES = new Set([ + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + PHASE.SQLITE_GENERIC_WRITE, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, +]); + +export function matchesXtreamPhaseItemCounts( + phase: string, + actual: readonly (number | null)[], + expected: readonly (number | null)[] +): boolean { + const categories = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + ]); + if (!categories.has(phase)) { + return actual.every((value, index) => value === expected[index]); + } + const ordered = (values: readonly (number | null)[]) => + [...values].sort( + (left, right) => + (left === null ? -1 : left) - (right === null ? -1 : right) + ); + return ( + actual.length === expected.length && + ordered(actual).every( + (value, index) => value === ordered(expected)[index] + ) + ); +} + +export function expectedXtreamPhases( + scenarioId: XtreamScenarioId +): ReadonlyMap { + const result = new Map(); + const add: AddExpectation = (phase, occurrences, itemCounts) => + result.set(phase, { + occurrences, + ...(itemCounts ? { itemCounts } : {}), + }); + const provider = + scenarioId === 'xtream-delete-large' + ? 0 + : scenarioId === 'xtream-cancel-import' + ? 6 + : 8; + for (const phase of [ + PHASE.NETWORK_TOTAL, + PHASE.JSON_TRANSFORM, + PHASE.RESPONSE_READY, + ]) { + add(phase, provider); + } + if (scenarioId === 'xtream-delete-large') { + add(PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, 2, [100_100, 0]); + add(PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, 2, [100_101, 1]); + add(PHASE.STORE_DELETE_ROW, 1, [1]); + return result; + } + add(PHASE.SERIALIZE_PLAYLIST, 1); + add(PHASE.SQLITE_GENERIC_WRITE, 1); + const playlistReads = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ? 1 + : 0; + add(PHASE.SQLITE_GENERIC_READ, playlistReads); + add(PHASE.DESERIALIZE_PLAYLIST, playlistReads); + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + addFullCategories(add, routeHydratesAfterImport); + if (scenarioId === 'xtream-cancel-import') { + add(PHASE.CANCEL_SESSION, 1); + add(PHASE.SQLITE_CONTENT_READ, 1, [0]); + add(PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, 1, [60]); + add(PHASE.NORMALIZE_CONTENT, 1, [60_000]); + add(PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, 1, [null]); + add(PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, 3); + add(PHASE.STORE_PUBLISH_CATEGORIES, 1, [100]); + add(PHASE.STORE_IMPORT_TERMINAL, 1, [0]); + return result; + } + if ( + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ) { + add(PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, 1, [100_100]); + add(PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, 1, [100_100]); + add(PHASE.STORE_REFRESH_META, 1, [1]); + } + addFullContent(add, routeHydratesAfterImport); + add( + PHASE.STORE_PUBLISH_CATEGORIES, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [100, 100] : [100] + ); + add( + PHASE.STORE_PUBLISH_LIVE, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [60_000, 60_000] : [60_000] + ); + add( + PHASE.STORE_PUBLISH_VOD, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [20_000, 20_000] : [20_000] + ); + add( + PHASE.STORE_PUBLISH_SERIES, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [20_000, 20_000] : [20_000] + ); + add( + PHASE.STORE_IMPORT_TERMINAL, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [100_000, 100_000] : [100_000] + ); + return result; +} + +export function expectedXtreamIpc( + scenarioId: XtreamScenarioId +): ReadonlyMap { + const counts = + scenarioId === 'xtream-delete-large' + ? { dbDeletePlaylist: 2 } + : scenarioId === 'xtream-cancel-import' + ? { + dbCancelOperation: 1, + dbGetCategories: 6, + dbGetContent: 1, + dbSaveCategories: 3, + dbSaveContent: -1, + dbUpsertAppPlaylist: 1, + xtreamCancelSession: 1, + xtreamRequest: 6, + } + : scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ? { + dbDeleteXtreamContent: 1, + dbGetAppPlaylist: 1, + dbGetCategories: 9, + dbGetContent: 9, + dbRestoreXtreamUserData: 1, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + } + : { + dbGetCategories: 6, + dbGetContent: 6, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + }; + return new Map( + Object.entries(counts).map(([method, count]) => [ + method, + { error: count < 0 ? -count : 0, success: Math.max(0, count) }, + ]) + ); +} + +function addFullCategories( + add: AddExpectation, + routeHydratesAfterImport: boolean +): void { + add( + PHASE.SQLITE_CATEGORIES_READ, + routeHydratesAfterImport ? 9 : 6, + routeHydratesAfterImport + ? [0, 0, 0, 60, 20, 20, 60, 20, 20] + : [0, 0, 0, 60, 20, 20] + ); + add(PHASE.NORMALIZE_CATEGORIES, 3, [60, 20, 20]); + add(PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, 3, [60, 20, 20]); +} + +function addFullContent( + add: AddExpectation, + routeHydratesAfterImport: boolean +): void { + add( + PHASE.SQLITE_CONTENT_READ, + routeHydratesAfterImport ? 9 : 6, + routeHydratesAfterImport + ? [0, 60_000, 0, 20_000, 0, 20_000, 60_000, 20_000, 20_000] + : [0, 60_000, 0, 20_000, 0, 20_000] + ); + add(PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, 3, [60, 20, 20]); + add(PHASE.NORMALIZE_CONTENT, 3, [60_000, 20_000, 20_000]); + add(PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, 3, [60_000, 20_000, 20_000]); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts new file mode 100644 index 000000000..23ce5285e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts @@ -0,0 +1,109 @@ +import { XtreamCodeActions } from '@iptvnator/shared/interfaces'; + +import { + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; + +export type XtreamPhaseCategoryType = 'live' | 'movies' | 'series'; +export type XtreamPhaseContentType = 'live' | 'movie' | 'series'; +export type XtreamPhaseProviderAction = + | XtreamCodeActions.GetAccountInfo + | XtreamCodeActions.GetLiveCategories + | XtreamCodeActions.GetLiveStreams + | XtreamCodeActions.GetSeries + | XtreamCodeActions.GetSeriesCategories + | XtreamCodeActions.GetVodCategories + | XtreamCodeActions.GetVodStreams; + +export interface XtreamPhaseSemanticSource { + readonly categoryType: XtreamPhaseCategoryType | null; + readonly contentType: XtreamPhaseContentType | null; + readonly providerAction: XtreamPhaseProviderAction | null; +} + +const ACTION_SEMANTICS = new Map([ + [XtreamCodeActions.GetAccountInfo, SEMANTIC.ACCOUNT], + [XtreamCodeActions.GetLiveCategories, SEMANTIC.LIVE_CATEGORIES], + [XtreamCodeActions.GetVodCategories, SEMANTIC.VOD_CATEGORIES], + [XtreamCodeActions.GetSeriesCategories, SEMANTIC.SERIES_CATEGORIES], + [XtreamCodeActions.GetLiveStreams, SEMANTIC.LIVE_CONTENT], + [XtreamCodeActions.GetVodStreams, SEMANTIC.VOD_CONTENT], + [XtreamCodeActions.GetSeries, SEMANTIC.SERIES_CONTENT], +]); +const CATEGORY_SEMANTICS = new Map([ + ['live', SEMANTIC.LIVE_CATEGORIES], + ['movies', SEMANTIC.VOD_CATEGORIES], + ['series', SEMANTIC.SERIES_CATEGORIES], +]); +const CONTENT_SEMANTICS = new Map([ + ['live', SEMANTIC.LIVE_CONTENT], + ['movie', SEMANTIC.VOD_CONTENT], + ['series', SEMANTIC.SERIES_CONTENT], +]); + +export function semanticTypeFromXtreamPhaseSource( + source: Readonly> +): XtreamPhaseSemanticType | null | undefined { + const populated = [ + source.providerAction, + source.categoryType, + source.contentType, + ].filter((value) => value !== null); + if (populated.length === 0) return null; + if (populated.length !== 1) return undefined; + if (source.providerAction !== null) { + return typeof source.providerAction === 'string' + ? ACTION_SEMANTICS.get(source.providerAction) + : undefined; + } + if (source.categoryType !== null) { + return typeof source.categoryType === 'string' + ? CATEGORY_SEMANTICS.get(source.categoryType) + : undefined; + } + return typeof source.contentType === 'string' + ? CONTENT_SEMANTICS.get(source.contentType) + : undefined; +} + +export function xtreamDataSemanticSource( + semanticType: XtreamPhaseSemanticType | null +): XtreamPhaseSemanticSource { + switch (semanticType) { + case SEMANTIC.LIVE_CATEGORIES: + return source(null, 'live', null); + case SEMANTIC.VOD_CATEGORIES: + return source(null, 'movies', null); + case SEMANTIC.SERIES_CATEGORIES: + return source(null, 'series', null); + case SEMANTIC.LIVE_CONTENT: + return source(null, null, 'live'); + case SEMANTIC.VOD_CONTENT: + return source(null, null, 'movie'); + case SEMANTIC.SERIES_CONTENT: + return source(null, null, 'series'); + case SEMANTIC.ACCOUNT: + throw new Error('account has no database semantic source'); + case null: + return source(null, null, null); + } +} + +export function xtreamProviderSemanticSource( + semanticType: XtreamPhaseSemanticType +): XtreamPhaseSemanticSource { + const action = [...ACTION_SEMANTICS].find( + ([, mapped]) => mapped === semanticType + )?.[0]; + if (!action) throw new Error('invalid provider semantic type'); + return source(action as XtreamPhaseProviderAction, null, null); +} + +function source( + providerAction: XtreamPhaseProviderAction | null, + categoryType: XtreamPhaseCategoryType | null, + contentType: XtreamPhaseContentType | null +): XtreamPhaseSemanticSource { + return Object.freeze({ categoryType, contentType, providerAction }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts new file mode 100644 index 000000000..d53e219af --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; + +import { + type XtreamAttributionPhaseEvent, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import { xtreamDataSemanticSource } from './xtream-phase-semantic-source'; + +export function swapProviderTimelines( + events: readonly XtreamAttributionPhaseEvent[], + leftId: string, + rightId: string +): XtreamAttributionPhaseEvent[] { + const epochs = new Map( + events + .filter( + ({ correlationId }) => + correlationId === leftId || correlationId === rightId + ) + .map(({ boundary, correlationId, epochMs, phase }) => [ + `${correlationId}\0${phase}\0${boundary}`, + epochMs, + ]) + ); + return events.map((event) => { + if (event.correlationId !== leftId && event.correlationId !== rightId) { + return event; + } + const sourceId = event.correlationId === leftId ? rightId : leftId; + const epochMs = epochs.get( + `${sourceId}\0${event.phase}\0${event.boundary}` + ); + assert.notEqual(epochMs, undefined); + return { ...event, epochMs: Number(epochMs) }; + }); +} + +export function phaseEpoch( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + boundary: 'end' | 'start' +): number { + const event = events.find( + (candidate) => + candidate.phase === phase && candidate.boundary === boundary + ); + assert.ok(event); + return event.epochMs; +} + +export function relabelInterleavedCategoryRequests( + events: readonly XtreamAttributionPhaseEvent[] +): XtreamAttributionPhaseEvent[] { + const layout = new Map< + string, + { semanticType: XtreamPhaseSemanticType; startEpochMs: number } + >([ + [ + `category-save-1\0${PHASE.NORMALIZE_CATEGORIES}`, + { semanticType: SEMANTIC.VOD_CATEGORIES, startEpochMs: 148 }, + ], + [ + `category-save-2\0${PHASE.NORMALIZE_CATEGORIES}`, + { semanticType: SEMANTIC.SERIES_CATEGORIES, startEpochMs: 150 }, + ], + [ + `category-save-1\0${PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS}`, + { semanticType: SEMANTIC.SERIES_CATEGORIES, startEpochMs: 152 }, + ], + [ + `category-save-2\0${PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS}`, + { semanticType: SEMANTIC.VOD_CATEGORIES, startEpochMs: 154 }, + ], + [ + `category-read-2-post\0${PHASE.SQLITE_CATEGORIES_READ}`, + { semanticType: SEMANTIC.SERIES_CATEGORIES, startEpochMs: 156 }, + ], + [ + `category-read-1-post\0${PHASE.SQLITE_CATEGORIES_READ}`, + { semanticType: SEMANTIC.VOD_CATEGORIES, startEpochMs: 158 }, + ], + ]); + return events.map((event) => { + const replacement = layout.get( + `${event.correlationId}\0${event.phase}` + ); + if (!replacement) return event; + const source = xtreamDataSemanticSource(replacement.semanticType); + return { + ...event, + ...source, + epochMs: + replacement.startEpochMs + + (event.boundary === 'end' ? Number(event.durationMs) : 0), + semanticType: replacement.semanticType, + }; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts new file mode 100644 index 000000000..71fa2949e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { XtreamCodeActions } from '@iptvnator/shared/interfaces'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + attributeXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { + phaseEpoch, + relabelInterleavedCategoryRequests, + swapProviderTimelines, +} from './xtream-phase-topology.fixtures'; +import type { XtreamPhaseProviderAction } from './xtream-phase-semantic-source'; + +describe('Xtream request-level phase topology', () => { + it('never consumes caller-owned event or IPC iterators', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const invoked: string[] = []; + const events: (typeof initial.events)[number][] = []; + const ipcSpans: (typeof initial.ipcSpans)[number][] = []; + Object.defineProperty(events, Symbol.iterator, { + value() { + invoked.push('events'); + return initial.events[Symbol.iterator](); + }, + }); + Object.defineProperty(ipcSpans, Symbol.iterator, { + value() { + invoked.push('ipcSpans'); + return initial.ipcSpans[Symbol.iterator](); + }, + }); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events, ipcSpans }), + /invalid Xtream phase capture/ + ); + assert.deepEqual(invoked, []); + }); + + it('rejects own phase-capture array keys beyond dense indices and length', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = [...initial.events]; + const ipcSpans = [...initial.ipcSpans]; + Object.defineProperty(events, 'map', { + value: Array.prototype.map.bind(events), + }); + Object.defineProperty(ipcSpans, Symbol('extra'), { value: true }); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => attributeXtreamPhases({ ...initial, ipcSpans }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects accessor-backed event and IPC records without invoking them', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const invoked: string[] = []; + const event = { ...initial.events[0] }; + const ipcSpan = { ...initial.ipcSpans[0] }; + Object.defineProperty(event, 'phase', { + enumerable: true, + get() { + invoked.push('event.phase'); + return initial.events[0]?.phase; + }, + }); + Object.defineProperty(ipcSpan, 'method', { + enumerable: true, + get() { + invoked.push('ipcSpan.method'); + return initial.ipcSpans[0]?.method; + }, + }); + + assert.throws( + () => + attributeXtreamPhases({ + ...initial, + events: [event, ...initial.events.slice(1)], + }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...initial, + ipcSpans: [ipcSpan, ...initial.ipcSpans.slice(1)], + }), + /invalid Xtream phase capture/ + ); + assert.deepEqual(invoked, []); + }); + + it('requires category phases to preserve request correlation and order', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const write = initial.events.find( + ({ boundary, phase }) => + boundary === 'start' && + phase === PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS + ); + assert.ok(write); + const writeBeforeNormalize = initial.events.map((event) => + event.phase === PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS && + event.correlationId === write.correlationId + ? { + ...event, + epochMs: + initial.operationStartEpochMs + + (event.boundary === 'end' + ? Number(event.durationMs) + : 0), + } + : event + ); + const mismatchedRequest = initial.events.map((event) => + event.phase === PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS && + event.correlationId === write.correlationId + ? { ...event, correlationId: 'mismatched-category-request' } + : event + ); + + for (const events of [writeBeforeNormalize, mismatchedRequest]) { + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + } + }); + + it('rejects one worker request identity reused by different operations', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const chainedReuse = initial.events.map((event) => + event.correlationId === 'content-save-0' && + new Set([ + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ]).has(event.phase) + ? { ...event, correlationId: 'category-save-0' } + : event + ); + const standaloneReuse = initial.events.map((event) => + event.correlationId === 'content-read-0-pre' + ? { ...event, correlationId: 'category-read-0-pre' } + : event + ); + + for (const events of [chainedReuse, standaloneReuse]) { + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + } + }); + + it('rejects equal-count VOD and series category phases relabeled across worker requests', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = relabelInterleavedCategoryRequests(initial.events); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects category and content worker requests split across source domains', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + for (const [correlationId, phase, providerAction] of [ + [ + 'category-save-0', + PHASE.NORMALIZE_CATEGORIES, + XtreamCodeActions.GetLiveCategories, + ], + [ + 'content-save-1', + PHASE.NORMALIZE_CONTENT, + XtreamCodeActions.GetVodStreams, + ], + ] as const) { + const events = initial.events.map((event) => + event.correlationId === correlationId && event.phase === phase + ? { + ...event, + categoryType: null, + contentType: null, + providerAction: + providerAction as XtreamPhaseProviderAction, + } + : event + ); + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + } + }); + + it('rejects one IPC identity reused by different methods', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const ipcSpans = initial.ipcSpans.map((span) => + span.correlationId === 'dbGetCategories-0' + ? { ...span, correlationId: 'dbUpsertAppPlaylist-0' } + : span + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, ipcSpans }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects a provider response that becomes ready after its consumer DB and store stages', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const liveStoreEnd = phaseEpoch( + initial.events, + PHASE.STORE_PUBLISH_LIVE, + 'end' + ); + const lateStart = liveStoreEnd + 1; + const events = initial.events.map((event) => { + if (event.correlationId !== 'provider-content-0') return event; + const phaseOffset = + event.phase === PHASE.NETWORK_TOTAL + ? 0 + : event.phase === PHASE.JSON_TRANSFORM + ? 1 + : 5; + const duration = + event.phase === PHASE.NETWORK_TOTAL + ? 4 + : event.phase === PHASE.JSON_TRANSFORM + ? 1 + : 1; + return { + ...event, + epochMs: + lateStart + + phaseOffset + + (event.boundary === 'end' ? duration : 0), + }; + }); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects swapping the account provider lifecycle with a category lifecycle', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = swapProviderTimelines( + initial.events, + 'provider-account', + 'provider-category-0' + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('keeps parallel category completion order independent of category type', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = swapProviderTimelines( + initial.events, + 'provider-category-0', + 'provider-category-1' + ); + + assert.doesNotThrow(() => + attributeXtreamPhases({ ...initial, events }) + ); + }); + + it('rejects swapping sequential live and VOD provider lifecycles', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = swapProviderTimelines( + initial.events, + 'provider-content-0', + 'provider-content-1' + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('derives provider semantics from the persisted typed action source', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = initial.events.map((event) => + event.correlationId === 'provider-content-0' + ? { + ...event, + providerAction: + XtreamCodeActions.GetVodStreams as XtreamPhaseProviderAction, + } + : event + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts new file mode 100644 index 000000000..6515e3e2d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts @@ -0,0 +1,368 @@ +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import type { + XtreamPhaseCategoryType, + XtreamPhaseContentType, + XtreamPhaseProviderAction, +} from './xtream-phase-semantic-source'; +import { assertXtreamProviderCausality } from './xtream-provider-phase-topology'; +import { assertXtreamRefreshRouteHydration } from './xtream-refresh-route-phase-topology'; +import { assertXtreamWorkerCorrelationOwnership } from './xtream-worker-phase-topology'; + +export interface XtreamTopologyPhaseSpan { + readonly categoryType: XtreamPhaseCategoryType | null; + readonly contentType: XtreamPhaseContentType | null; + readonly correlationId: string; + readonly durationMs: number; + readonly endEpochMs: number; + readonly itemCount: number | null; + readonly phase: string; + readonly providerAction: XtreamPhaseProviderAction | null; + readonly semanticType: XtreamPhaseSemanticType | null; + readonly startEpochMs: number; +} + +const CATEGORY_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, +]); + +export function assertXtreamPhaseTopology( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + assertWorkerRequestChains(scenarioId, spans); + assertCategoryCorrelation(scenarioId, spans); + assertXtreamProviderCausality(scenarioId, spans); + if (scenarioId === 'xtream-delete-large') { + assertDeleteTopology(spans); + return; + } + if ( + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ) { + assertRefreshPrelude(spans); + } + if (scenarioId === 'xtream-cancel-import') { + assertCancelTopology(spans); + return; + } + assertFullImportTopology(scenarioId, spans); +} + +function assertWorkerRequestChains( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + assertXtreamWorkerCorrelationOwnership(spans); + assertCorrelatedChains(spans, [ + PHASE.SERIALIZE_PLAYLIST, + PHASE.SQLITE_GENERIC_WRITE, + ]); + assertCorrelatedChains(spans, [ + PHASE.SQLITE_GENERIC_READ, + PHASE.DESERIALIZE_PLAYLIST, + ]); + assertCorrelatedChains( + spans, + [ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + ], + [[0, 1]] + ); + assertCorrelatedChains( + spans, + [ + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ], + scenarioId === 'xtream-cancel-import' ? [] : [[1, 2]] + ); + assertCorrelatedChains(spans, [ + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + ]); + assertCorrelatedChains(spans, [ + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + ]); +} + +function assertCorrelatedChains( + spans: readonly XtreamTopologyPhaseSpan[], + phases: readonly string[], + equalItemCountIndexes: readonly (readonly [number, number])[] = [] +): void { + const phaseSet = new Set(phases); + const selected = spans.filter(({ phase }) => phaseSet.has(phase)); + if (selected.length === 0) return; + const groups = new Map(); + for (const span of selected) { + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + for (const group of groups.values()) { + const chain = phases.map((phase) => { + const matches = group.filter((span) => span.phase === phase); + const match = matches[0]; + if (matches.length !== 1 || !match) invalid(); + return match; + }); + if (chain.length !== group.length) invalid(); + ordered(...chain); + for (const [leftIndex, rightIndex] of equalItemCountIndexes) { + const left = chain[leftIndex]; + const right = chain[rightIndex]; + if (!left || !right || left.itemCount !== right.itemCount) + invalid(); + } + } +} + +function assertCategoryCorrelation( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + if (select(spans, PHASE.NORMALIZE_CATEGORIES).length === 0) return; + const completedReads = select(spans, PHASE.SQLITE_CATEGORIES_READ) + .map(({ itemCount }) => itemCount) + .filter((itemCount) => itemCount !== 0); + const normalized = select(spans, PHASE.NORMALIZE_CATEGORIES).map( + ({ itemCount }) => itemCount + ); + const written = select( + spans, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS + ).map(({ itemCount }) => itemCount); + const expectedCompletedReads = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ? [...normalized, ...normalized] + : normalized; + if ( + !sameCountMultiset(completedReads, expectedCompletedReads) || + !sameCountMultiset(normalized, written) + ) { + invalid(); + } +} + +function assertRefreshPrelude(spans: readonly XtreamTopologyPhaseSpan[]): void { + const collect = one(spans, PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA); + const write = one(spans, PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS); + const refreshMeta = one(spans, PHASE.STORE_REFRESH_META); + ordered(collect, write, refreshMeta); + const importPrelude = spans.filter( + ({ phase }) => + phase === PHASE.NETWORK_TOTAL || + phase === PHASE.JSON_TRANSFORM || + phase === PHASE.RESPONSE_READY || + phase === PHASE.SERIALIZE_PLAYLIST || + phase === PHASE.SQLITE_GENERIC_READ || + phase === PHASE.SQLITE_GENERIC_WRITE || + phase === PHASE.DESERIALIZE_PLAYLIST || + CATEGORY_PHASES.has(phase) + ); + if ( + importPrelude.length === 0 || + refreshMeta.endEpochMs > + Math.min(...importPrelude.map(({ startEpochMs }) => startEpochMs)) + ) { + invalid(); + } +} + +function assertFullImportTopology( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const categoryStores = select(spans, PHASE.STORE_PUBLISH_CATEGORIES); + if (categoryStores.length !== (routeHydratesAfterImport ? 2 : 1)) { + invalid(); + } + const categoryStore = required(categoryStores, 0); + assertCategoriesBeforeStore(spans, categoryStore); + const storeGroups = [ + select(spans, PHASE.STORE_PUBLISH_LIVE), + select(spans, PHASE.STORE_PUBLISH_VOD), + select(spans, PHASE.STORE_PUBLISH_SERIES), + ]; + const expectedStoreCount = routeHydratesAfterImport ? 2 : 1; + if (storeGroups.some(({ length }) => length !== expectedStoreCount)) { + invalid(); + } + const stores = storeGroups.map((group) => required(group, 0)); + const terminals = select(spans, PHASE.STORE_IMPORT_TERMINAL); + if (terminals.length !== expectedStoreCount) invalid(); + const terminal = required(terminals, 0); + const reads = select(spans, PHASE.SQLITE_CONTENT_READ); + const maps = select(spans, PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ); + const normalizations = select(spans, PHASE.NORMALIZE_CONTENT); + const writes = select(spans, PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS); + if ( + reads.length !== (routeHydratesAfterImport ? 9 : 6) || + maps.length !== 3 || + normalizations.length !== 3 || + writes.length !== 3 + ) { + invalid(); + } + ordered(categoryStore, reads[0]); + for (let index = 0; index < 3; index += 1) { + const preRead = reads[index * 2]; + const postRead = reads[index * 2 + 1]; + ordered( + preRead, + maps[index], + normalizations[index], + writes[index], + postRead, + stores[index] + ); + if (index < 2) ordered(stores[index], reads[(index + 1) * 2]); + } + ordered(stores[2], terminal); + if (routeHydratesAfterImport) { + assertXtreamRefreshRouteHydration( + spans, + terminal, + required(categoryStores, 1), + reads.slice(6), + storeGroups.map((group) => required(group, 1)), + required(terminals, 1) + ); + } else { + assertTerminalLast(spans, terminal); + } +} + +function assertCancelTopology(spans: readonly XtreamTopologyPhaseSpan[]): void { + const categoryStore = one(spans, PHASE.STORE_PUBLISH_CATEGORIES); + assertCategoriesBeforeStore(spans, categoryStore); + const read = one(spans, PHASE.SQLITE_CONTENT_READ); + const map = one(spans, PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ); + const normalization = one(spans, PHASE.NORMALIZE_CONTENT); + const write = one(spans, PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS); + const cancel = one(spans, PHASE.CANCEL_SESSION); + const cleanup = select( + spans, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS + ); + const terminal = one(spans, PHASE.STORE_IMPORT_TERMINAL); + if (cleanup.length !== 3) invalid(); + ordered(categoryStore, read, map, normalization, write); + if ( + cancel.startEpochMs >= write.endEpochMs || + cancel.endEpochMs <= write.startEpochMs + ) { + invalid(); + } + if ( + write.endEpochMs > cleanup[0].startEpochMs || + cancel.endEpochMs > cleanup[0].startEpochMs + ) { + invalid(); + } + ordered(cleanup[0], cleanup[1], cleanup[2], terminal); + assertTerminalLast(spans, terminal); +} + +function assertDeleteTopology(spans: readonly XtreamTopologyPhaseSpan[]): void { + const collects = select(spans, PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS); + const writes = select( + spans, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS + ); + const store = one(spans, PHASE.STORE_DELETE_ROW); + if (collects.length !== 2 || writes.length !== 2) invalid(); + ordered(collects[0], writes[0], store, collects[1], writes[1]); +} + +function assertCategoriesBeforeStore( + spans: readonly XtreamTopologyPhaseSpan[], + store: XtreamTopologyPhaseSpan +): void { + const categories = spans.filter( + ({ endEpochMs, phase }) => + CATEGORY_PHASES.has(phase) && endEpochMs <= store.startEpochMs + ); + if ( + categories.length !== 12 || + Math.max(...categories.map(({ endEpochMs }) => endEpochMs)) > + store.startEpochMs + ) { + invalid(); + } +} + +function assertTerminalLast( + spans: readonly XtreamTopologyPhaseSpan[], + terminal: XtreamTopologyPhaseSpan +): void { + if ( + spans.some( + (span) => + span !== terminal && span.endEpochMs > terminal.startEpochMs + ) + ) { + invalid(); + } +} + +function one( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan { + const matches = spans.filter((span) => span.phase === phase); + const match = matches[0]; + if (matches.length !== 1 || !match) invalid(); + return match; +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + if (!value) invalid(); + return value; +} + +function ordered(...spans: XtreamTopologyPhaseSpan[]): void { + for (let index = 1; index < spans.length; index += 1) { + if (spans[index - 1].endEpochMs > spans[index].startEpochMs) invalid(); + } +} + +function select( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan[] { + return spans + .filter((span) => span.phase === phase) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function sameCountMultiset( + left: readonly (number | null)[], + right: readonly (number | null)[] +): boolean { + const sort = (values: readonly (number | null)[]) => + [...values].sort( + (a, b) => (a === null ? -1 : a) - (b === null ? -1 : b) + ); + return JSON.stringify(sort(left)) === JSON.stringify(sort(right)); +} + +function invalid(): never { + throw new Error('invalid phase topology'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts new file mode 100644 index 000000000..186ee5f7e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import type { ElectronApplication } from '@playwright/test'; + +import { + captureXtreamLaunchIdentity, + createXtreamIterationProcessEvidence, +} from './xtream-process-evidence'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream process evidence', () => { + it('proves the Electron child and evaluated main PID are identical and fresh', async () => { + const seen = new Set(); + const first = await captureXtreamLaunchIdentity( + electronApp(41_001), + 41_001, + seen, + () => '00000000-0000-4000-8000-000000000001' + ); + const second = await captureXtreamLaunchIdentity( + electronApp(41_002), + 41_002, + seen, + () => '00000000-0000-4000-8000-000000000002' + ); + + assert.deepEqual(first, { + electronPid: 41_001, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + }); + assert.equal(second.electronPid, 41_002); + assert.deepEqual([...seen], [41_001, 41_002]); + await assert.rejects( + () => + captureXtreamLaunchIdentity(electronApp(41_001), 41_001, seen), + /xtream-electron-process-identity-invalid/ + ); + }); + + it('rejects a launcher PID that differs from the Electron main process', async () => { + await assert.rejects( + () => + captureXtreamLaunchIdentity( + electronApp(41_002), + 41_001, + new Set() + ), + /xtream-electron-process-identity-invalid/ + ); + }); + + it('hashes the canonical iteration directory outside diagnostics', async () => { + const root = await temporaryDirectory(); + const iterationDirectory = join(root, 'run-01'); + await mkdir(iterationDirectory); + + const evidence = await createXtreamIterationProcessEvidence({ + diagnosticDirectorySha256: null, + electronPid: 41_001, + iterationDirectory, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + }); + + assert.equal(evidence.freshProcessVerified, true); + assert.match(evidence.profileDirectorySha256, /^[a-f0-9]{64}$/u); + }); + + it('uses validated diagnostic directory evidence verbatim', async () => { + const root = await temporaryDirectory(); + const iterationDirectory = join(root, 'diagnostic'); + await mkdir(iterationDirectory); + const directorySha256 = 'a'.repeat(64); + + const evidence = await createXtreamIterationProcessEvidence({ + diagnosticDirectorySha256: directorySha256, + electronPid: 41_001, + iterationDirectory, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + }); + + assert.equal(evidence.profileDirectorySha256, directorySha256); + }); +}); + +function electronApp(pid: number): ElectronApplication { + return { + evaluate: async () => pid, + } as unknown as ElectronApplication; +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-process-evidence-') + ); + directories.push(directory); + return directory; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts new file mode 100644 index 000000000..3b25a80ec --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts @@ -0,0 +1,99 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, realpath } from 'node:fs/promises'; + +import type { ElectronApplication } from '@playwright/test'; + +import type { XtreamIterationProcessEvidence } from './xtream-iteration-assembler'; + +export interface XtreamLaunchIdentity { + readonly electronPid: number; + readonly launchId: string; +} + +export interface XtreamIterationProcessEvidenceOptions extends XtreamLaunchIdentity { + readonly diagnosticDirectorySha256: string | null; + readonly iterationDirectory: string; +} + +export async function captureXtreamLaunchIdentity( + electronApp: ElectronApplication, + launcherPid: number | undefined, + seenElectronPids: Set, + createUuid: () => string = randomUUID +): Promise { + try { + const evaluatedPid = await electronApp.evaluate( + async () => process.pid + ); + if ( + !Number.isSafeInteger(launcherPid) || + Number(launcherPid) <= 0 || + evaluatedPid !== launcherPid || + seenElectronPids.has(Number(launcherPid)) + ) { + invalidProcess(); + } + const launchId = `launch-${createUuid()}`; + if (!/^launch-[a-f0-9-]{36}$/u.test(launchId)) invalidProcess(); + seenElectronPids.add(Number(launcherPid)); + return Object.freeze({ + electronPid: Number(launcherPid), + launchId, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-electron-process-identity-invalid' + ) { + throw error; + } + invalidProcess(); + } +} + +export async function createXtreamIterationProcessEvidence( + options: XtreamIterationProcessEvidenceOptions +): Promise { + try { + if ( + !Number.isSafeInteger(options.electronPid) || + options.electronPid <= 0 || + !/^launch-[a-f0-9-]{36}$/u.test(options.launchId) + ) { + invalidProcess(); + } + const stats = await lstat(options.iterationDirectory); + if (!stats.isDirectory() || stats.isSymbolicLink()) invalidProcess(); + const canonicalDirectory = await realpath(options.iterationDirectory); + const pathSha256 = sha256(canonicalDirectory); + const profileDirectorySha256 = + options.diagnosticDirectorySha256 === null + ? pathSha256 + : options.diagnosticDirectorySha256; + if (!/^[a-f0-9]{64}$/u.test(profileDirectorySha256)) { + invalidProcess(); + } + return Object.freeze({ + electronPid: options.electronPid, + freshProcessVerified: true, + launchId: options.launchId, + profileDirectorySha256, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-electron-process-identity-invalid' + ) { + throw error; + } + invalidProcess(); + } +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function invalidProcess(): never { + throw new Error('xtream-electron-process-identity-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts new file mode 100644 index 000000000..7f1c8d689 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts @@ -0,0 +1,372 @@ +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const CATEGORIES = [ + { count: 60, type: SEMANTIC.LIVE_CATEGORIES }, + { count: 20, type: SEMANTIC.VOD_CATEGORIES }, + { count: 20, type: SEMANTIC.SERIES_CATEGORIES }, +] as const; +const CONTENT = [ + { + categoryCount: 60, + itemCount: 60_000, + storePhase: PHASE.STORE_PUBLISH_LIVE, + type: SEMANTIC.LIVE_CONTENT, + }, + { + categoryCount: 20, + itemCount: 20_000, + storePhase: PHASE.STORE_PUBLISH_VOD, + type: SEMANTIC.VOD_CONTENT, + }, + { + categoryCount: 20, + itemCount: 20_000, + storePhase: PHASE.STORE_PUBLISH_SERIES, + type: SEMANTIC.SERIES_CONTENT, + }, +] as const; +const CATEGORY_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, +]); +const CATEGORY_TYPES = new Set( + CATEGORIES.map(({ type }) => type) +); +const CONTENT_PHASES = new Set([ + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, +]); +const CONTENT_TYPES = new Set( + CONTENT.map(({ type }) => type) +); +const PROVIDER_PHASES = new Set([ + PHASE.JSON_TRANSFORM, + PHASE.NETWORK_TOTAL, + PHASE.RESPONSE_READY, +]); + +export function assertXtreamProviderCausality( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + assertSemanticOwnership(spans); + if (scenarioId === 'xtream-delete-large') return; + + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const accounts = semanticMany(spans, PHASE.NETWORK_TOTAL, SEMANTIC.ACCOUNT); + if (accounts.length !== 2) invalid(); + if ( + accounts.some( + (account, index) => + index > 0 && + required(accounts, index - 1).startEpochMs >= + account.startEpochMs + ) + ) { + invalid(); + } + const accountResponses = accounts.map((network) => + accountResponse(spans, network) + ); + // Fresh-add windows start the passive Sources check before route bootstrap, + // so the later response gates the import. Refresh windows first run the + // import and only then hydrate the recreated route from SQLite. + const account = required( + accountResponses, + routeHydratesAfterImport ? 0 : accountResponses.length - 1 + ); + const routeAccount = routeHydratesAfterImport + ? required(accountResponses, 1) + : null; + const routeAccountNetwork = routeHydratesAfterImport + ? required(accounts, 1) + : null; + const routeStore = routeHydratesAfterImport + ? required(phaseMany(spans, PHASE.STORE_PUBLISH_CATEGORIES), 1) + : null; + const terminal = routeHydratesAfterImport + ? required(phaseMany(spans, PHASE.STORE_IMPORT_TERMINAL), 0) + : null; + if ( + routeAccount && + routeAccountNetwork && + terminal && + terminal.endEpochMs > routeAccountNetwork.startEpochMs + ) { + invalid(); + } + for (const category of CATEGORIES) { + const preRead = semanticOne( + spans, + PHASE.SQLITE_CATEGORIES_READ, + category.type, + 0 + ); + const response = semanticOne( + spans, + PHASE.RESPONSE_READY, + category.type + ); + const normalization = semanticOne( + spans, + PHASE.NORMALIZE_CATEGORIES, + category.type, + category.count + ); + const write = semanticOne( + spans, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + category.type, + category.count + ); + const completedReads = semanticMany( + spans, + PHASE.SQLITE_CATEGORIES_READ, + category.type + ).filter(({ itemCount }) => itemCount === category.count); + if (completedReads.length !== (routeHydratesAfterImport ? 2 : 1)) { + invalid(); + } + const postRead = required(completedReads, 0); + ordered(account, preRead, response, normalization, write, postRead); + if (routeAccount && routeStore) { + ordered(routeAccount, required(completedReads, 1), routeStore); + } + } + + const expectedContent = + scenarioId === 'xtream-cancel-import' ? CONTENT.slice(0, 1) : CONTENT; + for (const content of expectedContent) { + assertContentCausality(spans, content, scenarioId); + } +} + +function accountResponse( + spans: readonly XtreamTopologyPhaseSpan[], + network: XtreamTopologyPhaseSpan +): XtreamTopologyPhaseSpan { + const transform = correlatedSemanticOne( + spans, + PHASE.JSON_TRANSFORM, + SEMANTIC.ACCOUNT, + network.correlationId + ); + const response = correlatedSemanticOne( + spans, + PHASE.RESPONSE_READY, + SEMANTIC.ACCOUNT, + network.correlationId + ); + if ( + network.startEpochMs > transform.startEpochMs || + transform.endEpochMs > network.endEpochMs || + network.endEpochMs > response.startEpochMs + ) { + invalid(); + } + return response; +} + +function assertContentCausality( + spans: readonly XtreamTopologyPhaseSpan[], + content: (typeof CONTENT)[number], + scenarioId: XtreamScenarioId +): void { + const categoryStore = required( + phaseMany(spans, PHASE.STORE_PUBLISH_CATEGORIES), + 0 + ); + const preRead = semanticOne( + spans, + PHASE.SQLITE_CONTENT_READ, + content.type, + 0 + ); + const response = semanticOne(spans, PHASE.RESPONSE_READY, content.type); + const categoryMap = semanticOne( + spans, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + content.type, + content.categoryCount + ); + const normalization = semanticOne( + spans, + PHASE.NORMALIZE_CONTENT, + content.type, + content.itemCount + ); + const write = semanticOne( + spans, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + content.type, + scenarioId === 'xtream-cancel-import' ? null : content.itemCount + ); + ordered( + categoryStore, + preRead, + response, + categoryMap, + normalization, + write + ); + if (scenarioId === 'xtream-cancel-import') return; + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const completedReads = semanticMany( + spans, + PHASE.SQLITE_CONTENT_READ, + content.type + ).filter(({ itemCount }) => itemCount === content.itemCount); + const stores = semanticMany(spans, content.storePhase, content.type).filter( + ({ itemCount }) => itemCount === content.itemCount + ); + const expectedCount = routeHydratesAfterImport ? 2 : 1; + if ( + completedReads.length !== expectedCount || + stores.length !== expectedCount + ) { + invalid(); + } + ordered(write, required(completedReads, 0), required(stores, 0)); + if (routeHydratesAfterImport) { + const routeCategoryStore = required( + phaseMany(spans, PHASE.STORE_PUBLISH_CATEGORIES), + 1 + ); + ordered( + routeCategoryStore, + required(completedReads, 1), + required(stores, 1) + ); + } +} + +function assertSemanticOwnership( + spans: readonly XtreamTopologyPhaseSpan[] +): void { + for (const span of spans) { + if (PROVIDER_PHASES.has(span.phase)) { + if (span.semanticType === null) invalid(); + continue; + } + if (CATEGORY_PHASES.has(span.phase)) { + if ( + span.semanticType === null || + !CATEGORY_TYPES.has(span.semanticType) + ) { + invalid(); + } + continue; + } + if (CONTENT_PHASES.has(span.phase)) { + if ( + span.semanticType === null || + !CONTENT_TYPES.has(span.semanticType) + ) { + invalid(); + } + continue; + } + const expectedStoreType = storeSemanticType(span.phase); + if ( + (expectedStoreType === null && span.semanticType !== null) || + (expectedStoreType !== null && + span.semanticType !== expectedStoreType) + ) { + invalid(); + } + } +} + +function storeSemanticType(phase: string): XtreamPhaseSemanticType | null { + if (phase === PHASE.STORE_PUBLISH_LIVE) return SEMANTIC.LIVE_CONTENT; + if (phase === PHASE.STORE_PUBLISH_VOD) return SEMANTIC.VOD_CONTENT; + if (phase === PHASE.STORE_PUBLISH_SERIES) return SEMANTIC.SERIES_CONTENT; + return null; +} + +function semanticOne( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string, + semanticType: XtreamPhaseSemanticType, + itemCount?: number | null +): XtreamTopologyPhaseSpan { + const matches = spans.filter( + (span) => + span.phase === phase && + span.semanticType === semanticType && + (itemCount === undefined || span.itemCount === itemCount) + ); + if (matches.length !== 1) invalid(); + return required(matches, 0); +} + +function semanticMany( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string, + semanticType: XtreamPhaseSemanticType +): XtreamTopologyPhaseSpan[] { + return spans + .filter( + (span) => span.phase === phase && span.semanticType === semanticType + ) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function phaseMany( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan[] { + return spans + .filter((span) => span.phase === phase) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function correlatedSemanticOne( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string, + semanticType: XtreamPhaseSemanticType, + correlationId: string +): XtreamTopologyPhaseSpan { + const matches = spans.filter( + (span) => + span.phase === phase && + span.semanticType === semanticType && + span.correlationId === correlationId + ); + if (matches.length !== 1) invalid(); + return required(matches, 0); +} + +function ordered(...spans: readonly XtreamTopologyPhaseSpan[]): void { + for (let index = 1; index < spans.length; index += 1) { + if ( + required(spans, index - 1).endEpochMs > + required(spans, index).startEpochMs + ) { + invalid(); + } + } +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + if (!value) invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid provider phase topology'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts b/apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts new file mode 100644 index 000000000..04fd6c1cb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path'; + +import { readXtreamSafeRegularFile } from './xtream-safe-file-read'; +import type { + XtreamPersistedRawEvidence, + XtreamPersistenceReceipt, +} from './xtream-summary-contract'; + +export async function verifyXtreamPersistenceReceipt( + serialized: string, + receipt: XtreamPersistenceReceipt +): Promise { + const bytes = Buffer.from(serialized, 'utf8'); + const rawSha256 = sha256(bytes); + try { + if (!hasExactReceiptShape(receipt)) { + invalidReceipt(); + } + const { + absolutePath, + bytes: persistedBytes, + sha256: persistedSha256, + } = receipt; + if ( + typeof absolutePath !== 'string' || + !isAbsolute(absolutePath) || + persistedBytes !== bytes.byteLength || + persistedSha256 !== rawSha256 + ) { + invalidReceipt(); + } + const { content: persisted, realPath: path } = + await readXtreamSafeRegularFile(absolutePath); + if (!persisted.equals(bytes)) invalidReceipt(); + return { + bytes: bytes.byteLength, + pathSha256: sha256(Buffer.from(path, 'utf8')), + rawSha256, + }; + } catch (error) { + if ( + error instanceof Error && + error.message === 'invalid Xtream persistence receipt' + ) { + throw error; + } + invalidReceipt(); + } +} + +function hasExactReceiptShape( + value: unknown +): value is XtreamPersistenceReceipt { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + return false; + } + const expected = ['absolutePath', 'bytes', 'sha256']; + const ownKeys = Reflect.ownKeys(value); + return ( + ownKeys.length === expected.length && + ownKeys.every( + (key) => typeof key === 'string' && expected.includes(key) + ) + ); + } catch { + return false; + } +} + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function invalidReceipt(): never { + throw new Error('invalid Xtream persistence receipt'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts new file mode 100644 index 000000000..4a660a41b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts @@ -0,0 +1,122 @@ +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const EXPECTED_ROUTE_CONTENT = [ + { + count: 60_000, + semanticType: 'live-content', + storePhase: PHASE.STORE_PUBLISH_LIVE, + }, + { + count: 20_000, + semanticType: 'vod-content', + storePhase: PHASE.STORE_PUBLISH_VOD, + }, + { + count: 20_000, + semanticType: 'series-content', + storePhase: PHASE.STORE_PUBLISH_SERIES, + }, +] as const; + +export function assertXtreamRefreshRouteHydration( + spans: readonly XtreamTopologyPhaseSpan[], + terminal: XtreamTopologyPhaseSpan, + routeCategoryStore: XtreamTopologyPhaseSpan, + routeContentReads: readonly XtreamTopologyPhaseSpan[], + routeContentStores: readonly XtreamTopologyPhaseSpan[], + routeTerminal: XtreamTopologyPhaseSpan +): void { + const categoryReads = select(spans, PHASE.SQLITE_CATEGORIES_READ).filter( + ({ startEpochMs }) => startEpochMs >= terminal.endEpochMs + ); + if ( + categoryReads.length !== 3 || + !sameCountMultiset( + categoryReads.map(({ itemCount }) => itemCount), + [60, 20, 20] + ) || + new Set(categoryReads.map(({ semanticType }) => semanticType)).size !== + 3 || + categoryReads.some( + ({ endEpochMs, startEpochMs }) => + terminal.endEpochMs > startEpochMs || + endEpochMs > routeCategoryStore.startEpochMs + ) + ) { + invalid(); + } + if ( + routeContentReads.length !== EXPECTED_ROUTE_CONTENT.length || + routeContentStores.length !== EXPECTED_ROUTE_CONTENT.length + ) { + invalid(); + } + const sequence = [routeCategoryStore]; + for (const [index, item] of EXPECTED_ROUTE_CONTENT.entries()) { + const read = required(routeContentReads, index); + const store = required(routeContentStores, index); + if ( + read.itemCount !== item.count || + read.semanticType !== item.semanticType || + store.phase !== item.storePhase || + store.itemCount !== item.count || + store.semanticType !== item.semanticType + ) { + invalid(); + } + sequence.push(read, store); + } + ordered(...sequence, routeTerminal); + assertTerminalLast(spans, routeTerminal); +} + +function assertTerminalLast( + spans: readonly XtreamTopologyPhaseSpan[], + terminal: XtreamTopologyPhaseSpan +): void { + if ( + spans.some( + (span) => + span !== terminal && span.endEpochMs > terminal.startEpochMs + ) + ) { + invalid(); + } +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + if (!value) invalid(); + return value; +} + +function ordered(...spans: XtreamTopologyPhaseSpan[]): void { + for (let index = 1; index < spans.length; index += 1) { + if (spans[index - 1].endEpochMs > spans[index].startEpochMs) invalid(); + } +} + +function select( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan[] { + return spans + .filter((span) => span.phase === phase) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function sameCountMultiset( + left: readonly (number | null)[], + right: readonly (number | null)[] +): boolean { + const sort = (values: readonly (number | null)[]) => + [...values].sort( + (a, b) => (a === null ? -1 : a) - (b === null ? -1 : b) + ); + return JSON.stringify(sort(left)) === JSON.stringify(sort(right)); +} + +function invalid(): never { + throw new Error('invalid phase topology'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts new file mode 100644 index 000000000..3ef5503ec --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface CaptureModule { + advanceXtreamRendererHeartbeatDeadline?: ( + deadlineEpochMs: number, + nowEpochMs: number + ) => number; + selectXtreamRendererTarget?: (session: FakeSession) => Promise; + startXtreamRendererCapture?: unknown; +} + +interface ArtifactModule { + assertRendererMeasurementWindow?: (input: { + readonly captureCutoffEpochMs: number; + readonly measurementStartEpochMs: number; + readonly operationStartEpochMs: number; + readonly terminalEpochMs: number; + }) => void; + selectRendererPeakHeapUsedBytes?: ( + samples: readonly { + readonly epochMs: number; + readonly usedSize: number; + }[], + measurementStartEpochMs: number, + terminalEpochMs: number + ) => number; +} + +interface TargetInfo { + readonly targetId: string; + readonly title: string; + readonly type: string; + readonly url: string; +} + +class FakeSession { + constructor(private readonly targets: readonly TargetInfo[]) {} + + async send(method: string): Promise> { + assert.equal(method, 'Target.getTargets'); + return { targetInfos: this.targets }; + } +} + +const captureUrl = new URL('./xtream-renderer-capture.ts', import.meta.url); +const artifactUrl = new URL( + './m3u-import-renderer-artifacts.ts', + import.meta.url +); +const modulePromise = import(captureUrl.href) + .then((module) => module as unknown as CaptureModule) + .catch(() => null); +const artifactModulePromise = import(artifactUrl.href) + .then((module) => module as unknown as ArtifactModule) + .catch(() => null); + +test('exports capture and persists the one exact IPTVnator renderer target', async () => { + const module = await modulePromise; + assert.ok(module, 'Xtream renderer capture module must exist'); + assert.equal(typeof module.startXtreamRendererCapture, 'function'); + assert.equal(typeof module.selectXtreamRendererTarget, 'function'); + + const targetId = await module.selectXtreamRendererTarget?.( + new FakeSession([ + { + targetId: 'renderer-7', + title: 'IPTVnator', + type: 'page', + url: 'http://localhost:4200/workspace/sources', + }, + { + targetId: 'devtools', + title: 'DevTools', + type: 'page', + url: 'devtools://devtools/bundled/inspector.html', + }, + ]) + ); + assert.equal(targetId, 'renderer-7'); +}); + +test('fails closed when renderer target identity is missing or ambiguous', async () => { + const module = await modulePromise; + assert.ok(module?.selectXtreamRendererTarget); + const target = { + targetId: 'renderer-1', + title: 'IPTVnator', + type: 'page', + url: 'http://localhost:4200/workspace/sources', + }; + + await assert.rejects( + module.selectXtreamRendererTarget(new FakeSession([])), + /xtream-renderer-target-count:0/ + ); + await assert.rejects( + module.selectXtreamRendererTarget( + new FakeSession([target, { ...target, targetId: 'renderer-2' }]) + ), + /xtream-renderer-target-count:2/ + ); +}); + +test('arms the renderer without sampling heap or starting diagnostics before the operation boundary', () => { + const source = readFileSync(captureUrl, 'utf8'); + const artifacts = readFileSync(artifactUrl, 'utf8'); + const operationBoundary = source.indexOf( + 'await beginXtreamRendererOperation(page)' + ); + const initialHeapSample = source.indexOf('await sampleHeap()'); + const diagnosticStart = source.indexOf( + 'await startRendererDiagnosticCapture(session, artifacts)' + ); + const heapTimerStart = source.indexOf( + 'setInterval(() => void sampleHeap(), 20)' + ); + + assert.ok(operationBoundary >= 0); + assert.ok(initialHeapSample > operationBoundary); + assert.ok(diagnosticStart > operationBoundary); + assert.ok(heapTimerStart > operationBoundary); + assert.equal(source.match(/await sampleHeap\(\)/g)?.length, 1); + assert.match(source, /HEARTBEAT_INTERVAL_MS\s*=\s*50/); + assert.match(source, /Runtime\.getHeapUsage/); + assert.match(source, /HeapProfiler\.collectGarbage/); + assert.match(source, /createRendererArtifactCapture/); + assert.match(source, /startRendererDiagnosticCapture/); + assert.match(source, /stopRendererDiagnosticCapture/); + assert.match(source, /takeRendererHeapSnapshot/); + assert.match(artifacts, /Profiler\.start/); + assert.match(artifacts, /Tracing\.start/); +}); + +test('bounds peak heap to timestamped samples inside the renderer operation window', async () => { + const artifacts = await artifactModulePromise; + assert.ok(artifacts?.selectRendererPeakHeapUsedBytes); + + assert.equal( + artifacts.selectRendererPeakHeapUsedBytes( + [ + { epochMs: 99, usedSize: 900 }, + { epochMs: 100, usedSize: 10 }, + { epochMs: 150, usedSize: 30 }, + { epochMs: 200, usedSize: 20 }, + { epochMs: 201, usedSize: 800 }, + ], + 100, + 200 + ), + 30 + ); + assert.throws( + () => + artifacts.selectRendererPeakHeapUsedBytes?.( + [{ epochMs: 201, usedSize: 800 }], + 100, + 200 + ), + /renderer-heap-window-empty/ + ); +}); + +test('validates and persists an explicit renderer measurement window', async () => { + const artifacts = await artifactModulePromise; + assert.ok(artifacts?.assertRendererMeasurementWindow); + + artifacts.assertRendererMeasurementWindow({ + captureCutoffEpochMs: 200, + measurementStartEpochMs: 110, + operationStartEpochMs: 100, + terminalEpochMs: 190, + }); + assert.throws( + () => + artifacts.assertRendererMeasurementWindow?.({ + captureCutoffEpochMs: 200, + measurementStartEpochMs: 99, + operationStartEpochMs: 100, + terminalEpochMs: 190, + }), + /renderer-measurement-window-invalid/ + ); + assert.throws( + () => + artifacts.assertRendererMeasurementWindow?.({ + captureCutoffEpochMs: 189, + measurementStartEpochMs: 110, + operationStartEpochMs: 100, + terminalEpochMs: 190, + }), + /renderer-measurement-window-invalid/ + ); + + const source = readFileSync(captureUrl, 'utf8'); + assert.match(source, /readonly measurementStartEpochMs: number/); + assert.match(source, /readonly captureCutoffEpochMs: number/); +}); + +test('cuts off scheduling and diagnostics before forced GC and snapshot work', () => { + const source = readFileSync(captureUrl, 'utf8'); + const stopCapture = source.indexOf('const stopCapture'); + const stopScheduling = source.indexOf('stopScheduling()', stopCapture); + const diagnosticStop = source.indexOf( + 'await stopRendererDiagnosticCapture(session, artifacts)', + stopCapture + ); + const forcedGc = source.indexOf( + "await session.send('HeapProfiler.collectGarbage')", + stopCapture + ); + const heapSnapshot = source.indexOf( + 'await takeRendererHeapSnapshot(', + stopCapture + ); + + assert.ok(stopCapture >= 0); + assert.ok(stopScheduling > stopCapture); + assert.ok(diagnosticStop > stopScheduling); + assert.ok(forcedGc > diagnosticStop); + assert.ok(heapSnapshot > forcedGc); +}); + +test('persists only error counts and cleans up listeners/session idempotently', () => { + const source = readFileSync(captureUrl, 'utf8'); + + assert.match(source, /consoleErrorCount/); + assert.match(source, /pageErrorCount/); + assert.doesNotMatch(source, /consoleErrorMessages|pageErrorMessages/); + assert.match(source, /page\.off\('console'/); + assert.match(source, /page\.off\('pageerror'/); + assert.match(source, /disposePromise/); +}); + +test('uses deadline-based heartbeat catch-up without accumulating drift', async () => { + const module = await modulePromise; + assert.ok(module?.advanceXtreamRendererHeartbeatDeadline); + + assert.equal(module.advanceXtreamRendererHeartbeatDeadline(150, 149), 200); + assert.equal(module.advanceXtreamRendererHeartbeatDeadline(150, 251), 300); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts new file mode 100644 index 000000000..71aa99b8a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts @@ -0,0 +1,384 @@ +import type { CDPSession, ConsoleMessage, Page } from '@playwright/test'; + +import type { + NumericDistribution, + XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + assertRendererMeasurementWindow, + createRendererArtifactCapture, + disposeRendererDiagnosticCapture, + type RendererArtifactCapture, + type RendererHeapUsageSample, + selectRendererPeakHeapUsedBytes, + startRendererDiagnosticCapture, + stopRendererDiagnosticCapture, + takeRendererHeapSnapshot, +} from './m3u-import-renderer-artifacts'; +import { summarizeNumbers } from './performance-statistics'; +import { + assertCompleteXtreamRendererProbe, + beginXtreamRendererOperation, + installXtreamRendererProbe, + recordXtreamRendererHeartbeat, + stopXtreamRendererProbe, + type XtreamRendererProbeMetrics, + waitForXtreamRendererTerminal, +} from './xtream-renderer-probe'; +import { + createXtreamRendererConsoleErrorBreakdown, + recordXtreamRendererConsoleError, + type XtreamRendererConsoleErrorBreakdown, +} from './xtream-renderer-console-errors'; + +const HEARTBEAT_INTERVAL_MS = 50; + +export interface XtreamRendererCaptureOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly scenarioId: XtreamScenarioId; + readonly sourceTitle: string; +} + +export interface XtreamRendererCaptureMetrics { + readonly captureCutoffEpochMs: number; + readonly consoleErrorBreakdown: XtreamRendererConsoleErrorBreakdown; + readonly consoleErrorCount: number; + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly measurementStartEpochMs: number; + readonly pageErrorCount: number; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number; + readonly probe: XtreamRendererProbeMetrics; + readonly storeToPaintMs: number; + readonly targetId: string; + readonly tracePath: string | null; +} + +export interface RunningXtreamRendererCapture { + beginOperation(): Promise; + dispose(): Promise; + readonly session: CDPSession; + stop(): Promise; + readonly targetId: string; + waitForTerminal(): Promise; +} + +interface HeapUsage { + readonly usedSize: number; +} + +interface TargetInfo { + readonly targetId: string; + readonly title: string; + readonly type: string; + readonly url: string; +} + +export async function selectXtreamRendererTarget( + session: Pick +): Promise { + const response = (await session.send('Target.getTargets')) as { + readonly targetInfos?: readonly TargetInfo[]; + }; + const matches = (response.targetInfos ?? []).filter( + ({ title, type, url }) => + type === 'page' && + !url.startsWith('devtools://') && + title.toLocaleLowerCase().includes('iptvnator') && + /\/workspace(?:\/|$)/.test(url) + ); + if (matches.length !== 1 || !matches[0]) { + throw new Error(`xtream-renderer-target-count:${matches.length}`); + } + return matches[0].targetId; +} + +export async function startXtreamRendererCapture( + page: Page, + options: XtreamRendererCaptureOptions +): Promise { + const session = await page.context().newCDPSession(page); + let targetId: string; + try { + targetId = await selectXtreamRendererTarget(session); + } catch (error) { + continueBestEffort(() => session.detach()); + throw error; + } + let consoleErrorCount = 0; + const consoleErrorBreakdown = createXtreamRendererConsoleErrorBreakdown(); + let pageErrorCount = 0; + const onConsole = (message: ConsoleMessage): void => { + if (message.type() !== 'error') return; + consoleErrorCount += 1; + recordXtreamRendererConsoleError( + consoleErrorBreakdown, + options.scenarioId, + message.text() + ); + }; + const onPageError = (): void => { + pageErrorCount += 1; + }; + page.on('console', onConsole); + page.on('pageerror', onPageError); + + let artifacts: RendererArtifactCapture | null = null; + let heapTimer: NodeJS.Timeout | null = null; + let heapInFlight: Promise | null = null; + let heapError: unknown = null; + let probeInstalled = false; + const heapSamples: RendererHeapUsageSample[] = []; + const sampleHeap = (): Promise => { + heapInFlight ??= (async () => { + try { + const sample = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsage; + if (Number.isFinite(sample.usedSize) && sample.usedSize >= 0) { + heapSamples.push({ + epochMs: Date.now(), + usedSize: sample.usedSize, + }); + } + } catch (error) { + heapError ??= error; + } finally { + heapInFlight = null; + } + })(); + return heapInFlight; + }; + + try { + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installXtreamRendererProbe(page, options); + probeInstalled = true; + artifacts = createRendererArtifactCapture(options); + } catch (error) { + if (heapTimer) clearInterval(heapTimer); + if (artifacts && options.diagnostic) { + await disposeRendererDiagnosticCapture(session, artifacts).catch( + () => undefined + ); + } + if (probeInstalled) { + continueBestEffort(() => stopXtreamRendererProbe(page)); + } + removePageErrorListeners(page, onConsole, onPageError); + continueBestEffort(() => session.detach()); + throw error; + } + if (!artifacts) { + throw new Error('xtream-renderer-capture-initialization-failed'); + } + + let stopped = false; + let operationStarted = false; + let measurementStartEpochMs: number | null = null; + let heartbeatDeadlineEpochMs: number | null = null; + let heartbeatError: unknown = null; + let heartbeatInFlight: Promise | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let stopPromise: Promise | null = null; + let disposePromise: Promise | null = null; + let detachPromise: Promise | null = null; + const detach = (): Promise => { + detachPromise ??= session.detach(); + return detachPromise; + }; + const stopScheduling = (): void => { + stopped = true; + if (heartbeatTimer) { + clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } + if (heapTimer) { + clearInterval(heapTimer); + heapTimer = null; + } + }; + const scheduleHeartbeat = (): void => { + if (stopped || heartbeatDeadlineEpochMs === null) return; + const deadline = heartbeatDeadlineEpochMs; + heartbeatTimer = setTimeout( + () => { + heartbeatTimer = null; + heartbeatInFlight = recordXtreamRendererHeartbeat( + page, + deadline + ) + .catch((error: unknown) => { + heartbeatError ??= error; + }) + .finally(() => { + heartbeatInFlight = null; + if (stopped) return; + heartbeatDeadlineEpochMs = + advanceXtreamRendererHeartbeatDeadline( + deadline, + Date.now() + ); + scheduleHeartbeat(); + }); + }, + Math.max(0, deadline - Date.now()) + ); + }; + const stopCapture = async (): Promise => { + try { + stopScheduling(); + const captureCutoffEpochMs = Date.now(); + if (measurementStartEpochMs === null) { + throw new Error('xtream-renderer-operation-not-started'); + } + if (options.diagnostic) { + await stopRendererDiagnosticCapture(session, artifacts); + } + await heartbeatInFlight; + await heapInFlight; + const probe = await stopXtreamRendererProbe(page); + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsage; + if (options.diagnostic) { + await takeRendererHeapSnapshot( + session, + artifacts.heapSnapshotPath + ); + } + await detach(); + removePageErrorListeners(page, onConsole, onPageError); + throwIfError(heapError, 'xtream-renderer-heap-sampling-failed'); + throwIfError(heartbeatError, 'xtream-renderer-heartbeat-failed'); + assertCompleteXtreamRendererProbe(probe); + assertRendererMeasurementWindow({ + captureCutoffEpochMs, + measurementStartEpochMs, + operationStartEpochMs: probe.operationStartEpochMs, + terminalEpochMs: probe.terminalEpochMs, + }); + return Object.freeze({ + captureCutoffEpochMs, + consoleErrorBreakdown: Object.freeze({ + ...consoleErrorBreakdown, + }), + consoleErrorCount, + cpuProfilePath: artifacts.cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath: artifacts.heapSnapshotPath, + heartbeatDelay: summarizeNumbers(probe.heartbeatDelaysMs), + longTask: summarizeNumbers(probe.longTasksMs), + measurementStartEpochMs, + pageErrorCount, + peakHeapUsedBytes: selectRendererPeakHeapUsedBytes( + heapSamples, + measurementStartEpochMs, + probe.terminalEpochMs + ), + postGcHeapUsedBytes: postGc.usedSize, + probe, + storeToPaintMs: + probe.uiPaintedEpochMs - probe.storeTerminalEpochMs, + targetId, + tracePath: artifacts.tracePath, + }); + } catch (error) { + await dispose(); + throw error; + } + }; + const dispose = (): Promise => { + disposePromise ??= (async () => { + stopScheduling(); + removePageErrorListeners(page, onConsole, onPageError); + continueBestEffort(() => heartbeatInFlight ?? Promise.resolve()); + continueBestEffort(() => heapInFlight ?? Promise.resolve()); + continueBestEffort(() => stopXtreamRendererProbe(page)); + if (options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + continueBestEffort(detach); + })(); + return disposePromise; + }; + + return Object.freeze({ + beginOperation: async () => { + if (stopped || operationStarted) { + throw new Error('xtream-renderer-operation-already-started'); + } + operationStarted = true; + const startedAt = await beginXtreamRendererOperation(page); + measurementStartEpochMs = startedAt; + if (options.diagnostic) { + await startRendererDiagnosticCapture(session, artifacts); + } + await sampleHeap(); + throwIfError(heapError, 'xtream-renderer-heap-sampling-failed'); + heapTimer = setInterval(() => void sampleHeap(), 20); + heartbeatDeadlineEpochMs = startedAt + HEARTBEAT_INTERVAL_MS; + scheduleHeartbeat(); + return startedAt; + }, + dispose, + session, + stop: () => { + if (disposePromise && !stopPromise) { + return Promise.reject( + new Error('xtream-renderer-capture-disposed') + ); + } + stopPromise ??= stopCapture(); + return stopPromise; + }, + targetId, + waitForTerminal: () => waitForXtreamRendererTerminal(page), + }); +} + +export function advanceXtreamRendererHeartbeatDeadline( + deadlineEpochMs: number, + nowEpochMs: number +): number { + const next = deadlineEpochMs + HEARTBEAT_INTERVAL_MS; + if (next > nowEpochMs) return next; + return ( + next + + (Math.floor((nowEpochMs - next) / HEARTBEAT_INTERVAL_MS) + 1) * + HEARTBEAT_INTERVAL_MS + ); +} + +function removePageErrorListeners( + page: Page, + onConsole: (message: ConsoleMessage) => void, + onPageError: () => void +): void { + page.off('console', onConsole); + page.off('pageerror', onPageError); +} + +function throwIfError(error: unknown, label: string): void { + if (error instanceof Error) throw error; + if (error !== null) throw new Error(`${label}:${String(error)}`); +} + +function continueBestEffort(work: () => Promise): void { + try { + void work().catch(() => undefined); + } catch { + // The owning Electron process is closed after failed-iteration cleanup. + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts new file mode 100644 index 000000000..3c696fee7 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + createXtreamRendererConsoleErrorBreakdown, + recordXtreamRendererConsoleError, + validXtreamRendererConsoleErrorEvidence, +} from './xtream-renderer-console-errors'; + +const cancelledInvoke = + 'Error invoking remote method DB_SAVE_CONTENT: AbortError: ' + + 'Operation "save-content" was cancelled'; + +describe('Xtream renderer console error evidence', () => { + it('accepts only the exact three current IPC cancellation errors', () => { + const exact = createXtreamRendererConsoleErrorBreakdown(); + for (const message of [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + `[withContent] Error initializing content ${cancelledInvoke}`, + ]) { + recordXtreamRendererConsoleError( + exact, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + message + ); + } + + assert.equal( + validXtreamRendererConsoleErrorEvidence( + 3, + exact, + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + true + ); + assert.equal( + validXtreamRendererConsoleErrorEvidence( + 3, + exact, + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + false + ); + + for (const messages of [ + ['generic error', 'generic error', 'generic error'], + [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + ], + [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + ], + [ + 'Error saving Xtream content: request failed', + '[withContent] Error fetching content request failed', + '[withContent] Error initializing content request failed', + ], + [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + `[withContent] Error initializing content ${cancelledInvoke}`, + 'unexpected fourth error', + ], + ]) { + const candidate = createXtreamRendererConsoleErrorBreakdown(); + for (const message of messages) { + recordXtreamRendererConsoleError( + candidate, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + message + ); + } + assert.equal( + validXtreamRendererConsoleErrorEvidence( + messages.length, + candidate, + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + false + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts new file mode 100644 index 000000000..f9bdcf75b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts @@ -0,0 +1,98 @@ +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; + +export interface XtreamRendererConsoleErrorBreakdown { + databaseSaveCancellation: number; + fetchContentCancellation: number; + initializationCancellation: number; + unexpected: number; +} + +type XtreamRendererConsoleErrorKind = keyof XtreamRendererConsoleErrorBreakdown; + +const CANCELLATION_SIGNATURE = 'Operation "save-content" was cancelled'; + +export function createXtreamRendererConsoleErrorBreakdown(): XtreamRendererConsoleErrorBreakdown { + return { + databaseSaveCancellation: 0, + fetchContentCancellation: 0, + initializationCancellation: 0, + unexpected: 0, + }; +} + +export function recordXtreamRendererConsoleError( + breakdown: XtreamRendererConsoleErrorBreakdown, + scenarioId: XtreamScenarioId, + message: string +): void { + const kind = classifyXtreamRendererConsoleError(scenarioId, message); + breakdown[kind] += 1; +} + +export function validXtreamRendererConsoleErrorEvidence( + total: number, + breakdown: XtreamRendererConsoleErrorBreakdown, + scenarioId: XtreamScenarioId +): boolean { + if ( + !Number.isSafeInteger(total) || + total < 0 || + !validBreakdownCounts(breakdown) || + total !== totalBreakdownCount(breakdown) + ) { + return false; + } + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + return ( + breakdown.databaseSaveCancellation === 1 && + breakdown.fetchContentCancellation === 1 && + breakdown.initializationCancellation === 1 && + breakdown.unexpected === 0 + ); + } + return total === 0; +} + +function classifyXtreamRendererConsoleError( + scenarioId: XtreamScenarioId, + message: string +): XtreamRendererConsoleErrorKind { + if ( + scenarioId !== XTREAM_SCENARIO_ID.CANCEL_IMPORT || + !message.includes(CANCELLATION_SIGNATURE) + ) { + return 'unexpected'; + } + if (message.startsWith('Error saving Xtream content:')) { + return 'databaseSaveCancellation'; + } + if (message.startsWith('[withContent] Error fetching content')) { + return 'fetchContentCancellation'; + } + if (message.startsWith('[withContent] Error initializing content')) { + return 'initializationCancellation'; + } + return 'unexpected'; +} + +function totalBreakdownCount( + breakdown: XtreamRendererConsoleErrorBreakdown +): number { + return ( + breakdown.databaseSaveCancellation + + breakdown.fetchContentCancellation + + breakdown.initializationCancellation + + breakdown.unexpected + ); +} + +function validBreakdownCounts( + breakdown: XtreamRendererConsoleErrorBreakdown +): boolean { + return Object.values(breakdown).every( + (value) => Number.isSafeInteger(value) && value >= 0 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts new file mode 100644 index 000000000..a78c75cfe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts @@ -0,0 +1,43 @@ +import type { RendererPerformancePhaseEvent } from '@iptvnator/shared/logging'; + +import type { XtreamScenarioId } from './xtream-benchmark-contract'; + +export const XTREAM_RENDERER_STATE_KEY = '__iptvnatorXtreamRendererCapture'; +export const XTREAM_RENDERER_STOPPED_KEY = + '__iptvnatorXtreamRendererCaptureStopped'; + +export interface XtreamRendererDbEvent { + readonly current: number | null; + readonly epochMs: number; + readonly increment: number | null; + readonly operation: string; + readonly operationId: string | null; + readonly phase: string | null; + readonly playlistId: string | null; + readonly status: string; + readonly total: number | null; +} + +export interface XtreamRendererProbeMetrics { + readonly cancelEligibleProgressEpochMs: number | null; + readonly cancellationClickEpochMs: number | null; + readonly dbCancellationTerminalEpochMs: number | null; + readonly dbOperationEvents: readonly XtreamRendererDbEvent[]; + readonly domReadyEpochMs: number | null; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly invalidReason: string | null; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[]; + readonly routeReadyEpochMs: number | null; + readonly scenarioId: XtreamScenarioId; + readonly storeTerminalEpochMs: number | null; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; +} + +export interface XtreamRendererProbeOptions { + readonly scenarioId: XtreamScenarioId; + readonly sourceTitle: string; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts new file mode 100644 index 000000000..5e6b73cfb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts @@ -0,0 +1,288 @@ +import type { Page } from '@playwright/test'; +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + XTREAM_RENDERER_STATE_KEY, + XTREAM_RENDERER_STOPPED_KEY, + type XtreamRendererProbeMetrics, +} from './xtream-renderer-probe-contract'; + +export async function beginXtreamRendererOperation( + page: Page +): Promise { + return page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + operationStartEpochMs: number; + operationStartMonotonicMs: number; + } + | undefined; + if (!state || state.operationStartEpochMs > 0) { + throw new Error('xtream-renderer-probe-unavailable'); + } + state.operationStartMonotonicMs = performance.now(); + state.operationStartEpochMs = + performance.timeOrigin + state.operationStartMonotonicMs; + return state.operationStartEpochMs; + }, XTREAM_RENDERER_STATE_KEY); +} + +export async function recordXtreamRendererHeartbeat( + page: Page, + deadlineEpochMs: number +): Promise { + await page.evaluate( + ({ deadlineEpochMs, stateKey }) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + terminalEpochMs: number | null; + } + | undefined; + if (!state || state.operationStartEpochMs <= 0) { + throw new Error('xtream-renderer-probe-unavailable'); + } + if ( + deadlineEpochMs >= state.operationStartEpochMs && + (state.terminalEpochMs === null || + deadlineEpochMs <= state.terminalEpochMs) + ) { + const received = performance.timeOrigin + performance.now(); + state.heartbeatDelaysMs.push( + Math.max( + 0, + Math.min(received, state.terminalEpochMs ?? received) - + deadlineEpochMs + ) + ); + } + }, + { deadlineEpochMs, stateKey: XTREAM_RENDERER_STATE_KEY } + ); +} + +export async function assertXtreamBackgroundDeleteActive( + page: Page +): Promise { + await page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + activeDeleteOperationId: string | null; + invalidReason: string | null; + } + | undefined; + if ( + !state || + state.invalidReason !== null || + typeof state.activeDeleteOperationId !== 'string' || + state.activeDeleteOperationId.length === 0 + ) { + throw new Error('xtream-background-delete-not-active'); + } + }, XTREAM_RENDERER_STATE_KEY); +} + +export async function assertXtreamBackgroundRefreshActive( + page: Page, + identity: { + readonly operationId: string; + readonly playlistId: string; + } +): Promise { + await page.evaluate( + ({ identity, stateKey }) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + dbOperationEvents: { + operation: string; + operationId: string | null; + playlistId: string | null; + status: string; + }[]; + invalidReason: string | null; + operationStartEpochMs: number; + scenarioId: string; + storeTerminalEpochMs: number | null; + terminalEpochMs: number | null; + } + | undefined; + const deleteEvents = + state?.dbOperationEvents.filter( + ({ operation }) => operation === 'delete-xtream-content' + ) ?? []; + const started = deleteEvents.filter( + ({ status }) => status === 'started' + ); + const terminal = deleteEvents.filter(({ status }) => + ['cancelled', 'completed', 'error'].includes(status) + ); + if ( + !state || + state.scenarioId !== 'xtream-background-ui' || + state.invalidReason !== null || + state.operationStartEpochMs <= 0 || + state.storeTerminalEpochMs !== null || + state.terminalEpochMs !== null || + started.length !== 1 || + terminal.length > 1 || + terminal.some(({ status }) => status !== 'completed') || + deleteEvents.some( + ({ operationId, playlistId }) => + operationId !== identity.operationId || + playlistId !== identity.playlistId + ) + ) { + throw new Error('xtream-background-refresh-not-active'); + } + }, + { identity, stateKey: XTREAM_RENDERER_STATE_KEY } + ); +} + +export async function waitForXtreamRendererTerminal(page: Page): Promise { + await page.waitForFunction( + (stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + invalidReason: string | null; + terminalEpochMs: number | null; + } + | undefined; + return ( + typeof state?.terminalEpochMs === 'number' || + typeof state?.invalidReason === 'string' + ); + }, + XTREAM_RENDERER_STATE_KEY, + { timeout: 120_000 } + ); + await page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as { invalidReason: string | null } | undefined; + if (!state) throw new Error('xtream-renderer-probe-unavailable'); + if (state.invalidReason) throw new Error(state.invalidReason); + }, XTREAM_RENDERER_STATE_KEY); +} + +export async function stopXtreamRendererProbe( + page: Page +): Promise { + return page.evaluate( + ({ hookKey, stateKey, stoppedKey }) => { + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const previous = target[stoppedKey] as + XtreamRendererProbeMetrics | undefined; + if (previous) return previous; + const state = target[stateKey] as + | (XtreamRendererProbeMetrics & { + dbUnsubscribe: (() => void) | null; + frameRequestId: number; + longTaskObserver: PerformanceObserver | null; + recordLongTask(entry: PerformanceEntry): void; + removeCancelClickListener: (() => void) | null; + }) + | undefined; + if (!state) { + throw new Error('xtream-renderer-probe-unavailable'); + } + cancelAnimationFrame(state.frameRequestId); + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + state.dbUnsubscribe?.(); + state.removeCancelClickListener?.(); + const snapshot: XtreamRendererProbeMetrics = { + cancelEligibleProgressEpochMs: + state.cancelEligibleProgressEpochMs, + cancellationClickEpochMs: state.cancellationClickEpochMs, + dbCancellationTerminalEpochMs: + state.dbCancellationTerminalEpochMs, + dbOperationEvents: [...state.dbOperationEvents], + domReadyEpochMs: state.domReadyEpochMs, + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + invalidReason: state.invalidReason, + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + performancePhaseEvents: [...state.performancePhaseEvents], + routeReadyEpochMs: state.routeReadyEpochMs, + scenarioId: state.scenarioId, + storeTerminalEpochMs: state.storeTerminalEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + }; + target[stoppedKey] = snapshot; + delete target[stateKey]; + delete target[Symbol.for(hookKey)]; + return snapshot; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: XTREAM_RENDERER_STATE_KEY, + stoppedKey: XTREAM_RENDERER_STOPPED_KEY, + } + ); +} + +export function assertCompleteXtreamRendererProbe( + probe: XtreamRendererProbeMetrics | null | undefined +): asserts probe is XtreamRendererProbeMetrics & { + readonly storeTerminalEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} { + const scenarios = new Set(Object.values(XTREAM_SCENARIO_ID)); + if ( + !probe || + !scenarios.has(probe.scenarioId) || + probe.invalidReason !== null || + !positive(probe.operationStartEpochMs) || + !positive(probe.routeReadyEpochMs) || + !positive(probe.storeTerminalEpochMs) || + !positive(probe.domReadyEpochMs) || + !positive(probe.uiPaintedEpochMs) || + !positive(probe.terminalEpochMs) || + probe.storeTerminalEpochMs < probe.operationStartEpochMs || + probe.routeReadyEpochMs < probe.operationStartEpochMs || + probe.domReadyEpochMs < probe.storeTerminalEpochMs || + probe.uiPaintedEpochMs < probe.storeTerminalEpochMs || + probe.uiPaintedEpochMs < probe.domReadyEpochMs || + probe.terminalEpochMs < probe.uiPaintedEpochMs + ) { + throw new Error('xtream-renderer-probe-incomplete'); + } + if ( + probe.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT && + (!positive(probe.cancelEligibleProgressEpochMs) || + !positive(probe.cancellationClickEpochMs) || + !positive(probe.dbCancellationTerminalEpochMs) || + probe.cancellationClickEpochMs < + probe.cancelEligibleProgressEpochMs || + probe.dbCancellationTerminalEpochMs < + probe.cancellationClickEpochMs || + probe.uiPaintedEpochMs < probe.dbCancellationTerminalEpochMs) + ) { + throw new Error('xtream-renderer-probe-incomplete'); + } +} + +function positive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts new file mode 100644 index 000000000..99047cbba --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; + +interface ProbeModule { + XTREAM_RENDERER_STATE_KEY?: string; + assertCompleteXtreamRendererProbe?: (value: unknown) => void; + assertXtreamBackgroundDeleteActive?: (page: unknown) => Promise; + assertXtreamBackgroundRefreshActive?: ( + page: unknown, + identity: { + readonly operationId: string; + readonly playlistId: string; + } + ) => Promise; + beginXtreamRendererOperation?: (page: unknown) => Promise; + installXtreamRendererProbe?: ( + page: unknown, + options: { + readonly scenarioId: string; + readonly sourceTitle: string; + } + ) => Promise; + recordXtreamRendererHeartbeat?: ( + page: unknown, + deadlineEpochMs: number + ) => Promise; + stopXtreamRendererProbe?: (page: unknown) => Promise; + waitForXtreamRendererTerminal?: (page: unknown) => Promise; +} + +const probeUrl = new URL('./xtream-renderer-probe.ts', import.meta.url); +const modulePromise = import(probeUrl.href) + .then((module) => module as unknown as ProbeModule) + .catch(() => null); + +test('exports the fail-closed Xtream renderer probe lifecycle', async () => { + const module = await modulePromise; + + assert.ok(module, 'Xtream renderer probe module must exist'); + assert.equal(typeof module.XTREAM_RENDERER_STATE_KEY, 'string'); + assert.equal(typeof module.installXtreamRendererProbe, 'function'); + assert.equal(typeof module.beginXtreamRendererOperation, 'function'); + assert.equal(typeof module.recordXtreamRendererHeartbeat, 'function'); + assert.equal(typeof module.waitForXtreamRendererTerminal, 'function'); + assert.equal(typeof module.stopXtreamRendererProbe, 'function'); + assert.equal(typeof module.assertCompleteXtreamRendererProbe, 'function'); + assert.equal(typeof module.assertXtreamBackgroundDeleteActive, 'function'); + assert.equal(typeof module.assertXtreamBackgroundRefreshActive, 'function'); +}); + +test('passively correlates the driver-owned cancel click with live DB progress', () => { + const source = readFileSync(probeUrl, 'utf8'); + + assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/); + assert.match(source, /onDbOperationEvent/); + assert.doesNotMatch(source, /event\.error/); + assert.match(source, /Math\.max\(\s*100,[\s\S]*Math\.floor/); + assert.match(source, /workspace-loading-overlay__action/); + assert.match(source, /addEventListener\('click'/); + assert.doesNotMatch(source, /\.click\(\)/); + assert.match(source, /Math\.min\(/); + assert.match(source, /requestAnimationFrame/); + assert.match(source, /longtask/); +}); + +test('requires a final store marker, DOM gate, and two rendered frames', () => { + const source = readFileSync(probeUrl, 'utf8'); + + assert.match(source, /store\.xtream-import-terminal/); + assert.match(source, /routeHydratesAfterImport/); + assert.match(source, /requiredStoreTerminalCount/); + assert.match(source, /routeHydratesAfterImport\s*\?\s*2\s*:\s*1/); + assert.match(source, /store\.xtream-delete-row/); + assert.match(source, /workspace-loading-overlay/); + assert.match(source, /xtream-content-gate/); + assert.match(source, /catalog/); + assert.match(source, /paintFrameCount/); + assert.match(source, />=\s*2/); +}); + +test('rejects incomplete and internally inconsistent terminal evidence', async () => { + const module = await modulePromise; + assert.ok(module?.assertCompleteXtreamRendererProbe); + const valid = completeProbe(); + + assert.doesNotThrow(() => + module.assertCompleteXtreamRendererProbe?.(valid) + ); + assert.throws( + () => + module.assertCompleteXtreamRendererProbe?.({ + ...valid, + terminalEpochMs: null, + }), + /xtream-renderer-probe-incomplete/ + ); + assert.throws( + () => + module.assertCompleteXtreamRendererProbe?.({ + ...valid, + uiPaintedEpochMs: 90, + }), + /xtream-renderer-probe-incomplete/ + ); +}); + +test('keeps a completed delete inside the identified refresh lifecycle until store terminal', async () => { + const module = await modulePromise; + assert.ok(module?.assertXtreamBackgroundRefreshActive); + assert.ok(module.XTREAM_RENDERER_STATE_KEY); + const stateKey = module.XTREAM_RENDERER_STATE_KEY; + const target = globalThis as unknown as Record; + const identity = { + operationId: 'delete-operation-1', + playlistId: 'playlist-1', + }; + const page = { + evaluate: async ( + callback: (argument: unknown) => unknown, + argument: unknown + ) => callback(argument), + }; + target[stateKey] = backgroundRefreshState(identity); + try { + await assert.doesNotReject(() => + module.assertXtreamBackgroundRefreshActive?.(page, identity) + ); + target[stateKey] = { + ...backgroundRefreshState(identity), + storeTerminalEpochMs: 200, + }; + await assert.rejects( + () => + module.assertXtreamBackgroundRefreshActive?.(page, identity) ?? + Promise.resolve(), + /xtream-background-refresh-not-active/ + ); + target[stateKey] = backgroundRefreshState({ + ...identity, + operationId: 'different-operation', + }); + await assert.rejects( + () => + module.assertXtreamBackgroundRefreshActive?.(page, identity) ?? + Promise.resolve(), + /xtream-background-refresh-not-active/ + ); + } finally { + delete target[stateKey]; + } +}); + +function completeProbe(): Record { + return { + cancellationClickEpochMs: null, + cancelEligibleProgressEpochMs: null, + dbCancellationTerminalEpochMs: null, + dbOperationEvents: [], + domReadyEpochMs: 150, + frameGapsMs: [16], + heartbeatDelaysMs: [2], + invalidReason: null, + longTasksMs: [], + operationStartEpochMs: 100, + performancePhaseEvents: [ + { + boundary: 'end', + epochMs: 140, + monotonicMs: 40, + outcome: 'success', + phase: 'store.xtream-import-terminal', + phaseId: 'terminal-1', + schemaVersion: 1, + }, + ], + routeReadyEpochMs: 120, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + storeTerminalEpochMs: 140, + terminalEpochMs: 160, + uiPaintedEpochMs: 160, + }; +} + +function backgroundRefreshState(identity: { + readonly operationId: string; + readonly playlistId: string; +}): Record { + return { + dbOperationEvents: [ + { + operation: 'delete-xtream-content', + ...identity, + status: 'started', + }, + { + operation: 'delete-xtream-content', + ...identity, + status: 'completed', + }, + ], + invalidReason: null, + operationStartEpochMs: 100, + scenarioId: XTREAM_SCENARIO_ID.BACKGROUND_UI, + storeTerminalEpochMs: null, + terminalEpochMs: null, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts new file mode 100644 index 000000000..a402b593e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -0,0 +1,409 @@ +/* eslint-disable max-lines -- Playwright serializes this self-contained renderer probe; external helpers would be unavailable in page context. */ +import type { Page } from '@playwright/test'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, + type RendererPerformancePhaseHook, +} from '@iptvnator/shared/logging'; + +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_RENDERER_STATE_KEY, + XTREAM_RENDERER_STOPPED_KEY, + type XtreamRendererDbEvent, + type XtreamRendererProbeOptions, +} from './xtream-renderer-probe-contract'; + +export * from './xtream-renderer-probe-contract'; + +export async function installXtreamRendererProbe( + page: Page, + options: XtreamRendererProbeOptions +): Promise { + await page.evaluate( + ({ hookKey, scenarioId, sourceTitle, stateKey, stoppedKey }) => { + interface DbEvent { + current?: number; + increment?: number; + operation?: string; + operationId?: string; + phase?: string; + playlistId?: string; + status?: string; + total?: number; + } + interface State { + activeDeleteOperationId: string | null; + cancelEligibleProgressEpochMs: number | null; + cancellationClickEpochMs: number | null; + dbCancellationTerminalEpochMs: number | null; + dbOperationEvents: XtreamRendererDbEvent[]; + dbUnsubscribe: (() => void) | null; + domReadyEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + invalidReason: string | null; + lastFrameAt: number | null; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + paintFrameCount: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + recordLongTask(entry: PerformanceEntry): void; + removeCancelClickListener: (() => void) | null; + routeReadyEpochMs: number | null; + saveContentOperationId: string | null; + scenarioId: XtreamScenarioId; + sourceTitle: string; + storeTerminalCount: number; + storeTerminalEpochMs: number | null; + terminalEpochMs: 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('xtream-renderer-probe-already-installed'); + } + delete target[stoppedKey]; + const epoch = (): number => + performance.timeOrigin + performance.now(); + const numberOrNull = (value: unknown): number | null => + typeof value === 'number' && + Number.isFinite(value) && + value >= 0 + ? value + : null; + const stringOrNull = (value: unknown): string | null => + typeof value === 'string' && value.length > 0 ? value : null; + const state: State = { + activeDeleteOperationId: null, + cancelEligibleProgressEpochMs: null, + cancellationClickEpochMs: null, + dbCancellationTerminalEpochMs: null, + dbOperationEvents: [], + dbUnsubscribe: null, + domReadyEpochMs: null, + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + invalidReason: null, + lastFrameAt: null, + longTaskObserver: null, + longTasksMs: [], + operationStartEpochMs: 0, + operationStartMonotonicMs: 0, + paintFrameCount: 0, + performancePhaseEvents: [], + 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 + ); + } + }, + removeCancelClickListener: null, + routeReadyEpochMs: null, + saveContentOperationId: null, + scenarioId, + sourceTitle, + storeTerminalCount: 0, + storeTerminalEpochMs: null, + terminalEpochMs: null, + uiPaintedEpochMs: null, + }; + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const requiredStoreTerminalCount = routeHydratesAfterImport ? 2 : 1; + const isVisible = (element: Element | null): boolean => + element instanceof HTMLElement && + element.getClientRects().length > 0; + const catalogVisible = (): boolean => + isVisible( + document.querySelector( + 'app-workspace-context-panel .category-item, ' + + '.content-card, [data-test-id="channel-item"], mat-card' + ) + ); + const overlayGone = (): boolean => + !isVisible( + document.querySelector('.workspace-loading-overlay') + ); + const sourceRowAbsent = (): boolean => + !Array.from( + document.querySelectorAll('app-playlist-item') + ).some( + (row) => + row + .querySelector('.playlist-title') + ?.textContent?.trim() === state.sourceTitle + ); + const terminalDomGate = (): boolean => { + if ( + state.storeTerminalEpochMs === null || + !overlayGone() || + state.invalidReason !== null + ) { + return false; + } + if (scenarioId === 'xtream-cancel-import') { + return ( + location.pathname.includes('/workspace/xtreams/') && + state.dbCancellationTerminalEpochMs !== null && + isVisible( + document.querySelector('.xtream-content-gate') + ) + ); + } + if (scenarioId === 'xtream-delete-large') { + return ( + sourceRowAbsent() && + !location.pathname.includes('/workspace/xtreams/') + ); + } + return ( + location.pathname.includes('/workspace/xtreams/') && + catalogVisible() + ); + }; + const phaseHook: RendererPerformancePhaseHook = (event) => { + state.performancePhaseEvents.push(event); + const requiredPhase = + scenarioId === 'xtream-delete-large' + ? 'store.xtream-delete-row' + : 'store.xtream-import-terminal'; + if ( + state.operationStartEpochMs > 0 && + event.boundary === 'end' && + event.outcome === 'success' && + event.phase === requiredPhase + ) { + state.storeTerminalCount += 1; + if ( + state.storeTerminalCount === requiredStoreTerminalCount + ) { + state.storeTerminalEpochMs = event.epochMs; + } else if ( + state.storeTerminalCount > requiredStoreTerminalCount + ) { + state.invalidReason ??= 'duplicate-store-terminal'; + } + } + }; + target[hookSymbol] = phaseHook; + const onDbEvent = (event: DbEvent): void => { + const sampledEpochMs = epoch(); + const operation = stringOrNull(event.operation); + const status = stringOrNull(event.status); + if (operation === null || status === null) { + state.invalidReason ??= 'invalid-db-operation-event'; + return; + } + const sanitized: XtreamRendererDbEvent = { + current: numberOrNull(event.current), + epochMs: sampledEpochMs, + increment: numberOrNull(event.increment), + operation, + operationId: stringOrNull(event.operationId), + phase: stringOrNull(event.phase), + playlistId: stringOrNull(event.playlistId), + status, + total: numberOrNull(event.total), + }; + state.dbOperationEvents.push(sanitized); + if (operation === 'delete-xtream-content') { + if (status === 'started' || status === 'progress') { + state.activeDeleteOperationId ??= sanitized.operationId; + if ( + state.activeDeleteOperationId === null || + sanitized.operationId !== + state.activeDeleteOperationId + ) { + state.invalidReason ??= + 'ambiguous-delete-xtream-content'; + } + } else if ( + sanitized.operationId === + state.activeDeleteOperationId && + ['cancelled', 'completed', 'error'].includes(status) + ) { + state.activeDeleteOperationId = null; + } + } + if ( + scenarioId !== 'xtream-cancel-import' || + operation !== 'save-content' || + state.operationStartEpochMs <= 0 + ) { + return; + } + if (status === 'started' || status === 'progress') { + state.saveContentOperationId ??= sanitized.operationId; + if ( + state.saveContentOperationId === null || + sanitized.operationId !== state.saveContentOperationId + ) { + state.invalidReason ??= 'ambiguous-save-content'; + return; + } + } + if ( + state.cancellationClickEpochMs !== null && + (status === 'completed' || status === 'started') + ) { + state.invalidReason ??= 'save-content-after-cancel'; + return; + } + if (status === 'cancelled') { + if ( + sanitized.operationId !== state.saveContentOperationId + ) { + state.invalidReason ??= 'ambiguous-save-content'; + return; + } + state.dbCancellationTerminalEpochMs = sampledEpochMs; + return; + } + const total = sanitized.total; + const current = sanitized.current; + if ( + status !== 'progress' || + total === null || + current === null || + current < Math.max(100, Math.floor(total * 0.1)) || + state.cancelEligibleProgressEpochMs !== null + ) { + return; + } + state.cancelEligibleProgressEpochMs = sampledEpochMs; + }; + const onCancelClick = (event: Event): void => { + const clicked = event.target; + if ( + scenarioId !== 'xtream-cancel-import' || + !(clicked instanceof Element) || + clicked.closest('.workspace-loading-overlay__action') === + null + ) { + return; + } + if ( + state.operationStartEpochMs <= 0 || + state.cancelEligibleProgressEpochMs === null + ) { + state.invalidReason ??= 'cancel-click-before-progress'; + return; + } + if (state.cancellationClickEpochMs !== null) { + state.invalidReason ??= 'duplicate-cancel-click'; + return; + } + state.cancellationClickEpochMs = epoch(); + }; + document.addEventListener('click', onCancelClick, true); + state.removeCancelClickListener = () => + document.removeEventListener('click', onCancelClick, true); + const bridge = ( + globalThis as unknown as { + electron?: { + onDbOperationEvent?: ( + listener: (event: DbEvent) => void + ) => () => void; + }; + } + ).electron; + if (typeof bridge?.onDbOperationEvent !== 'function') { + throw new Error('xtream-db-operation-listener-unavailable'); + } + state.dbUnsubscribe = bridge.onDbOperationEvent(onDbEvent); + const frame = (timestamp: number): void => { + if ( + state.lastFrameAt !== null && + state.operationStartEpochMs > 0 && + state.terminalEpochMs === null + ) { + const start = Math.max( + state.lastFrameAt, + state.operationStartMonotonicMs + ); + if (timestamp > start) + state.frameGapsMs.push(timestamp - start); + } + state.lastFrameAt = timestamp; + const routeReady = + scenarioId === 'xtream-delete-large' + ? location.pathname.endsWith('/workspace/sources') + : location.pathname.includes('/workspace/xtreams/'); + if (state.operationStartEpochMs > 0 && routeReady) { + state.routeReadyEpochMs ??= epoch(); + } + if (state.operationStartEpochMs > 0 && terminalDomGate()) { + state.domReadyEpochMs ??= epoch(); + state.paintFrameCount += 1; + if (state.paintFrameCount >= 2) { + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + } + } else { + state.paintFrameCount = 0; + } + 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, + scenarioId: options.scenarioId, + sourceTitle: options.sourceTitle, + stateKey: XTREAM_RENDERER_STATE_KEY, + stoppedKey: XTREAM_RENDERER_STOPPED_KEY, + } + ); +} + +export { + assertCompleteXtreamRendererProbe, + assertXtreamBackgroundDeleteActive, + assertXtreamBackgroundRefreshActive, + beginXtreamRendererOperation, + recordXtreamRendererHeartbeat, + stopXtreamRendererProbe, + waitForXtreamRendererTerminal, +} from './xtream-renderer-probe-control'; diff --git a/apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts b/apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts new file mode 100644 index 000000000..3236e38b5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts @@ -0,0 +1,88 @@ +import { constants, type BigIntStats } from 'node:fs'; +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises'; + +export interface XtreamSafeFileSnapshot { + readonly content: Buffer; + readonly realPath: string; +} + +const UNSAFE_FILE_ERROR = 'unsafe-xtream-evidence-file'; + +export async function readXtreamSafeRegularFile( + path: string +): Promise { + let handle: FileHandle | undefined; + try { + const pathBefore = await lstat(path, { bigint: true }); + assertSafeRegularFile(pathBefore); + handle = await open( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ); + const handleBefore = await handle.stat({ bigint: true }); + assertSafeRegularFile(handleBefore); + assertSameFile(pathBefore, handleBefore); + + const content = await handle.readFile(); + const handleAfter = await handle.stat({ bigint: true }); + assertSafeRegularFile(handleAfter); + assertUnchanged(handleBefore, handleAfter); + + const pathAfter = await lstat(path, { bigint: true }); + assertSafeRegularFile(pathAfter); + assertUnchanged(handleBefore, pathAfter); + const canonicalPath = await realpath(path); + const canonicalAfter = await lstat(canonicalPath, { bigint: true }); + assertSafeRegularFile(canonicalAfter); + assertUnchanged(handleBefore, canonicalAfter); + const pathFinal = await lstat(path, { bigint: true }); + assertSafeRegularFile(pathFinal); + assertUnchanged(handleBefore, pathFinal); + + return { content, realPath: canonicalPath }; + } catch { + throw new Error(UNSAFE_FILE_ERROR); + } finally { + await closeHandle(handle); + } +} + +export function isXtreamUnsafeFileError(error: unknown): boolean { + return error instanceof Error && error.message === UNSAFE_FILE_ERROR; +} + +function assertSafeRegularFile(stats: BigIntStats): void { + if ( + !stats.isFile() || + stats.isSymbolicLink() || + stats.nlink !== BigInt(1) + ) { + throw new Error(UNSAFE_FILE_ERROR); + } +} + +function assertSameFile(left: BigIntStats, right: BigIntStats): void { + if (left.dev !== right.dev || left.ino !== right.ino) { + throw new Error(UNSAFE_FILE_ERROR); + } +} + +function assertUnchanged(before: BigIntStats, after: BigIntStats): void { + assertSameFile(before, after); + if ( + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new Error(UNSAFE_FILE_ERROR); + } +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return; + try { + await handle.close(); + } catch { + throw new Error(UNSAFE_FILE_ERROR); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts new file mode 100644 index 000000000..613444345 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract'; +import { BUILD_IDENTITY } from './xtream-benchmark-report.fixtures'; +import { + assertXtreamBenchmarkManifest, + assertXtreamSafePathIdentifier, +} from './xtream-exact-schema'; + +describe('Xtream safe path identifiers', () => { + it('accepts portable benchmark path segments', () => { + for (const identifier of [ + 'run-01', + 'diagnostic', + 'xtream-refresh-large', + 'baseline.v1', + ]) { + assert.doesNotThrow(() => + assertXtreamSafePathIdentifier(identifier, 'run-id') + ); + } + }); + + it('rejects traversal, trailing dots, and Windows device basenames', () => { + for (const identifier of [ + '', + '.', + '..', + '../../outside', + 'run/02', + 'run\\02', + 'drive:stream', + 'run.', + 'CON', + 'con.json', + 'PRN.log', + 'AUX', + 'nul.txt', + 'COM1', + 'com9.cpuprofile', + 'LPT1', + 'lpt9.trace.json', + ]) { + assert.throws( + () => assertXtreamSafePathIdentifier(identifier, 'run-id'), + /^Error: benchmark requires a safe Xtream run-id$/ + ); + } + }); + + it('applies the same boundary to manifest variants before path use', () => { + assert.throws( + () => + assertXtreamBenchmarkManifest({ + 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: 1, + catalogSha256: 'a'.repeat(64), + 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: 'a'.repeat(40), + gitDiffSha256: 'b'.repeat(64), + gitDirty: false, + }, + suite: 'xtream-performance-100k', + variant: 'CON.json', + }), + /^Error: invalid Xtream benchmark manifest$/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts new file mode 100644 index 000000000..41b9fb348 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts @@ -0,0 +1,268 @@ +import type { Page } from '@playwright/test'; + +const CANCELLATION_STATE_KEY = '__iptvnatorXtreamScenarioCancellationObserver'; +const OPERATION_TIMEOUT_MS = 120_000; + +export interface XtreamDbOperationEventLike { + readonly current?: number; + readonly operation?: string; + readonly operationId?: string; + readonly status?: string; + readonly total?: number; +} + +export interface XtreamCancellationEvidence { + readonly armedEpochMs: number; + readonly clickEpochMs: number; + readonly current: number; + readonly operationId: string; + readonly progressEpochMs: number; + readonly threshold: number; + readonly total: number; +} + +export interface XtreamCancellationObserver { + dispose(): Promise; + waitForClick(): Promise; +} + +export function xtreamCancellationThreshold(total: number): number { + if (!Number.isFinite(total) || total <= 0) { + throw new Error('xtream-cancellation-total-invalid'); + } + return Math.max(100, Math.floor(total * 0.1)); +} + +export function isXtreamCancellationProgress(event: unknown): boolean { + if (!isRecord(event)) return false; + const current = event['current']; + const operationId = event['operationId']; + const total = event['total']; + return ( + event['operation'] === 'save-content' && + event['status'] === 'progress' && + typeof current === 'number' && + Number.isSafeInteger(current) && + typeof operationId === 'string' && + operationId.length > 0 && + typeof total === 'number' && + Number.isSafeInteger(total) && + total > 0 && + current <= total && + current >= xtreamCancellationThreshold(total) + ); +} + +export async function installXtreamCancellationObserver( + page: Page +): Promise { + await page.evaluate( + ({ command, stateKey }) => { + interface State { + armedEpochMs: number; + clickEpochMs: number | null; + current: number | null; + failure: string | null; + operationId: string | null; + progressEpochMs: number | null; + threshold: number | null; + total: number | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + if (command !== 'install-cancellation' || target[stateKey]) { + throw new Error('xtream-cancellation-observer-state-invalid'); + } + const electron = target['electron'] as + | { + onDbOperationEvent?: ( + callback: (event: Record) => void + ) => () => void; + } + | undefined; + if (typeof electron?.onDbOperationEvent !== 'function') { + throw new Error('xtream-db-operation-listener-unavailable'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: State = { + armedEpochMs: epoch(), + clickEpochMs: null, + current: null, + failure: null, + operationId: null, + progressEpochMs: null, + threshold: null, + total: null, + unsubscribe: () => undefined, + }; + state.unsubscribe = electron.onDbOperationEvent((event) => { + if (state.clickEpochMs !== null || state.failure !== null) { + return; + } + const current = event['current']; + const total = event['total']; + if ( + event['operation'] !== 'save-content' || + event['status'] !== 'progress' || + typeof current !== 'number' || + !Number.isSafeInteger(current) || + typeof total !== 'number' || + !Number.isSafeInteger(total) || + total <= 0 || + current > total + ) { + return; + } + const threshold = Math.max(100, Math.floor(total * 0.1)); + if (current < threshold) return; + state.current = current; + state.total = total; + state.threshold = threshold; + state.progressEpochMs = epoch(); + const operationId = event['operationId']; + if ( + typeof operationId !== 'string' || + operationId.length === 0 + ) { + state.failure = 'xtream-save-content-identity-missing'; + return; + } + state.operationId = operationId; + const button = document.querySelector( + '.workspace-loading-overlay__action' + ); + if ( + !(button instanceof HTMLButtonElement) || + !button.isConnected + ) { + state.failure = 'xtream-cancel-button-missing'; + return; + } + if (button.disabled) { + state.failure = 'xtream-cancel-button-disabled'; + return; + } + state.clickEpochMs = epoch(); + button.click(); + }); + target[stateKey] = state; + }, + { + command: 'install-cancellation', + stateKey: CANCELLATION_STATE_KEY, + } + ); + return { + dispose: () => disposeCancellationState(page), + async waitForClick() { + await page.waitForFunction( + ({ stateKey }) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { + clickEpochMs?: number | null; + failure?: string | null; + } + | undefined; + return Boolean( + state && + (typeof state.clickEpochMs === 'number' || + typeof state.failure === 'string') + ); + }, + { + stateKey: CANCELLATION_STATE_KEY, + waitFor: 'cancellation-click', + }, + { timeout: OPERATION_TIMEOUT_MS } + ); + return readCancellationEvidence(page); + }, + }; +} + +async function readCancellationEvidence( + page: Page +): Promise { + const value = await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + Record | undefined; + if (!state) throw new Error('xtream-cancellation-observer-missing'); + if (state['failure']) throw new Error(String(state['failure'])); + return { + armedEpochMs: state['armedEpochMs'], + clickEpochMs: state['clickEpochMs'], + current: state['current'], + operationId: state['operationId'], + progressEpochMs: state['progressEpochMs'], + threshold: state['threshold'], + total: state['total'], + }; + }, + { command: 'snapshot-cancellation', stateKey: CANCELLATION_STATE_KEY } + ); + if (!isCancellationEvidence(value)) { + throw new Error('xtream-cancellation-evidence-invalid'); + } + return value; +} + +async function disposeCancellationState(page: Page): Promise { + await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { unsubscribe?: () => void } | undefined; + state?.unsubscribe?.(); + delete target[stateKey]; + }, + { command: 'dispose-observer', stateKey: CANCELLATION_STATE_KEY } + ); +} + +function isCancellationEvidence( + value: unknown +): value is XtreamCancellationEvidence { + if (!isRecord(value)) return false; + const armedEpochMs = value['armedEpochMs']; + const clickEpochMs = value['clickEpochMs']; + const current = value['current']; + const operationId = value['operationId']; + const progressEpochMs = value['progressEpochMs']; + const threshold = value['threshold']; + const total = value['total']; + return ( + finitePositive(armedEpochMs) && + finitePositive(progressEpochMs) && + finitePositive(clickEpochMs) && + armedEpochMs <= progressEpochMs && + progressEpochMs <= clickEpochMs && + typeof operationId === 'string' && + operationId.length > 0 && + typeof total === 'number' && + Number.isSafeInteger(total) && + total > 0 && + typeof current === 'number' && + Number.isSafeInteger(current) && + current >= 0 && + current <= total && + typeof threshold === 'number' && + Number.isSafeInteger(threshold) && + threshold === xtreamCancellationThreshold(total) && + current >= threshold + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function finitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts new file mode 100644 index 000000000..abaa38811 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +const source = (name: string): string => + readFileSync(resolve(process.cwd(), 'src/performance', name), 'utf8'); + +describe('Xtream scenario driver source wiring', () => { + it('keeps each scenario module below the repository line limit', () => { + for (const name of [ + 'xtream-scenario-cancellation-observer.ts', + 'xtream-scenario-driver.ts', + 'xtream-scenario-driver.fake-page.ts', + 'xtream-scenario-operation-observer.ts', + 'xtream-scenario-ui.ts', + ]) { + assert.ok( + source(name).split('\n').length <= 400, + `${name} must stay at or below 400 lines` + ); + } + }); + + it('uses exact resilient UI selectors and deterministic terminal gates', () => { + const ui = source('xtream-scenario-ui.ts'); + + for (const selector of [ + "'Add playlist'", + '\'mat-button-toggle[value="xtream"]\'', + "'#title'", + "'#serverUrl'", + "'#username'", + "'#password'", + "'app-playlist-item'", + "'.refresh-btn'", + "'.delete-btn'", + "'Yes'", + "'.busy-state__message'", + "'.action-spinner'", + "'.cancel-btn'", + "'.workspace-loading-overlay'", + ]) { + assert.ok(ui.includes(selector), `missing ${selector}`); + } + assert.match(ui, /xtream.*credentials/i); + assert.match(ui, /name:\s*'Add',\s*exact:\s*true/); + assert.match(ui, /\/workspace\\\/xtreams/); + }); + + it('arms exact operation listeners with no timer-based coordination', () => { + const cancellation = source('xtream-scenario-cancellation-observer.ts'); + const observer = source('xtream-scenario-operation-observer.ts'); + const driver = source('xtream-scenario-driver.ts'); + const combined = `${cancellation}\n${observer}\n${driver}`; + + assert.ok(observer.includes('onDbOperationEvent')); + assert.ok(cancellation.includes('onDbOperationEvent')); + assert.ok(cancellation.includes("'save-content'")); + assert.ok(observer.includes("'delete-xtream-content'")); + assert.ok( + cancellation.includes("'.workspace-loading-overlay__action'") + ); + assert.match( + cancellation, + /Math\.max\(100,\s*Math\.floor\(total \* 0\.1\)\)/ + ); + assert.ok(driver.includes('installXtreamCancellationObserver')); + assert.ok(driver.includes('installXtreamOperationWindow')); + assert.ok(driver.includes('runBackgroundProbe')); + assert.doesNotMatch(combined, /waitForTimeout\s*\(/); + assert.doesNotMatch(combined, /setTimeout\s*\(/); + assert.doesNotMatch(combined, /\bsleep\s*\(/); + }); + + it('finishes add-dialog setup before arming a seed capture', () => { + const support = source('xtream-benchmark-iteration-support.ts'); + const seedStart = support.indexOf( + 'export async function seedXtreamExistingPortal' + ); + const prepare = support.indexOf( + 'await prepareXtreamScenario', + seedStart + ); + const capture = support.indexOf('await startMainCapture', seedStart); + const trigger = support.indexOf('await seed.trigger()', seedStart); + + assert.ok(seedStart >= 0); + assert.ok(prepare > seedStart); + assert.ok(capture > prepare); + assert.ok(trigger > capture); + }); + + it('fences seed settlement on the exact renderer store terminal', () => { + const support = source('xtream-benchmark-iteration-support.ts'); + const seedStart = support.indexOf( + 'export async function seedXtreamExistingPortal' + ); + const install = support.indexOf( + 'await installXtreamRendererProbe', + seedStart + ); + const capture = support.indexOf('await startMainCapture', seedStart); + const begin = support.indexOf( + 'await beginXtreamRendererOperation', + seedStart + ); + const trigger = support.indexOf('await seed.trigger()', seedStart); + const terminal = support.indexOf( + 'await waitForXtreamRendererTerminal', + seedStart + ); + const complete = support.indexOf( + 'assertCompleteXtreamRendererProbe', + terminal + ); + const settlement = support.indexOf( + 'await assertXtreamSettlementAfterTerminal', + terminal + ); + + assert.ok(install > seedStart); + assert.ok(capture > install); + assert.ok(begin > capture); + assert.ok(trigger > begin); + assert.ok(terminal > trigger); + assert.ok(complete > terminal); + assert.ok(settlement > complete); + }); + + it('prearms the persistent database worker outside the measured capture', () => { + const iteration = source('xtream-benchmark-iteration.ts'); + const support = source('xtream-benchmark-iteration-support.ts'); + const install = iteration.indexOf( + 'await installMainCapture(app.electronApp)' + ); + const prearm = iteration.indexOf( + 'await prearmXtreamDatabaseWorker(app)' + ); + const captureOptions = iteration.indexOf( + 'const captureOptions: MainCaptureStartOptions' + ); + + assert.match( + support, + /export async function prearmXtreamDatabaseWorker/ + ); + assert.match(support, /window\.electron\.dbGetAppPlaylists\(\)/); + assert.ok(install >= 0); + assert.ok(prearm > install); + assert.ok(captureOptions > prearm); + }); + + it('subscribes the renderer probe before the cancellation observer is armed', () => { + const iteration = source('xtream-benchmark-iteration.ts'); + const renderer = iteration.indexOf('await startXtreamRendererCapture'); + const operation = iteration.indexOf( + 'await renderer.beginOperation()', + renderer + ); + const trigger = iteration.indexOf( + 'trigger = await prepared.trigger()', + operation + ); + + assert.ok(renderer >= 0); + assert.ok(operation > renderer); + assert.ok(trigger > operation); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts new file mode 100644 index 000000000..db793d438 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts @@ -0,0 +1,131 @@ +import type { Page } from '@playwright/test'; + +export class FakeXtreamScenarioPage { + readonly log: string[] = []; + operationSnapshot: unknown = null; + readonly operationSnapshots: unknown[] = []; + readonly asPageReference: Page; + cancellationSnapshot: Record = { + armedEpochMs: 100, + clickEpochMs: 201, + current: 1_000, + operationId: 'save-content-1', + progressEpochMs: 200, + threshold: 1_000, + total: 10_000, + }; + + constructor() { + this.asPageReference = this.createPage(); + } + + asPage(): Page { + return this.asPageReference; + } + + clicksMatching(fragment: string): number { + return this.log.filter( + (entry) => entry.startsWith('click:') && entry.includes(fragment) + ).length; + } + + private createPage(): Page { + return { + evaluate: async (_callback: unknown, argument: unknown) => { + const command = recordValue(argument, 'command'); + this.log.push(`evaluate:${String(command)}`); + if (command === 'snapshot-operation') { + return ( + this.operationSnapshots.shift() ?? + this.operationSnapshot + ); + } + if (command === 'snapshot-cancellation') { + return this.cancellationSnapshot; + } + return undefined; + }, + getByRole: (role: string, options?: unknown) => + new FakeLocator( + this, + `page.role(${role};${roleOptions(options)})` + ), + locator: (selector: string) => new FakeLocator(this, selector), + waitForFunction: async (_callback: unknown, argument: unknown) => { + this.log.push( + `waitForFunction:${String( + recordValue(argument, 'waitFor') + )}` + ); + }, + waitForURL: async (url: unknown) => { + this.log.push(`waitForURL:${String(url)}`); + }, + } as unknown as Page; + } +} + +class FakeLocator { + constructor( + private readonly page: FakeXtreamScenarioPage, + private readonly path: string + ) {} + + first(): FakeLocator { + return new FakeLocator(this.page, `${this.path}.first`); + } + + last(): FakeLocator { + return new FakeLocator(this.page, `${this.path}.last`); + } + + filter(options: unknown): FakeLocator { + return new FakeLocator( + this.page, + `${this.path}.filter(${String(recordValue(options, 'hasText'))})` + ); + } + + getByRole(role: string, options?: unknown): FakeLocator { + return new FakeLocator( + this.page, + `${this.path}.role(${role};${roleOptions(options)})` + ); + } + + locator(selector: string): FakeLocator { + return new FakeLocator(this.page, `${this.path} >> ${selector}`); + } + + async click(): Promise { + this.page.log.push(`click:${this.path}`); + } + + async count(): Promise { + return 1; + } + + async fill(value: string): Promise { + const selector = this.path.split(' >> ').at(-1) ?? this.path; + this.page.log.push(`fill:${selector}:${value}`); + } + + async isDisabled(): Promise { + return false; + } + + async waitFor(options: { readonly state: string }): Promise { + this.page.log.push(`waitFor:${this.path}:${options.state}`); + } +} + +function recordValue(value: unknown, key: string): unknown { + if (typeof value !== 'object' || value === null) return undefined; + return Reflect.get(value, key); +} + +function roleOptions(value: unknown): string { + const name = recordValue(value, 'name'); + const exact = recordValue(value, 'exact'); + return `name=${name instanceof RegExp ? name.toString() : String(name)};exact=${String(exact)}`; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts new file mode 100644 index 000000000..fdd9df3b2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + expectedXtreamWorkerRequestInventory, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT, + XTREAM_SYNTHETIC_CREDENTIALS, + prepareXtreamScenario, +} from './xtream-scenario-driver'; +import { + installXtreamCancellationObserver, + isXtreamCancellationProgress, + xtreamCancellationThreshold, +} from './xtream-scenario-cancellation-observer'; +import { FakeXtreamScenarioPage as FakePage } from './xtream-scenario-driver.fake-page'; + +const SOURCE_TITLE = 'Synthetic Xtream performance portal'; +const SERVER_URL = 'http://127.0.0.1:43123'; + +describe('Xtream Playwright scenario driver', () => { + it('pins local synthetic credentials and exact database request counts', () => { + assert.deepEqual(XTREAM_SYNTHETIC_CREDENTIALS, { + password: 'performance', + username: 'performance', + }); + assert.deepEqual(XTREAM_SCENARIO_DATABASE_REQUEST_COUNT, { + 'xtream-background-ui': 54, + 'xtream-cancel-import': 24, + 'xtream-delete-large': 2, + 'xtream-initial-import-large': 30, + 'xtream-refresh-large': 51, + }); + + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const inventoryCount = expectedXtreamWorkerRequestInventory( + scenarioId + ).reduce((sum, entry) => sum + entry.count, 0); + assert.equal( + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT[scenarioId], + inventoryCount + ); + } + }); + + it('accepts only the first qualifying live save-content progress event', () => { + assert.equal(xtreamCancellationThreshold(999), 100); + assert.equal(xtreamCancellationThreshold(10_000), 1_000); + assert.equal( + isXtreamCancellationProgress({ + current: 999, + operation: 'save-content', + status: 'progress', + total: 10_000, + }), + false + ); + assert.equal( + isXtreamCancellationProgress({ + current: 1_000, + operation: 'save-content', + operationId: 'save-content-1', + status: 'progress', + total: 10_000, + }), + true + ); + assert.equal( + isXtreamCancellationProgress({ + current: 10_000, + operation: 'delete-xtream-content', + status: 'progress', + total: 10_000, + }), + false + ); + assert.equal( + isXtreamCancellationProgress({ + current: 10_000, + operation: 'save-content', + status: 'completed', + total: 10_000, + }), + false + ); + }); + + it('retains exact cancellation click identity and rejects anonymous evidence', async () => { + const fake = new FakePage(); + const observer = await installXtreamCancellationObserver(fake.asPage()); + + assert.deepEqual(await observer.waitForClick(), { + armedEpochMs: 100, + clickEpochMs: 201, + current: 1_000, + operationId: 'save-content-1', + progressEpochMs: 200, + threshold: 1_000, + total: 10_000, + }); + + const anonymous = new FakePage(); + anonymous.cancellationSnapshot = { + ...anonymous.cancellationSnapshot, + operationId: null, + }; + const anonymousObserver = await installXtreamCancellationObserver( + anonymous.asPage() + ); + await assert.rejects( + () => anonymousObserver.waitForClick(), + /cancellation-evidence-invalid/ + ); + }); + + it('prepares initial import without clicking the final Add trigger', async () => { + const fake = new FakePage(); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE) + ); + + assert.equal(prepared.expectedDatabaseRequestCount, 30); + assert.equal(prepared.backgroundOperation, null); + assert.equal(fake.clicksMatching('name=Add;exact=true'), 0); + assert.ok( + fake.log.includes('fill:#title:Synthetic Xtream performance portal') + ); + assert.ok(fake.log.includes('fill:#serverUrl:http://127.0.0.1:43123')); + assert.ok(fake.log.includes('fill:#username:performance')); + assert.ok(fake.log.includes('fill:#password:performance')); + const dashboardReadyIndex = fake.log.indexOf( + 'waitForFunction:empty-dashboard-ready' + ); + const openDialogIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Add playlist;exact=true') + ); + const openSourcesIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Sources;exact=true') + ); + assert.ok(dashboardReadyIndex >= 0); + assert.ok(openSourcesIndex > dashboardReadyIndex); + assert.ok(openDialogIndex > openSourcesIndex); + assert.ok(fake.log.includes('waitFor:app-workspace-sources:visible')); + + const evidence = await prepared.trigger(); + assert.ok(Number.isFinite(evidence.triggerEpochMs)); + assert.equal(fake.clicksMatching('name=Add;exact=true'), 1); + await assert.rejects(() => prepared.trigger(), /already-triggered/); + }); + + it('does not require a misleading portal-status badge before refresh or delete', async () => { + for (const [scenarioId, selector] of [ + [XTREAM_SCENARIO_ID.REFRESH_LARGE, '.refresh-btn'], + [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn'], + ] as const) { + const fake = new FakePage(); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(scenarioId) + ); + + assert.equal(fake.clicksMatching(selector), 1); + assert.equal(fake.clicksMatching('name=Yes;exact=true'), 0); + const portalReadyIndex = fake.log.findIndex((entry) => + entry.includes('[aria-label="Portal status: active"]:visible') + ); + const sourceActionIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && entry.includes(selector) + ); + assert.equal(portalReadyIndex, -1); + assert.ok(sourceActionIndex >= 0); + await prepared.trigger(); + assert.equal(fake.clicksMatching('name=Yes;exact=true'), 1); + } + }); + + it('arms cancellation after preparation and immediately before the final Add trigger', async () => { + const fake = new FakePage(); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.CANCEL_IMPORT) + ); + const openIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Add playlist;exact=true') + ); + + assert.equal( + fake.log.indexOf('evaluate:install-cancellation'), + -1, + 'the renderer probe must be able to subscribe before cancellation' + ); + await prepared.trigger(); + const installIndex = fake.log.indexOf('evaluate:install-cancellation'); + const triggerIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Add;exact=true') + ); + assert.ok(installIndex > openIndex); + assert.ok(triggerIndex > installIndex); + assert.equal(prepared.expectedDatabaseRequestCount, 24); + }); + + it('uses the exact delete request as the causal start of the full refresh probe', async () => { + const fake = new FakePage(); + fake.operationSnapshot = { + active: true, + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-1', + playlistId: 'playlist-1', + startedEpochMs: 100, + terminalEpochMs: null, + terminalStatus: null, + }; + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.BACKGROUND_UI) + ); + await prepared.trigger(); + const calls: string[] = []; + const evidence = await prepared.runBackgroundProbe( + async ({ operation, page, sourceTitle }) => { + calls.push('type-filter'); + calls.push('dashboard'); + calls.push('sources'); + calls.push('name-a-z'); + calls.push('global-search'); + assert.equal(operation, 'delete-xtream-content'); + assert.equal(page, fake.asPageReference); + assert.equal(sourceTitle, SOURCE_TITLE); + return { samples: 5 }; + } + ); + + assert.deepEqual(calls, [ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', + ]); + assert.equal(evidence.operation, 'delete-xtream-content'); + assert.deepEqual(evidence.result, { samples: 5 }); + assert.ok( + fake.log.some( + (entry) => entry === 'waitForFunction:operation-active' + ) + ); + }); + + it('rejects non-loopback origins before touching the page', async () => { + const fake = new FakePage(); + + await assert.rejects( + () => + prepareXtreamScenario(fake.asPage(), { + ...scenarioOptions(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE), + serverUrl: 'https://provider.example', + }), + /loopback mock origin/ + ); + assert.deepEqual(fake.log, []); + }); + + it('allows the refresh probe to continue after the delete prelude closes', async () => { + const fake = new FakePage(); + const active = { + active: true, + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-1', + playlistId: 'playlist-1', + startedEpochMs: 100, + terminalEpochMs: null, + terminalStatus: null, + } as const; + fake.operationSnapshots.push(active, active, { + ...active, + active: false, + terminalEpochMs: 150, + terminalStatus: 'completed', + }); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.BACKGROUND_UI) + ); + await prepared.trigger(); + let probeCalls = 0; + + await prepared.runBackgroundProbe(async () => { + probeCalls += 1; + return null; + }); + assert.equal(probeCalls, 1); + }); + + it('fails closed before the probe when delete request identity is missing', async () => { + const fake = new FakePage(); + fake.operationSnapshot = { + active: true, + operation: 'delete-xtream-content', + operationId: null, + playlistId: null, + startedEpochMs: 100, + terminalEpochMs: null, + terminalStatus: null, + }; + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.BACKGROUND_UI) + ); + await prepared.trigger(); + let probeCalls = 0; + + await assert.rejects( + () => + prepared.runBackgroundProbe(async () => { + probeCalls += 1; + }), + /operation-window-invalid/ + ); + assert.equal(probeCalls, 0); + }); +}); + +function scenarioOptions(scenarioId: XtreamScenarioId) { + return { + scenarioId, + serverUrl: SERVER_URL, + sourceTitle: SOURCE_TITLE, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts new file mode 100644 index 000000000..2f87c7c7b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts @@ -0,0 +1,329 @@ +import type { Locator, Page } from '@playwright/test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + installXtreamCancellationObserver, + type XtreamCancellationEvidence, + type XtreamCancellationObserver, +} from './xtream-scenario-cancellation-observer'; +import { + installXtreamOperationWindow, + type XtreamOperationWindow, + type XtreamOperationWindowSnapshot, +} from './xtream-scenario-operation-observer'; +import { + prepareXtreamAddTrigger, + prepareXtreamSourceActionTrigger, + waitForXtreamCancellationTerminal, + waitForXtreamCatalogTerminal, + waitForXtreamDeleteTerminal, +} from './xtream-scenario-ui'; +import { assertXtreamMockOrigin } from './xtream-control-client'; +import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; + +export const XTREAM_SYNTHETIC_CREDENTIALS = Object.freeze({ + password: 'performance', + username: 'performance', +} as const); + +export const XTREAM_SCENARIO_DATABASE_REQUEST_COUNT: Readonly< + Record +> = Object.freeze({ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: 30, + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: 51, + [XTREAM_SCENARIO_ID.DELETE_LARGE]: 2, + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: 24, + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: 54, +}); + +export interface PrepareXtreamScenarioOptions { + readonly scenarioId: XtreamScenarioId; + readonly serverUrl: string; + readonly sourceTitle: string; +} + +export interface XtreamBackgroundActionContext { + readonly operation: 'delete-xtream-content'; + readonly operationId: string; + readonly page: Page; + readonly playlistId: string; + readonly sourceTitle: string; +} + +export interface XtreamBackgroundProbeEvidence { + readonly completedEpochMs: number; + readonly operation: 'delete-xtream-content'; + readonly operationId: string; + readonly playlistId: string; + readonly result: Result; + readonly startedEpochMs: number; +} + +export type XtreamBackgroundProbe = ( + context: XtreamBackgroundActionContext +) => Promise; + +export interface XtreamScenarioTriggerEvidence { + readonly scenarioId: XtreamScenarioId; + readonly triggerEpochMs: number; +} + +export interface XtreamScenarioTerminalEvidence { + readonly backgroundProbe: XtreamBackgroundProbeEvidence | null; + readonly cancellation: XtreamCancellationEvidence | null; + readonly operation: XtreamOperationWindowSnapshot | null; + readonly scenarioId: XtreamScenarioId; + readonly terminalEpochMs: number; +} + +export interface PreparedXtreamScenario { + readonly backgroundOperation: XtreamOperationWindow | null; + readonly expectedDatabaseRequestCount: number; + readonly scenarioId: XtreamScenarioId; + readonly sourceTitle: string; + dispose(): Promise; + runBackgroundProbe( + probe: XtreamBackgroundProbe + ): Promise>; + trigger(): Promise; + waitForTerminal(): Promise; +} + +export async function prepareXtreamScenario( + page: Page, + options: PrepareXtreamScenarioOptions +): Promise { + validateOptions(options); + const cancellationObserver: XtreamCancellationObserver | null = null; + let backgroundOperation: XtreamOperationWindow | null = null; + try { + if (options.scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI) { + backgroundOperation = await installXtreamOperationWindow(page); + } + const trigger = await prepareFinalTrigger(page, options); + return createPreparedScenario({ + backgroundOperation, + cancellationObserver, + options, + page, + trigger, + }); + } catch (error) { + await Promise.allSettled([ + cancellationObserver?.dispose(), + backgroundOperation?.dispose(), + ]); + throw error; + } +} + +interface PreparedState { + readonly backgroundOperation: XtreamOperationWindow | null; + cancellationObserver: XtreamCancellationObserver | null; + readonly options: PrepareXtreamScenarioOptions; + readonly page: Page; + readonly trigger: Locator; +} + +function createPreparedScenario(state: PreparedState): PreparedXtreamScenario { + let triggered = false; + let disposed = false; + let backgroundProbeStarted = false; + let backgroundProbe: XtreamBackgroundProbeEvidence | null = null; + let terminalPromise: Promise | null = null; + const dispose = async (): Promise => { + if (disposed) return; + disposed = true; + await Promise.allSettled([ + state.cancellationObserver?.dispose(), + state.backgroundOperation?.dispose(), + ]); + }; + const prepared: PreparedXtreamScenario = { + backgroundOperation: state.backgroundOperation, + expectedDatabaseRequestCount: + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT[state.options.scenarioId], + scenarioId: state.options.scenarioId, + sourceTitle: state.options.sourceTitle, + dispose, + async runBackgroundProbe(probe: XtreamBackgroundProbe) { + if (!triggered) { + throw new Error('xtream-scenario-not-triggered'); + } + if (!state.backgroundOperation) { + throw new Error('xtream-background-operation-unavailable'); + } + if (backgroundProbeStarted) { + throw new Error('xtream-background-probe-already-started'); + } + if (typeof probe !== 'function') { + throw new Error('xtream-background-probe-invalid'); + } + backgroundProbeStarted = true; + await state.backgroundOperation.waitForActive(); + const before = await state.backgroundOperation.assertActive(); + const startedEpochMs = Date.now(); + const result = await probe(actionContext(state, before)); + await assertXtreamBackgroundRefreshActive(state.page, { + operationId: before.operationId, + playlistId: before.playlistId, + }); + const completedEpochMs = Date.now(); + const evidence: XtreamBackgroundProbeEvidence = + Object.freeze({ + completedEpochMs, + operation: before.operation, + operationId: before.operationId, + playlistId: before.playlistId, + result, + startedEpochMs, + }); + backgroundProbe = evidence; + return evidence; + }, + async trigger() { + if (triggered) { + throw new Error('xtream-scenario-already-triggered'); + } + triggered = true; + if (state.options.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + state.cancellationObserver = + await installXtreamCancellationObserver(state.page); + } + const triggerEpochMs = Date.now(); + await state.trigger.click(); + return { + scenarioId: state.options.scenarioId, + triggerEpochMs, + }; + }, + async waitForTerminal() { + if (!triggered) { + throw new Error('xtream-scenario-not-triggered'); + } + terminalPromise ??= waitForScenarioTerminal( + state, + () => backgroundProbe + ).finally(dispose); + return terminalPromise; + }, + }; + return Object.freeze(prepared); +} + +async function waitForScenarioTerminal( + state: PreparedState, + readBackgroundProbe: () => XtreamBackgroundProbeEvidence | null +): Promise { + let cancellation: XtreamCancellationEvidence | null = null; + let operation: XtreamOperationWindowSnapshot | null = null; + switch (state.options.scenarioId) { + case XTREAM_SCENARIO_ID.CANCEL_IMPORT: + if (!state.cancellationObserver) { + throw new Error('xtream-cancellation-observer-unavailable'); + } + cancellation = await state.cancellationObserver.waitForClick(); + await waitForXtreamCancellationTerminal(state.page); + break; + case XTREAM_SCENARIO_ID.DELETE_LARGE: + await waitForXtreamDeleteTerminal( + state.page, + state.options.sourceTitle + ); + break; + case XTREAM_SCENARIO_ID.BACKGROUND_UI: + if (readBackgroundProbe() === null) { + throw new Error('xtream-background-probe-incomplete'); + } + if (!state.backgroundOperation) { + throw new Error('xtream-background-operation-unavailable'); + } + operation = await state.backgroundOperation.waitForTerminal(); + if (operation.terminalStatus !== 'completed') { + throw new Error('xtream-background-delete-did-not-complete'); + } + await waitForXtreamCatalogTerminal(state.page); + break; + case XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE: + case XTREAM_SCENARIO_ID.REFRESH_LARGE: + await waitForXtreamCatalogTerminal(state.page); + break; + } + return { + backgroundProbe: readBackgroundProbe(), + cancellation, + operation, + scenarioId: state.options.scenarioId, + terminalEpochMs: Date.now(), + }; +} + +async function prepareFinalTrigger( + page: Page, + options: PrepareXtreamScenarioOptions +): Promise { + switch (options.scenarioId) { + case XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE: + case XTREAM_SCENARIO_ID.CANCEL_IMPORT: + return prepareXtreamAddTrigger(page, { + ...XTREAM_SYNTHETIC_CREDENTIALS, + serverUrl: options.serverUrl, + sourceTitle: options.sourceTitle, + }); + case XTREAM_SCENARIO_ID.REFRESH_LARGE: + case XTREAM_SCENARIO_ID.BACKGROUND_UI: + return prepareXtreamSourceActionTrigger( + page, + options.sourceTitle, + 'refresh' + ); + case XTREAM_SCENARIO_ID.DELETE_LARGE: + return prepareXtreamSourceActionTrigger( + page, + options.sourceTitle, + 'delete' + ); + } +} + +function actionContext( + state: PreparedState, + snapshot: XtreamOperationWindowSnapshot +): XtreamBackgroundActionContext { + return { + operation: snapshot.operation, + operationId: snapshot.operationId, + page: state.page, + playlistId: snapshot.playlistId, + sourceTitle: state.options.sourceTitle, + }; +} + +function validateOptions(options: PrepareXtreamScenarioOptions): void { + if (!(options.scenarioId in XTREAM_SCENARIO_DATABASE_REQUEST_COUNT)) { + throw new Error('xtream-scenario-id-invalid'); + } + try { + assertXtreamMockOrigin(options.serverUrl); + } catch { + throw new Error('Xtream scenario requires a loopback mock origin'); + } + if ( + options.sourceTitle.length === 0 || + options.sourceTitle.length > 128 || + options.sourceTitle.trim() !== options.sourceTitle || + hasControlCharacter(options.sourceTitle) + ) { + throw new Error('xtream-scenario-source-title-invalid'); + } +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts new file mode 100644 index 000000000..e582ab632 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts @@ -0,0 +1,239 @@ +import type { Page } from '@playwright/test'; + +const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow'; +const DELETE_OPERATION = 'delete-xtream-content'; +const OPERATION_TIMEOUT_MS = 120_000; + +export interface XtreamOperationWindowSnapshot { + readonly active: boolean; + readonly operation: typeof DELETE_OPERATION; + readonly operationId: string; + readonly playlistId: string; + readonly startedEpochMs: number; + readonly terminalEpochMs: number | null; + readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null; +} + +export interface XtreamOperationWindow { + assertActive(): Promise; + dispose(): Promise; + waitForActive(): Promise; + waitForTerminal(): Promise; +} + +export async function installXtreamOperationWindow( + page: Page +): Promise { + await page.evaluate( + ({ command, operation, stateKey }) => { + interface State { + active: boolean; + operation: string; + operationId: string | null; + playlistId: string | null; + startedEpochMs: number | null; + terminalEpochMs: number | null; + terminalStatus: string | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + if (command !== 'install-operation' || target[stateKey]) { + throw new Error('xtream-operation-window-state-invalid'); + } + const electron = target['electron'] as + | { + onDbOperationEvent?: ( + callback: (event: Record) => void + ) => () => void; + } + | undefined; + if (typeof electron?.onDbOperationEvent !== 'function') { + throw new Error('xtream-db-operation-listener-unavailable'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: State = { + active: false, + operation, + operationId: null, + playlistId: null, + startedEpochMs: null, + terminalEpochMs: null, + terminalStatus: null, + unsubscribe: () => undefined, + }; + state.unsubscribe = electron.onDbOperationEvent((event) => { + if (event['operation'] !== operation) return; + const status = event['status']; + if (status === 'started' && state.startedEpochMs === null) { + state.active = true; + state.startedEpochMs = epoch(); + state.operationId = + typeof event['operationId'] === 'string' + ? event['operationId'] + : null; + state.playlistId = + typeof event['playlistId'] === 'string' + ? event['playlistId'] + : null; + return; + } + if ( + !state.active || + !['cancelled', 'completed', 'error'].includes( + String(status) + ) + ) { + return; + } + const eventOperationId = + typeof event['operationId'] === 'string' + ? event['operationId'] + : null; + if ( + state.operationId !== null && + eventOperationId !== state.operationId + ) { + return; + } + state.active = false; + state.terminalEpochMs = epoch(); + state.terminalStatus = String(status); + }); + target[stateKey] = state; + }, + { + command: 'install-operation', + operation: DELETE_OPERATION, + stateKey: OPERATION_STATE_KEY, + } + ); + return { + assertActive: () => assertOperationActive(page), + dispose: () => disposeOperationState(page), + async waitForActive() { + await waitForOperationState(page, 'operation-active'); + return assertOperationActive(page); + }, + async waitForTerminal() { + await waitForOperationState(page, 'operation-terminal'); + return readOperationSnapshot(page); + }, + }; +} + +async function waitForOperationState( + page: Page, + waitFor: 'operation-active' | 'operation-terminal' +): Promise { + await page.waitForFunction( + ({ stateKey, waitFor: condition }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + active?: boolean; + terminalEpochMs?: number | null; + } + | undefined; + return condition === 'operation-active' + ? state?.active === true + : typeof state?.terminalEpochMs === 'number'; + }, + { stateKey: OPERATION_STATE_KEY, waitFor }, + { timeout: OPERATION_TIMEOUT_MS } + ); +} + +async function assertOperationActive( + page: Page +): Promise { + const snapshot = await readOperationSnapshot(page); + if (!snapshot.active || snapshot.terminalEpochMs !== null) { + throw new Error('xtream-background-action-outside-delete-window'); + } + return snapshot; +} + +async function readOperationSnapshot( + page: Page +): Promise { + const value = await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey]; + if (!state) throw new Error('xtream-operation-window-missing'); + const { + active, + operation, + operationId, + playlistId, + startedEpochMs, + terminalEpochMs, + terminalStatus, + } = state as Record; + return { + active, + operation, + operationId, + playlistId, + startedEpochMs, + terminalEpochMs, + terminalStatus, + }; + }, + { command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY } + ); + if (!isOperationSnapshot(value)) { + throw new Error('xtream-operation-window-invalid'); + } + return value; +} + +async function disposeOperationState(page: Page): Promise { + await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { unsubscribe?: () => void } | undefined; + state?.unsubscribe?.(); + delete target[stateKey]; + }, + { command: 'dispose-observer', stateKey: OPERATION_STATE_KEY } + ); +} + +function isOperationSnapshot( + value: unknown +): value is XtreamOperationWindowSnapshot { + if (!isRecord(value)) return false; + const active = value['active']; + const startedEpochMs = value['startedEpochMs']; + const terminalEpochMs = value['terminalEpochMs']; + const terminalStatus = value['terminalStatus']; + return ( + value['operation'] === DELETE_OPERATION && + typeof active === 'boolean' && + finitePositive(startedEpochMs) && + nonEmptyString(value['operationId']) && + nonEmptyString(value['playlistId']) && + (active + ? terminalEpochMs === null && terminalStatus === null + : finitePositive(terminalEpochMs) && + terminalEpochMs >= startedEpochMs && + ['cancelled', 'completed', 'error'].includes( + String(terminalStatus) + )) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function finitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts new file mode 100644 index 000000000..457ee6554 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -0,0 +1,178 @@ +import type { Locator, Page } from '@playwright/test'; + +export interface XtreamSyntheticPortalFields { + readonly password: 'performance'; + readonly serverUrl: string; + readonly sourceTitle: string; + readonly username: 'performance'; +} + +export type XtreamSourceAction = 'delete' | 'refresh'; + +const XTREAM_METHOD_NAME = /xtream(?:\s+credentials)?/i; +const XTREAM_ROUTE = /\/workspace\/xtreams\/[^/]+/; + +export async function prepareXtreamAddTrigger( + page: Page, + fields: XtreamSyntheticPortalFields +): Promise { + await waitForEmptyDashboardReady(page); + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await page.waitForURL(/\/workspace\/sources$/); + await page.locator('app-workspace-sources').waitFor({ state: 'visible' }); + await page + .getByRole('button', { name: 'Add playlist', exact: true }) + .first() + .click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + await selectXtreamMethod(dialog); + await dialog.locator('#title').fill(fields.sourceTitle); + await dialog.locator('#serverUrl').fill(fields.serverUrl); + await dialog.locator('#username').fill(fields.username); + await dialog.locator('#password').fill(fields.password); + + const exactAdd = dialog + .getByRole('button', { name: 'Add', exact: true }) + .last(); + const add = + (await exactAdd.count()) > 0 + ? exactAdd + : dialog + .getByRole('button', { + name: /^(add|add playlist)$/i, + }) + .last(); + await assertTriggerReady(add, 'xtream-final-add'); + return add; +} + +async function waitForEmptyDashboardReady(page: Page): Promise { + await page.waitForURL(/\/workspace\/dashboard$/); + await page.waitForFunction( + () => + document.querySelector( + 'lib-workspace-dashboard-rails.rails-page-host--empty' + ) !== null, + { waitFor: 'empty-dashboard-ready' } + ); +} + +export async function prepareXtreamSourceActionTrigger( + page: Page, + sourceTitle: string, + action: XtreamSourceAction +): Promise { + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await page.waitForURL(/\/workspace\/sources$/); + const row = xtreamSourceRow(page, sourceTitle); + await row.waitFor({ state: 'visible' }); + for (const selector of [ + '.busy-state__message', + '.action-spinner', + '.cancel-btn', + ]) { + await row.locator(selector).waitFor({ state: 'detached' }); + } + const actionSelector = + action === 'refresh' ? '.refresh-btn' : '.delete-btn'; + const actionButton = row.locator(actionSelector); + await assertTriggerReady(actionButton, `xtream-source-${action}`); + await actionButton.click(); + + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + const confirmation = dialog + .getByRole('button', { name: 'Yes', exact: true }) + .last(); + await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); + return confirmation; +} + +export async function waitForXtreamCatalogTerminal(page: Page): Promise { + await page.waitForURL(XTREAM_ROUTE); + await page + .locator('.workspace-loading-overlay') + .waitFor({ state: 'detached' }); + await page + .locator('app-workspace-context-panel .category-item') + .first() + .waitFor({ state: 'visible' }); + await page + .locator('.content-card, [data-test-id="channel-item"], mat-card') + .first() + .waitFor({ state: 'visible' }); +} + +export async function waitForXtreamCancellationTerminal( + page: Page +): Promise { + await page + .locator('.workspace-loading-overlay') + .waitFor({ state: 'detached' }); + await page.waitForFunction( + ({ selector, text }) => + document.querySelector(selector)?.textContent?.trim() === text, + { + selector: '.xtream-content-gate .error-view__title', + text: 'Import cancelled', + waitFor: 'cancellation-terminal', + }, + { timeout: 120_000 } + ); +} + +export async function waitForXtreamDeleteTerminal( + page: Page, + sourceTitle: string +): Promise { + await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' }); +} + +async function selectXtreamMethod(dialog: Locator): Promise { + const radio = dialog + .getByRole('radio', { name: XTREAM_METHOD_NAME }) + .first(); + if ((await radio.count()) > 0) { + await radio.click(); + return; + } + const button = dialog + .getByRole('button', { name: XTREAM_METHOD_NAME }) + .first(); + if ((await button.count()) > 0) { + await button.click(); + return; + } + const fallback = dialog.locator('mat-button-toggle[value="xtream"]'); + if ((await fallback.count()) === 0) { + throw new Error('xtream-method-selector-missing'); + } + await fallback.click(); +} + +function xtreamSourceRow(page: Page, sourceTitle: string): Locator { + const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u'); + return page + .locator('app-playlist-item') + .filter({ + has: page.locator('.playlist-title', { + hasText: exactTitle, + }), + }) + .first(); +} + +async function assertTriggerReady( + trigger: Locator, + label: string +): Promise { + await trigger.waitFor({ state: 'visible' }); + if (await trigger.isDisabled()) { + throw new Error(`${label}-disabled`); + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts new file mode 100644 index 000000000..bcaf428ca --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts @@ -0,0 +1,318 @@ +import type { + NumericDistribution, + XtreamBenchmarkManifest, + XtreamIterationDefinition, + XtreamScenarioMetrics, + XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture'; +import type { + XtreamPhaseAttribution, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; + +export type { + NumericDistribution, + XtreamBenchmarkManifest, + XtreamScenarioMetrics, +}; + +export const XTREAM_MEMORY_ACCOUNTING = Object.freeze({ + mainRss: 'electron-main-process-including-worker-threads-and-native-memory', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + workerRss: 'unavailable-per-worker-thread', +}); + +export const XTREAM_NOT_APPLICABLE = { + COMMIT_INCLUDED_IN_SQLITE_WRITE: + 'included-in-sqlite-write-transaction-spans', + DATABASE_WORKER_RSS: 'unavailable-per-worker-thread', + DEBUG_COPY: 'production-debug-send-disabled', + INDEXES_PRE_EXIST: 'schema-indexes-pre-exist; operation-creates-none', + MAIN_EVENT_LOOP_UTILIZATION: 'electron-main-embedded-event-loop', + NATIVE_SQLITE_MEMORY: 'included-in-main-rss-not-separately-attributable', + PLAYLIST_REFRESH_WORKER: 'not-instantiated-by-xtream-flow', + PROFILE_OUTSIDE_DIAGNOSTIC: 'not-captured-outside-diagnostic', + RESTORE_NOT_INVOKED: 'not-invoked-by-scenario', + RESTORE_NO_USER_DATA: 'no-user-data', + SOURCES_SEARCH_MARKER: + 'sources-search-does-not-publish-xtream-search-results', +} as const; + +export const XTREAM_RENDERER_VISIBILITY = { + VISIBLE_WITHOUT_OVERLAY: 'visible-without-overlay', +} as const; + +export const XTREAM_CANCEL_NETWORK_ABORT_REASON = { + AFTER_NETWORK_COMPLETE: 'cancel-triggered-after-network-complete', +} as const; + +export const XTREAM_WORKER_REQUEST_INVALID_REASON = { + OVERLAPPING_DATABASE_REQUESTS: 'overlapping-database-worker-requests', +} as const; + +export const XTREAM_WORKER_REQUEST_METRIC_SCOPE = { + VALID_REQUEST_WINDOW_PROXY: 'valid-request-window-proxy', +} as const; + +export const XTREAM_MAIN_CPU_METRIC_SCOPE = { + ELECTRON_MAIN_THREAD: 'electron-main-thread', +} as const; + +export const XTREAM_WORKER_CPU_METRIC_SCOPE = { + SUM_VALID_REQUEST_WORK: 'sum-valid-request-thread-cpu', +} as const; + +export const XTREAM_WORKER_WHOLE_METRIC_SCOPE = { + OPERATION_WINDOW: 'whole-worker-operation-window', +} as const; + +export const XTREAM_STRUCTURED_CLONE_ATTRIBUTION = { + SELECTED_IPC_PROXY: 'selected-ipc-proxy-includes-queueing-and-scheduling', +} as const; + +type ProfileUnavailableReason = + typeof XTREAM_NOT_APPLICABLE.PROFILE_OUTSIDE_DIAGNOSTIC | null; + +export interface XtreamProfileCapture { + readonly cpuProfileDurationMicros: number | null; + readonly cpuProfileUnavailableReason: ProfileUnavailableReason; +} + +export interface XtreamRendererMetrics extends XtreamProfileCapture { + readonly actionLatencyMs: number | null; + readonly consoleErrors: readonly string[]; + readonly dashboardPaintVisibility: + typeof XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY | null; + readonly frameGapMaxMs: number; + readonly frameGapP95Ms: number; + readonly heartbeatMaxMs: number; + readonly heartbeatP95Ms: number; + readonly longTaskCount: number; + readonly longTaskMaxMs: number; + readonly navigationLatencyMs: number | null; + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number; + readonly searchLatencyMs: number | null; + readonly storeToPaintMs: number; + readonly targetId: string; +} + +export interface XtreamMainMetrics extends XtreamProfileCapture { + readonly browserWindowId: number; + readonly cpuMetricScope: typeof XTREAM_MAIN_CPU_METRIC_SCOPE.ELECTRON_MAIN_THREAD; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: null; + readonly eventLoopUtilizationUnavailableReason: typeof XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION; + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number; + readonly responsiveEvents: number; + readonly unresponsiveEvents: number; + readonly webContentsId: number; +} + +export interface XtreamDatabaseWorkerMetrics extends XtreamProfileCapture { + readonly cpuMetricScope: typeof XTREAM_WORKER_CPU_METRIC_SCOPE.SUM_VALID_REQUEST_WORK; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly currentForCapture: boolean; + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayMetricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: number; + readonly kind: 'database.worker'; + readonly ordinal: number; + readonly peakExternalBytes: number; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number; + readonly requests: XtreamWorkerRequestAccounting; + readonly rssBytes: null; + readonly rssUnavailableReason: 'unavailable-per-worker-thread'; + readonly wholeWorkerMetricScope: typeof XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW; +} + +export interface XtreamWorkerRequestAccounting { + readonly evidence: readonly WorkerRequestPerformanceMetrics[]; + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: number; + readonly invalidReasons: readonly XtreamWorkerRequestInvalidReasonCount[]; + readonly invalidSampleCount: number; + readonly metricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly totalCount: number; + readonly validSampleCount: number; +} + +export interface XtreamWorkerRequestInvalidReasonCount { + readonly count: number; + readonly reason: typeof XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS; +} + +export interface XtreamPlaylistRefreshWorkerState { + readonly instantiated: false; + readonly reason: 'not-instantiated-by-xtream-flow'; +} + +export interface XtreamCancellationMetrics { + readonly acknowledgementLatencyMs: number | null; + readonly authoritativeDatabaseAcknowledgementObserved: boolean; + readonly authoritativeTerminalEpochMs: number | null; + readonly clickEpochMs: number | null; + readonly clickToDatabaseDispatchMs: number | null; + readonly clickToPaintMs: number | null; + readonly clickToPreloadReturnMs: number | null; + readonly databaseDispatchEpochMs: number | null; + readonly failedDatabaseRequestId: string | null; + readonly failedOperationId: string | null; + readonly mainNetworkAbortObserved: boolean; + readonly mainNetworkAbortReason: + typeof XTREAM_CANCEL_NETWORK_ABORT_REASON.AFTER_NETWORK_COMPLETE | null; + readonly paintedEpochMs: number | null; + readonly preloadReturnEpochMs: number | null; + readonly preloadStartEpochMs: number | null; + readonly workerReceiptEpochMs: number | null; + readonly workerReceiptIsAuthoritative: false; + readonly workerReceiptLatencyMs: number | null; +} + +export interface XtreamCancellationEvidence { + readonly timeline: readonly DatabaseWorkerCancelTimelineRecord[]; +} + +export interface XtreamCatalogVerification { + readonly databaseStateValid: boolean; + readonly fixtureIdentityValid: boolean; + readonly providerCategoryCount: 100; + readonly providerItemCount: 100_000; + readonly verifiedAfterCapture: boolean; +} + +export interface XtreamIterationValidity { + readonly actionContractValid: boolean; + readonly catalogContractValid: boolean; + readonly databaseWorkerCount: number; + readonly dbPhaseContractValid: boolean; + readonly diagnosticArtifactsValid: boolean | null; + readonly lateDatabaseRequestCount: number; + readonly playlistWorkerCount: number; + readonly rendererTargetCount: number; +} + +export interface XtreamIterationProcessIdentity { + readonly captureGeneration: number; + readonly electronPid: number; + readonly freshProcess: true; + readonly generationIdentitySha256: string; + readonly launchId: string; + readonly profileDirectorySha256: string; +} + +export interface XtreamRawIterationResult extends XtreamIterationDefinition { + readonly cancellation: XtreamCancellationMetrics; + readonly cancellationEvidence: XtreamCancellationEvidence | null; + readonly catalogVerification: XtreamCatalogVerification; + readonly databaseWorker: XtreamDatabaseWorkerMetrics; + readonly main: XtreamMainMetrics; + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly phases: XtreamPhaseAttribution; + readonly playlistRefreshWorker: XtreamPlaylistRefreshWorkerState; + readonly processIdentity: XtreamIterationProcessIdentity; + readonly renderer: XtreamRendererMetrics; + readonly validity: XtreamIterationValidity; +} + +export interface XtreamArtifactSafetyContext { + readonly controlToken: string; + readonly syntheticCredentials: { + readonly password: 'performance'; + readonly username: 'performance'; + }; +} + +export interface XtreamPersistenceReceipt { + readonly absolutePath: string; + readonly bytes: number; + readonly sha256: string; +} + +export interface XtreamPersistedRawEvidence { + readonly bytes: number; + readonly pathSha256: string; + readonly rawSha256: string; +} + +export interface XtreamValidatedIterationResult extends XtreamRawIterationResult { + readonly persistence: XtreamPersistedRawEvidence; + readonly validationStatus: 'valid'; +} + +export interface XtreamNotApplicableMetric { + readonly reason: string; + readonly value: null; +} + +export interface XtreamDiagnosticProfiles { + readonly artifactProvenance: XtreamDiagnosticArtifactProvenance; + readonly databaseWorkerDurationMicros: number | null; + readonly mainDurationMicros: number | null; + readonly rendererDurationMicros: number | null; +} + +export interface XtreamDiagnosticArtifactProvenance { + readonly artifacts: readonly XtreamDiagnosticArtifactReceipt[]; + readonly directorySha256: string; +} + +export interface XtreamDiagnosticArtifactReceipt { + readonly bytes: number; + readonly filename: string; + readonly label: string; + readonly sha256: string; +} + +export interface XtreamScenarioSummary { + readonly databaseWorkerEventLoopDelayMetricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly databaseWorkerRequestInvalidReasons: readonly (typeof XTREAM_WORKER_REQUEST_INVALID_REASON)[keyof typeof XTREAM_WORKER_REQUEST_INVALID_REASON][]; + readonly databaseWorkerRequestMetricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly databaseWorkerWholeWorkerMetricScope: typeof XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW; + readonly dashboardPaintVisibility: + typeof XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY | null; + readonly diagnosticProfiles: XtreamDiagnosticProfiles; + readonly measuredRuns: number; + readonly memory: { + readonly databaseWorkerRss: XtreamNotApplicableMetric; + readonly nativeAndSqlite: XtreamNotApplicableMetric; + readonly playlistRefreshWorker: XtreamNotApplicableMetric; + }; + readonly mainEventLoopUtilization: XtreamNotApplicableMetric; + readonly metrics: XtreamScenarioMetrics; + readonly phases: { + readonly backgroundSearchMarker: XtreamNotApplicableMetric | null; + readonly debugCopy: XtreamNotApplicableMetric; + readonly indexes: XtreamNotApplicableMetric; + readonly restoreUserData: XtreamNotApplicableMetric; + readonly structuredCloneAttribution: typeof XTREAM_STRUCTURED_CLONE_ATTRIBUTION.SELECTED_IPC_PROXY; + readonly transactionCommit: XtreamNotApplicableMetric; + }; + readonly rendererLongTaskCountTotal: number; + readonly responsiveEventsTotal: number; + readonly scenarioId: XtreamScenarioId; + readonly unresponsiveEventsTotal: number; +} + +export interface XtreamBenchmarkSummary { + readonly iterations: readonly XtreamValidatedIterationResult[]; + readonly manifest: XtreamBenchmarkManifest; + readonly scenarios: readonly XtreamScenarioSummary[]; + readonly validForComparison: boolean; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts new file mode 100644 index 000000000..632801dc2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import { + assertXtreamSettlementAfterTerminal, + finalizeXtreamCaptureAfterRendererTerminal, +} from './xtream-benchmark-iteration-support'; +import type { XtreamMainCaptureStatus } from './m3u-refresh-main-capture'; + +describe('Xtream terminal settlement ordering', () => { + it('reads main status exactly once and only after terminal evidence resolves', async () => { + const terminal = deferred(); + let readCount = 0; + const settlement = assertXtreamSettlementAfterTerminal({ + captureGeneration: 2, + readStatus: async () => { + readCount += 1; + return settledStatus(); + }, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: terminal.promise, + }); + + assert.equal(readCount, 0); + terminal.resolve(); + await settlement; + assert.equal(readCount, 1); + }); + + it('fails closed on pending terminal status without polling again', async () => { + let readCount = 0; + const status = settledStatus(); + const first = status.operationOutcomes[0]; + assert.ok(first); + + await assert.rejects( + assertXtreamSettlementAfterTerminal({ + captureGeneration: 2, + readStatus: async () => { + readCount += 1; + return { + ...status, + activeOperations: [ + { + operation: first.operation, + operationId: 'operation-1', + playlistId: 'playlist-1', + progress: null, + requestId: 'request-1', + }, + ], + }; + }, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: Promise.resolve(), + }), + /xtream-main-not-settled-after-terminal/ + ); + assert.equal(readCount, 1); + }); + + it('rejects a changed capture generation after terminal evidence', async () => { + await assert.rejects( + assertXtreamSettlementAfterTerminal({ + captureGeneration: 3, + readStatus: async () => settledStatus(), + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: Promise.resolve(), + }), + /xtream-measured-capture-generation-changed/ + ); + }); + + it('freezes both captures after renderer settlement without waiting for late driver evidence', async () => { + const rendererTerminal = deferred(); + const driverTerminal = deferred<{ terminalEpochMs: number }>(); + const calls: string[] = []; + const finalization = finalizeXtreamCaptureAfterRendererTerminal({ + captureGeneration: 2, + driverTerminal: driverTerminal.promise, + readStatus: async () => { + calls.push('status'); + return settledStatus(); + }, + rendererTerminal: rendererTerminal.promise, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + stopMain: async () => { + calls.push('stop-main'); + return 'main'; + }, + stopRenderer: async () => { + calls.push('stop-renderer'); + return 'renderer'; + }, + }); + + await Promise.resolve(); + assert.deepEqual(calls, []); + + rendererTerminal.resolve(); + await waitFor(() => calls.includes('stop-main')); + assert.deepEqual(calls, ['status', 'stop-renderer', 'stop-main']); + + let completed = false; + void finalization.then(() => { + completed = true; + }); + await Promise.resolve(); + assert.equal(completed, false); + + driverTerminal.resolve({ terminalEpochMs: 2_000 }); + assert.deepEqual(await finalization, { + driverTerminal: { terminalEpochMs: 2_000 }, + mainCapture: 'main', + rendererCapture: 'renderer', + }); + }); +}); + +function settledStatus(): XtreamMainCaptureStatus { + return { + active: true, + activeOperations: [], + captureGeneration: 2, + captureStartedEpochMs: 1_000, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 1_001, + operationOutcomes: expectedXtreamWorkerRequestInventory( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ).map(({ count, operation, success }) => ({ + count, + operation, + outcome: success ? 'success' : 'error', + })), + playlistRefreshWorkerCount: 0, + }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolvePromise: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: resolvePromise, + }; +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 10 && !predicate(); attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(predicate(), true); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts new file mode 100644 index 000000000..aefb1725c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface UiActionModule { + XTREAM_UI_ACTION_ORDER?: readonly string[]; + runXtreamBackgroundUiActions?: ( + page: unknown, + options: { + readonly operationId: string; + readonly playlistId: string; + readonly searchTerm: string; + } + ) => Promise; + summarizeXtreamUiActionSamples?: ( + samples: readonly { + readonly category: 'action' | 'navigation' | 'search'; + readonly endEpochMs: number; + readonly kind: string; + readonly latencyMs: number; + readonly startEpochMs: number; + }[] + ) => { + readonly action: { + readonly max: number | null; + readonly p95: number | null; + }; + readonly navigation: { + readonly max: number | null; + readonly p95: number | null; + }; + readonly search: { + readonly max: number | null; + readonly p95: number | null; + }; + }; +} + +const sourceUrl = new URL('./xtream-ui-action-probe.ts', import.meta.url); +const modulePromise = import(sourceUrl.href) + .then((module) => module as unknown as UiActionModule) + .catch(() => null); + +test('exports the fixed background interaction sequence', async () => { + const module = await modulePromise; + + assert.ok(module, 'Xtream UI action probe module must exist'); + assert.equal(typeof module.runXtreamBackgroundUiActions, 'function'); + assert.deepEqual(module.XTREAM_UI_ACTION_ORDER, [ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', + ]); +}); + +test('requires the full refresh lifecycle before and after every action', () => { + const source = readFileSync(sourceUrl, 'utf8'); + + assert.match(source, /assertXtreamBackgroundRefreshActive/); + assert.match(source, /refresh-lifecycle/); + assert.doesNotMatch(source, /assertXtreamBackgroundDeleteActive/); + assert.match(source, /activeBefore/); + assert.match(source, /activeAfter/); +}); + +test('uses the concrete UI controls and two-frame paint gates', () => { + const source = readFileSync(sourceUrl, 'utf8'); + + assert.match(source, /app-workspace-sources-filters-panel/); + assert.match(source, /Xtream/); + assert.match(source, /workspace\/dashboard/); + assert.match(source, /lib-workspace-dashboard-rails/); + assert.match(source, /workspace-loading-overlay/); + assert.match(source, /workspace\/sources/); + assert.match(source, /input\[type="search"\]/); + assert.match(source, /Name \(A-Z\)/); + assert.match(source, /requestAnimationFrame/); +}); + +test('keeps the debounced search last and finishes only after its result paint', async () => { + const module = await modulePromise; + assert.ok(module?.runXtreamBackgroundUiActions); + const page = new SearchCompletionFakePage(); + + await module.runXtreamBackgroundUiActions(page, { + operationId: 'delete-operation-1', + playlistId: 'playlist-1', + searchTerm: 'Performance', + }); + + assert.equal( + page.searchGateObserved, + true, + 'the current input-only gate lets the probe finish before the app-owned 350 ms q/filter update' + ); + assert.equal( + page.searchRestored, + false, + 'an unmeasured search reset must not consume the remaining delete window' + ); + assert.deepEqual(page.searchPaintOrder, [ + 'app-owned-search-complete', + 'two-rendered-frames', + ]); +}); + +test('retains raw samples and summarizes navigation, search, and other actions separately', async () => { + const module = await modulePromise; + assert.ok(module?.summarizeXtreamUiActionSamples); + const samples = [ + sample('action', 'type-filter', 10), + sample('navigation', 'dashboard', 20), + sample('navigation', 'sources', 40), + sample('action', 'name-a-z', 50), + sample('search', 'global-search', 30), + ] as const; + + const summary = module.summarizeXtreamUiActionSamples(samples); + + assert.equal(summary.action.max, 50); + assert.equal(summary.navigation.max, 40); + assert.equal(summary.search.max, 30); + assert.ok((summary.action.p95 ?? 0) >= 10); + assert.ok((summary.navigation.p95 ?? 0) >= 20); +}); + +function sample( + category: 'action' | 'navigation' | 'search', + kind: string, + latencyMs: number +) { + return { + category, + endEpochMs: 100 + latencyMs, + kind, + latencyMs, + startEpochMs: 100, + }; +} + +class SearchCompletionFakePage { + private epochMs = 100; + private searchWaitVisited = false; + private searchResultsApplied = false; + readonly searchPaintOrder: string[] = []; + searchGateObserved = false; + searchRestored = false; + + async evaluate(callback: (...args: never[]) => unknown): Promise { + const source = callback.toString(); + if ( + this.searchWaitVisited && + source.includes('requestAnimationFrame') && + this.searchPaintOrder.length === 1 + ) { + this.searchPaintOrder.push('two-rendered-frames'); + } + this.epochMs += 1; + return this.epochMs as T; + } + + async waitForFunction( + callback: (...args: never[]) => unknown, + argument?: unknown + ): Promise { + const searchTerm = recordString(argument, 'searchTerm'); + if (searchTerm === null) return; + this.searchWaitVisited = true; + const source = callback.toString(); + if (searchTerm.length === 0) { + this.searchRestored = + /searchParams\.has\((['"])q\1\)/u.test(source) && + source.includes('sort-trigger'); + if (this.searchRestored) this.searchResultsApplied = false; + return; + } + this.searchGateObserved = + /searchParams\.get\((['"])q\1\)/u.test(source) && + source.includes('app-playlist-item') && + source.includes('no-results-state'); + if (this.searchGateObserved) { + this.searchResultsApplied = true; + this.searchPaintOrder.push('app-owned-search-complete'); + } + } + + getByRole(): { waitFor(): Promise } { + if (this.searchResultsApplied) { + throw new Error('sort-control-hidden-by-filtered-no-results'); + } + return { waitFor: async () => undefined }; + } +} + +function recordString(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object') return null; + const candidate = (value as Record)[key]; + return typeof candidate === 'string' ? candidate : null; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts new file mode 100644 index 000000000..5d382c55c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -0,0 +1,359 @@ +import type { Page } from '@playwright/test'; + +import type { NumericDistribution } from './xtream-benchmark-contract'; +import { summarizeNumbers } from './performance-statistics'; +import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; + +export const XTREAM_BACKGROUND_WINDOW = 'refresh-lifecycle'; +export const XTREAM_UI_ACTION_ORDER = Object.freeze([ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', +] as const); + +export type XtreamUiActionKind = (typeof XTREAM_UI_ACTION_ORDER)[number]; +export type XtreamUiActionCategory = 'action' | 'navigation' | 'search'; + +export interface XtreamUiActionSample { + readonly activeAfter: true; + readonly activeBefore: true; + readonly category: XtreamUiActionCategory; + readonly endEpochMs: number; + readonly kind: XtreamUiActionKind; + readonly latencyMs: number; + readonly startEpochMs: number; +} + +export interface XtreamUiActionSummary { + readonly action: NumericDistribution; + readonly navigation: NumericDistribution; + readonly search: NumericDistribution; +} + +export interface XtreamUiActionProbeResult extends XtreamUiActionSummary { + readonly dashboardPaintVisibility: 'visible-without-overlay'; + readonly samples: readonly XtreamUiActionSample[]; +} + +export interface XtreamUiActionProbeOptions { + readonly operationId: string; + readonly playlistId: string; + readonly searchTerm: string; +} + +export async function runXtreamBackgroundUiActions( + page: Page, + options: XtreamUiActionProbeOptions +): Promise { + const samples: XtreamUiActionSample[] = []; + samples.push( + await measureAction( + page, + options, + 'type-filter', + 'action', + async () => { + const start = await clickTextOption( + page, + 'app-workspace-sources-filters-panel .option-row', + '.option-label', + 'Xtream' + ); + await page.waitForFunction(() => { + const rows = Array.from( + document.querySelectorAll( + 'app-workspace-sources-filters-panel .option-row.selected' + ) + ); + return rows.some( + (row) => + row + .querySelector('.option-label') + ?.textContent?.trim() === 'Xtream' + ); + }); + return start; + } + ) + ); + samples.push( + await measureAction( + page, + options, + 'dashboard', + 'navigation', + async () => { + const start = await clickSelector( + page, + 'a.brand[href$="/workspace/dashboard"]' + ); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '.workspace-loading-overlay' + ); + const dashboard = document.querySelector( + 'lib-workspace-dashboard-rails' + ); + return ( + location.pathname.endsWith('/workspace/dashboard') && + visible(dashboard) && + !visible(overlay) + ); + function visible(element: Element | null): boolean { + return ( + element instanceof HTMLElement && + element.getClientRects().length > 0 + ); + } + }); + return start; + } + ) + ); + samples.push( + await measureAction( + page, + options, + 'sources', + 'navigation', + async () => { + const start = await clickSelector( + page, + 'a[href$="/workspace/sources"]' + ); + await page.waitForFunction( + () => + location.pathname.endsWith('/workspace/sources') && + document.querySelector('app-workspace-sources') !== null + ); + return start; + } + ) + ); + samples.push(await measureNameSort(page, options)); + samples.push( + await measureAction( + page, + options, + 'global-search', + 'search', + async () => { + const start = await setSearchInput(page, options.searchTerm); + await page.waitForFunction( + ({ searchTerm }) => { + const input = document.querySelector( + 'app-workspace-shell-header .search-field input[type="search"]' + ); + const normalizedSearchTerm = searchTerm + .trim() + .toLowerCase(); + const searchParams = new URLSearchParams( + location.search + ); + const resultRows = Array.from( + document.querySelectorAll( + 'app-workspace-sources app-playlist-item' + ) + ); + const resultPainted = + resultRows.length > 0 + ? resultRows.every((row) => + ( + row.querySelector('.playlist-title') + ?.textContent ?? '' + ) + .trim() + .toLowerCase() + .includes(normalizedSearchTerm) + ) + : visible( + document.querySelector( + 'app-workspace-sources .no-results-state' + ) + ); + return ( + input instanceof HTMLInputElement && + input.value === searchTerm && + searchParams.get('q') === searchTerm.trim() && + resultPainted + ); + function visible(element: Element | null): boolean { + return ( + element instanceof HTMLElement && + element.getClientRects().length > 0 + ); + } + }, + { searchTerm: options.searchTerm } + ); + return start; + } + ) + ); + assertExactActionOrder(samples); + const summary = summarizeXtreamUiActionSamples(samples); + return Object.freeze({ + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples: Object.freeze([...samples]), + }); +} + +export function summarizeXtreamUiActionSamples( + samples: readonly Pick< + XtreamUiActionSample, + 'category' | 'endEpochMs' | 'kind' | 'latencyMs' | 'startEpochMs' + >[] +): XtreamUiActionSummary { + const values = (category: XtreamUiActionCategory): readonly number[] => + samples + .filter((sample) => sample.category === category) + .map((sample) => sample.latencyMs); + return Object.freeze({ + action: summarizeNumbers(values('action')), + navigation: summarizeNumbers(values('navigation')), + search: summarizeNumbers(values('search')), + }); +} + +async function measureAction( + page: Page, + options: Pick, + kind: XtreamUiActionKind, + category: XtreamUiActionCategory, + triggerAndWait: () => Promise +): Promise { + await assertXtreamBackgroundRefreshActive(page, options); + const activeBefore = true as const; + const startEpochMs = await triggerAndWait(); + const endEpochMs = await waitForTwoRenderedFrames(page); + await assertXtreamBackgroundRefreshActive(page, options); + const activeAfter = true as const; + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error(`xtream-ui-action-invalid:${kind}`); + } + return Object.freeze({ + activeAfter, + activeBefore, + category, + endEpochMs, + kind, + latencyMs: endEpochMs - startEpochMs, + startEpochMs, + }); +} + +async function measureNameSort( + page: Page, + options: Pick +): Promise { + return measureAction(page, options, 'name-a-z', 'action', async () => { + const start = await clickSelector( + page, + 'app-workspace-sources .sort-trigger' + ); + await page + .getByRole('menuitem', { name: 'Name (A-Z)' }) + .waitFor({ state: 'visible' }); + await clickTextOption(page, '[role="menuitem"]', 'span', 'Name (A-Z)'); + await page.waitForFunction( + () => + document + .querySelector('app-workspace-sources .sort-trigger__label') + ?.textContent?.trim() === 'Name (A-Z)' + ); + return start; + }); +} + +async function clickSelector(page: Page, selector: string): Promise { + return page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) { + throw new Error('xtream-ui-action-target-unavailable'); + } + const startedAt = performance.timeOrigin + performance.now(); + element.click(); + return startedAt; + }, selector); +} + +async function clickTextOption( + page: Page, + rowSelector: string, + labelSelector: string, + expectedLabel: string +): Promise { + return page.evaluate( + ({ expectedLabel, labelSelector, rowSelector }) => { + const match = Array.from( + document.querySelectorAll(rowSelector) + ).find( + (element) => + element + .querySelector(labelSelector) + ?.textContent?.trim() === expectedLabel + ); + if (!(match instanceof HTMLElement)) { + throw new Error('xtream-ui-action-option-unavailable'); + } + const startedAt = performance.timeOrigin + performance.now(); + match.click(); + return startedAt; + }, + { expectedLabel, labelSelector, rowSelector } + ); +} + +async function setSearchInput(page: Page, value: string): Promise { + return page.evaluate((value) => { + const input = document.querySelector( + 'app-workspace-shell-header .search-field input[type="search"]' + ); + if (!(input instanceof HTMLInputElement) || input.disabled) { + throw new Error('xtream-ui-search-unavailable'); + } + const startedAt = performance.timeOrigin + performance.now(); + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + return startedAt; + }, value); +} + +async function waitForTwoRenderedFrames(page: Page): Promise { + return page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(performance.timeOrigin + performance.now()); + }); + }); + }) + ); +} + +function assertExactActionOrder( + samples: readonly XtreamUiActionSample[] +): void { + if ( + samples.length !== XTREAM_UI_ACTION_ORDER.length || + samples.some( + (sample, index) => sample.kind !== XTREAM_UI_ACTION_ORDER[index] + ) + ) { + throw new Error( + `xtream-ui-action-order-invalid:${XTREAM_BACKGROUND_WINDOW}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts new file mode 100644 index 000000000..83a309d89 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { parseXtreamWorkerPerformancePhaseEvents } from './xtream-worker-phase-events'; + +function phasePair( + phase: string, + startEpochMs: number, + itemCount: number, + requestId = 'request-1' +) { + return [ + { + boundary: 'start', + durationMs: null, + epochMs: startEpochMs, + phase, + requestId, + }, + { + boundary: 'end', + durationMs: 2, + epochMs: startEpochMs + 2, + metadata: { itemCount }, + phase, + requestId, + }, + ]; +} + +describe('Xtream worker performance phase parser', () => { + it('accepts each exact finalized operation sequence', () => { + const fixtures = [ + ['DB_GET_CATEGORIES', phasePair('sqlite.categories.read', 11, 100)], + [ + 'DB_SAVE_CATEGORIES', + [ + ...phasePair('normalize.categories', 11, 100), + ...phasePair( + 'sqlite.categories.write-transactions', + 14, + 100 + ), + ], + ], + ['DB_GET_CONTENT', phasePair('sqlite.content.read', 11, 60_000)], + [ + 'DB_SAVE_CONTENT', + [ + ...phasePair('sqlite.content.category-map-read', 11, 60), + ...phasePair('normalize.content', 14, 60_000), + ...phasePair( + 'sqlite.content.write-transactions', + 17, + 60_000 + ), + ], + ], + [ + 'DB_CLEAR_XTREAM_IMPORT_CACHE', + phasePair( + 'sqlite.xtream-cache-clear.write-transactions', + 11, + 60_060 + ), + ], + [ + 'DB_DELETE_XTREAM_CONTENT', + [ + ...phasePair( + 'sqlite.xtream-delete.collect-user-data', + 11, + 60_060 + ), + ...phasePair( + 'sqlite.xtream-delete.write-transactions', + 14, + 60_060 + ), + ], + ], + [ + 'DB_DELETE_PLAYLIST', + [ + ...phasePair( + 'sqlite.playlist-delete.collect-ids', + 11, + 60_060 + ), + ...phasePair( + 'sqlite.playlist-delete.write-transactions', + 14, + 60_061 + ), + ], + ], + [ + 'DB_SEARCH_CONTENT', + [ + ...phasePair('sqlite.search.query', 11, 200), + ...phasePair('normalize.search-rank', 14, 50), + ], + ], + ] as const; + + for (const [operation, events] of fixtures) { + assert.deepEqual( + parseXtreamWorkerPerformancePhaseEvents( + operation, + events, + 10, + 30, + true + ), + events + ); + } + }); + + it('returns undefined for non-Xtream operations and null for invalid Xtream events', () => { + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_APP_PLAYLIST', + [], + 10, + 30, + true + ), + undefined + ); + + const valid = phasePair('sqlite.categories.read', 11, 100); + for (const invalid of [ + [], + valid.slice(0, 1), + [...valid].reverse(), + [ + valid[0], + { ...valid[1], metadata: { itemCount: 100, secret: 'no' } }, + ], + [valid[0], { ...valid[1], epochMs: 9 }], + [valid[0], { ...valid[1], requestId: 'request-2' }], + [valid[0], { ...valid[1], phase: 'sqlite.write' }], + [valid[0], { ...valid[1], durationMs: -1 }], + ]) { + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + invalid, + 10, + 30, + true + ), + null + ); + } + }); + + it('allows missing end count metadata only for unsuccessful partial captures', () => { + const partial = [ + { + boundary: 'start', + durationMs: null, + epochMs: 11, + phase: 'sqlite.categories.read', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 2, + epochMs: 13, + phase: 'sqlite.categories.read', + requestId: 'request-1', + }, + ]; + + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + partial, + 10, + 30, + true + ), + null + ); + assert.deepEqual( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + partial, + 10, + 30, + false + ), + partial + ); + + assert.deepEqual( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_SAVE_CONTENT', + phasePair('sqlite.content.category-map-read', 11, 60), + 10, + 30, + false + ), + phasePair('sqlite.content.category-map-read', 11, 60) + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts new file mode 100644 index 000000000..9955c5a35 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts @@ -0,0 +1,176 @@ +import { + XTREAM_DATABASE_PERFORMANCE_PHASE, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, + type XtreamDatabasePerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { fitsPerformanceDurationEnvelope } from './performance-phase-duration-envelope'; + +const PHASE_SEQUENCES = { + DB_CLEAR_XTREAM_IMPORT_CACHE: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + ], + DB_DELETE_PLAYLIST: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + ], + DB_DELETE_XTREAM_CONTENT: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + ], + DB_GET_CATEGORIES: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + ], + DB_GET_CONTENT: [XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ], + DB_SAVE_CATEGORIES: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + ], + DB_SAVE_CONTENT: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ], + DB_SEARCH_CONTENT: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + ], +} as const satisfies Readonly< + Record +>; +const EVENT_KEYS = new Set([ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', +]); + +export function parseXtreamWorkerPerformancePhaseEvents( + operation: string | null, + input: unknown, + workStartedEpochMs: number, + workEndedEpochMs: number, + requestSucceeded: boolean +): + | readonly PerformancePhaseEvent[] + | null + | undefined { + const phaseSequence = + operation === null + ? undefined + : PHASE_SEQUENCES[operation as keyof typeof PHASE_SEQUENCES]; + if (!phaseSequence) { + return undefined; + } + const completeEventCount = phaseSequence.length * 2; + if ( + !Array.isArray(input) || + input.length === 0 || + input.length % 2 !== 0 || + input.length > completeEventCount || + (requestSucceeded && input.length !== completeEventCount) + ) { + return null; + } + + const parsed: PerformancePhaseEvent[] = []; + 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)) || + (boundary === 'end' && + requestSucceeded && + metadata?.itemCount === undefined) || + 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 XtreamDatabasePerformancePhase, + requestId, + }); + lastEpochMs = epochMs; + } + return parsed; +} + +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 { + 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; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts new file mode 100644 index 000000000..22cb34cbe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts @@ -0,0 +1,235 @@ +import type { + DatabaseWorkerPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { + XtreamAttributionPhaseEvent, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +const WORKER_PHASES = new Set([ + PHASE.DESERIALIZE_PLAYLIST, + PHASE.NORMALIZE_CATEGORIES, + PHASE.NORMALIZE_CONTENT, + PHASE.NORMALIZE_SEARCH_RANK, + PHASE.SERIALIZE_PLAYLIST, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + PHASE.SQLITE_GENERIC_READ, + PHASE.SQLITE_GENERIC_WRITE, + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + PHASE.SQLITE_SEARCH_QUERY, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, +]); + +interface WorkerGroup { + readonly correlationId: string; + readonly events: readonly XtreamAttributionPhaseEvent[]; + readonly phaseSignature: string; +} + +export function bindFixtureWorkerEvidence( + input: XtreamPhaseAttributionInput, + requests: readonly WorkerRequestPerformanceMetrics[] +): { + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; +} { + const groups = workerGroups(input.events); + const unused = new Set(groups); + const replacements = new Map< + string, + readonly XtreamAttributionPhaseEvent[] + >(); + const bound = requests.map((request) => { + if (request.phaseEvents.length === 0) return request; + const phaseSignature = signature( + request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ phase }) => phase) + ); + const candidates = groups.filter( + (group) => + unused.has(group) && group.phaseSignature === phaseSignature + ); + const matched = + candidates.find((group) => sameCounts(group, request)) ?? + candidates[0]; + if (!matched) fixtureError(); + unused.delete(matched); + const events = replaceItemCounts(matched, request); + replacements.set(matched.correlationId, events); + return alignRequest(request, matched.correlationId, events); + }); + if (unused.size !== 0) fixtureError(); + return { + phaseCapture: { + ...input, + events: input.events.map((event) => { + if (!WORKER_PHASES.has(event.phase)) return event; + const replacement = replacements + .get(event.correlationId) + ?.find( + (candidate) => + candidate.phase === event.phase && + candidate.boundary === event.boundary + ); + if (!replacement) fixtureError(); + return replacement; + }), + }, + requests: resequenceFixtureIpcCallIds(bound), + }; +} + +function resequenceFixtureIpcCallIds( + requests: readonly WorkerRequestPerformanceMetrics[] +): readonly WorkerRequestPerformanceMetrics[] { + const correlated = requests + .filter( + (request) => + request.ipcCallId !== null && request.sourceEpochMs !== null + ) + .sort( + (left, right) => + Number(left.sourceEpochMs) - Number(right.sourceEpochMs) || + Number(left.ipcCallId) - Number(right.ipcCallId) + ); + const ids = correlated + .map(({ ipcCallId }) => Number(ipcCallId)) + .sort((left, right) => left - right); + const reassigned = new Map( + correlated.map((request, index) => [request, ids[index]]) + ); + return requests.map((request) => { + const ipcCallId = reassigned.get(request); + return ipcCallId === undefined ? request : { ...request, ipcCallId }; + }); +} + +function workerGroups( + events: readonly XtreamAttributionPhaseEvent[] +): readonly WorkerGroup[] { + const grouped = new Map(); + for (const event of events) { + if (!WORKER_PHASES.has(event.phase)) continue; + grouped.set(event.correlationId, [ + ...(grouped.get(event.correlationId) ?? []), + event, + ]); + } + return [...grouped.entries()].map(([correlationId, selected]) => ({ + correlationId, + events: selected, + phaseSignature: signature( + selected + .filter(({ boundary }) => boundary === 'end') + .map(({ phase }) => phase) + ), + })); +} + +function sameCounts( + group: WorkerGroup, + request: WorkerRequestPerformanceMetrics +): boolean { + const captured = group.events + .filter(({ boundary }) => boundary === 'end') + .map(({ itemCount }) => itemCount); + const requested = request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); + return JSON.stringify(captured) === JSON.stringify(requested); +} + +function replaceItemCounts( + group: WorkerGroup, + request: WorkerRequestPerformanceMetrics +): readonly XtreamAttributionPhaseEvent[] { + const counts = request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); + let endIndex = 0; + return group.events.map((event) => ({ + ...event, + itemCount: + event.boundary === 'end' ? (counts[endIndex++] ?? null) : null, + })); +} + +function alignRequest( + request: WorkerRequestPerformanceMetrics, + requestId: string, + events: readonly XtreamAttributionPhaseEvent[] +): WorkerRequestPerformanceMetrics { + const phaseEvents = events.map( + (event): PerformancePhaseEvent => ({ + boundary: event.boundary, + durationMs: event.durationMs, + epochMs: event.epochMs, + ...(event.boundary === 'end' && event.itemCount !== null + ? { metadata: { itemCount: event.itemCount } } + : {}), + phase: event.phase as DatabaseWorkerPerformancePhase, + requestId, + }) + ); + const firstEpochMs = Math.min(...events.map(({ epochMs }) => epochMs)); + const lastEpochMs = Math.max(...events.map(({ epochMs }) => epochMs)); + const requestReceivedEpochMs = firstEpochMs - 2; + const workStartedEpochMs = firstEpochMs - 1; + const workEndedEpochMs = lastEpochMs + 0.25; + const histogramFlushedEpochMs = + request.histogramFlushedEpochMs === null + ? null + : workEndedEpochMs + 0.25; + const responsePostedEpochMs = workEndedEpochMs + 0.5; + return normalizeWorkerRequestPerformanceOutcome({ + ipcCallId: request.ipcCallId, + operation: request.operation, + operationId: request.operationId, + operationIdUnavailableReason: request.operationIdUnavailableReason, + performanceCapture: { + eventLoopDelay: request.eventLoopDelay, + eventLoopDelayUnavailableReason: + request.eventLoopDelayUnavailableReason, + eventLoopUtilization: request.eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + request.eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs, + invalidReason: request.invalidReason, + phaseEvents, + requestReceivedEpochMs, + responsePostedEpochMs, + threadCpuSystemMicros: request.threadCpuSystemMicros, + threadCpuUnavailableReason: request.threadCpuUnavailableReason, + threadCpuUserMicros: request.threadCpuUserMicros, + workEndedEpochMs, + workStartedEpochMs, + }, + playlistId: request.playlistId, + requestId, + responseEpochMs: responsePostedEpochMs + 0.25, + sourceEpochMs: + request.ipcCallId === null ? null : requestReceivedEpochMs - 0.25, + success: request.success, + }); +} + +function signature(phases: readonly string[]): string { + return phases.join('\0'); +} + +function fixtureError(): never { + throw new Error('invalid Xtream worker phase fixture binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts new file mode 100644 index 000000000..abe106362 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts @@ -0,0 +1,99 @@ +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const CATEGORY_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, +]); +const CONTENT_PHASES = new Set([ + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, +]); +const WORKER_REQUEST_SIGNATURES = [ + [PHASE.SERIALIZE_PLAYLIST, PHASE.SQLITE_GENERIC_WRITE], + [PHASE.SQLITE_GENERIC_READ, PHASE.DESERIALIZE_PLAYLIST], + [PHASE.SQLITE_CATEGORIES_READ], + [PHASE.NORMALIZE_CATEGORIES, PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS], + [PHASE.SQLITE_CONTENT_READ], + [ + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ], + [ + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + ], + [PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS], + [ + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + ], + [PHASE.SQLITE_SEARCH_QUERY, PHASE.NORMALIZE_SEARCH_RANK], +] as const; +const WORKER_PHASES = new Set(WORKER_REQUEST_SIGNATURES.flat()); +const WORKER_SIGNATURES = new Set( + WORKER_REQUEST_SIGNATURES.map(phaseSignature) +); + +export function assertXtreamWorkerCorrelationOwnership( + spans: readonly XtreamTopologyPhaseSpan[] +): void { + const groups = new Map(); + for (const span of spans) { + if (!WORKER_PHASES.has(span.phase)) continue; + if (!hasExpectedWorkerSource(span)) invalid(); + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + for (const group of groups.values()) { + if ( + new Set(group.map(({ semanticType }) => semanticType)).size !== 1 || + new Set(group.map(sourceDomain)).size !== 1 || + !WORKER_SIGNATURES.has( + phaseSignature(group.map(({ phase }) => phase)) + ) + ) { + invalid(); + } + } +} + +function sourceDomain(span: XtreamTopologyPhaseSpan): string { + return `${span.providerAction ?? ''}\0${span.categoryType ?? ''}\0${span.contentType ?? ''}`; +} + +function hasExpectedWorkerSource(span: XtreamTopologyPhaseSpan): boolean { + if (CATEGORY_PHASES.has(span.phase)) { + return ( + span.providerAction === null && + span.categoryType !== null && + span.contentType === null + ); + } + if (CONTENT_PHASES.has(span.phase)) { + return ( + span.providerAction === null && + span.categoryType === null && + span.contentType !== null + ); + } + return ( + span.providerAction === null && + span.categoryType === null && + span.contentType === null + ); +} + +function phaseSignature(phases: readonly string[]): string { + return [...phases].sort().join('\0'); +} + +function invalid(): never { + throw new Error('invalid worker correlation ownership'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts new file mode 100644 index 000000000..f7ff9f351 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts @@ -0,0 +1,245 @@ +import type { + DatabaseWorkerPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { + XTREAM_WORKER_REQUEST_IDENTITY_KIND, + xtreamWorkerRequestIdentityKind, +} from './xtream-worker-request-identity'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +export function workerRequestEvidence( + scenarioId: XtreamScenarioId +): WorkerRequestPerformanceMetrics[] { + let requestOrdinal = 0; + const operationOrdinals = new Map(); + return expectedXtreamWorkerRequestInventory(scenarioId).flatMap((entry) => + Array.from({ length: entry.count }, () => { + requestOrdinal += 1; + const operationOrdinal = + (operationOrdinals.get(entry.operation) ?? 0) + 1; + operationOrdinals.set(entry.operation, operationOrdinal); + return request( + scenarioId, + entry.operation, + entry.phases, + entry.success, + operationOrdinal, + requestOrdinal + ); + }) + ); +} + +export function invalidateWorkerRequestMetrics( + requests: readonly WorkerRequestPerformanceMetrics[], + count: number +): WorkerRequestPerformanceMetrics[] { + return requests.map((request, index) => + index >= count + ? request + : { + ...request, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'overlapping-database-worker-requests', + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'overlapping-database-worker-requests', + histogramFlushedEpochMs: null, + invalidReason: 'overlapping-database-worker-requests', + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + 'overlapping-database-worker-requests', + threadCpuUserMicros: null, + } + ); +} + +function request( + scenarioId: XtreamScenarioId, + operation: string, + phases: readonly string[], + success: boolean, + operationOrdinal: number, + requestOrdinal: number +): WorkerRequestPerformanceMetrics { + const requestId = `request-${requestOrdinal}`; + const start = 1_000 + requestOrdinal * 20; + const phaseEvents = createPhaseEvents( + phases, + phaseItemCounts(scenarioId, operation, operationOrdinal), + requestId, + start + 2, + success + ); + const identity = requestIdentity( + scenarioId, + operation, + operationOrdinal, + requestOrdinal, + start + ); + return normalizeWorkerRequestPerformanceOutcome({ + ...identity, + operation, + performanceCapture: { + eventLoopDelay: { maxMs: 9, p95Ms: 3, p99Ms: 5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: start + 13, + invalidReason: null, + phaseEvents, + requestReceivedEpochMs: start, + responsePostedEpochMs: start + 14, + threadCpuSystemMicros: 10, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 20, + workEndedEpochMs: start + 12, + workStartedEpochMs: start + 1, + }, + playlistId: 'synthetic-playlist', + requestId, + responseEpochMs: start + 15, + success, + }); +} + +function requestIdentity( + scenarioId: XtreamScenarioId, + operation: string, + operationOrdinal: number, + requestOrdinal: number, + start: number +) { + const kind = xtreamWorkerRequestIdentityKind(operation); + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.NOT_APPLICABLE) { + return { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + sourceEpochMs: null, + } as const; + } + const correlatedLegacy = + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD && + (scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI) && + (operation === 'DB_UPSERT_APP_PLAYLIST' || operationOrdinal === 1); + const emptyDeleteFollowUp = + operation === 'DB_DELETE_PLAYLIST' && operationOrdinal === 2; + const operationId = + (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION || + correlatedLegacy) && + !emptyDeleteFollowUp + ? `operation-${requestOrdinal}` + : null; + return { + ipcCallId: requestOrdinal, + operationId, + operationIdUnavailableReason: + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD && + !correlatedLegacy + ? 'preload-performance-marker-uncorrelated' + : null, + sourceEpochMs: start - 1, + } as const; +} + +function createPhaseEvents( + phases: readonly string[], + itemCounts: readonly (number | null)[], + requestId: string, + firstEpochMs: number, + success: boolean +): PerformancePhaseEvent[] { + return phases.flatMap((phase, index) => { + const startEpochMs = firstEpochMs + index * 2; + const itemCount = itemCounts[index] ?? null; + const omitCancelledWriteCount = !success && index === phases.length - 1; + return [ + { + boundary: 'start' as const, + durationMs: null, + epochMs: startEpochMs, + phase: phase as DatabaseWorkerPerformancePhase, + requestId, + }, + { + boundary: 'end' as const, + durationMs: 1, + epochMs: startEpochMs + 1, + ...(!omitCancelledWriteCount && itemCount !== null + ? { metadata: { itemCount } } + : {}), + phase: phase as DatabaseWorkerPerformancePhase, + requestId, + }, + ]; + }); +} + +function phaseItemCounts( + scenarioId: XtreamScenarioId, + operation: string, + ordinal: number +): readonly (number | null)[] { + if (operation === 'DB_UPSERT_APP_PLAYLIST') return [null, 1]; + if (operation === 'DB_GET_APP_PLAYLIST') return [1, 0]; + if (operation === 'DB_DELETE_XTREAM_CONTENT') return [100_100, 100_100]; + if (operation === 'DB_DELETE_PLAYLIST') { + return ordinal === 1 ? [100_100, 100_101] : [0, 1]; + } + if (operation === 'DB_GET_CATEGORIES') { + return ( + [[0], [60], [0], [20], [0], [20], [60], [20], [20]][ordinal - 1] ?? + [] + ); + } + if (operation === 'DB_SAVE_CATEGORIES') { + const count = [60, 20, 20][ordinal - 1] ?? 0; + return [count, count]; + } + if (operation === 'DB_GET_CONTENT') { + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) return [0]; + return ( + [ + [0], + [60_000], + [0], + [20_000], + [0], + [20_000], + [60_000], + [20_000], + [20_000], + ][ordinal - 1] ?? [] + ); + } + if (operation === 'DB_SAVE_CONTENT') { + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + return [60, 60_000, null]; + } + const index = ordinal - 1; + return ( + [ + [60, 60_000, 60_000], + [20, 20_000, 20_000], + [20, 20_000, 20_000], + ][index] ?? [] + ); + } + if (operation === 'DB_CLEAR_XTREAM_IMPORT_CACHE') { + return [ordinal === 1 ? 6_060 : 20]; + } + return []; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts new file mode 100644 index 000000000..69c34fac0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts @@ -0,0 +1,382 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { + hasFiniteNonNegativeKeys, + hasOrderedEventLoopDelay, + isExactRecord, + isPositiveSafeInteger, + isSafeNonNegativeInteger, + isUtilization, +} from './xtream-exact-schema'; +import { + XTREAM_WORKER_REQUEST_INVALID_REASON, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, +} from './xtream-summary-contract'; +import { + hasDistinctMonotonicXtreamIpcProducerIdentities, + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForWorkerOperation, + type XtreamIpcProducerIdentity, +} from './xtream-ipc-producer-identity'; +import { deriveXtreamWorkerRequestWindowProxy } from './xtream-worker-request-proxy'; +import { validXtreamWorkerRequestIdentity } from './xtream-worker-request-identity'; +import { + snapshotXtreamWorkerRequestAccounting, + XTREAM_WORKER_REQUEST_KEYS, +} from './xtream-worker-request-snapshot'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +export function validXtreamWorkerRequestAccounting( + value: unknown, + scenarioId: XtreamScenarioId +): boolean { + const accounting = snapshotXtreamWorkerRequestAccounting(value); + if ( + accounting === null || + !hasFiniteNonNegativeKeys(accounting, [ + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'eventLoopUtilization', + ]) || + !hasOrderedEventLoopDelay(accounting) || + !isUtilization(accounting['eventLoopUtilization']) || + !isPositiveSafeInteger(accounting['totalCount']) || + !isPositiveSafeInteger(accounting['validSampleCount']) || + !isSafeNonNegativeInteger(accounting['invalidSampleCount']) || + accounting['metricScope'] !== + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY + ) { + return false; + } + + const evidence = accounting[ + 'evidence' + ] as readonly WorkerRequestPerformanceMetrics[]; + const invalidReasons = accounting['invalidReasons'] as readonly unknown[]; + if ( + evidence.length !== accounting['totalCount'] || + evidence.some((request) => !isCanonicalRequest(request)) || + !hasDistinctRequestIdentities(evidence) || + !hasMonotonicProducerChronology(evidence) || + !hasExpectedInventory(evidence, scenarioId) || + !hasExpectedItemCounts(evidence, scenarioId) || + !hasExpectedDeleteIdentities(evidence, scenarioId) + ) { + return false; + } + const invalidSampleCount = evidence.filter( + (request) => + request.invalidReason === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ).length; + if ( + invalidSampleCount !== accounting['invalidSampleCount'] || + evidence.length - invalidSampleCount !== + accounting['validSampleCount'] || + (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE && + invalidSampleCount > 0) + ) { + return false; + } + const proxy = deriveXtreamWorkerRequestWindowProxy(evidence); + return ( + proxy !== null && + proxy.eventLoopDelayMaxMs === accounting['eventLoopDelayMaxMs'] && + proxy.eventLoopDelayP95Ms === accounting['eventLoopDelayP95Ms'] && + proxy.eventLoopDelayP99Ms === accounting['eventLoopDelayP99Ms'] && + proxy.eventLoopUtilization === accounting['eventLoopUtilization'] && + hasExactInvalidReasons(invalidReasons, invalidSampleCount) + ); +} + +function isCanonicalRequest( + value: unknown +): value is WorkerRequestPerformanceMetrics { + if ( + !isExactRecord(value, XTREAM_WORKER_REQUEST_KEYS) || + typeof value['operation'] !== 'string' || + !value['operation'].startsWith('DB_') || + typeof value['requestId'] !== 'string' || + value['requestId'].length === 0 || + typeof value['success'] !== 'boolean' || + value['performanceCaptureUnavailableReason'] !== null || + !validNullableIdentifier(value['playlistId']) || + !validXtreamWorkerRequestIdentity(value) + ) { + return false; + } + const normalized = normalizeWorkerRequestPerformanceOutcome({ + ipcCallId: value['ipcCallId'] as number | null, + operation: value['operation'], + operationId: value['operationId'] as string | null, + operationIdUnavailableReason: value['operationIdUnavailableReason'] as + string | null, + performanceCapture: { + eventLoopDelay: value['eventLoopDelay'], + eventLoopDelayUnavailableReason: + value['eventLoopDelayUnavailableReason'], + eventLoopUtilization: value['eventLoopUtilization'], + eventLoopUtilizationUnavailableReason: + value['eventLoopUtilizationUnavailableReason'], + histogramFlushedEpochMs: value['histogramFlushedEpochMs'], + invalidReason: value['invalidReason'], + phaseEvents: value['phaseEvents'], + requestReceivedEpochMs: value['requestReceivedEpochMs'], + responsePostedEpochMs: value['responsePostedEpochMs'], + threadCpuSystemMicros: value['threadCpuSystemMicros'], + threadCpuUnavailableReason: value['threadCpuUnavailableReason'], + threadCpuUserMicros: value['threadCpuUserMicros'], + workEndedEpochMs: value['workEndedEpochMs'], + workStartedEpochMs: value['workStartedEpochMs'], + }, + playlistId: value['playlistId'] as string | null, + requestId: value['requestId'], + responseEpochMs: value['responseEpochMs'] as number, + sourceEpochMs: value['sourceEpochMs'] as number | null, + success: value['success'], + }); + return ( + normalized.performanceCaptureUnavailableReason === null && + isDeepStrictEqual(normalized, value) + ); +} + +function hasDistinctRequestIdentities( + evidence: readonly WorkerRequestPerformanceMetrics[] +): boolean { + const requestIds = evidence.map(({ requestId }) => requestId); + const producerIdentities = collectProducerIdentities(evidence); + if (producerIdentities === null) return false; + const ipcCallIds = producerIdentities.map(({ ipcCallId, namespace }) => + xtreamIpcProducerIdentityKey(namespace, ipcCallId) + ); + const operationIds = evidence.flatMap(({ operationId }) => + operationId === null ? [] : [operationId] + ); + return ( + new Set(requestIds).size === requestIds.length && + new Set(ipcCallIds).size === ipcCallIds.length && + new Set(operationIds).size === operationIds.length + ); +} + +function hasMonotonicProducerChronology( + evidence: readonly WorkerRequestPerformanceMetrics[] +): boolean { + // Evidence is only a subset of preload calls, so ID gaps are valid. The + // producer clamps its clock, which also makes equal source epochs valid. + const identities = collectProducerIdentities(evidence); + return ( + identities !== null && + hasDistinctMonotonicXtreamIpcProducerIdentities(identities) + ); +} + +function collectProducerIdentities( + evidence: readonly WorkerRequestPerformanceMetrics[] +): readonly XtreamIpcProducerIdentity[] | null { + const identities: XtreamIpcProducerIdentity[] = []; + for (const { ipcCallId, operation, sourceEpochMs } of evidence) { + if (ipcCallId === null && sourceEpochMs === null) continue; + const namespace = xtreamIpcProducerNamespaceForWorkerOperation( + typeof operation === 'string' ? operation : '' + ); + if ( + namespace === null || + !isPositiveSafeInteger(ipcCallId) || + typeof sourceEpochMs !== 'number' + ) { + return null; + } + identities.push({ ipcCallId, namespace, sourceEpochMs }); + } + return identities; +} + +function hasExpectedInventory( + evidence: readonly WorkerRequestPerformanceMetrics[], + scenarioId: XtreamScenarioId +): boolean { + const expected = new Map(); + for (const entry of expectedXtreamWorkerRequestInventory(scenarioId)) { + expected.set(signature(entry), entry.count); + } + const actual = new Map(); + for (const request of evidence) { + const phases = request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ phase }) => phase); + const key = signature({ + operation: request.operation ?? '', + phases, + success: request.success, + }); + actual.set(key, (actual.get(key) ?? 0) + 1); + } + return isDeepStrictEqual(actual, expected); +} + +function hasExpectedItemCounts( + evidence: readonly WorkerRequestPerformanceMetrics[], + scenarioId: XtreamScenarioId +): boolean { + const operations = new Set( + expectedXtreamWorkerRequestInventory(scenarioId).map( + ({ operation }) => operation + ) + ); + for (const operation of operations) { + const actual = evidence + .filter((request) => request.operation === operation) + .map(requestItemCounts); + const expected = expectedItemCounts(scenarioId, operation); + if (expected === null) continue; + if (operation === 'DB_CLEAR_XTREAM_IMPORT_CACHE') { + const values = actual.flat(); + if ( + values.length !== 3 || + values.filter((value) => value === 20).length !== 2 || + values.filter( + (value) => + typeof value === 'number' && + value >= 6_060 && + value < 60_060 + ).length !== 1 + ) { + return false; + } + continue; + } + if (!isDeepStrictEqual(sorted(actual), sorted(expected))) return false; + } + return true; +} + +function expectedItemCounts( + scenarioId: XtreamScenarioId, + operation: string +): readonly (readonly (number | null)[])[] | null { + if (operation === 'DB_UPSERT_APP_PLAYLIST') return [[null, 1]]; + if (operation === 'DB_GET_APP_PLAYLIST') return [[1, 0]]; + if (operation === 'DB_GET_CATEGORIES') { + const importReads = [[0], [0], [0], [60], [20], [20]] as const; + return scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ? [...importReads, [60], [20], [20]] + : importReads; + } + if (operation === 'DB_SAVE_CATEGORIES') { + return [ + [60, 60], + [20, 20], + [20, 20], + ]; + } + if (operation === 'DB_GET_CONTENT') { + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) return [[0]]; + const importReads = [ + [0], + [60_000], + [0], + [20_000], + [0], + [20_000], + ] as const; + return scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ? [...importReads, [60_000], [20_000], [20_000]] + : importReads; + } + if (operation === 'DB_SAVE_CONTENT') { + return scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT + ? [[60, 60_000, null]] + : [ + [60, 60_000, 60_000], + [20, 20_000, 20_000], + [20, 20_000, 20_000], + ]; + } + if (operation === 'DB_DELETE_XTREAM_CONTENT') { + return [[100_100, 100_100]]; + } + if (operation === 'DB_DELETE_PLAYLIST') { + return [ + [100_100, 100_101], + [0, 1], + ]; + } + if (operation === 'DB_CLEAR_XTREAM_IMPORT_CACHE') return [[], [], []]; + return null; +} + +function hasExpectedDeleteIdentities( + evidence: readonly WorkerRequestPerformanceMetrics[], + scenarioId: XtreamScenarioId +): boolean { + if (scenarioId !== XTREAM_SCENARIO_ID.DELETE_LARGE) return true; + const deletes = evidence.filter( + ({ operation }) => operation === 'DB_DELETE_PLAYLIST' + ); + const populated = deletes.find((request) => + isDeepStrictEqual(requestItemCounts(request), [100_100, 100_101]) + ); + const emptyFollowUp = deletes.find((request) => + isDeepStrictEqual(requestItemCounts(request), [0, 1]) + ); + return ( + deletes.length === 2 && + typeof populated?.operationId === 'string' && + populated.operationId.length > 0 && + populated.operationIdUnavailableReason === null && + emptyFollowUp?.operationId === null && + emptyFollowUp.operationIdUnavailableReason === null + ); +} + +function requestItemCounts( + request: WorkerRequestPerformanceMetrics +): readonly (number | null)[] { + return request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); +} + +function hasExactInvalidReasons( + value: readonly unknown[], + invalidSampleCount: number +): boolean { + if (invalidSampleCount === 0) return value.length === 0; + const reason = value[0]; + return ( + value.length === 1 && + isExactRecord(reason, ['count', 'reason']) && + reason['count'] === invalidSampleCount && + reason['reason'] === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ); +} + +function signature(value: { + readonly operation: string; + readonly phases: readonly string[]; + readonly success: boolean; +}): string { + return `${value.operation}\0${value.success ? 'success' : 'error'}\0${value.phases.join('\0')}`; +} + +function sorted( + values: readonly (readonly (number | null)[])[] +): readonly string[] { + return values.map((value) => JSON.stringify(value)).sort(); +} + +function validNullableIdentifier(value: unknown): boolean { + return value === null || (typeof value === 'string' && value.length > 0); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts new file mode 100644 index 000000000..07646609d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts @@ -0,0 +1,118 @@ +import { isPositiveSafeInteger } from './xtream-exact-schema'; + +const SOURCE_RECEIPT_CLOCK_SKEW_TOLERANCE_MS = 1; + +export const XTREAM_WORKER_REQUEST_IDENTITY_KIND = { + LEGACY_PRELOAD: 'legacy-preload', + NOT_APPLICABLE: 'not-applicable', + PRODUCER_IPC: 'producer-ipc', + PRODUCER_OPERATION: 'producer-operation', +} as const; + +export type XtreamWorkerRequestIdentityKind = + (typeof XTREAM_WORKER_REQUEST_IDENTITY_KIND)[keyof typeof XTREAM_WORKER_REQUEST_IDENTITY_KIND]; + +const PRODUCER_OPERATION_ID_OPERATIONS = new Set([ + 'DB_DELETE_PLAYLIST', + 'DB_DELETE_XTREAM_CONTENT', + 'DB_RESTORE_XTREAM_USER_DATA', + 'DB_SAVE_CONTENT', +]); +const PRODUCER_IPC_OPERATIONS = new Set([ + 'DB_GET_CATEGORIES', + 'DB_GET_CONTENT', + 'DB_GLOBAL_SEARCH', + 'DB_SAVE_CATEGORIES', + 'DB_SEARCH_CONTENT', +]); +const LEGACY_PRELOAD_OPERATIONS = new Set([ + 'DB_GET_APP_PLAYLIST', + 'DB_UPSERT_APP_PLAYLIST', +]); + +export function xtreamWorkerRequestIdentityKind( + operation: string +): XtreamWorkerRequestIdentityKind { + if (PRODUCER_OPERATION_ID_OPERATIONS.has(operation)) { + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION; + } + if (PRODUCER_IPC_OPERATIONS.has(operation)) { + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_IPC; + } + if (LEGACY_PRELOAD_OPERATIONS.has(operation)) { + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD; + } + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.NOT_APPLICABLE; +} + +export function validXtreamWorkerRequestIdentity( + value: Record +): boolean { + const operation = value['operation']; + if (typeof operation !== 'string') return false; + const kind = xtreamWorkerRequestIdentityKind(operation); + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.NOT_APPLICABLE) { + return ( + value['ipcCallId'] === null && + value['sourceEpochMs'] === null && + value['operationId'] === null && + value['operationIdUnavailableReason'] === + 'preload-performance-marker-not-applicable' + ); + } + if (!hasOrderedIpcIdentity(value)) return false; + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION) { + if (operation === 'DB_DELETE_PLAYLIST') { + // The explicit Xtream delete has an operation ID, while the NgRx + // persistence follow-up is a real correlated preload call without + // one. Request evidence binds each shape to its exact row counts. + return ( + value['operationIdUnavailableReason'] === null && + (nonEmptyString(value['operationId']) || + value['operationId'] === null) + ); + } + return ( + nonEmptyString(value['operationId']) && + value['operationIdUnavailableReason'] === null + ); + } + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_IPC) { + return ( + value['operationId'] === null && + value['operationIdUnavailableReason'] === null + ); + } + return nonEmptyString(value['operationId']) + ? value['operationIdUnavailableReason'] === null + : value['operationId'] === null && + value['operationIdUnavailableReason'] === + 'preload-performance-marker-uncorrelated'; +} + +function hasOrderedIpcIdentity(value: Record): boolean { + const sourceEpochMs = value['sourceEpochMs']; + const requestReceivedEpochMs = value['requestReceivedEpochMs']; + const responseEpochMs = value['responseEpochMs']; + return ( + isPositiveSafeInteger(value['ipcCallId']) && + isFinitePositive(sourceEpochMs) && + isFiniteNonNegative(requestReceivedEpochMs) && + isFiniteNonNegative(responseEpochMs) && + sourceEpochMs - requestReceivedEpochMs < + SOURCE_RECEIPT_CLOCK_SKEW_TOLERANCE_MS && + requestReceivedEpochMs <= responseEpochMs + ); +} + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts new file mode 100644 index 000000000..8a4d8495e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; + +describe('Xtream database-worker request inventory', () => { + it('defines the exact operation, outcome, and phase multiset for every scenario', () => { + const expectedTotals = new Map([ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, 30], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, 51], + [XTREAM_SCENARIO_ID.DELETE_LARGE, 2], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT, 24], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, 54], + ]); + + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const inventory = expectedXtreamWorkerRequestInventory(scenarioId); + assert.equal( + inventory.reduce((total, entry) => total + entry.count, 0), + expectedTotals.get(scenarioId) + ); + assert.ok( + inventory.every( + ({ count, operation, success, phases }) => + Number.isSafeInteger(count) && + count > 0 && + operation.startsWith('DB_') && + typeof success === 'boolean' && + phases.every((phase) => phase.length > 0) + ) + ); + } + }); + + it('includes the route hydration requests observed after refresh commit', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ]) { + const counts = new Map( + expectedXtreamWorkerRequestInventory(scenarioId).map( + ({ count, operation }) => [operation, count] + ) + ); + + assert.equal(counts.get('DB_GET_PLAYLIST'), 2); + assert.equal(counts.get('DB_GET_APP_STATE'), 12); + assert.equal(counts.get('DB_GET_CATEGORIES'), 9); + assert.equal(counts.get('DB_GET_CONTENT'), 9); + assert.equal(counts.get('DB_SET_APP_STATE'), 6); + } + }); + + it('attributes portal-route hydration without accepting dashboard work', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ]) { + const operations = expectedXtreamWorkerRequestInventory( + scenarioId + ).map(({ operation }) => operation); + assert.ok(operations.includes('DB_GET_PLAYLIST')); + } + + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + ]) { + const operations = expectedXtreamWorkerRequestInventory( + scenarioId + ).map(({ operation }) => operation); + assert.ok(operations.includes('DB_GET_ALL_PLAYBACK_POSITIONS')); + assert.equal( + operations.includes('DB_GET_GLOBAL_RECENTLY_ADDED'), + false + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts new file mode 100644 index 000000000..ccc0ebe11 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + invalidateWorkerRequestMetrics, + workerRequestEvidence, +} from './xtream-worker-request-evidence.fixtures'; +import { deriveXtreamWorkerRequestWindowProxy } from './xtream-worker-request-proxy'; + +describe('Xtream request-window proxy', () => { + it('uses the worst valid request window and excludes only overlap-invalid samples', () => { + const evidence = workerRequestEvidence( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ).map((request, index) => + index === 1 + ? { + ...request, + eventLoopDelay: { + maxMs: 12, + p95Ms: 4, + p99Ms: 7, + }, + eventLoopUtilization: 0.75, + } + : request + ); + const withOverlap = invalidateWorkerRequestMetrics(evidence, 1); + + assert.deepEqual(deriveXtreamWorkerRequestWindowProxy(withOverlap), { + eventLoopDelayMaxMs: 12, + eventLoopDelayP95Ms: 4, + eventLoopDelayP99Ms: 7, + eventLoopUtilization: 0.75, + }); + assert.equal( + deriveXtreamWorkerRequestWindowProxy( + invalidateWorkerRequestMetrics(evidence, evidence.length) + ), + null + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts new file mode 100644 index 000000000..6fc05dbd2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts @@ -0,0 +1,71 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { XTREAM_WORKER_REQUEST_INVALID_REASON } from './xtream-summary-contract'; + +export interface XtreamWorkerRequestWindowProxy { + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: number; +} + +export function deriveXtreamWorkerRequestWindowProxy( + evidence: readonly WorkerRequestPerformanceMetrics[] +): XtreamWorkerRequestWindowProxy | null { + const valid = evidence.filter( + ({ invalidReason }) => + invalidReason !== + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ); + if ( + valid.length === 0 || + valid.some( + ({ eventLoopDelay, eventLoopUtilization, invalidReason }) => + invalidReason !== null || + !isDelay(eventLoopDelay) || + !isUtilization(eventLoopUtilization) + ) + ) { + return null; + } + return { + eventLoopDelayMaxMs: Math.max( + ...valid.map(({ eventLoopDelay }) => eventLoopDelay?.maxMs ?? 0) + ), + eventLoopDelayP95Ms: Math.max( + ...valid.map(({ eventLoopDelay }) => eventLoopDelay?.p95Ms ?? 0) + ), + eventLoopDelayP99Ms: Math.max( + ...valid.map(({ eventLoopDelay }) => eventLoopDelay?.p99Ms ?? 0) + ), + eventLoopUtilization: Math.max( + ...valid.map( + ({ eventLoopUtilization }) => eventLoopUtilization ?? 0 + ) + ), + }; +} + +function isDelay( + value: WorkerRequestPerformanceMetrics['eventLoopDelay'] +): value is NonNullable { + return ( + value !== null && + [value.p95Ms, value.p99Ms, value.maxMs].every( + (sample) => + typeof sample === 'number' && + Number.isFinite(sample) && + sample >= 0 + ) && + value.p95Ms <= value.p99Ms && + value.p99Ms <= value.maxMs + ); +} + +function isUtilization(value: number | null): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value <= 1 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts new file mode 100644 index 000000000..6404d2f58 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createXtreamIterationDefinitions, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import type { XtreamWorkerRequestAccounting } from './xtream-summary-contract'; +import { validXtreamWorkerRequestAccounting } from './xtream-worker-request-evidence'; + +describe('Xtream worker request canonical snapshot', () => { + it('rejects an accessor-backed request record without invoking it', () => { + const requests = deleteAccounting(); + const target = required(requests.evidence, 0); + const forged = { ...target }; + const invoked: string[] = []; + Object.defineProperty(forged, 'operation', { + enumerable: true, + get() { + invoked.push('request.operation'); + return target.operation; + }, + }); + + assert.equal( + validXtreamWorkerRequestAccounting( + withRequest(requests, target, forged), + XTREAM_SCENARIO_ID.DELETE_LARGE + ), + false + ); + assert.deepEqual(invoked, []); + }); + + it('rejects caller-owned nested phase array methods without invoking them', () => { + const requests = deleteAccounting(); + const target = required(requests.evidence, 0); + const phaseEvents = [...target.phaseEvents]; + const invoked: string[] = []; + for (const method of ['filter', 'flatMap', 'map', 'some'] as const) { + Object.defineProperty(phaseEvents, method, { + configurable: true, + value(...args: unknown[]) { + invoked.push(method); + return Reflect.apply( + Array.prototype[method], + target.phaseEvents, + args + ); + }, + }); + } + const forged = { ...target, phaseEvents }; + + assert.equal( + validXtreamWorkerRequestAccounting( + withRequest(requests, target, forged), + XTREAM_SCENARIO_ID.DELETE_LARGE + ), + false + ); + assert.deepEqual(invoked, []); + }); +}); + +function deleteAccounting(): XtreamWorkerRequestAccounting { + const definition = createXtreamIterationDefinitions(false).find( + ({ scenarioId }) => scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE + ); + assert.ok(definition); + return iteration(definition, 1).databaseWorker.requests; +} + +function withRequest( + accounting: XtreamWorkerRequestAccounting, + target: XtreamWorkerRequestAccounting['evidence'][number], + replacement: XtreamWorkerRequestAccounting['evidence'][number] +): XtreamWorkerRequestAccounting { + return { + ...accounting, + evidence: accounting.evidence.map((request) => + request === target ? replacement : request + ), + }; +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + assert.ok(value); + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts new file mode 100644 index 000000000..9006561e6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts @@ -0,0 +1,230 @@ +import type { + DatabaseWorkerPerformancePhase, + PerformancePhaseEvent, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; + +const ACCOUNTING_KEYS = [ + 'evidence', + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'eventLoopUtilization', + 'invalidReasons', + 'invalidSampleCount', + 'metricScope', + 'totalCount', + 'validSampleCount', +] as const; +const EVENT_LOOP_DELAY_KEYS = ['maxMs', 'p95Ms', 'p99Ms'] as const; +const PHASE_EVENT_KEYS = [ + 'boundary', + 'durationMs', + 'epochMs', + 'phase', + 'requestId', +] as const; +const PHASE_METADATA_KEYS = new Set(['byteCount', 'itemCount']); +const INVALID_REASON_KEYS = ['count', 'reason'] as const; + +export const XTREAM_WORKER_REQUEST_KEYS = [ + 'eventLoopDelay', + 'eventLoopDelayUnavailableReason', + 'eventLoopUtilization', + 'eventLoopUtilizationUnavailableReason', + 'histogramFlushedEpochMs', + 'ipcCallId', + 'invalidReason', + 'operation', + 'operationId', + 'operationIdUnavailableReason', + 'performanceCaptureUnavailableReason', + 'phaseEvents', + 'playlistId', + 'requestId', + 'requestReceivedEpochMs', + 'responseEpochMs', + 'responsePostedEpochMs', + 'sourceEpochMs', + 'success', + 'threadCpuSystemMicros', + 'threadCpuUnavailableReason', + 'threadCpuUserMicros', + 'workEndedEpochMs', + 'workStartedEpochMs', +] as const; + +export function snapshotXtreamWorkerRequestAccounting( + value: unknown +): Readonly> | null { + try { + const accounting = snapshotExactRecord(value, ACCOUNTING_KEYS); + const evidence = snapshotArray( + accounting['evidence'], + snapshotWorkerRequest + ); + const invalidReasons = snapshotArray( + accounting['invalidReasons'], + (reason) => snapshotExactRecord(reason, INVALID_REASON_KEYS) + ); + return Object.freeze({ + ...accounting, + evidence, + invalidReasons, + }); + } catch { + return null; + } +} + +function snapshotWorkerRequest( + value: unknown +): WorkerRequestPerformanceMetrics { + const request = snapshotExactRecord(value, XTREAM_WORKER_REQUEST_KEYS); + const eventLoopDelay = + request['eventLoopDelay'] === null + ? null + : snapshotExactRecord( + request['eventLoopDelay'], + EVENT_LOOP_DELAY_KEYS + ); + const phaseEvents = snapshotArray( + request['phaseEvents'], + snapshotPhaseEvent + ); + return Object.freeze({ + ...request, + eventLoopDelay, + phaseEvents, + }) as unknown as WorkerRequestPerformanceMetrics; +} + +function snapshotPhaseEvent( + value: unknown +): PerformancePhaseEvent { + const hasMetadata = hasOwnDataProperty(value, 'metadata'); + const event = snapshotExactRecord( + value, + hasMetadata ? [...PHASE_EVENT_KEYS, 'metadata'] : PHASE_EVENT_KEYS + ); + if (!hasMetadata) { + return event as unknown as PerformancePhaseEvent; + } + const metadata = + event['metadata'] === undefined + ? undefined + : snapshotPhaseMetadata(event['metadata']); + return Object.freeze({ + ...event, + metadata, + }) as unknown as PerformancePhaseEvent; +} + +function snapshotPhaseMetadata(value: unknown): PerformancePhaseMetadata { + const keys = ownDataKeys(value); + if ( + keys.length > PHASE_METADATA_KEYS.size || + keys.some((key) => !PHASE_METADATA_KEYS.has(key)) + ) { + invalid(); + } + return snapshotExactRecord( + value, + keys + ) as unknown as PerformancePhaseMetadata; +} + +function snapshotArray( + value: unknown, + snapshotElement: (element: unknown) => T +): readonly T[] { + if (!Array.isArray(value)) invalid(); + const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, 'length'); + const length = dataDescriptorValue(lengthDescriptor); + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 512 + ) { + invalid(); + } + const expectedKeys = new Set(['length']); + const snapshot: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const key = String(index); + expectedKeys.add(key); + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + snapshot[index] = snapshotElement(dataDescriptorValue(descriptor)); + } + 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> { + const ownKeys = ownDataKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => !keys.includes(key)) + ) { + invalid(); + } + const snapshot: Record = {}; + for (const key of keys) { + snapshot[key] = dataDescriptorValue( + Reflect.getOwnPropertyDescriptor(value as object, key) + ); + } + return Object.freeze(snapshot); +} + +function ownDataKeys(value: unknown): string[] { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) invalid(); + for (const key of keys) { + dataDescriptorValue(Reflect.getOwnPropertyDescriptor(value, key)); + } + return keys as string[]; +} + +function hasOwnDataProperty(value: unknown, key: string): boolean { + if (typeof value !== 'object' || value === null) invalid(); + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if (!descriptor) return false; + dataDescriptorValue(descriptor); + return true; +} + +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 worker request snapshot'); +} diff --git a/apps/electron-backend-e2e/src/xtream.performance.ts b/apps/electron-backend-e2e/src/xtream.performance.ts new file mode 100644 index 000000000..16f86859d --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream.performance.ts @@ -0,0 +1,7 @@ +import { test } from '@playwright/test'; + +import { runXtreamBenchmark } from './performance/xtream-benchmark-orchestrator'; + +test('profiles the synthetic 100k Xtream workflows', async () => { + await runXtreamBenchmark(); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts index 69c8a7e0f..1b8d01fd8 100644 --- a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts +++ b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts @@ -3,6 +3,7 @@ import type { Playlist, PlaylistRefreshPayload, PreloadPerformanceMarker, + XtreamPreloadPerformanceMarker, } from '@iptvnator/shared/interfaces'; export const PRELOAD_ENVIRONMENT = { @@ -31,6 +32,7 @@ export interface PreloadPerformanceHarness { getApi: () => ExposedElectronApi; getMarkers: () => PreloadPerformanceMarker[]; getSentPayloads: (channel: string) => T[]; + getXtreamMarkers: () => XtreamPreloadPerformanceMarker[]; ipcRenderer: MockIpcRenderer; load: (environment?: PreloadEnvironment) => Promise; } @@ -130,6 +132,10 @@ export function createPreloadPerformanceHarness(): PreloadPerformanceHarness { 'IPTVNATOR_PERF_CAPTURE_MARKER' ), getSentPayloads, + getXtreamMarkers: () => + getSentPayloads( + 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER' + ), ipcRenderer, load: async (environment = {}) => { jest.resetModules(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6f39995da..f9c4c5fd3 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -15,6 +15,11 @@ import { createPreloadPerformanceCapture, toPreloadPerformanceTargetMethod, } from './preload-performance-capture'; +import { + createXtreamPreloadPerformanceCapture, + isXtreamPreloadPerformanceCaptureEnabled, + toXtreamPreloadPerformanceTargetMethod, +} from './xtream-preload-performance-capture'; import type { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, @@ -78,10 +83,17 @@ const dbSaveContentProgressListeners = new Set< const shouldTraceRendererApi = isRendererApiTraceEnabled(); const shouldCapturePerformance = isPerformanceCaptureEnabled(); +const shouldCaptureXtreamPerformance = + isXtreamPreloadPerformanceCaptureEnabled(); const preloadPerformanceCapture = createPreloadPerformanceCapture( shouldCapturePerformance, (channel, marker) => ipcRenderer.send(channel, marker) ); +const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance + ? createXtreamPreloadPerformanceCapture(true, (channel, marker) => + ipcRenderer.send(channel, marker) + ) + : null; function emitRendererTrace(payload: { method: string; @@ -105,12 +117,18 @@ function wrapElectronApi(api: T): T { return Object.fromEntries( Object.entries(api).map(([name, value]) => { + const isPreloadPerformanceTarget = + toPreloadPerformanceTargetMethod(name) !== null; + const isXtreamPreloadPerformanceTarget = + shouldCaptureXtreamPerformance && + toXtreamPreloadPerformanceTargetMethod(name) !== null; if ( typeof value !== 'function' || name.startsWith('on') || name.startsWith('remove') || (!shouldTraceRendererApi && - !toPreloadPerformanceTargetMethod(name)) + !isPreloadPerformanceTarget && + !isXtreamPreloadPerformanceTarget) ) { return [name, value]; } @@ -134,6 +152,9 @@ function wrapElectronApi(api: T): T { name, args ); + const xtreamPerformanceCall = + xtreamPreloadPerformanceCapture?.start(name, args) ?? + null; try { const result = original(...args); @@ -162,6 +183,9 @@ function wrapElectronApi(api: T): T { performanceCall, resolvedValue ); + xtreamPreloadPerformanceCapture?.success( + xtreamPerformanceCall + ); return resolvedValue; }) .catch((error: unknown) => { @@ -179,6 +203,9 @@ function wrapElectronApi(api: T): T { preloadPerformanceCapture.error( performanceCall ); + xtreamPreloadPerformanceCapture?.error( + xtreamPerformanceCall + ); throw error; }); } @@ -198,6 +225,9 @@ function wrapElectronApi(api: T): T { performanceCall, result ); + xtreamPreloadPerformanceCapture?.success( + xtreamPerformanceCall + ); return result; } catch (error) { @@ -213,6 +243,9 @@ function wrapElectronApi(api: T): T { }); } preloadPerformanceCapture.error(performanceCall); + xtreamPreloadPerformanceCapture?.error( + xtreamPerformanceCall + ); throw error; } }, @@ -738,11 +771,7 @@ const electronApi: ElectronBridgeApi = { kind: 'all' | 'vod' | 'series', limit?: number, playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url' + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url' ) => ipcRenderer.invoke( 'DB_GET_GLOBAL_RECENTLY_ADDED', diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts new file mode 100644 index 000000000..4a77bbe9f --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts @@ -0,0 +1,187 @@ +import { + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + type XtreamPreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { + createPreloadPerformanceHarness, + createRefreshPayload, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; +import { + XTREAM_PRELOAD_REQUEST_PAYLOAD, + XTREAM_PRELOAD_TARGET_CASES, +} from './main.preload.xtream-performance.spec-data'; + +const MARKER_KEYS = [ + 'action', + 'boundary', + 'categoryType', + 'contentType', + 'ipcCallId', + 'itemCount', + 'method', + 'operationId', + 'outcome', + 'playlistId', + 'schemaVersion', + 'sessionId', + 'sourceEpochMs', +] as const; + +describe('main preload Xtream performance marker contract', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + jest.restoreAllMocks(); + }); + + it.each(XTREAM_PRELOAD_TARGET_CASES)( + 'marks $method without changing IPC arguments or result identity', + async ({ expectedInvoke, expectedMetadata, invoke, method }) => { + const result = new Proxy({}, { get: () => undefined }); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect( + Promise.resolve(invoke(harness.getApi())) + ).resolves.toBe(result); + + expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + ...expectedInvoke + ); + const markers = harness.getXtreamMarkers(); + expect(markers).toHaveLength(2); + expect(markers).toEqual([ + expect.objectContaining({ + ...expectedMetadata, + boundary: 'start', + ipcCallId: 1, + method, + outcome: null, + schemaVersion: 1, + }), + expect.objectContaining({ + ...expectedMetadata, + boundary: 'end', + ipcCallId: 1, + method, + outcome: 'success', + schemaVersion: 1, + }), + ]); + expectFixedMarkerShape(markers); + } + ); + + it('preserves exact rejection and synchronous throw identities', async () => { + const rejection = new Error('provider-error-secret'); + const synchronousError = new Error('provider-sync-secret'); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect( + harness.getApi().dbGetContent('playlist-1', 'live') + ).rejects.toBe(rejection); + expect( + harness.getXtreamMarkers().map(({ outcome }) => outcome) + ).toEqual([null, 'error']); + + harness.ipcRenderer.invoke.mockImplementationOnce(() => { + throw synchronousError; + }); + expect(() => harness.getApi().dbCancelOperation('operation-1')).toThrow( + synchronousError + ); + expect( + harness.getXtreamMarkers().map(({ outcome }) => outcome) + ).toEqual([null, 'error', null, 'error']); + }); + + it('preserves a synchronous target return identity', async () => { + const result = { synchronous: true }; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockReturnValueOnce(result); + + const actual = harness + .getApi() + .dbCancelOperation('operation-1') as unknown; + + expect(actual).toBe(result); + expect( + harness.getXtreamMarkers().map(({ outcome }) => outcome) + ).toEqual([null, 'success']); + }); + + it('keeps M3U and Xtream protocols on their own channels', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + + await harness.getApi().refreshPlaylist(createRefreshPayload()); + await harness.getApi().xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD); + + expect(harness.getMarkers()).toHaveLength(2); + expect(harness.getXtreamMarkers()).toHaveLength(2); + expect( + harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL) + ).toEqual(harness.getMarkers()); + expect( + harness.getSentPayloads(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL) + ).toEqual(harness.getXtreamMarkers()); + expect( + harness + .getMarkers() + .every(({ method }) => method === 'refreshPlaylist') + ).toBe(true); + expect( + harness + .getXtreamMarkers() + .every(({ method }) => method === 'xtreamRequest') + ).toBe(true); + }); +}); + +function expectFixedMarkerShape( + markers: XtreamPreloadPerformanceMarker[] +): void { + for (const [index, marker] of markers.entries()) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + expect(Number.isFinite(marker.sourceEpochMs)).toBe(true); + if (index > 0) { + expect(marker.sourceEpochMs).toBeGreaterThanOrEqual( + markers[index - 1].sourceEpochMs + ); + } + } + const serialized = JSON.stringify(markers); + for (const forbidden of [ + 'provider-password-secret', + 'provider-user-secret', + 'provider-url-secret', + 'request-secret', + 'secret-title', + 'secret-stream-title', + 'secret-favorite-title', + 'secret-recent-title', + 'provider-search-secret', + 'global-search-secret', + 'secret-source', + 'provider-error-secret', + ]) { + expect(serialized).not.toContain(forbidden); + } +} diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts new file mode 100644 index 000000000..b7ba6da61 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts @@ -0,0 +1,180 @@ +import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; +import { + createPreloadPerformanceHarness, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload Xtream performance marker gates', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + jest.restoreAllMocks(); + }); + + it.each([ + undefined, + '', + '0', + 'false', + 'true', + ' TRUE ', + 'yes', + 'On', + ' 1 ', + ])( + 'requires the exact performance flag value 1, not %p', + async (flagValue) => { + await harness.load( + flagValue === undefined + ? {} + : { + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + } + ); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect(harness.getXtreamMarkers()).toEqual([]); + } + ); + + it('enables the Xtream marker only for the exact value 1', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect(harness.getXtreamMarkers()).toHaveLength(2); + }); + + it('does no Xtream clock or metadata work while its exact gate is disabled', async () => { + const clock = jest + .spyOn(globalThis.performance, 'now') + .mockImplementation(() => { + throw new Error('clock must stay idle'); + }); + const payload = new Proxy( + {}, + { + get: () => { + throw new Error('metadata must stay opaque'); + }, + } + ); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: 'true', + }); + + await expect( + harness.getApi().xtreamRequest(payload as never) + ).resolves.toEqual({ success: true }); + + expect(clock).not.toHaveBeenCalled(); + expect(harness.getXtreamMarkers()).toEqual([]); + expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe( + 'XTREAM_REQUEST' + ); + expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(payload); + }); + + it('does not inspect or mark non-target methods in performance-only mode', async () => { + const settings = new Proxy( + {}, + { + get: () => { + throw new Error('non-target data must stay opaque'); + }, + } + ); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + + await harness.getApi().updateSettings(settings); + + expect(harness.getXtreamMarkers()).toEqual([]); + expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe( + 'SETTINGS_UPDATE' + ); + expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(settings); + }); + + it('keeps trace-only Xtream calls on the existing debug channel', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect( + harness + .getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { method: 'dbGetContent', phase: 'start' }, + { method: 'dbGetContent', phase: 'success' }, + ]); + expect(harness.getXtreamMarkers()).toEqual([]); + }); + + it('keeps both debug tracing and Xtream markers when both exact gates are enabled', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect(harness.getSentPayloads(DEBUG_TRACE_EVENT_CHANNEL)).toHaveLength( + 2 + ); + expect(harness.getXtreamMarkers()).toHaveLength(2); + }); + + it('keeps the bridge fail-neutral when marker delivery fails', async () => { + const result = { success: true }; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + harness.ipcRenderer.send.mockImplementation((channel: string) => { + if (channel === 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER') { + throw new Error('marker sink failure'); + } + }); + + await expect( + harness.getApi().dbGetContent('playlist-1', 'live') + ).resolves.toBe(result); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1); + }); + + it('keeps the bridge fail-neutral when the high-resolution clock fails', async () => { + const result = { success: true }; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + jest.spyOn(globalThis.performance, 'now').mockImplementation(() => { + throw new Error('clock failure'); + }); + + await expect( + harness.getApi().dbGetContent('playlist-1', 'live') + ).resolves.toBe(result); + expect( + harness + .getXtreamMarkers() + .every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs)) + ).toBe(true); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts new file mode 100644 index 000000000..4f3855676 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts @@ -0,0 +1,230 @@ +import { + XtreamCodeActions, + type XtreamPreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import type { ExposedElectronApi } from './main.preload.performance.test-helpers'; + +type ExpectedMetadata = Pick< + XtreamPreloadPerformanceMarker, + | 'action' + | 'categoryType' + | 'contentType' + | 'itemCount' + | 'operationId' + | 'playlistId' + | 'sessionId' +>; + +const EMPTY_METADATA: ExpectedMetadata = { + action: null, + categoryType: null, + contentType: null, + itemCount: null, + operationId: null, + playlistId: null, + sessionId: null, +}; + +export const XTREAM_PRELOAD_REQUEST_PAYLOAD = { + params: { + action: XtreamCodeActions.GetLiveStreams, + password: 'provider-password-secret', + username: 'provider-user-secret', + }, + requestId: 'request-secret', + sessionId: 'session-1', + suppressErrorLog: true, + url: 'http://provider-url-secret', +}; + +const categoryItems = [ + { + category_id: '1', + category_name: 'secret-title', + parent_id: 0, + }, +]; +const streamItems = [{ name: 'secret-stream-title', stream_id: 1 }]; +const favorites = [{ title: 'secret-favorite-title' }]; +const recent = [{ title: 'secret-recent-title' }, {}]; +const globalSources = [{ playlistId: 'secret-source' }]; +const globalOptions = { limit: 25 }; + +interface TargetCase { + expectedInvoke: readonly unknown[]; + expectedMetadata: ExpectedMetadata; + invoke: (api: ExposedElectronApi) => unknown; + method: string; +} + +export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [ + { + expectedInvoke: ['XTREAM_REQUEST', XTREAM_PRELOAD_REQUEST_PAYLOAD], + expectedMetadata: { + ...EMPTY_METADATA, + action: XtreamCodeActions.GetLiveStreams, + sessionId: 'session-1', + }, + invoke: (api) => api.xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD), + method: 'xtreamRequest', + }, + { + expectedInvoke: ['XTREAM_CANCEL_SESSION', 'session-2'], + expectedMetadata: { ...EMPTY_METADATA, sessionId: 'session-2' }, + invoke: (api) => api.xtreamCancelSession('session-2'), + method: 'xtreamCancelSession', + }, + { + expectedInvoke: ['DB_GET_CATEGORIES', 'playlist-1', 'movies'], + expectedMetadata: { + ...EMPTY_METADATA, + categoryType: 'movies', + playlistId: 'playlist-1', + }, + invoke: (api) => api.dbGetCategories('playlist-1', 'movies'), + method: 'dbGetCategories', + }, + { + expectedInvoke: [ + 'DB_SAVE_CATEGORIES', + 'playlist-2', + categoryItems, + 'live', + [91], + ], + expectedMetadata: { + ...EMPTY_METADATA, + categoryType: 'live', + itemCount: 1, + playlistId: 'playlist-2', + }, + invoke: (api) => + api.dbSaveCategories('playlist-2', categoryItems, 'live', [91]), + method: 'dbSaveCategories', + }, + { + expectedInvoke: ['DB_GET_CONTENT', 'playlist-3', 'series'], + expectedMetadata: { + ...EMPTY_METADATA, + contentType: 'series', + playlistId: 'playlist-3', + }, + invoke: (api) => api.dbGetContent('playlist-3', 'series'), + method: 'dbGetContent', + }, + { + expectedInvoke: [ + 'DB_SAVE_CONTENT', + 'playlist-4', + streamItems, + 'movie', + 'operation-4', + ], + expectedMetadata: { + ...EMPTY_METADATA, + contentType: 'movie', + itemCount: 1, + operationId: 'operation-4', + playlistId: 'playlist-4', + }, + invoke: (api) => + api.dbSaveContent( + 'playlist-4', + streamItems as never, + 'movie', + 'operation-4' + ), + method: 'dbSaveContent', + }, + { + expectedInvoke: [ + 'DB_DELETE_XTREAM_CONTENT', + 'playlist-5', + 'operation-5', + ], + expectedMetadata: { + ...EMPTY_METADATA, + operationId: 'operation-5', + playlistId: 'playlist-5', + }, + invoke: (api) => api.dbDeleteXtreamContent('playlist-5', 'operation-5'), + method: 'dbDeleteXtreamContent', + }, + { + expectedInvoke: ['DB_DELETE_PLAYLIST', 'playlist-6', 'operation-6'], + expectedMetadata: { + ...EMPTY_METADATA, + operationId: 'operation-6', + playlistId: 'playlist-6', + }, + invoke: (api) => api.dbDeletePlaylist('playlist-6', 'operation-6'), + method: 'dbDeletePlaylist', + }, + { + expectedInvoke: [ + 'DB_RESTORE_XTREAM_USER_DATA', + 'playlist-7', + favorites, + recent, + 'operation-7', + ], + expectedMetadata: { + ...EMPTY_METADATA, + itemCount: 3, + operationId: 'operation-7', + playlistId: 'playlist-7', + }, + invoke: (api) => + api.dbRestoreXtreamUserData( + 'playlist-7', + favorites as never, + recent as never, + 'operation-7' + ), + method: 'dbRestoreXtreamUserData', + }, + { + expectedInvoke: [ + 'DB_SEARCH_CONTENT', + 'playlist-8', + 'provider-search-secret', + ['live'], + true, + ], + expectedMetadata: { ...EMPTY_METADATA, playlistId: 'playlist-8' }, + invoke: (api) => + api.dbSearchContent( + 'playlist-8', + 'provider-search-secret', + ['live'], + true + ), + method: 'dbSearchContent', + }, + { + expectedInvoke: [ + 'DB_GLOBAL_SEARCH', + 'global-search-secret', + ['live'], + true, + globalSources, + globalOptions, + ], + expectedMetadata: EMPTY_METADATA, + invoke: (api) => + api.dbGlobalSearch( + 'global-search-secret', + ['live'], + true, + globalSources as never, + globalOptions + ), + method: 'dbGlobalSearch', + }, + { + expectedInvoke: ['DB_CANCEL_OPERATION', 'operation-9'], + expectedMetadata: { ...EMPTY_METADATA, operationId: 'operation-9' }, + invoke: (api) => api.dbCancelOperation('operation-9'), + method: 'dbCancelOperation', + }, +]; diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts new file mode 100644 index 000000000..42f4babbc --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts @@ -0,0 +1,127 @@ +import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces'; +import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture'; + +describe('Xtream preload performance capture runtime', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does zero target metadata or clock work when disabled', () => { + const clock = jest.fn(() => { + throw new Error('clock must not run'); + }); + const sendMarker = jest.fn(); + const args = new Proxy([{}], { + get: () => { + throw new Error('metadata must not run'); + }, + }); + const capture = createXtreamPreloadPerformanceCapture( + false, + sendMarker, + clock + ); + + expect(capture.start('xtreamRequest', args)).toBeNull(); + expect(capture.start('updateSettings', args)).toBeNull(); + capture.success(null); + capture.error(null); + + expect(clock).not.toHaveBeenCalled(); + expect(sendMarker).not.toHaveBeenCalled(); + }); + + it('keeps calls independent with positive ids and nondecreasing finite timestamps', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const clockValues = [10.5, 9.5, Number.NaN, 11.25]; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => clockValues.shift() as number + ); + + const first = capture.start('dbGetContent', ['playlist-1', 'live']); + const second = capture.start('dbGetContent', ['playlist-2', 'movie']); + capture.success(second); + capture.error(first); + + expect( + markers.map(({ boundary, ipcCallId, outcome }) => ({ + boundary, + ipcCallId, + outcome, + })) + ).toEqual([ + { boundary: 'start', ipcCallId: 1, outcome: null }, + { boundary: 'start', ipcCallId: 2, outcome: null }, + { boundary: 'end', ipcCallId: 2, outcome: 'success' }, + { boundary: 'end', ipcCallId: 1, outcome: 'error' }, + ]); + expect( + markers.every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs)) + ).toBe(true); + expect(markers.map(({ sourceEpochMs }) => sourceEpochMs)).toEqual([ + 10.5, 10.5, 10.5, 11.25, + ]); + }); + + it.each([ + ['non-finite', () => Number.NaN], + [ + 'throwing', + () => { + throw new Error('clock failure'); + }, + ], + ])( + 'uses a positive finite epoch fallback for a first %s clock sample', + (_description, clock) => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + clock + ); + + capture.start('dbGetContent', ['playlist-1', 'live']); + + expect(markers[0].sourceEpochMs).toBe(1_700_000_000_000); + expect(markers[0].sourceEpochMs).toBeGreaterThan(0); + expect(Number.isFinite(markers[0].sourceEpochMs)).toBe(true); + } + ); + + it('swallows sink and clock failures without inspecting result or error values', () => { + const result = new Proxy( + {}, + { + get: () => { + throw new Error('result must stay opaque'); + }, + } + ); + const error = new Proxy( + {}, + { + get: () => { + throw new Error('error must stay opaque'); + }, + } + ); + const capture = createXtreamPreloadPerformanceCapture( + true, + () => { + throw new Error('sink failure'); + }, + () => { + throw new Error('clock failure'); + } + ); + + const call = capture.start('dbGetContent', ['playlist-1', 'live']); + + expect(() => capture.success(call, result)).not.toThrow(); + expect(() => capture.error(call, error)).not.toThrow(); + }); +}); diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts new file mode 100644 index 000000000..102d1c479 --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts @@ -0,0 +1,230 @@ +import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces'; +import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture'; + +describe('Xtream preload performance capture safety', () => { + it.each([ + ['', null], + [' ', null], + ['-leading-dash', null], + ['contains space', null], + ['contains/slash', null], + ['x'.repeat(129), null], + [' playlist.ok:1 ', null], + ['playlist.ok:1', 'playlist.ok:1'], + ['A', 'A'], + ])('normalizes identifier %p to %p', (identifier, expected) => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + capture.start('dbDeletePlaylist', [identifier, identifier]); + + expect(markers[0]).toMatchObject({ + operationId: expected, + playlistId: expected, + }); + }); + + it.each([ + ['xtreamRequest', [{ params: { action: 'provider-action-secret' } }]], + ['dbGetCategories', ['playlist-1', 'vod']], + ['dbGetCategories', ['playlist-1', 'LIVE']], + ['dbGetContent', ['playlist-1', 'movies']], + ['dbGetContent', ['playlist-1', 'vod']], + ] as const)( + 'turns invalid action or type metadata into null for %s', + (method, args) => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + capture.start(method, args); + + expect(markers[0]).toMatchObject({ + action: null, + categoryType: null, + contentType: null, + }); + expect(JSON.stringify(markers)).not.toContain( + 'provider-action-secret' + ); + } + ); + + it('never enumerates provider payloads, reads array entries, or records forbidden strings', () => { + const forbidden = [ + 'provider-url-secret', + 'provider-user-secret', + 'provider-password-secret', + 'provider-title-secret', + 'provider-search-secret', + 'provider-result-secret', + 'provider-error-secret', + ]; + const params = Object.defineProperties( + { action: 'get_series' }, + { + password: { + enumerable: true, + get: () => { + throw new Error(forbidden[2]); + }, + }, + username: { + enumerable: true, + get: () => { + throw new Error(forbidden[1]); + }, + }, + } + ); + const payload = { + params, + sessionId: 'session-1', + title: forbidden[3], + url: forbidden[0], + }; + const entryRead = jest.fn(() => { + throw new Error('array entries must stay opaque'); + }); + const streams = new Array(1); + Object.defineProperty(streams, 0, { get: entryRead }); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + const request = capture.start('xtreamRequest', [payload]); + capture.success(request); + const save = capture.start('dbSaveContent', [ + 'playlist-1', + streams, + 'live', + 'operation-1', + ]); + capture.error(save); + capture.start('dbSearchContent', [ + 'playlist-1', + forbidden[4], + ['live'], + ]); + capture.start('dbGlobalSearch', [ + forbidden[4], + ['live'], + false, + [{ title: forbidden[3] }], + ]); + + expect(entryRead).not.toHaveBeenCalled(); + const serialized = JSON.stringify(markers); + for (const value of forbidden) { + expect(serialized).not.toContain(value); + } + }); + + it('swallows hostile argument, payload, params, and array length traps', () => { + const throwingProxy = new Proxy( + {}, + { + get: () => { + throw new Error('hostile getter'); + }, + } + ); + const hostileArray = new Proxy([], { + get: () => { + throw new Error('hostile array length'); + }, + }); + const hostileArgs = new Proxy([throwingProxy], { + get: () => { + throw new Error('hostile args'); + }, + }); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + expect(() => capture.start('xtreamRequest', hostileArgs)).not.toThrow(); + expect(() => + capture.start('dbSaveCategories', [ + 'playlist-1', + hostileArray, + 'live', + ]) + ).not.toThrow(); + + expect(markers).toHaveLength(2); + expect(markers[0]).toMatchObject({ + action: null, + sessionId: null, + }); + expect(markers[1].itemCount).toBeNull(); + }); + + it('swallows revoked array proxies without changing the call', () => { + const { proxy, revoke } = Proxy.revocable([], {}); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + revoke(); + + expect(() => + capture.start('dbSaveContent', [ + 'playlist-1', + proxy, + 'live', + 'operation-1', + ]) + ).not.toThrow(); + + expect(markers[0].itemCount).toBeNull(); + }); + + it('accepts only bounded array lengths and safely sums restore counts', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + capture.start('dbSaveCategories', [ + 'playlist-1', + new Array(1_000_001), + 'live', + ]); + capture.start('dbRestoreXtreamUserData', [ + 'playlist-1', + new Array(600_000), + new Array(400_000), + 'operation-1', + ]); + capture.start('dbRestoreXtreamUserData', [ + 'playlist-1', + new Array(600_000), + new Array(400_001), + 'operation-1', + ]); + + expect(markers.map(({ itemCount }) => itemCount)).toEqual([ + null, + 1_000_000, + null, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts new file mode 100644 index 000000000..959ca5b7c --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts @@ -0,0 +1,219 @@ +import { + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + type XtreamPreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { XtreamCodeActions } from '@iptvnator/shared/interfaces'; +import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture'; + +const MARKER_KEYS = [ + 'action', + 'boundary', + 'categoryType', + 'contentType', + 'ipcCallId', + 'itemCount', + 'method', + 'operationId', + 'outcome', + 'playlistId', + 'schemaVersion', + 'sessionId', + 'sourceEpochMs', +] as const; + +const EMPTY_METADATA = { + action: null, + categoryType: null, + contentType: null, + itemCount: null, + operationId: null, + playlistId: null, + sessionId: null, +} as const; + +describe('Xtream preload performance capture contract', () => { + it.each([ + { + args: [ + { + params: { action: XtreamCodeActions.GetLiveStreams }, + sessionId: 'session-1', + }, + ], + expected: { + ...EMPTY_METADATA, + action: XtreamCodeActions.GetLiveStreams, + sessionId: 'session-1', + }, + method: 'xtreamRequest', + }, + { + args: ['session-2'], + expected: { ...EMPTY_METADATA, sessionId: 'session-2' }, + method: 'xtreamCancelSession', + }, + { + args: ['playlist-1', 'movies'], + expected: { + ...EMPTY_METADATA, + categoryType: 'movies', + playlistId: 'playlist-1', + }, + method: 'dbGetCategories', + }, + { + args: ['playlist-2', [{}, {}, {}], 'live', [99]], + expected: { + ...EMPTY_METADATA, + categoryType: 'live', + itemCount: 3, + playlistId: 'playlist-2', + }, + method: 'dbSaveCategories', + }, + { + args: ['playlist-3', 'series'], + expected: { + ...EMPTY_METADATA, + contentType: 'series', + playlistId: 'playlist-3', + }, + method: 'dbGetContent', + }, + { + args: ['playlist-4', [{}, {}], 'movie', 'operation-4'], + expected: { + ...EMPTY_METADATA, + contentType: 'movie', + itemCount: 2, + operationId: 'operation-4', + playlistId: 'playlist-4', + }, + method: 'dbSaveContent', + }, + { + args: ['playlist-5', 'operation-5'], + expected: { + ...EMPTY_METADATA, + operationId: 'operation-5', + playlistId: 'playlist-5', + }, + method: 'dbDeleteXtreamContent', + }, + { + args: ['playlist-6', 'operation-6'], + expected: { + ...EMPTY_METADATA, + operationId: 'operation-6', + playlistId: 'playlist-6', + }, + method: 'dbDeletePlaylist', + }, + { + args: ['playlist-7', [{}, {}], [{}], 'operation-7'], + expected: { + ...EMPTY_METADATA, + itemCount: 3, + operationId: 'operation-7', + playlistId: 'playlist-7', + }, + method: 'dbRestoreXtreamUserData', + }, + { + args: ['playlist-8', 'provider search', ['live'], true], + expected: { + ...EMPTY_METADATA, + playlistId: 'playlist-8', + }, + method: 'dbSearchContent', + }, + { + args: ['provider search', ['live'], true, [{ id: 'source-1' }]], + expected: EMPTY_METADATA, + method: 'dbGlobalSearch', + }, + { + args: ['operation-9'], + expected: { ...EMPTY_METADATA, operationId: 'operation-9' }, + method: 'dbCancelOperation', + }, + ] as const)( + 'extracts only safe positional metadata for $method', + ({ args, expected, method }) => { + const sendMarker = jest.fn(); + const capture = createXtreamPreloadPerformanceCapture( + true, + sendMarker, + () => 1_000.25 + ); + + const call = capture.start(method, args); + + expect(call).not.toBeNull(); + expect(sendMarker).toHaveBeenCalledWith( + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + { + ...expected, + boundary: 'start', + ipcCallId: 1, + method, + outcome: null, + schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + sourceEpochMs: 1_000.25, + } + ); + } + ); + + it('emits fixed-key start and end markers with matching call metadata', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 2_000.5 + ); + + const successCall = capture.start('dbGetContent', [ + 'playlist-1', + 'live', + ]); + capture.success(successCall); + const errorCall = capture.start('dbCancelOperation', ['operation-2']); + capture.error(errorCall); + + expect( + markers.map(({ boundary, ipcCallId, outcome }) => ({ + boundary, + ipcCallId, + outcome, + })) + ).toEqual([ + { boundary: 'start', ipcCallId: 1, outcome: null }, + { boundary: 'end', ipcCallId: 1, outcome: 'success' }, + { boundary: 'start', ipcCallId: 2, outcome: null }, + { boundary: 'end', ipcCallId: 2, outcome: 'error' }, + ]); + for (const marker of markers) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + } + }); + + it('allows every current Xtream action including the legacy alias', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 3_000 + ); + const actions = Object.values(XtreamCodeActions); + + for (const action of actions) { + capture.start('xtreamRequest', [{ params: { action } }]); + } + + expect(markers.map(({ action }) => action)).toEqual(actions); + expect(actions).toContain('get_simple_data_table'); + expect(actions).toContain('get_simple_date_table'); + }); +}); diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts new file mode 100644 index 000000000..ace7569cc --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts @@ -0,0 +1,191 @@ +import { + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + type XtreamPreloadPerformanceBoundary, + type XtreamPreloadPerformanceMarker, + type XtreamPreloadPerformanceMethod, + type XtreamPreloadPerformanceOutcome, +} from '@iptvnator/shared/interfaces'; +import { + extractXtreamPreloadPerformanceMetadata, + type XtreamPreloadMarkerMetadata, +} from './xtream-preload-performance-metadata'; + +export interface XtreamPreloadPerformanceCallContext extends XtreamPreloadMarkerMetadata { + ipcCallId: number; + method: XtreamPreloadPerformanceMethod; +} + +export interface XtreamPreloadPerformanceCapture { + error: ( + call: XtreamPreloadPerformanceCallContext | null, + ignoredError?: unknown + ) => void; + start: ( + methodName: string, + args: readonly unknown[] + ) => XtreamPreloadPerformanceCallContext | null; + success: ( + call: XtreamPreloadPerformanceCallContext | null, + ignoredResult?: unknown + ) => void; +} + +type MarkerSender = ( + channel: typeof XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + marker: XtreamPreloadPerformanceMarker +) => void; + +type EpochReader = () => number; + +export function toXtreamPreloadPerformanceTargetMethod( + methodName: string +): XtreamPreloadPerformanceMethod | null { + switch (methodName) { + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION: + return methodName; + default: + return null; + } +} + +export function isXtreamPreloadPerformanceCaptureEnabled(): boolean { + return process.env['IPTVNATOR_PERF_CAPTURE'] === '1'; +} + +function readHighResolutionEpochMs(): number { + try { + const timeOrigin = globalThis.performance?.timeOrigin; + const monotonicNow = globalThis.performance?.now?.(); + const highResolutionEpoch = + typeof timeOrigin === 'number' && typeof monotonicNow === 'number' + ? timeOrigin + monotonicNow + : Number.NaN; + if (Number.isFinite(highResolutionEpoch)) { + return highResolutionEpoch; + } + } catch { + // Fall through to the wall clock. + } + + try { + return Date.now(); + } catch { + return Number.NaN; + } +} + +function readPositiveWallClockEpochMs(): number { + try { + const wallClockEpochMs = Date.now(); + return Number.isFinite(wallClockEpochMs) && wallClockEpochMs > 0 + ? wallClockEpochMs + : 1; + } catch { + return 1; + } +} + +function readMonotonicEpochMs( + readEpochMs: EpochReader, + lastSourceEpochMs: number +): number { + let clockValue = Number.NaN; + try { + clockValue = readEpochMs(); + } catch { + // Use the last valid sample or the wall clock below. + } + + const fallbackEpochMs = + lastSourceEpochMs > 0 + ? lastSourceEpochMs + : readPositiveWallClockEpochMs(); + const sourceEpochMs = + Number.isFinite(clockValue) && clockValue > 0 + ? clockValue + : fallbackEpochMs; + return Math.max(sourceEpochMs, lastSourceEpochMs); +} + +export function createXtreamPreloadPerformanceCapture( + enabled: boolean, + sendMarker: MarkerSender, + readEpochMs: EpochReader = readHighResolutionEpochMs +): XtreamPreloadPerformanceCapture { + let lastSourceEpochMs = 0; + let nextIpcCallId = 1; + + function emit( + call: XtreamPreloadPerformanceCallContext, + boundary: XtreamPreloadPerformanceBoundary, + outcome: XtreamPreloadPerformanceOutcome + ): void { + const sourceEpochMs = readMonotonicEpochMs( + readEpochMs, + lastSourceEpochMs + ); + lastSourceEpochMs = sourceEpochMs; + try { + sendMarker(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, { + schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + sourceEpochMs, + ipcCallId: call.ipcCallId, + method: call.method, + boundary, + outcome, + playlistId: call.playlistId, + operationId: call.operationId, + sessionId: call.sessionId, + action: call.action, + categoryType: call.categoryType, + contentType: call.contentType, + itemCount: call.itemCount, + }); + } catch { + // Performance instrumentation must never affect the bridge call. + } + } + + return { + error: (call) => { + if (call) { + emit(call, 'end', 'error'); + } + }, + start: (methodName, args) => { + if (!enabled) { + return null; + } + const method = toXtreamPreloadPerformanceTargetMethod(methodName); + if (!method || nextIpcCallId > Number.MAX_SAFE_INTEGER) { + return null; + } + const call = { + ipcCallId: nextIpcCallId, + method, + ...extractXtreamPreloadPerformanceMetadata(method, args), + }; + nextIpcCallId += 1; + emit(call, 'start', null); + return call; + }, + success: (call) => { + if (call) { + emit(call, 'end', 'success'); + } + }, + }; +} diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts new file mode 100644 index 000000000..812a0946a --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts @@ -0,0 +1,190 @@ +import { + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XtreamCodeActions, + type XtreamPreloadPerformanceCategoryType, + type XtreamPreloadPerformanceContentType, + type XtreamPreloadPerformanceMarker, + type XtreamPreloadPerformanceMethod, +} from '@iptvnator/shared/interfaces'; + +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_ITEM_COUNT = 1_000_000; +const ALLOWED_ACTIONS = new Set(Object.values(XtreamCodeActions)); +const ALLOWED_CATEGORY_TYPES = new Set(['live', 'movies', 'series']); +const ALLOWED_CONTENT_TYPES = new Set(['live', 'movie', 'series']); + +export type XtreamPreloadMarkerMetadata = Pick< + XtreamPreloadPerformanceMarker, + | 'action' + | 'categoryType' + | 'contentType' + | 'itemCount' + | 'operationId' + | 'playlistId' + | 'sessionId' +>; + +function readValue(value: unknown, property: PropertyKey): unknown { + if ( + (typeof value !== 'object' || value === null) && + typeof value !== 'function' + ) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; + } +} + +function readArgument(args: readonly unknown[], index: number): unknown { + return readValue(args, index); +} + +function normalizeIdentifier(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + return value.length >= 1 && + value.length <= MAX_IDENTIFIER_LENGTH && + IDENTIFIER_PATTERN.test(value) + ? value + : null; +} + +function normalizeAction(value: unknown): XtreamCodeActions | null { + return typeof value === 'string' && ALLOWED_ACTIONS.has(value) + ? (value as XtreamCodeActions) + : null; +} + +function normalizeCategoryType( + value: unknown +): XtreamPreloadPerformanceCategoryType | null { + return typeof value === 'string' && ALLOWED_CATEGORY_TYPES.has(value) + ? (value as XtreamPreloadPerformanceCategoryType) + : null; +} + +function normalizeContentType( + value: unknown +): XtreamPreloadPerformanceContentType | null { + return typeof value === 'string' && ALLOWED_CONTENT_TYPES.has(value) + ? (value as XtreamPreloadPerformanceContentType) + : null; +} + +function readArrayCount(value: unknown): number | null { + let isArray = false; + try { + isArray = Array.isArray(value); + } catch { + return null; + } + if (!isArray) { + return null; + } + + const length = readValue(value, 'length'); + return typeof length === 'number' && + Number.isSafeInteger(length) && + length >= 0 && + length <= MAX_ITEM_COUNT + ? length + : null; +} + +function sumArrayCounts(first: unknown, second: unknown): number | null { + const firstCount = readArrayCount(first); + const secondCount = readArrayCount(second); + if (firstCount === null || secondCount === null) { + return null; + } + + const total = firstCount + secondCount; + return Number.isSafeInteger(total) && total <= MAX_ITEM_COUNT + ? total + : null; +} + +function emptyMetadata(): XtreamPreloadMarkerMetadata { + return { + action: null, + categoryType: null, + contentType: null, + itemCount: null, + operationId: null, + playlistId: null, + sessionId: null, + }; +} + +export function extractXtreamPreloadPerformanceMetadata( + method: XtreamPreloadPerformanceMethod, + args: readonly unknown[] +): XtreamPreloadMarkerMetadata { + const metadata = emptyMetadata(); + const first = readArgument(args, 0); + + switch (method) { + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST: { + const params = readValue(first, 'params'); + metadata.sessionId = normalizeIdentifier( + readValue(first, 'sessionId') + ); + metadata.action = normalizeAction(readValue(params, 'action')); + return metadata; + } + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION: + metadata.sessionId = normalizeIdentifier(first); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES: + metadata.playlistId = normalizeIdentifier(first); + metadata.categoryType = normalizeCategoryType( + readArgument(args, 1) + ); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES: + metadata.playlistId = normalizeIdentifier(first); + metadata.itemCount = readArrayCount(readArgument(args, 1)); + metadata.categoryType = normalizeCategoryType( + readArgument(args, 2) + ); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT: + metadata.playlistId = normalizeIdentifier(first); + metadata.contentType = normalizeContentType(readArgument(args, 1)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT: + metadata.playlistId = normalizeIdentifier(first); + metadata.itemCount = readArrayCount(readArgument(args, 1)); + metadata.contentType = normalizeContentType(readArgument(args, 2)); + metadata.operationId = normalizeIdentifier(readArgument(args, 3)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST: + metadata.playlistId = normalizeIdentifier(first); + metadata.operationId = normalizeIdentifier(readArgument(args, 1)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA: + metadata.playlistId = normalizeIdentifier(first); + metadata.itemCount = sumArrayCounts( + readArgument(args, 1), + readArgument(args, 2) + ); + metadata.operationId = normalizeIdentifier(readArgument(args, 3)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT: + metadata.playlistId = normalizeIdentifier(first); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION: + metadata.operationId = normalizeIdentifier(first); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH: + return metadata; + } +} diff --git a/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts new file mode 100644 index 000000000..f03a60895 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts @@ -0,0 +1,110 @@ +import type { AppDatabase } from '../database.types'; +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import { getCategories, saveCategories } from './category.operations'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('category operation performance phases', () => { + it('keeps the categories read phase open until the query resolves', async () => { + const rows = [{ id: 1, name: 'News' }]; + const pending = createDeferred(); + const orderBy = jest.fn(() => pending.promise); + const where = jest.fn(() => ({ orderBy })); + const from = jest.fn(() => ({ where })); + const db = { + select: jest.fn(() => ({ from })), + } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + const result = getCategories( + db, + 'playlist-1', + 'live', + recording.capture + ); + + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + }, + ]); + + pending.resolve(rows); + await expect(result).resolves.toBe(rows); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + }, + ]); + }); + + it('captures normalization and the existing category write separately', async () => { + const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const select = jest.fn(() => ({ + from: jest.fn(() => ({ where: existingWhere })), + })); + const onConflictDoNothing = jest.fn().mockResolvedValue(undefined); + const values = jest.fn(() => ({ onConflictDoNothing })); + const insert = jest.fn(() => ({ values })); + const db = { insert, select } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await expect( + saveCategories( + db, + 'playlist-1', + [ + { category_name: 'News', category_id: '101' }, + { category_name: 'Broken', category_id: 'not-a-number' }, + { category_name: 'Sports', category_id: 102 }, + ], + 'live', + [102], + recording.capture + ) + ).resolves.toEqual({ success: true }); + + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'News', xtreamId: 101 }), + expect.objectContaining({ + hidden: true, + name: 'Sports', + xtreamId: 102, + }), + ]); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + }, + { + boundary: 'end', + metadata: { itemCount: 2 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 2 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index 27bd2cb46..8bfd5b6e8 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -1,12 +1,16 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; type XtreamCategoryInput = { category_name: string; category_id: string | number; }; +type XtreamCategoryValue = typeof schema.categories.$inferInsert; + // Category rows cross the DB-worker IPC boundary in the snake_case wire // shape declared by XCategoryFromDb/XtreamCategoryFromDb. A bare select() // would leak Drizzle's camelCase property names (xtreamId, playlistId) @@ -29,6 +33,49 @@ function normalizeXtreamCategoryId( return Number.isNaN(xtreamId) ? null : xtreamId; } +function normalizeXtreamCategories( + playlistId: string, + categories: XtreamCategoryInput[], + type: 'live' | 'movies' | 'series', + hiddenCategoryXtreamIds?: number[] +): XtreamCategoryValue[] { + const hiddenSet = new Set(hiddenCategoryXtreamIds || []); + + return categories.flatMap((category) => { + const xtreamId = normalizeXtreamCategoryId(category.category_id); + + if (xtreamId === null) { + return []; + } + + return [ + { + playlistId, + name: category.category_name, + type, + xtreamId, + hidden: hiddenSet.has(xtreamId), + }, + ]; + }); +} + +async function insertXtreamCategories( + db: AppDatabase, + values: XtreamCategoryValue[] +): Promise { + await db + .insert(schema.categories) + .values(values) + .onConflictDoNothing({ + target: [ + schema.categories.playlistId, + schema.categories.type, + schema.categories.xtreamId, + ], + }); +} + export async function hasCategories( db: AppDatabase, playlistId: string, @@ -50,13 +97,14 @@ export async function hasCategories( export async function getCategories( db: AppDatabase, playlistId: string, - type: 'live' | 'movies' | 'series' + type: 'live' | 'movies' | 'series', + capturePhase?: DatabaseOperationPerformancePhaseCapture ) { // Xtream categories are inserted once in provider order and existing // xtream IDs are preserved, so row id order represents server order. // If partial category re-inserts are added later, persist a provider // sort index instead of relying on the insertion id. - return db + const query = db .select(categoryWireShape) .from(schema.categories) .where( @@ -67,6 +115,14 @@ export async function getCategories( ) ) .orderBy(schema.categories.id); + + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + async () => query, + (rows) => ({ itemCount: rows.length }) + ) + : query; } export async function saveCategories( @@ -74,7 +130,8 @@ export async function saveCategories( playlistId: string, categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean }> { if (!categories || categories.length === 0) { return { success: true }; @@ -94,39 +151,38 @@ export async function saveCategories( return { success: true }; } - const hiddenSet = new Set(hiddenCategoryXtreamIds || []); - const values = categories.flatMap((category) => { - const xtreamId = normalizeXtreamCategoryId(category.category_id); - - if (xtreamId === null) { - return []; - } - - return [ - { - playlistId, - name: category.category_name, - type, - xtreamId, - hidden: hiddenSet.has(xtreamId), - }, - ]; - }); + const values = capturePhase + ? capturePhase.captureSync( + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + () => + normalizeXtreamCategories( + playlistId, + categories, + type, + hiddenCategoryXtreamIds + ), + (result) => ({ itemCount: result.length }) + ) + : normalizeXtreamCategories( + playlistId, + categories, + type, + hiddenCategoryXtreamIds + ); if (values.length === 0) { return { success: true }; } - await db - .insert(schema.categories) - .values(values) - .onConflictDoNothing({ - target: [ - schema.categories.playlistId, - schema.categories.type, - schema.categories.xtreamId, - ], - }); + if (capturePhase) { + await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + () => insertXtreamCategories(db, values), + () => ({ itemCount: values.length }) + ); + } else { + await insertXtreamCategories(db, values); + } return { success: true }; } diff --git a/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts b/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts new file mode 100644 index 000000000..60383828c --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts @@ -0,0 +1,269 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { + clearXtreamImportCache, + getContent, + saveContent, +} from './content.operations'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; + +function createContentDb(streamCount: number, timeline: string[]) { + const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const categoriesWhere = jest + .fn() + .mockResolvedValue([{ id: 42, xtreamId: 201 }]); + const select = jest + .fn() + .mockReturnValueOnce({ + from: jest.fn(() => ({ + innerJoin: jest.fn(() => ({ where: existingWhere })), + })), + }) + .mockReturnValueOnce({ + from: jest.fn(() => ({ where: categoriesWhere })), + }); + const run = jest.fn(); + const values = jest.fn((_chunk: unknown[]) => ({ + onConflictDoNothing: jest.fn(() => ({ run })), + })); + const insert = jest.fn(() => ({ values })); + const transactionResults: unknown[] = []; + const transaction = jest.fn((callback: (tx: unknown) => unknown) => { + timeline.push('transaction'); + const result = callback({ insert }); + transactionResults.push(result); + return result; + }); + const streams = Array.from({ length: streamCount }, (_, index) => ({ + category_id: '201', + name: `Channel ${index}`, + stream_id: String(index + 1), + })); + + return { + db: { select, transaction } as unknown as AppDatabase, + streams, + transaction, + transactionResults, + values, + }; +} + +describe('content import performance phases', () => { + it('keeps the content read phase open until the ordered query resolves', async () => { + const rows = [{ id: 1, title: 'One' }]; + let resolveRows!: (value: typeof rows) => void; + const pending = new Promise((resolve) => { + resolveRows = resolve; + }); + const orderBy = jest.fn(() => pending); + const where = jest.fn(() => ({ orderBy })); + const innerJoin = jest.fn(() => ({ where })); + const from = jest.fn(() => ({ innerJoin })); + const db = { + select: jest.fn(() => ({ from })), + } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + const result = getContent(db, 'playlist-1', 'live', recording.capture); + expect(recording.events).toHaveLength(1); + + resolveRows(rows); + await expect(result).resolves.toBe(rows); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + }, + ]); + }); + + it('uses one aggregate write pair around every existing batch commit and progress callback', async () => { + const timeline: string[] = []; + const recording = createRecordingOperationPhaseCapture((event) => + timeline.push(`${event.phase}:${event.boundary}`) + ); + const harness = createContentDb(205, timeline); + const checkpoint = jest.fn(() => { + timeline.push('checkpoint'); + }); + const onProgress = jest.fn((progress: { current?: number }) => { + timeline.push(`progress:${progress.current}`); + }); + + await expect( + saveContent( + harness.db, + 'playlist-1', + harness.streams, + 'live', + { checkpoint, onProgress }, + recording.capture + ) + ).resolves.toEqual({ success: true, count: 205 }); + + expect(harness.transaction).toHaveBeenCalledTimes(3); + expect(harness.transactionResults).toEqual([ + undefined, + undefined, + undefined, + ]); + expect( + harness.values.mock.calls.map(([chunk]) => chunk.length) + ).toEqual([100, 100, 5]); + expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([ + 100, 200, 205, + ]); + const writeStart = timeline.indexOf( + 'sqlite.content.write-transactions:start' + ); + const writeEnd = timeline.indexOf( + 'sqlite.content.write-transactions:end' + ); + expect(writeStart).toBeGreaterThanOrEqual(0); + expect(writeEnd).toBeGreaterThan(timeline.lastIndexOf('progress:205')); + expect( + timeline + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry === 'transaction') + .every(({ index }) => index > writeStart && index < writeEnd) + ).toBe(true); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + }, + { + boundary: 'end', + metadata: { itemCount: 205 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 205 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('closes the aggregate write phase on cancellation without changing the error', async () => { + const timeline: string[] = []; + const recording = createRecordingOperationPhaseCapture(); + const harness = createContentDb(101, timeline); + const cancellation = new Error('cancelled'); + cancellation.name = 'AbortError'; + let checkpointCount = 0; + + await expect( + saveContent( + harness.db, + 'playlist-1', + harness.streams, + 'live', + { + checkpoint: () => { + checkpointCount += 1; + if (checkpointCount === 2) throw cancellation; + }, + }, + recording.capture + ) + ).rejects.toBe(cancellation); + + expect(harness.transaction).toHaveBeenCalledTimes(1); + expect(recording.events.at(-1)).toEqual({ + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + }); + }); + + it('captures cache deletion as one pair across content and category chunks', async () => { + const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id })); + const contentRows = Array.from({ length: 205 }, (_, id) => ({ id })); + const select = jest + .fn() + .mockReturnValueOnce({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(categoryRows), + })), + }) + .mockReturnValueOnce({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(contentRows), + })), + }); + const run = jest.fn(); + const deleteRows = jest.fn(() => ({ + where: jest.fn(() => ({ run })), + })); + const transaction = jest.fn((callback: (tx: unknown) => unknown) => + callback({ delete: deleteRows }) + ); + const db = { select, transaction } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await clearXtreamImportCache( + db, + 'playlist-1', + 'live', + recording.capture + ); + + expect(transaction).toHaveBeenCalledTimes(5); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 355 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('emits one zero-count cache-clear pair when no categories exist', async () => { + const where = jest.fn().mockResolvedValue([]); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + const transaction = jest.fn(); + const db = { select, transaction } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await expect( + clearXtreamImportCache(db, 'playlist-1', 'live', recording.capture) + ).resolves.toEqual({ success: true }); + + expect(transaction).not.toHaveBeenCalled(); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 0 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts b/apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts new file mode 100644 index 000000000..e6f20599f --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts @@ -0,0 +1,124 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { searchContent } from './content.operations'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; + +function createSearchDb(query: Promise) { + const limit = jest.fn(() => query); + const where = jest.fn(() => ({ limit })); + const innerJoin = jest.fn(() => ({ where })); + const from = jest.fn(() => ({ innerJoin })); + const select = jest.fn(() => ({ from })); + return { + db: { select } as unknown as AppDatabase, + select, + }; +} + +describe('in-playlist search performance phases', () => { + it('closes query before measuring synchronous filtering and ranking', async () => { + const candidates = [ + { + id: 1, + title: 'News One', + xtream_id: 101, + type: 'live', + }, + { + id: 2, + title: 'Movies', + xtream_id: 102, + type: 'live', + }, + ]; + let resolveQuery!: (value: typeof candidates) => void; + const query = new Promise((resolve) => { + resolveQuery = resolve; + }); + const { db } = createSearchDb(query); + const recording = createRecordingOperationPhaseCapture(); + + const result = searchContent( + db, + 'playlist-1', + 'news', + ['live'], + false, + recording.capture + ); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + ]); + + resolveQuery(candidates); + await expect(result).resolves.toEqual([candidates[0]]); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + { + boundary: 'end', + metadata: { itemCount: 2 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + }, + ]); + }); + + it('closes a rejected query without starting rank and preserves identity', async () => { + const queryError = new Error('search query failed'); + const { db } = createSearchDb(Promise.reject(queryError)); + const recording = createRecordingOperationPhaseCapture(); + + await expect( + searchContent( + db, + 'playlist-1', + 'news', + ['live'], + false, + recording.capture + ) + ).rejects.toBe(queryError); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + { + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + ]); + }); + + it('keeps invalid input as zero-work with no query or phases', async () => { + const { db, select } = createSearchDb(Promise.resolve([])); + const recording = createRecordingOperationPhaseCapture(); + + await expect( + searchContent( + db, + 'playlist-1', + '', + ['live'], + false, + recording.capture + ) + ).resolves.toEqual([]); + expect(select).not.toHaveBeenCalled(); + expect(recording.events).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index b2eeaf8c8..f22822e3c 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -9,6 +9,7 @@ import { GlobalSearchResult, GlobalSearchResultSource, M3uGlobalSearchResult, + XTREAM_DATABASE_PERFORMANCE_PHASE, getXtreamRecentlyAddedMaxEpochSeconds, toXtreamRecentlyAddedEpochSeconds, XtreamGlobalSearchResult, @@ -31,12 +32,15 @@ import { shouldUseContentTitleFts, shouldUseContentTitlePrefixIndex, } from './content-search.util'; +import type { OperationControl } from './operation-control'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; import { - checkpointOperation, - chunkValues, - type OperationControl, - reportOperationProgress, -} from './operation-control'; + deleteXtreamCacheRows, + normalizeXtreamContentValues, + rankSearchCandidates, + writeXtreamContentValues, + type XtreamContentValue, +} from './xtream-content-operation-steps'; export { scoreSearchTextMatch } from './content-search.util'; @@ -673,7 +677,8 @@ export async function hasContent( export async function getContent( db: AppDatabase, playlistId: string, - type: 'live' | 'movie' | 'series' + type: 'live' | 'movie' | 'series', + capturePhase?: DatabaseOperationPerformancePhaseCapture ) { const baseQuery = db .select(selectContentFields()) @@ -689,17 +694,22 @@ export async function getContent( ) ); - return type === 'live' - ? baseQuery.orderBy(asc(schema.content.id)) - : baseQuery.orderBy(desc(schema.content.added)); + const query = + type === 'live' + ? baseQuery.orderBy(asc(schema.content.id)) + : baseQuery.orderBy(desc(schema.content.added)); + + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + async () => query, + (rows) => ({ itemCount: rows.length }) + ) + : query; } export type RecentlyAddedPlaylistType = - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; export async function getGlobalRecentlyAdded( db: AppDatabase, @@ -796,20 +806,6 @@ function getGlobalRecentlyAddedByType( .limit(limit); } -type XtreamContentValue = { - categoryId: number; - title: string; - rating: string; - added: string; - posterUrl: string; - epgChannelId?: string | null; - tvArchive?: number | null; - tvArchiveDuration?: number | null; - directSource?: string | null; - xtreamId: number; - type: 'live' | 'movie' | 'series'; -}; - type XtreamContentSource = Record & { category_id?: string | number; rating?: string | number; @@ -898,7 +894,8 @@ export async function saveContent( playlistId: string, streams: Array>, type: 'live' | 'movie' | 'series', - control?: OperationControl + control?: OperationControl, + capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean; count: number }> { const dbType = type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; @@ -922,7 +919,7 @@ export async function saveContent( return { success: true, count: existingContent[0].count }; } - const categories = await db + const categoriesQuery = db .select({ id: schema.categories.id, xtreamId: schema.categories.xtreamId, @@ -935,49 +932,46 @@ export async function saveContent( ) ); - const categoryMap = new Map( - categories.map((category) => [category.xtreamId, category.id]) - ); + const categories = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + async () => categoriesQuery, + (rows) => ({ itemCount: rows.length }) + ) + : await categoriesQuery; + const values = capturePhase + ? capturePhase.captureSync( + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + () => + normalizeXtreamContentValues( + streams, + type, + categories, + toXtreamContentValue + ), + (result) => ({ itemCount: result.length }) + ) + : normalizeXtreamContentValues( + streams, + type, + categories, + toXtreamContentValue + ); - const values = streams - .map((stream) => toXtreamContentValue(stream, type, categoryMap)) - .filter((value): value is XtreamContentValue => value !== null); - - const total = values.length; - const chunkSize = 100; - let totalInserted = 0; - - for (let index = 0; index < values.length; index += chunkSize) { - await checkpointOperation(control); - const chunk = values.slice(index, index + chunkSize); - await db.transaction((tx) => { - tx.insert(schema.content) - .values(chunk) - .onConflictDoNothing({ - target: [ - schema.content.categoryId, - schema.content.type, - schema.content.xtreamId, - ], - }) - .run(); - }); - totalInserted += chunk.length; - await reportOperationProgress(control, { - phase: 'saving-content', - current: totalInserted, - total, - increment: chunk.length, - }); - } - - return { success: true, count: totalInserted }; + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + () => writeXtreamContentValues(db, values, control), + (result) => ({ itemCount: result.count }) + ) + : writeXtreamContentValues(db, values, control); } export async function clearXtreamImportCache( db: AppDatabase, playlistId: string, - type: 'live' | 'movie' | 'series' + type: 'live' | 'movie' | 'series', + capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean }> { const dbType = type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; @@ -994,7 +988,13 @@ export async function clearXtreamImportCache( const categoryIds = categoryRows.map((category) => category.id); if (categoryIds.length === 0) { - return { success: true }; + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + async () => ({ success: true }), + () => ({ itemCount: 0 }) + ) + : { success: true }; } const contentRows = await db @@ -1002,26 +1002,13 @@ export async function clearXtreamImportCache( .from(schema.content) .where(inArray(schema.content.categoryId, categoryIds)); - for (const chunk of chunkValues( - contentRows.map((row) => row.id), - 100 - )) { - await db.transaction((tx) => { - tx.delete(schema.content) - .where(inArray(schema.content.id, chunk)) - .run(); - }); - } - - for (const chunk of chunkValues(categoryIds, 100)) { - await db.transaction((tx) => { - tx.delete(schema.categories) - .where(inArray(schema.categories.id, chunk)) - .run(); - }); - } - - return { success: true }; + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + () => deleteXtreamCacheRows(db, contentRows, categoryIds), + () => ({ itemCount: contentRows.length + categoryIds.length }) + ) + : deleteXtreamCacheRows(db, contentRows, categoryIds); } export async function getContentByXtreamId( @@ -1057,7 +1044,8 @@ export async function searchContent( playlistId: string, searchTerm: string, types: string[], - excludeHidden = false + excludeHidden = false, + capturePhase?: DatabaseOperationPerformancePhaseCapture ) { if (!types || types.length === 0 || !normalizeSearchMatchText(searchTerm)) { return []; @@ -1076,7 +1064,7 @@ export async function searchContent( conditions.push(eq(schema.categories.hidden, false)); } - const candidates = await db + const query = db .select(selectContentFields()) .from(schema.content) .innerJoin( @@ -1086,12 +1074,21 @@ export async function searchContent( .where(and(...conditions)) .limit(200); - return candidates - .filter( - (item) => - scoreSearchTextMatch(item.title ?? '', searchTerm) !== null - ) - .slice(0, 50); + const candidates = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + async () => query, + (rows) => ({ itemCount: rows.length }) + ) + : await query; + + return capturePhase + ? capturePhase.captureSync( + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + () => rankSearchCandidates(candidates, searchTerm), + (result) => ({ itemCount: result.length }) + ) + : rankSearchCandidates(candidates, searchTerm); } export async function globalSearch( diff --git a/apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts b/apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts new file mode 100644 index 000000000..80a78d9c7 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts @@ -0,0 +1,68 @@ +import type { + XtreamDatabasePerformancePhase, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; + +export interface RecordedOperationPhase { + readonly boundary: 'end' | 'start'; + readonly metadata?: PerformancePhaseMetadata; + readonly phase: XtreamDatabasePerformancePhase; +} + +export function createRecordingOperationPhaseCapture( + onBoundary?: (event: RecordedOperationPhase) => void +): { + readonly capture: DatabaseOperationPerformancePhaseCapture; + readonly events: RecordedOperationPhase[]; +} { + const events: RecordedOperationPhase[] = []; + const record = (event: RecordedOperationPhase): void => { + events.push(event); + onBoundary?.(event); + }; + + return { + capture: { + async captureAsync( + phase: XtreamDatabasePerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): Promise { + record({ boundary: 'start', phase }); + try { + const result = await execute(); + record({ + boundary: 'end', + metadata: metadata?.(result), + phase, + }); + return result; + } catch (error) { + record({ boundary: 'end', phase }); + throw error; + } + }, + captureSync( + phase: XtreamDatabasePerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): TResult { + record({ boundary: 'start', phase }); + try { + const result = execute(); + record({ + boundary: 'end', + metadata: metadata?.(result), + phase, + }); + return result; + } catch (error) { + record({ boundary: 'end', phase }); + throw error; + } + }, + }, + events, + }; +} diff --git a/apps/electron-backend/src/app/database/operations/performance-phase-capture.ts b/apps/electron-backend/src/app/database/operations/performance-phase-capture.ts new file mode 100644 index 000000000..ecafad7e8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/performance-phase-capture.ts @@ -0,0 +1,17 @@ +import type { + PerformancePhaseMetadata, + XtreamDatabasePerformancePhase, +} from '@iptvnator/shared/interfaces'; + +export interface DatabaseOperationPerformancePhaseCapture { + captureAsync: ( + phase: XtreamDatabasePerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: XtreamDatabasePerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} diff --git a/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts new file mode 100644 index 000000000..695a273ec --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts @@ -0,0 +1,165 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; +import { deletePlaylist } from './playlist.operations'; + +function createSelectStep( + rows: readonly unknown[], + label: string, + timeline: string[] +) { + const where = jest.fn(() => { + timeline.push(label); + return Promise.resolve(rows); + }); + const from = jest.fn(() => ({ where })); + return { from }; +} + +function createDeleteHarness() { + const timeline: string[] = []; + const selections = [ + ['select:favorites', [{ id: 1 }, { id: 2 }]], + ['select:recently-viewed', [{ id: 3 }]], + ['select:playback-positions', []], + ['select:downloads', [{ id: 4 }]], + ['select:categories', [{ id: 10 }, { id: 11 }]], + [ + 'select:content', + Array.from({ length: 205 }, (_, id) => ({ id: id + 100 })), + ], + ] as const; + const select = jest.fn(); + for (const [label, rows] of selections) { + select.mockReturnValueOnce(createSelectStep(rows, label, timeline)); + } + const transactionRun = jest.fn(); + const transactionDelete = jest.fn(() => ({ + where: jest.fn(() => ({ run: transactionRun })), + })); + const transaction = jest.fn((execute: (tx: unknown) => unknown) => { + timeline.push('transaction'); + return execute({ delete: transactionDelete }); + }); + const playlistWhere = jest.fn(() => { + timeline.push('delete:playlist'); + return Promise.resolve(undefined); + }); + const deletePlaylistRow = jest.fn(() => ({ where: playlistWhere })); + + return { + db: { + delete: deletePlaylistRow, + select, + transaction, + } as unknown as AppDatabase, + timeline, + transaction, + }; +} + +describe('playlist delete performance phases', () => { + it('counts collected dependent IDs and includes the final playlist row in writes', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture((event) => { + harness.timeline.push(`${event.phase}:${event.boundary}`); + }); + const checkpoint = jest.fn(() => { + harness.timeline.push('checkpoint'); + }); + const onProgress = jest.fn((progress: { phase: string }) => { + harness.timeline.push(`progress:${progress.phase}`); + }); + + await expect( + deletePlaylist( + harness.db, + 'playlist-1', + { checkpoint, onProgress }, + recording.capture + ) + ).resolves.toEqual({ success: true }); + + expect(harness.transaction).toHaveBeenCalledTimes(7); + // Seven committed chunks plus the final playlist row each keep both + // the pre-write and post-progress cooperative checkpoints. + expect(checkpoint).toHaveBeenCalledTimes(16); + expect(harness.timeline.slice(1, 7)).toEqual([ + 'select:favorites', + 'select:recently-viewed', + 'select:playback-positions', + 'select:downloads', + 'select:categories', + 'select:content', + ]); + const writeStart = harness.timeline.indexOf( + 'sqlite.playlist-delete.write-transactions:start' + ); + const writeEnd = harness.timeline.indexOf( + 'sqlite.playlist-delete.write-transactions:end' + ); + expect(harness.timeline.indexOf('delete:playlist')).toBeGreaterThan( + writeStart + ); + expect(writeEnd).toBeGreaterThan( + harness.timeline.indexOf('progress:deleting-playlist') + ); + expect( + harness.timeline + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry === 'transaction') + .every(({ index }) => index > writeStart && index < writeEnd) + ).toBe(true); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + }, + { + boundary: 'end', + metadata: { itemCount: 211 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 212 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('closes the write phase on cancellation before deleting the playlist row', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture(); + const cancellation = new Error('synthetic cancellation'); + cancellation.name = 'AbortError'; + let checkpointCount = 0; + + await expect( + deletePlaylist( + harness.db, + 'playlist-1', + { + checkpoint: () => { + checkpointCount += 1; + if (checkpointCount === 2) { + throw cancellation; + } + }, + }, + recording.capture + ) + ).rejects.toBe(cancellation); + + expect(harness.transaction).toHaveBeenCalledTimes(1); + expect(harness.timeline).not.toContain('delete:playlist'); + expect(recording.events.at(-1)).toEqual({ + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index c8f5843db..8401fa991 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -4,6 +4,7 @@ import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; import { APP_PLAYLIST_GET_PERFORMANCE_PHASE, APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + XTREAM_DATABASE_PERFORMANCE_PHASE, type AppPlaylistGetPerformancePhase, type AppPlaylistUpsertPerformancePhase, type PerformancePhaseMetadata, @@ -15,6 +16,7 @@ import { type OperationControl, reportOperationProgress, } from './operation-control'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; const PLAYLIST_TYPES = { XTREAM: 'xtream', @@ -365,13 +367,10 @@ export async function upsertAppPlaylist( } const write = async () => { - await db - .insert(schema.playlists) - .values(row) - .onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); + await db.insert(schema.playlists).values(row).onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); }; if (capturePhase) { await capturePhase.captureAsync( @@ -483,17 +482,14 @@ export async function getAppPlaylist( (value) => ({ itemCount: value.length }) ) : await read(); - const deserialize = () => - rows[0] ? parseAppPlaylist(rows[0]) : null; + const deserialize = () => (rows[0] ? parseAppPlaylist(rows[0]) : null); return capturePhase ? capturePhase.captureSync( APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, deserialize, (value) => ({ - itemCount: value - ? (getPlaylistItemCount(value) ?? 0) - : 0, + itemCount: value ? (getPlaylistItemCount(value) ?? 0) : 0, }) ) : deserialize(); @@ -592,11 +588,19 @@ export async function updatePlaylist( return { success: true }; } -export async function deletePlaylist( +interface PlaylistDeletionCollection { + readonly categoryIds: number[]; + readonly contentRows: Array<{ id: number }>; + readonly downloadRows: Array<{ id: number }>; + readonly favoriteRows: Array<{ id: number }>; + readonly playbackPositionRows: Array<{ id: number }>; + readonly recentlyViewedRows: Array<{ id: number }>; +} + +async function collectPlaylistDeletionRows( db: AppDatabase, - playlistId: string, - control?: OperationControl -): Promise<{ success: boolean }> { + playlistId: string +): Promise { const [ favoriteRows, recentlyViewedRows, @@ -634,40 +638,69 @@ export async function deletePlaylist( .where(inArray(schema.content.categoryId, categoryIds)) : []; + return { + categoryIds, + contentRows, + downloadRows, + favoriteRows, + playbackPositionRows, + recentlyViewedRows, + }; +} + +function countPlaylistDeletionRows( + collection: PlaylistDeletionCollection +): number { + return ( + collection.favoriteRows.length + + collection.recentlyViewedRows.length + + collection.playbackPositionRows.length + + collection.downloadRows.length + + collection.contentRows.length + + collection.categoryIds.length + ); +} + +async function deleteCollectedPlaylistRows( + db: AppDatabase, + playlistId: string, + collection: PlaylistDeletionCollection, + control?: OperationControl +): Promise { for (const [phase, ids, column, table] of [ [ 'deleting-favorites', - favoriteRows.map((row) => row.id), + collection.favoriteRows.map((row) => row.id), schema.favorites.id, schema.favorites, ], [ 'deleting-recently-viewed', - recentlyViewedRows.map((row) => row.id), + collection.recentlyViewedRows.map((row) => row.id), schema.recentlyViewed.id, schema.recentlyViewed, ], [ 'deleting-playback-positions', - playbackPositionRows.map((row) => row.id), + collection.playbackPositionRows.map((row) => row.id), schema.playbackPositions.id, schema.playbackPositions, ], [ 'deleting-downloads', - downloadRows.map((row) => row.id), + collection.downloadRows.map((row) => row.id), schema.downloads.id, schema.downloads, ], [ 'deleting-content', - contentRows.map((row) => row.id), + collection.contentRows.map((row) => row.id), schema.content.id, schema.content, ], [ 'deleting-categories', - categoryIds, + collection.categoryIds, schema.categories.id, schema.categories, ], @@ -701,6 +734,41 @@ export async function deletePlaylist( increment: 1, }); + return countPlaylistDeletionRows(collection) + 1; +} + +export async function deletePlaylist( + db: AppDatabase, + playlistId: string, + control?: OperationControl, + capturePhase?: DatabaseOperationPerformancePhaseCapture +): Promise<{ success: boolean }> { + const collection = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + () => collectPlaylistDeletionRows(db, playlistId), + (result) => ({ + itemCount: countPlaylistDeletionRows(result), + }) + ) + : await collectPlaylistDeletionRows(db, playlistId); + + if (capturePhase) { + await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + () => + deleteCollectedPlaylistRows( + db, + playlistId, + collection, + control + ), + (itemCount) => ({ itemCount }) + ); + } else { + await deleteCollectedPlaylistRows(db, playlistId, collection, control); + } + return { success: true }; } diff --git a/apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts b/apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts new file mode 100644 index 000000000..a522ff993 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts @@ -0,0 +1,117 @@ +import { inArray } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { scoreSearchTextMatch } from './content-search.util'; +import { + checkpointOperation, + chunkValues, + type OperationControl, + reportOperationProgress, +} from './operation-control'; + +export type XtreamContentValue = { + categoryId: number; + title: string; + rating: string; + added: string; + posterUrl: string; + epgChannelId?: string | null; + tvArchive?: number | null; + tvArchiveDuration?: number | null; + directSource?: string | null; + xtreamId: number; + type: 'live' | 'movie' | 'series'; +}; + +export function normalizeXtreamContentValues( + streams: Array>, + type: 'live' | 'movie' | 'series', + categories: Array<{ id: number; xtreamId: number }>, + normalizeValue: ( + stream: Record, + type: 'live' | 'movie' | 'series', + categoryMap: Map + ) => XtreamContentValue | null +): XtreamContentValue[] { + const categoryMap = new Map( + categories.map((category) => [category.xtreamId, category.id]) + ); + + return streams + .map((stream) => normalizeValue(stream, type, categoryMap)) + .filter((value): value is XtreamContentValue => value !== null); +} + +export async function writeXtreamContentValues( + db: AppDatabase, + values: XtreamContentValue[], + control?: OperationControl +): Promise<{ success: boolean; count: number }> { + const total = values.length; + const chunkSize = 100; + let totalInserted = 0; + + for (let index = 0; index < values.length; index += chunkSize) { + await checkpointOperation(control); + const chunk = values.slice(index, index + chunkSize); + await db.transaction((tx) => { + tx.insert(schema.content) + .values(chunk) + .onConflictDoNothing({ + target: [ + schema.content.categoryId, + schema.content.type, + schema.content.xtreamId, + ], + }) + .run(); + }); + totalInserted += chunk.length; + await reportOperationProgress(control, { + phase: 'saving-content', + current: totalInserted, + total, + increment: chunk.length, + }); + } + + return { success: true, count: totalInserted }; +} + +export async function deleteXtreamCacheRows( + db: AppDatabase, + contentRows: Array<{ id: number }>, + categoryIds: number[] +): Promise<{ success: boolean }> { + for (const chunk of chunkValues( + contentRows.map((row) => row.id), + 100 + )) { + await db.transaction((tx) => { + tx.delete(schema.content) + .where(inArray(schema.content.id, chunk)) + .run(); + }); + } + + for (const chunk of chunkValues(categoryIds, 100)) { + await db.transaction((tx) => { + tx.delete(schema.categories) + .where(inArray(schema.categories.id, chunk)) + .run(); + }); + } + + return { success: true }; +} + +export function rankSearchCandidates< + TCandidate extends { title: string | null }, +>(candidates: TCandidate[], searchTerm: string): TCandidate[] { + return candidates + .filter( + (item) => + scoreSearchTextMatch(item.title ?? '', searchTerm) !== null + ) + .slice(0, 50); +} diff --git a/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts new file mode 100644 index 000000000..5be8b47b9 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts @@ -0,0 +1,217 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; +import { deleteXtreamContent } from './xtream.operations'; + +function createSelectStep( + rows: readonly unknown[], + label: string, + timeline: string[] +) { + const where = jest.fn(() => { + timeline.push(label); + return Promise.resolve(rows); + }); + const innerJoin = jest.fn(() => ({ where })); + const from = jest.fn(() => ({ innerJoin, where })); + return { from }; +} + +function createDeleteHarness() { + const timeline: string[] = []; + const categories = [ + { hidden: true, id: 11, type: 'live', xtreamId: 101 }, + { hidden: false, id: 12, type: 'movies', xtreamId: 102 }, + ]; + const favorites = [ + { + addedAt: '2026-01-01T00:00:00.000Z', + contentType: 'live', + position: 3, + xtreamId: 201, + }, + ]; + const recentlyViewed = [ + { + contentType: 'movie', + viewedAt: '2026-01-02T00:00:00.000Z', + xtreamId: 202, + }, + ]; + const content = Array.from({ length: 205 }, (_, id) => ({ id: id + 1 })); + const select = jest + .fn() + .mockReturnValueOnce( + createSelectStep(categories, 'select:categories', timeline) + ) + .mockReturnValueOnce( + createSelectStep(favorites, 'select:favorites', timeline) + ) + .mockReturnValueOnce( + createSelectStep(recentlyViewed, 'select:recently-viewed', timeline) + ) + .mockReturnValueOnce( + createSelectStep(content, 'select:content', timeline) + ); + const run = jest.fn(); + const deleteRows = jest.fn(() => ({ + where: jest.fn(() => ({ run })), + })); + const transaction = jest.fn((execute: (tx: unknown) => unknown) => { + timeline.push('transaction'); + return execute({ delete: deleteRows }); + }); + + return { + content, + db: { select, transaction } as unknown as AppDatabase, + timeline, + transaction, + }; +} + +describe('Xtream delete performance phases', () => { + it('times all user-data collection but counts only content/category deletion candidates', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture((event) => { + harness.timeline.push(`${event.phase}:${event.boundary}`); + }); + const checkpoint = jest.fn(() => { + harness.timeline.push('checkpoint'); + }); + const onProgress = jest.fn((progress: { current?: number }) => { + harness.timeline.push(`progress:${progress.current}`); + }); + + await expect( + deleteXtreamContent( + harness.db, + 'playlist-1', + { checkpoint, onProgress }, + recording.capture + ) + ).resolves.toEqual({ + favorites: [ + { + addedAt: '2026-01-01T00:00:00.000Z', + contentType: 'live', + position: 3, + xtreamId: 201, + }, + ], + hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }], + recentlyViewed: [ + { + contentType: 'movie', + viewedAt: '2026-01-02T00:00:00.000Z', + xtreamId: 202, + }, + ], + success: true, + }); + + expect(harness.transaction).toHaveBeenCalledTimes(4); + // Existing progress reporting performs a second cooperative + // checkpoint after each committed batch. + expect(checkpoint).toHaveBeenCalledTimes(8); + expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([ + 100, 200, 205, 2, + ]); + expect(harness.timeline.slice(1, 5)).toEqual([ + 'select:categories', + 'select:favorites', + 'select:recently-viewed', + 'select:content', + ]); + const writeStart = harness.timeline.indexOf( + 'sqlite.xtream-delete.write-transactions:start' + ); + const writeEnd = harness.timeline.indexOf( + 'sqlite.xtream-delete.write-transactions:end' + ); + expect(writeEnd).toBeGreaterThan( + harness.timeline.lastIndexOf('progress:2') + ); + expect( + harness.timeline + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry === 'transaction') + .every(({ index }) => index > writeStart && index < writeEnd) + ).toBe(true); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + { + boundary: 'end', + metadata: { itemCount: 207 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 207 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('closes the write phase on cancellation and preserves error identity', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture(); + const cancellation = new Error('synthetic cancellation'); + cancellation.name = 'AbortError'; + let checkpointCount = 0; + + await expect( + deleteXtreamContent( + harness.db, + 'playlist-1', + { + checkpoint: () => { + checkpointCount += 1; + if (checkpointCount === 2) { + throw cancellation; + } + }, + }, + recording.capture + ) + ).rejects.toBe(cancellation); + + expect(harness.transaction).toHaveBeenCalledTimes(1); + expect(recording.events.at(-1)).toEqual({ + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + }); + }); + + it('closes collection on query failure and preserves error identity', async () => { + const failure = new Error('synthetic select failure'); + const where = jest.fn().mockRejectedValue(failure); + const from = jest.fn(() => ({ where })); + const db = { + select: jest.fn(() => ({ from })), + } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await expect( + deleteXtreamContent(db, 'playlist-1', undefined, recording.capture) + ).rejects.toBe(failure); + + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + { + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/xtream.operations.ts b/apps/electron-backend/src/app/database/operations/xtream.operations.ts index 93643f951..779c7d779 100644 --- a/apps/electron-backend/src/app/database/operations/xtream.operations.ts +++ b/apps/electron-backend/src/app/database/operations/xtream.operations.ts @@ -1,9 +1,10 @@ import { and, eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; -import type { - XtreamBackupFavoriteItem, - XtreamBackupHiddenCategory, - XtreamBackupRecentlyViewedItem, +import { + XTREAM_DATABASE_PERFORMANCE_PHASE, + type XtreamBackupFavoriteItem, + type XtreamBackupHiddenCategory, + type XtreamBackupRecentlyViewedItem, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { @@ -12,6 +13,7 @@ import { type OperationControl, reportOperationProgress, } from './operation-control'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; const DEFAULT_BATCH_SIZE = 100; @@ -28,16 +30,18 @@ function toContentIdentityKey( return `${contentType}:${xtreamId}`; } -export async function deleteXtreamContent( +interface XtreamDeletionCollection { + readonly categoryIds: number[]; + readonly contentRows: Array<{ id: number }>; + readonly favorites: XtreamBackupFavoriteItem[]; + readonly hiddenCategories: XtreamBackupHiddenCategory[]; + readonly recentlyViewed: XtreamBackupRecentlyViewedItem[]; +} + +async function collectXtreamDeletionRows( db: AppDatabase, - playlistId: string, - control?: OperationControl -): Promise<{ - success: boolean; - favorites: XtreamBackupFavoriteItem[]; - recentlyViewed: XtreamBackupRecentlyViewedItem[]; - hiddenCategories: XtreamBackupHiddenCategory[]; -}> { + playlistId: string +): Promise { const categories = await db .select({ id: schema.categories.id, @@ -58,6 +62,7 @@ export async function deleteXtreamContent( let favorites: XtreamBackupFavoriteItem[] = []; let recentlyViewed: XtreamBackupRecentlyViewedItem[] = []; + let contentRows: Array<{ id: number }> = []; if (categoryIds.length > 0) { const favoritedContent = await db @@ -110,43 +115,58 @@ export async function deleteXtreamContent( viewedAt: item.viewedAt || new Date().toISOString(), })); - const contentRows = await db + contentRows = await db .select({ id: schema.content.id }) .from(schema.content) .where(inArray(schema.content.categoryId, categoryIds)); + } - let deletedContent = 0; - const totalContent = contentRows.length; + return { + categoryIds, + contentRows, + favorites, + hiddenCategories, + recentlyViewed, + }; +} - for (const chunk of chunkValues( - contentRows.map((content) => content.id), - DEFAULT_BATCH_SIZE - )) { - await checkpointOperation(control); - await db.transaction((tx) => { - tx - .delete(schema.content) - .where(inArray(schema.content.id, chunk)) - .run(); - }); - deletedContent += chunk.length; - await reportOperationProgress(control, { - phase: 'deleting-content', - current: deletedContent, - total: totalContent, - increment: chunk.length, - }); - } +async function deleteCollectedXtreamRows( + db: AppDatabase, + collection: XtreamDeletionCollection, + control?: OperationControl +): Promise { + let deletedContent = 0; + const totalContent = collection.contentRows.length; + + for (const chunk of chunkValues( + collection.contentRows.map((content) => content.id), + DEFAULT_BATCH_SIZE + )) { + await checkpointOperation(control); + await db.transaction((tx) => { + tx.delete(schema.content) + .where(inArray(schema.content.id, chunk)) + .run(); + }); + deletedContent += chunk.length; + await reportOperationProgress(control, { + phase: 'deleting-content', + current: deletedContent, + total: totalContent, + increment: chunk.length, + }); } let deletedCategories = 0; - const totalCategories = categoryIds.length; + const totalCategories = collection.categoryIds.length; - for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) { + for (const chunk of chunkValues( + collection.categoryIds, + DEFAULT_BATCH_SIZE + )) { await checkpointOperation(control); await db.transaction((tx) => { - tx - .delete(schema.categories) + tx.delete(schema.categories) .where(inArray(schema.categories.id, chunk)) .run(); }); @@ -159,11 +179,46 @@ export async function deleteXtreamContent( }); } + return totalContent + totalCategories; +} + +export async function deleteXtreamContent( + db: AppDatabase, + playlistId: string, + control?: OperationControl, + capturePhase?: DatabaseOperationPerformancePhaseCapture +): Promise<{ + success: boolean; + favorites: XtreamBackupFavoriteItem[]; + recentlyViewed: XtreamBackupRecentlyViewedItem[]; + hiddenCategories: XtreamBackupHiddenCategory[]; +}> { + const collection = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + () => collectXtreamDeletionRows(db, playlistId), + (result) => ({ + itemCount: + result.contentRows.length + result.categoryIds.length, + }) + ) + : await collectXtreamDeletionRows(db, playlistId); + + if (capturePhase) { + await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + () => deleteCollectedXtreamRows(db, collection, control), + (itemCount) => ({ itemCount }) + ); + } else { + await deleteCollectedXtreamRows(db, collection, control); + } + return { success: true, - favorites, - recentlyViewed, - hiddenCategories, + favorites: collection.favorites, + recentlyViewed: collection.recentlyViewed, + hiddenCategories: collection.hiddenCategories, }; } diff --git a/apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts b/apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts new file mode 100644 index 000000000..ed3af1e0f --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts @@ -0,0 +1,263 @@ +import type { + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + createXtreamMainPerformanceCapture, + createXtreamMeasuredTransformResponse, + type XtreamMainPerformanceCapture, + type XtreamMainPerformanceCaptureRuntime, +} from './xtream-performance'; + +type MainPhaseEvent = PerformancePhaseEvent; + +function requireCapture( + runtime: XtreamMainPerformanceCaptureRuntime, + events: MainPhaseEvent[], + requestId?: string +): XtreamMainPerformanceCapture { + const capture = createXtreamMainPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId, + runtime, + }); + if (!capture) { + throw new Error('Expected performance capture'); + } + return capture; +} + +describe('Xtream main performance runtime hardening', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses a clock-free safe identifier and epoch when every injected clock throws', () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => { + throw new Error('identifier failed'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch failed'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic failed'); + }), + }; + const wallClock = jest.spyOn(Date, 'now').mockImplementation(() => { + throw new Error('wall clock must stay untouched'); + }); + let capture: XtreamMainPerformanceCapture | undefined; + + expect(() => { + capture = requireCapture(runtime, events, ' unsafe request id '); + }).not.toThrow(); + expect(() => + capture?.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => undefined + ) + ).not.toThrow(); + + expect(wallClock).not.toHaveBeenCalled(); + expect(events).toHaveLength(2); + expect( + events.every( + ({ epochMs, requestId }) => + Number.isFinite(epochMs) && + epochMs > 0 && + /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId) + ) + ).toBe(true); + }); + + it('keeps emitted epochs positive, finite, and nondecreasing across invalid and backward samples', () => { + const events: MainPhaseEvent[] = []; + const epochSamples = [Number.NaN, 100, 90, Number.POSITIVE_INFINITY]; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'unused'), + readEpochMs: jest.fn(() => epochSamples.shift() ?? 80), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = requireCapture(runtime, events, 'xtream-mabc1001-1'); + + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => undefined + ); + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => undefined + ); + + const epochs = events.map(({ epochMs }) => epochMs); + expect(epochs.every((epochMs) => Number.isFinite(epochMs))).toBe(true); + expect(epochs.every((epochMs) => epochMs > 0)).toBe(true); + expect( + epochs.every( + (epochMs, index) => index === 0 || epochMs >= epochs[index - 1] + ) + ).toBe(true); + }); + + it('matches Axios by visiting a sparse transform entry and closing the failed JSON phase', () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'unused'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = requireCapture(runtime, events, 'xtream-mabc1002-2'); + const sparseTransforms = new Array(1); + const measured = createXtreamMeasuredTransformResponse( + capture, + sparseTransforms + ); + + expect(() => + measured.call( + {} as InternalAxiosRequestConfig, + 'raw', + {} as AxiosResponseHeaders, + 200 + ) + ).toThrow(TypeError); + expect(events.map(({ boundary }) => boundary)).toEqual([ + 'start', + 'end', + ]); + }); + + it('is fail-neutral and never publishes unsafe request identifiers or business data', () => { + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'xtream-main-safe-fallback'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = createXtreamMainPerformanceCapture({ + emit: () => { + throw new Error('diagnostics sink failed'); + }, + enabled: true, + requestId: 'provider-password-secret', + runtime, + }); + const businessError = new Error('provider-password-secret'); + + expect(() => + capture?.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + () => { + throw businessError; + } + ) + ).toThrow(businessError); + expect(runtime.createRequestId).toHaveBeenCalledTimes(1); + + const events: MainPhaseEvent[] = []; + const safeCapture = requireCapture( + runtime, + events, + 'provider-password-secret' + ); + safeCapture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => ({ secret: 'response-body-secret' }) + ); + expect( + events.every( + ({ requestId }) => requestId === 'xtream-main-safe-fallback' + ) + ).toBe(true); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('provider-password-secret'); + expect(serialized).not.toContain('response-body-secret'); + expect( + events.every( + (event) => + event.metadata === undefined && + Object.keys(event).every((key) => + [ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', + ].includes(key) + ) + ) + ).toBe(true); + }); + + it('correlates only the renderer-generated Xtream request ID shape', () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'xtream-main-unused'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = requireCapture(runtime, events, 'xtream-mabc1234-17'); + + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => undefined + ); + + expect(events.map(({ requestId }) => requestId)).toEqual([ + 'xtream-mabc1234-17', + 'xtream-mabc1234-17', + ]); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + }); + + it('keeps overlapping request phases isolated by capture-local request ID', async () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'unused'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const first = requireCapture(runtime, events, 'xtream-mabc1003-3'); + const second = requireCapture(runtime, events, 'xtream-mabc1004-4'); + let resolveFirst!: () => void; + let resolveSecond!: () => void; + const firstWork = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondWork = new Promise((resolve) => { + resolveSecond = resolve; + }); + + const firstResult = first.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => firstWork + ); + const secondResult = second.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => secondWork + ); + resolveSecond(); + await secondResult; + resolveFirst(); + await firstResult; + + for (const requestId of ['xtream-mabc1003-3', 'xtream-mabc1004-4']) { + const requestEvents = events.filter( + (event) => event.requestId === requestId + ); + expect(requestEvents.map(({ boundary }) => boundary)).toEqual([ + 'start', + 'end', + ]); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream-performance.spec.ts b/apps/electron-backend/src/app/events/xtream-performance.spec.ts new file mode 100644 index 000000000..6ef20b2a8 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-performance.spec.ts @@ -0,0 +1,235 @@ +import type { + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + createXtreamMainPerformanceCapture, + createXtreamMeasuredTransformResponse, + type XtreamMainPerformanceCapture, + type XtreamMainPerformanceCaptureRuntime, +} from './xtream-performance'; + +type MainPhaseEvent = PerformancePhaseEvent; + +function createRuntime( + requestId = 'xtream-main-generated' +): XtreamMainPerformanceCaptureRuntime { + let epochMs = 100; + let monotonicMs = 10; + return { + createRequestId: jest.fn(() => requestId), + readEpochMs: jest.fn(() => { + epochMs += 10; + return epochMs; + }), + readMonotonicMs: jest.fn(() => { + monotonicMs += 2; + return monotonicMs; + }), + }; +} + +function createCapture( + events: MainPhaseEvent[], + requestId = 'xtream-mabc1000-1', + runtime = createRuntime() +): XtreamMainPerformanceCapture { + const capture = createXtreamMainPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId, + runtime, + }); + if (!capture) { + throw new Error('Expected performance capture'); + } + return capture; +} + +describe('Xtream main-process performance capture', () => { + it('does no identifier, clock, or sink work while disabled', () => { + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => { + throw new Error('identifier must stay untouched'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch clock must stay untouched'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic clock must stay untouched'); + }), + }; + const emit = jest.fn(() => { + throw new Error('sink must stay untouched'); + }); + + expect( + createXtreamMainPerformanceCapture({ + emit, + enabled: false, + requestId: 'xtream-mabc1000-1', + runtime, + }) + ).toBeNull(); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + expect(runtime.readEpochMs).not.toHaveBeenCalled(); + expect(runtime.readMonotonicMs).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('preserves nested async results and exact errors while closing every phase', async () => { + const events: MainPhaseEvent[] = []; + const capture = createCapture(events); + const resultIdentity = { payload: 'same-object' }; + + const result = await capture.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + async () => + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + () => resultIdentity + ) + ); + + expect(result).toBe(resultIdentity); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + ]); + + const businessError = new Error('same-error'); + expect(() => + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + () => { + throw businessError; + } + ) + ).toThrow(businessError); + expect(events.slice(-2).map(({ boundary }) => boundary)).toEqual([ + 'start', + 'end', + ]); + }); + + it('replays the exact Axios transform chain, context, headers, and status', () => { + const events: MainPhaseEvent[] = []; + const capture = createCapture(events); + const context = { + responseType: undefined, + } as InternalAxiosRequestConfig; + const headers = { + normalize: jest.fn(function () { + return this; + }), + } as unknown as AxiosResponseHeaders; + const firstResult = { step: 1 }; + const finalResult = { step: 2 }; + const observations: unknown[] = []; + const transforms: AxiosResponseTransformer[] = [ + function (data, receivedHeaders, status) { + observations.push({ + context: this, + data, + headers: receivedHeaders, + status, + }); + return firstResult; + }, + function (data, receivedHeaders, status) { + observations.push({ + context: this, + data, + headers: receivedHeaders, + status, + }); + return finalResult; + }, + ]; + const measured = createXtreamMeasuredTransformResponse( + capture, + transforms + ); + + const result = measured.call(context, 'raw-json', headers, 200); + + expect(result).toBe(finalResult); + expect(observations).toEqual([ + { + context, + data: 'raw-json', + headers, + status: 200, + }, + { + context, + data: firstResult, + headers, + status: 200, + }, + ]); + expect(headers.normalize).toHaveBeenCalledTimes(1); + expect( + events.filter( + ({ phase }) => + phase === XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM + ) + ).toHaveLength(2); + }); + + it('runs redirect transforms unchanged but emits one aggregate pair for the final response', () => { + const events: MainPhaseEvent[] = []; + const capture = createCapture(events); + const transform = jest.fn(function (data: unknown) { + return data; + }) as AxiosResponseTransformer; + const measured = createXtreamMeasuredTransformResponse( + capture, + transform + ); + const context = {} as InternalAxiosRequestConfig; + const headers = {} as AxiosResponseHeaders; + + expect(measured.call(context, 'redirect-body', headers, 302)).toBe( + 'redirect-body' + ); + expect(measured.call(context, 'final-body', headers, 200)).toBe( + 'final-body' + ); + expect(transform).toHaveBeenCalledTimes(2); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream-performance.ts b/apps/electron-backend/src/app/events/xtream-performance.ts new file mode 100644 index 000000000..dfb58bcd8 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-performance.ts @@ -0,0 +1,262 @@ +import type { + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; +import { + PERFORMANCE_PHASE_BOUNDARY, + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { randomUUID } from 'node:crypto'; +import { channel, type Channel } from 'node:diagnostics_channel'; +import { performance } from 'node:perf_hooks'; + +export interface XtreamMainPerformanceCaptureRuntime { + readonly createRequestId: () => string; + readonly readEpochMs: () => number; + readonly readMonotonicMs: () => number; +} + +export interface XtreamMainPerformanceCapture { + measure( + phase: XtreamMainPerformancePhase, + execute: () => TResult + ): TResult; + measureAsync( + phase: XtreamMainPerformancePhase, + execute: () => Promise + ): Promise; +} + +interface CaptureOptions { + readonly emit?: (event: MainPhaseEvent) => void; + readonly enabled: boolean; + readonly requestId?: string; + readonly runtime?: XtreamMainPerformanceCaptureRuntime; +} + +interface PhaseStart { + readonly monotonicMs: number; + readonly phase: XtreamMainPerformancePhase; +} + +type MainPhaseEvent = PerformancePhaseEvent; +type TransformResponse = + AxiosResponseTransformer | AxiosResponseTransformer[] | undefined; + +const DEFAULT_RUNTIME: XtreamMainPerformanceCaptureRuntime = { + createRequestId: () => `xtream-main-${randomUUID()}`, + readEpochMs: () => performance.timeOrigin + performance.now(), + readMonotonicMs: () => performance.now(), +}; +const ENVIRONMENT_CAPTURE_ENABLED = + process.env['IPTVNATOR_PERF_CAPTURE'] === '1'; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const INTERNAL_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const RENDERER_REQUEST_ID = /^xtream-[0-9a-z]+-[1-9][0-9]*$/; +const MAX_RENDERER_REQUEST_ID_LENGTH = 64; + +let fallbackRequestSequence = 0; +let performanceChannel: Channel | null = null; + +export function createXtreamMainPerformanceCaptureForRequest( + requestId?: string +): XtreamMainPerformanceCapture | null { + return ENVIRONMENT_CAPTURE_ENABLED + ? createXtreamMainPerformanceCapture({ + enabled: true, + requestId, + }) + : null; +} + +export function createXtreamMainPerformanceCapture( + options: CaptureOptions +): XtreamMainPerformanceCapture | null { + if (!options.enabled) { + return null; + } + const runtime = options.runtime ?? DEFAULT_RUNTIME; + const requestId = + normalizeRendererRequestId(options.requestId) ?? + createRequestId(runtime); + const emit = options.emit ?? publishXtreamMainPerformancePhaseEvent; + let lastEpochMs = 0; + const readEpochMs = (): number => { + lastEpochMs = safeReadEpochMs(runtime, lastEpochMs); + return lastEpochMs; + }; + + const begin = (phase: XtreamMainPerformancePhase): PhaseStart => { + const monotonicMs = safeReadMonotonicMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: readEpochMs(), + metadata: undefined, + phase, + requestId, + }); + return { monotonicMs, phase }; + }; + const end = (start: PhaseStart): void => { + const endedMonotonicMs = safeReadMonotonicMs(runtime); + const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: Number.isFinite(durationMs) ? durationMs : 0, + epochMs: readEpochMs(), + metadata: undefined, + phase: start.phase, + requestId, + }); + }; + + return { + measure: (phase, execute) => { + const start = begin(phase); + try { + return execute(); + } finally { + end(start); + } + }, + measureAsync: async (phase, execute) => { + const start = begin(phase); + try { + return await execute(); + } finally { + end(start); + } + }, + }; +} + +export function createXtreamMeasuredTransformResponse( + capture: XtreamMainPerformanceCapture, + configuredTransforms: TransformResponse +): AxiosResponseTransformer { + const transforms = normalizeTransforms(configuredTransforms); + return function measuredTransformResponse(data, headers, status) { + const execute = () => + runTransformChain(transforms, this, data, headers, status); + return status !== undefined && REDIRECT_STATUSES.has(status) + ? execute() + : capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + execute + ); + }; +} + +function runTransformChain( + transforms: readonly AxiosResponseTransformer[], + context: InternalAxiosRequestConfig, + initialData: unknown, + headers: AxiosResponseHeaders, + status: number | undefined +): unknown { + let data = initialData; + for (let index = 0; index < transforms.length; index += 1) { + const transform = transforms[index]; + const normalizedHeaders = + index === 0 ? headers : normalizeHeaders(headers); + data = transform.call(context, data, normalizedHeaders, status); + } + return data; +} + +function normalizeTransforms( + transforms: TransformResponse +): readonly AxiosResponseTransformer[] { + if (!transforms) { + return []; + } + return Array.isArray(transforms) ? transforms.slice() : [transforms]; +} + +function normalizeHeaders(headers: AxiosResponseHeaders): AxiosResponseHeaders { + const normalize = ( + headers as AxiosResponseHeaders & { + normalize?: () => AxiosResponseHeaders; + } + ).normalize; + return typeof normalize === 'function' ? normalize.call(headers) : headers; +} + +function publishXtreamMainPerformancePhaseEvent(event: MainPhaseEvent): void { + try { + performanceChannel ??= channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + performanceChannel.publish(event); + } catch { + // Development-only instrumentation must never affect Xtream requests. + } +} + +function createRequestId(runtime: XtreamMainPerformanceCaptureRuntime): string { + try { + const requestId = normalizeInternalRequestId(runtime.createRequestId()); + if (requestId) { + return requestId; + } + } catch { + // Fall through to a process-local, credential-free identifier. + } + fallbackRequestSequence += 1; + return `xtream-main-fallback-${fallbackRequestSequence}`; +} + +function normalizeRendererRequestId(value: unknown): string | null { + return typeof value === 'string' && + value.length <= MAX_RENDERER_REQUEST_ID_LENGTH && + RENDERER_REQUEST_ID.test(value) + ? value + : null; +} + +function normalizeInternalRequestId(value: unknown): string | null { + return typeof value === 'string' && INTERNAL_REQUEST_ID.test(value) + ? value + : null; +} + +function safeReadEpochMs( + runtime: XtreamMainPerformanceCaptureRuntime, + previousEpochMs: number +): number { + try { + const value = runtime.readEpochMs(); + return Number.isFinite(value) && value > 0 + ? Math.max(previousEpochMs, value) + : Math.max(previousEpochMs, 1); + } catch { + return Math.max(previousEpochMs, 1); + } +} + +function safeReadMonotonicMs( + runtime: XtreamMainPerformanceCaptureRuntime +): number { + try { + const value = runtime.readMonotonicMs(); + return Number.isFinite(value) && value >= 0 ? value : 0; + } catch { + return 0; + } +} + +function safeEmit( + emit: (event: MainPhaseEvent) => void, + event: MainPhaseEvent +) { + try { + emit(event); + } catch { + // Instrumentation delivery is deliberately fail-neutral. + } +} diff --git a/apps/electron-backend/src/app/events/xtream-session-cancellation.ts b/apps/electron-backend/src/app/events/xtream-session-cancellation.ts new file mode 100644 index 000000000..5ff1f7637 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-session-cancellation.ts @@ -0,0 +1,26 @@ +interface ActiveXtreamRequest { + readonly controller: AbortController; + readonly sessionId?: string; +} + +interface XtreamCancellationResult { + readonly cancelled: number; + readonly success: boolean; +} + +export function cancelXtreamSessionRequests( + activeRequests: Iterable, + sessionId: string +): XtreamCancellationResult { + if (!sessionId) { + return { success: false, cancelled: 0 }; + } + let cancelled = 0; + for (const activeRequest of activeRequests) { + if (activeRequest.sessionId === sessionId) { + activeRequest.controller.abort(); + cancelled += 1; + } + } + return { success: cancelled > 0, cancelled }; +} diff --git a/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts b/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts new file mode 100644 index 000000000..724cdf49b --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts @@ -0,0 +1,204 @@ +import type { AxiosRequestConfig } from 'axios'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; +import { + applyConfiguredTransforms, + axiosMock, + getHandler, + loadXtreamEvents, + mockParseTransform, +} from './xtream.events.performance.test-helpers'; + +type MainPhaseEvent = PerformancePhaseEvent; + +function expectFailedResponsePhases(events: MainPhaseEvent[]): void { + expect(events.map(({ boundary, phase }) => ({ boundary, phase }))).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + ]); + expect( + events.some( + ({ phase }) => + phase === XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY + ) + ).toBe(false); +} + +describe('XtreamEvents failed-response performance phases', () => { + const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE']; + + afterEach(() => { + if (originalCaptureValue === undefined) { + delete process.env['IPTVNATOR_PERF_CAPTURE']; + } else { + process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue; + } + }); + + it('closes JSON and network phases without marking an Axios error response ready', async () => { + await loadXtreamEvents('1'); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + let axiosError: Error & { + response?: { + data: unknown; + headers: object; + status: number; + statusText: string; + }; + }; + axiosMock.mockImplementation((config: AxiosRequestConfig) => { + axiosError = Object.assign(new Error('provider unavailable'), { + response: { + data: applyConfiguredTransforms( + config, + '{"message":"provider unavailable"}', + 503 + ), + headers: {}, + status: 503, + statusText: 'Unavailable', + }, + }); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === axiosError + ); + return Promise.reject(axiosError); + }); + + await expect( + getHandler('XTREAM_REQUEST')( + {}, + { + params: { + action: 'get_live_categories', + password: 'provider-password-secret', + username: 'provider-user-secret', + }, + requestId: 'xtream-mabc1007-7', + sessionId: 'xtream-session-error', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) + ).rejects.toEqual({ + message: 'provider unavailable', + status: 503, + type: 'ERROR', + }); + + expectFailedResponsePhases(events); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); + + it('closes a fulfilled 4xx response without marking an IPC result ready', async () => { + await loadXtreamEvents('1'); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + axiosMock.mockImplementation((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: applyConfiguredTransforms( + config, + '{"message":"not found"}', + 404 + ), + headers: {}, + status: 404, + statusText: 'Not Found', + }) + ); + + await expect( + getHandler('XTREAM_REQUEST')( + {}, + { + params: { action: 'get_live_categories' }, + requestId: 'xtream-mabc1008-8', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) + ).rejects.toEqual({ + message: 'HTTP Error: Not Found', + status: 404, + }); + expectFailedResponsePhases(events); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); + + it('preserves an exact default-transform error while closing JSON and network once', async () => { + await loadXtreamEvents('1'); + const transformError = new Error('default transform failed'); + mockParseTransform.mockImplementationOnce(() => { + throw transformError; + }); + axiosMock.mockImplementation((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: applyConfiguredTransforms(config, '{"items":[]}', 200), + headers: {}, + status: 200, + statusText: 'OK', + }) + ); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + await expect( + getHandler('XTREAM_REQUEST')( + {}, + { + params: { action: 'get_live_categories' }, + requestId: 'xtream-mabc1009-9', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) + ).rejects.toBe(transformError); + expectFailedResponsePhases(events); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts b/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts new file mode 100644 index 000000000..be496f881 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts @@ -0,0 +1,254 @@ +import type { AxiosRequestConfig } from 'axios'; +import { + XTREAM_CANCEL_SESSION, + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; +import { + applyConfiguredTransforms, + axiosMock, + createDeferred, + getDefaultsReadCount, + getHandler, + loadXtreamEvents, + mockFinalizeTransform, + mockParseTransform, +} from './xtream.events.performance.test-helpers'; + +type MainPhaseEvent = PerformancePhaseEvent; + +describe('XtreamEvents performance phases', () => { + const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE']; + + afterEach(() => { + if (originalCaptureValue === undefined) { + delete process.env['IPTVNATOR_PERF_CAPTURE']; + } else { + process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue; + } + jest.restoreAllMocks(); + }); + + it.each([undefined, 'true', 'yes', 'on', ' 1 ', '0'])( + 'keeps the Axios path untouched for non-exact capture value %p', + async (captureValue) => { + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + await loadXtreamEvents(captureValue); + axiosMock.mockImplementation((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: { same: 'identity' }, + headers: {}, + status: 200, + statusText: 'OK', + }) + ); + + await getHandler('XTREAM_REQUEST')( + {}, + { + params: { action: 'get_live_categories' }, + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ); + + const config = axiosMock.mock.calls[0][0]; + expect( + Object.prototype.hasOwnProperty.call( + config, + 'transformResponse' + ) + ).toBe(false); + expect(getDefaultsReadCount()).toBe(0); + expect(events).toEqual([]); + } finally { + phaseChannel.unsubscribe(subscriber); + } + } + ); + + it('pairs one final JSON phase across redirects and marks the ready response', async () => { + await loadXtreamEvents('1'); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + let finalPayload: unknown; + try { + axiosMock + .mockImplementationOnce((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: applyConfiguredTransforms( + config, + '{"redirect":true}', + 302 + ), + headers: { location: '/final' }, + status: 302, + statusText: 'Found', + }) + ) + .mockImplementationOnce((config: AxiosRequestConfig) => { + finalPayload = applyConfiguredTransforms( + config, + '{"items":[1,2,3]}', + 200 + ); + return Promise.resolve({ + config, + data: finalPayload, + headers: {}, + status: 200, + statusText: 'OK', + }); + }); + + const result = (await getHandler('XTREAM_REQUEST')( + {}, + { + params: { + action: 'get_live_categories', + password: 'provider-password-secret', + username: 'provider-user-secret', + }, + requestId: 'xtream-mabc1005-5', + sessionId: 'xtream-session-1', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + )) as { action: string; payload: unknown }; + + expect(result.payload).toBe(finalPayload); + expect(result.action).toBe('get_live_categories'); + expect(getDefaultsReadCount()).toBe(1); + expect(mockParseTransform).toHaveBeenCalledTimes(2); + expect(mockFinalizeTransform).toHaveBeenCalledTimes(2); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + }, + ]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('localhost'); + expect(serialized).not.toContain('provider-user-secret'); + expect(serialized).not.toContain('provider-password-secret'); + expect(serialized).not.toContain('items'); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); + + it('closes network and cancel dispatch exactly once without a response-ready marker on abort', async () => { + await loadXtreamEvents('1'); + const enteredAxios = createDeferred(); + const cancelError = Object.assign(new Error('cancelled'), { + code: 'ERR_CANCELED', + }); + let signal: AbortSignal | undefined; + axiosMock.mockImplementation((config: AxiosRequestConfig) => { + signal = config.signal as AbortSignal; + enteredAxios.resolve(); + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(cancelError), { + once: true, + }); + }); + }); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === cancelError + ); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + const request = getHandler('XTREAM_REQUEST')( + {}, + { + params: { + action: 'get_live_streams', + password: 'secret', + username: 'user', + }, + requestId: 'xtream-mabc1006-6', + sessionId: 'xtream-session-abort', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) as Promise; + await enteredAxios.promise; + + await expect( + getHandler(XTREAM_CANCEL_SESSION)({}, 'xtream-session-abort') + ).resolves.toEqual({ cancelled: 1, success: true }); + await expect(request).rejects.toMatchObject({ + name: 'AbortError', + status: 499, + }); + expect(signal?.aborted).toBe(true); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + ]); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts b/apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts new file mode 100644 index 000000000..ed3b8c826 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts @@ -0,0 +1,118 @@ +import type { + AxiosRequestConfig, + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; + +const registeredHandlers = new Map unknown>(); +export const mockParseTransform = jest.fn(function (data: unknown) { + return typeof data === 'string' ? JSON.parse(data) : data; +}); +export const mockFinalizeTransform = jest.fn(function (data: unknown) { + return data; +}); +const mockDefaultTransforms: AxiosResponseTransformer[] = [ + mockParseTransform, + mockFinalizeTransform, +]; +let mockDefaultsReadCount = 0; +export const axiosMock = Object.assign(jest.fn(), { + isAxiosError: jest.fn(), +}); +Object.defineProperty(axiosMock, 'defaults', { + configurable: true, + get: () => { + mockDefaultsReadCount += 1; + return { transformResponse: mockDefaultTransforms }; + }, +}); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + (name: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(name, handler); + } + ), + }, +})); + +jest.mock('axios', () => ({ + __esModule: true, + default: axiosMock, +})); + +jest.mock('./portal-debug.events', () => ({ + emitPortalDebugEvent: jest.fn(), +})); + +export function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +export function applyConfiguredTransforms( + config: AxiosRequestConfig, + data: unknown, + status: number +): unknown { + const configured = config.transformResponse; + if (!configured) { + return data; + } + const headers = { + normalize: jest.fn(function () { + return this; + }), + } as unknown as AxiosResponseHeaders & { + normalize: () => AxiosResponseHeaders; + }; + const transforms = Array.isArray(configured) ? configured : [configured]; + let transformed = data; + for (const transform of transforms) { + transformed = transform.call( + config as InternalAxiosRequestConfig, + transformed, + headers.normalize(), + status + ); + } + headers.normalize(); + return transformed; +} + +export async function loadXtreamEvents( + captureValue: string | undefined +): Promise { + jest.resetModules(); + registeredHandlers.clear(); + axiosMock.mockReset(); + axiosMock.isAxiosError.mockReset(); + mockParseTransform.mockClear(); + mockFinalizeTransform.mockClear(); + mockDefaultsReadCount = 0; + if (captureValue === undefined) { + delete process.env['IPTVNATOR_PERF_CAPTURE']; + } else { + process.env['IPTVNATOR_PERF_CAPTURE'] = captureValue; + } + await import('./xtream.events'); +} + +export function getHandler(name: string): (...args: unknown[]) => unknown { + const handler = registeredHandlers.get(name); + if (!handler) { + throw new Error(`Missing IPC handler: ${name}`); + } + return handler; +} + +export function getDefaultsReadCount(): number { + return mockDefaultsReadCount; +} diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 5a6840fe0..7fb7a6a3e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -4,6 +4,8 @@ const registeredHandlers = new Map unknown>(); const axiosMock = Object.assign(jest.fn(), { isAxiosError: jest.fn(), }); +const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE'; +const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV]; function createDeferred() { let resolve!: (value: T) => void; @@ -40,6 +42,7 @@ describe('XtreamEvents session cancellation', () => { beforeEach(async () => { jest.resetModules(); + delete process.env[PERF_CAPTURE_ENV]; registeredHandlers.clear(); axiosMock.mockReset(); axiosMock.isAxiosError.mockReset(); @@ -52,6 +55,14 @@ describe('XtreamEvents session cancellation', () => { consoleErrorSpy.mockRestore(); }); + afterAll(() => { + if (originalPerformanceCaptureValue === undefined) { + delete process.env[PERF_CAPTURE_ENV]; + } else { + process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue; + } + }); + it('normalizes full Xtream API URLs before appending player_api.php', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); expect(requestHandler).toBeDefined(); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 0d63e0523..87ae81574 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -8,12 +8,18 @@ import { ipcMain } from 'electron'; import { PortalDebugEvent, XTREAM_CANCEL_SESSION, + XTREAM_MAIN_PERFORMANCE_PHASE, normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +import { + createXtreamMainPerformanceCaptureForRequest, + createXtreamMeasuredTransformResponse, +} from './xtream-performance'; +import { cancelXtreamSessionRequests } from './xtream-session-cancellation'; // Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to // challenge generic browser-looking User-Agents while allowlisting known @@ -101,6 +107,9 @@ ipcMain.handle( } ) => { const startedAt = Date.now(); + const performanceCapture = createXtreamMainPerformanceCaptureForRequest( + payload.requestId + ); let activeRequestKey: string | null = null; let requestUrlForLog = payload.url; try { @@ -132,13 +141,29 @@ ipcMain.handle( validateStatus: (status) => status < 500, // Don't throw on 4xx errors signal: controller.signal, }; + if (performanceCapture) { + config.transformResponse = + createXtreamMeasuredTransformResponse( + performanceCapture, + axios.defaults.transformResponse + ); + } - const response = await requestWithValidatedRedirects( - apiUrl.toString(), - config, - { allowPrivateNetworks: true } - ); - + const response = performanceCapture + ? await performanceCapture.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => + requestWithValidatedRedirects( + apiUrl.toString(), + config, + { allowPrivateNetworks: true } + ) + ) + : await requestWithValidatedRedirects( + apiUrl.toString(), + config, + { allowPrivateNetworks: true } + ); // Check if response is successful if (response.status >= 400) { throw { @@ -169,10 +194,16 @@ ipcMain.handle( } // Xtream API returns JSON data - return { + const result = { payload: response.data, action: params.action, }; + return performanceCapture + ? performanceCapture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => result + ) + : result; } catch (error) { const requestId = payload.requestId; if (requestId) { @@ -269,24 +300,20 @@ ipcMain.handle( _event, sessionId: string ): Promise<{ success: boolean; cancelled: number }> => { - if (!sessionId) { - return { success: false, cancelled: 0 }; - } - - let cancelled = 0; - for (const activeRequest of activeXtreamRequests.values()) { - if (activeRequest.sessionId !== sessionId) { - continue; - } - - activeRequest.controller.abort(); - cancelled += 1; - } - - return { - success: cancelled > 0, - cancelled, - }; + const capture = createXtreamMainPerformanceCaptureForRequest(); + return capture + ? capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + () => + cancelXtreamSessionRequests( + activeXtreamRequests.values(), + sessionId + ) + ) + : cancelXtreamSessionRequests( + activeXtreamRequests.values(), + sessionId + ); } ); type ActiveXtreamRequest = { @@ -329,8 +356,7 @@ ipcMain.handle( } ); const responseBody = response.data as - | { destroy?: () => void } - | undefined; + { destroy?: () => void } | undefined; responseBody?.destroy?.(); return { status: response.status, diff --git a/apps/electron-backend/src/app/services/database-worker-client.spec.ts b/apps/electron-backend/src/app/services/database-worker-client.spec.ts index 429cb7e4f..cfffa699a 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.spec.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.spec.ts @@ -148,6 +148,40 @@ describe('DatabaseWorkerClient', () => { }); }); + it('ignores profiling-only cancel receipts without settling or exposing the pending request', async () => { + const client = createClient(); + const onEvent = jest.fn(); + const requestPromise = client.request( + 'DB_DELETE_XTREAM_CONTENT', + { operationId: 'operation-1', playlistId: 'xtream-1' }, + { onEvent } + ); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'ready' }); + await flushPromises(); + + const request = worker.postMessage.mock.calls[0][0]; + worker.emit('message', { + type: 'performance-cancel-received', + operationId: 'operation-1', + requestId: request.requestId, + epochMs: 123, + }); + + expect(onEvent).not.toHaveBeenCalled(); + + const resultIdentity = { success: true }; + worker.emit('message', { + type: 'response', + requestId: request.requestId, + success: true, + result: resultIdentity, + }); + + await expect(requestPromise).resolves.toBe(resultIdentity); + }); + it('turns serialized worker errors into rejected Error instances', async () => { const client = createClient(); const requestPromise = client.request('DB_DELETE_PLAYLIST', { diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index a7b8294d5..033aa99b0 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -194,7 +194,13 @@ export class DatabaseWorkerClient { requestId: message.requestId, }); } - this.pendingRequests.get(message.requestId)?.onEvent?.(message.event); + this.pendingRequests + .get(message.requestId) + ?.onEvent?.(message.event); + return; + } + + if (message.type === 'performance-cancel-received') { return; } diff --git a/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts new file mode 100644 index 000000000..48ff97c6b --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts @@ -0,0 +1,120 @@ +import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +const originalProfilingValue = process.env[PROFILING_ENV]; + +describe('database worker performance cancellation receipt', () => { + beforeEach(() => { + delete process.env[PROFILING_ENV]; + }); + + afterAll(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('does no clock or transport work without the exact profiling opt-in', () => { + const publish = jest.fn(); + const readEpochMs = jest.fn(() => 123); + process.env[PROFILING_ENV] = 'true'; + + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { readEpochMs } + ); + + expect(readEpochMs).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('publishes only safe correlation IDs and a finite epoch', () => { + const publish = jest.fn(); + process.env[PROFILING_ENV] = '1'; + + publishDatabaseWorkerCancelReceipt( + 'playlist-delete:123_abc', + '08da28c4-cc14-4a79-95ec-dff6bba10db0', + publish, + { readEpochMs: () => 1_234.5 } + ); + + expect(publish).toHaveBeenCalledWith({ + type: 'performance-cancel-received', + operationId: 'playlist-delete:123_abc', + requestId: '08da28c4-cc14-4a79-95ec-dff6bba10db0', + epochMs: 1_234.5, + }); + expect( + Object.keys(publish.mock.calls[0][0] as Record) + ).toEqual(['type', 'operationId', 'requestId', 'epochMs']); + }); + + it.each([ + [' leading-space', 'request-1'], + ['trailing-space ', 'request-1'], + ['provider/password', 'request-1'], + ['operation-1', 'request with spaces'], + ['operation-1', 'request/with/slashes'], + [`a${'b'.repeat(128)}`, 'request-1'], + ])( + 'rejects unsafe operation/request correlation without transport work', + (operationId, requestId) => { + const publish = jest.fn(); + const readEpochMs = jest.fn(() => 123); + process.env[PROFILING_ENV] = '1'; + + publishDatabaseWorkerCancelReceipt( + operationId, + requestId, + publish, + { readEpochMs } + ); + + expect(readEpochMs).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + } + ); + + it('fails neutrally when the clock or diagnostic transport fails', () => { + process.env[PROFILING_ENV] = '1'; + const publish = jest.fn(() => { + throw new Error('transport unavailable'); + }); + + expect(() => + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { readEpochMs: () => 123 } + ) + ).not.toThrow(); + expect(() => + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { + readEpochMs: () => { + throw new Error('clock unavailable'); + }, + } + ) + ).not.toThrow(); + expect(() => + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { readEpochMs: () => Number.NaN } + ) + ).not.toThrow(); + + expect(publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts new file mode 100644 index 000000000..4c03b576b --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts @@ -0,0 +1,63 @@ +import { performance } from 'node:perf_hooks'; +import type { DbWorkerPerformanceCancelReceivedMessage } from './database-worker.types'; +import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime'; + +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +interface CancelReceiptRuntime { + readonly readEpochMs: () => number; +} + +type CancelReceiptPublisher = ( + message: DbWorkerPerformanceCancelReceivedMessage +) => void; + +const DEFAULT_RUNTIME: CancelReceiptRuntime = { + readEpochMs: () => performance.timeOrigin + performance.now(), +}; + +export function publishDatabaseWorkerCancelReceipt( + operationId: unknown, + requestId: unknown, + publish: CancelReceiptPublisher, + runtime: CancelReceiptRuntime = DEFAULT_RUNTIME +): void { + if (process.env[WORKER_PROFILING_ENV] !== '1') { + return; + } + if (!isSafeIdentifier(operationId) || !isSafeIdentifier(requestId)) { + return; + } + + let epochMs: number; + try { + epochMs = runtime.readEpochMs(); + } catch { + return; + } + if (!Number.isFinite(epochMs) || epochMs <= 0) { + return; + } + + try { + publish({ + type: 'performance-cancel-received', + operationId, + requestId, + epochMs, + }); + } catch { + // Development-only instrumentation must not affect cancellation. + } +} + +function isSafeIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_IDENTIFIER_LENGTH && + value === value.trim() && + SAFE_IDENTIFIER_PATTERN.test(value) + ); +} diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index d312ae21a..40f9d787a 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -88,11 +88,7 @@ export const DB_OPERATION_PHASES = { } as const; export type DbOperationStatus = - | 'started' - | 'progress' - | 'completed' - | 'cancelled' - | 'error'; + 'started' | 'progress' | 'completed' | 'cancelled' | 'error'; export interface DbOperationEvent { operationId?: string; @@ -134,6 +130,13 @@ export interface DbWorkerEventMessage { event: DbOperationEvent; } +export interface DbWorkerPerformanceCancelReceivedMessage { + type: 'performance-cancel-received'; + operationId: string; + requestId: string; + epochMs: number; +} + export interface DbWorkerResponseMessage { type: 'response'; requestId: string; @@ -144,10 +147,10 @@ export interface DbWorkerResponseMessage { } export type DbWorkerIncomingMessage = - | DbWorkerRequestMessage - | DbWorkerCancelMessage; + DbWorkerRequestMessage | DbWorkerCancelMessage; export type DbWorkerMessage = | DbWorkerReadyMessage | DbWorkerEventMessage + | DbWorkerPerformanceCancelReceivedMessage | DbWorkerResponseMessage; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 7ac3e6501..003dcb037 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -102,11 +102,13 @@ import { import { captureWorkerPerformancePhase, captureWorkerPerformancePhaseAsync, + createWorkerPerformancePhaseAdapter, } from './worker-performance-phase'; import { handleDatabaseWorkerPostGcHeapRequest, isDatabaseWorkerPostGcHeapRequest, } from './database-worker-post-gc-heap'; +import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt'; const loggerLabel = '[DB Worker]'; const batchDelayMs = Number.parseInt( @@ -116,6 +118,7 @@ const batchDelayMs = Number.parseInt( type ActiveOperationState = { cancelled: boolean; + performanceRequestId?: string; }; type PreRegisteredActiveOperation = { @@ -376,7 +379,14 @@ async function executeRequest( playlistId: string; type: 'live' | 'movies' | 'series'; }; - return getCategories(db, payload.playlistId, payload.type); + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); + return getCategories( + db, + payload.playlistId, + payload.type, + capturePhase + ); } case 'DB_SAVE_CATEGORIES': { @@ -389,12 +399,15 @@ async function executeRequest( type: 'live' | 'movies' | 'series'; hiddenCategoryXtreamIds?: number[]; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return saveCategories( db, payload.playlistId, payload.categories, payload.type, - payload.hiddenCategoryXtreamIds + payload.hiddenCategoryXtreamIds, + capturePhase ); } @@ -431,7 +444,14 @@ async function executeRequest( playlistId: string; type: 'live' | 'movie' | 'series'; }; - return getContent(db, payload.playlistId, payload.type); + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); + return getContent( + db, + payload.playlistId, + payload.type, + capturePhase + ); } case 'DB_GET_GLOBAL_RECENTLY_ADDED': { @@ -456,6 +476,8 @@ async function executeRequest( type: 'live' | 'movie' | 'series'; operationId?: string; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return executeTrackedOperation( { @@ -476,7 +498,8 @@ async function executeRequest( payload.playlistId, payload.streams, payload.type, - controller.control + controller.control, + capturePhase ); controller.emitCompleted({ @@ -496,8 +519,15 @@ async function executeRequest( playlistId: string; type: 'live' | 'movie' | 'series'; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); - return clearXtreamImportCache(db, payload.playlistId, payload.type); + return clearXtreamImportCache( + db, + payload.playlistId, + payload.type, + capturePhase + ); } case 'DB_GET_CONTENT_BY_XTREAM_ID': { @@ -533,12 +563,15 @@ async function executeRequest( types: string[]; excludeHidden?: boolean; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return searchContent( db, payload.playlistId, payload.searchTerm, payload.types, - payload.excludeHidden + payload.excludeHidden, + capturePhase ); } @@ -672,6 +705,8 @@ async function executeRequest( playlistId: string; operationId?: string; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return executeTrackedOperation( { @@ -689,7 +724,8 @@ async function executeRequest( const result = await deletePlaylist( db, payload.playlistId, - controller.control + controller.control, + capturePhase ); controller.emitCompleted({ @@ -784,6 +820,8 @@ async function executeRequest( playlistId: string; operationId?: string; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return executeTrackedOperation( { @@ -801,7 +839,8 @@ async function executeRequest( const result = await deleteXtreamContent( db, payload.playlistId, - controller.control + controller.control, + capturePhase ); controller.emitCompleted({ @@ -1049,6 +1088,13 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { const activeOperation = activeOperations.get(message.operationId); if (activeOperation) { activeOperation.cancelled = true; + if (activeOperation.performanceRequestId !== undefined) { + publishDatabaseWorkerCancelReceipt( + message.operationId, + activeOperation.performanceRequestId, + postMessage + ); + } } return; } @@ -1060,6 +1106,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { performanceCapture = startWorkerPerformanceCapture({ requestId: message.requestId, }); + if (performanceCapture && preRegisteredOperation) { + preRegisteredOperation.state.performanceRequestId = + message.requestId; + } registerDatabaseWorkerPerformanceCapture( activePerformanceCaptures, performanceCapture diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts index eadd2617f..ba613ca64 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -6,91 +6,17 @@ import type { PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage, } from './playlist-refresh.worker.types'; - -type WorkerMessageHandler = ( - message: TMessage -) => Promise | void; - -interface ArmGate { - readonly promise: Promise; - readonly release: () => void; -} - -function createArmGate(): ArmGate { - let release = (): void => undefined; - const promise = new Promise((resolvePromise) => { - release = resolvePromise; - }); - return { promise, release }; -} - -function createParentPortHarness() { - let messageHandler: WorkerMessageHandler | null = null; - const postMessage = jest.fn(); - const parentPort = { - on: jest.fn( - (event: string, handler: WorkerMessageHandler): void => { - if (event === 'message') { - messageHandler = handler; - } - } - ), - postMessage, - }; - - return { - getMessageHandler: (): WorkerMessageHandler => { - if (!messageHandler) { - throw new Error('Worker message handler was not registered'); - } - return messageHandler; - }, - parentPort, - postMessage, - }; -} - -function mockPerformanceCapture( - armGate: ArmGate, - failureGates?: { - readonly observed: ArmGate; - readonly profilingFinished: ArmGate; - } -): void { - jest.doMock('./worker-performance-capture', () => ({ - armWorkerPerformanceCapture: jest.fn(() => armGate.promise), - executeWithWorkerPerformanceCapture: jest.fn( - async (_capture: unknown, execute: () => Promise) => { - try { - return { - error: null, - performance: undefined, - result: await execute(), - success: true, - }; - } catch (error) { - failureGates?.observed.release(); - await failureGates?.profilingFinished.promise; - return { - error, - performance: undefined, - result: undefined, - success: false, - }; - } - } - ), - registerDatabaseWorkerPerformanceCapture: jest.fn(), - releaseDatabaseWorkerPerformanceCapture: jest.fn(), - stampWorkerPerformanceResponsePostedEpoch: jest.fn( - (_capture: unknown, performance: unknown) => performance - ), - startWorkerPerformanceCapture: jest.fn(() => ({})), - })); -} +import { + createArmGate, + createParentPortHarness, + mockPerformanceCapture, + restoreWorkerProfilingEnvironment, + WORKER_PROFILING_ENV, +} from './worker-performance-cancellation.test-helpers'; describe('worker cancellation while performance capture arms', () => { afterEach(() => { + restoreWorkerProfilingEnvironment(); jest.restoreAllMocks(); jest.resetModules(); }); @@ -175,7 +101,8 @@ describe('worker cancellation while performance capture arms', () => { ); }); - it('cancels cancellable database work when cancel arrives during arming', async () => { + it('acknowledges receipt separately before cancellable database work terminates', async () => { + process.env[WORKER_PROFILING_ENV] = '1'; const armGate = createArmGate(); const port = createParentPortHarness< DbWorkerIncomingMessage, @@ -225,6 +152,24 @@ describe('worker cancellation while performance capture arms', () => { type: 'cancel', operationId: 'database-operation-1', }); + + const receipt = port.postMessage.mock.calls.find( + ([message]) => message.type === 'performance-cancel-received' + )?.[0]; + expect(receipt).toEqual({ + type: 'performance-cancel-received', + operationId: 'database-operation-1', + requestId: 'request-1', + epochMs: expect.any(Number), + }); + expect( + port.postMessage.mock.calls.some( + ([message]) => + message.type === 'event' && + message.event.status === 'cancelled' + ) + ).toBe(false); + armGate.release(); await requestPromise; @@ -249,6 +194,79 @@ describe('worker cancellation while performance capture arms', () => { ); }); + it('does not acknowledge receipt when request capture is unavailable', async () => { + process.env[WORKER_PROFILING_ENV] = '1'; + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const saveContent = jest.fn( + async ( + _db: unknown, + _playlistId: string, + _streams: unknown[], + _type: string, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { count: 0 }; + } + ); + mockPerformanceCapture(armGate, undefined, null); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/content.operations', () => ({ + saveContent, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'database-operation-no-capture', + playlistId: 'playlist-1', + streams: [], + type: 'movie', + }, + requestId: 'request-no-capture', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-no-capture', + }); + + expect( + port.postMessage.mock.calls.some( + ([message]) => message.type === 'performance-cancel-received' + ) + ).toBe(false); + + armGate.release(); + await requestPromise; + + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'database-operation-no-capture', + status: 'cancelled', + }), + requestId: 'request-no-capture', + type: 'event', + }) + ); + }); + it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => { const armGate = createArmGate(); const port = createParentPortHarness< @@ -317,6 +335,7 @@ describe('worker cancellation while performance capture arms', () => { describe('database worker performance control messages', () => { afterEach(() => { + restoreWorkerProfilingEnvironment(); jest.restoreAllMocks(); jest.resetModules(); }); diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts new file mode 100644 index 000000000..bf3d2ac3c --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts @@ -0,0 +1,94 @@ +type WorkerMessageHandler = ( + message: TMessage +) => Promise | void; + +export interface ArmGate { + readonly promise: Promise; + readonly release: () => void; +} + +export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +const originalWorkerProfilingValue = process.env[WORKER_PROFILING_ENV]; + +export function restoreWorkerProfilingEnvironment(): void { + if (originalWorkerProfilingValue === undefined) { + delete process.env[WORKER_PROFILING_ENV]; + } else { + process.env[WORKER_PROFILING_ENV] = originalWorkerProfilingValue; + } +} + +export function createArmGate(): ArmGate { + let release = (): void => undefined; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +export function createParentPortHarness() { + let messageHandler: WorkerMessageHandler | null = null; + const postMessage = jest.fn(); + const parentPort = { + on: jest.fn( + (event: string, handler: WorkerMessageHandler): void => { + if (event === 'message') { + messageHandler = handler; + } + } + ), + postMessage, + }; + + return { + getMessageHandler: (): WorkerMessageHandler => { + if (!messageHandler) { + throw new Error('Worker message handler was not registered'); + } + return messageHandler; + }, + parentPort, + postMessage, + }; +} + +export function mockPerformanceCapture( + armGate: ArmGate, + failureGates?: { + readonly observed: ArmGate; + readonly profilingFinished: ArmGate; + }, + capture: unknown = {} +): void { + jest.doMock('./worker-performance-capture', () => ({ + armWorkerPerformanceCapture: jest.fn(() => armGate.promise), + executeWithWorkerPerformanceCapture: jest.fn( + async (_capture: unknown, execute: () => Promise) => { + try { + return { + error: null, + performance: undefined, + result: await execute(), + success: true, + }; + } catch (error) { + failureGates?.observed.release(); + await failureGates?.profilingFinished.promise; + return { + error, + performance: undefined, + result: undefined, + success: false, + }; + } + } + ), + registerDatabaseWorkerPerformanceCapture: jest.fn(), + releaseDatabaseWorkerPerformanceCapture: jest.fn(), + stampWorkerPerformanceResponsePostedEpoch: jest.fn( + (_capture: unknown, performance: unknown) => performance + ), + startWorkerPerformanceCapture: jest.fn(() => capture), + })); +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts index dff916076..f9430c601 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -1,6 +1,6 @@ import type { EventLoopUtilization } from 'node:perf_hooks'; import type { - AppPlaylistPerformancePhase, + DatabaseWorkerPerformancePhase, PerformancePhaseEvent, } from '@iptvnator/shared/interfaces'; @@ -50,7 +50,7 @@ export interface WorkerPerformanceCaptureResult { eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; - phaseEvents?: readonly PerformancePhaseEvent[]; + phaseEvents?: readonly PerformancePhaseEvent[]; requestReceivedEpochMs: number; responsePostedEpochMs?: number; threadCpuSystemMicros: number | null; @@ -91,7 +91,7 @@ export interface WorkerPerformanceCapture { histogramDisabled: boolean; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; - phaseEvents: PerformancePhaseEvent[]; + phaseEvents: PerformancePhaseEvent[]; requestReceivedEpochMs: number; requestId: string | null; runtime: WorkerPerformanceCaptureRuntime; diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts index f10fe3af9..84981304f 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -30,9 +30,12 @@ describe('worker performance integration order', () => { expect( handler.indexOf('executeWithWorkerPerformanceCapture') ).toBeLessThan(handler.indexOf('console.error')); - expect( - handler.indexOf('executeWithWorkerPerformanceCapture') - ).toBeLessThan(handler.indexOf('postMessage')); + const executionIndex = handler.indexOf( + 'executeWithWorkerPerformanceCapture' + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('postMessage', executionIndex) + ); expect( handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) ).toHaveLength(2); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts new file mode 100644 index 000000000..b2769df13 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts @@ -0,0 +1,76 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import { + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; +import { createWorkerPerformancePhaseAdapter } from './worker-performance-phase'; + +describe('database operation worker performance adapter', () => { + it('returns no adapter when profiling is disabled', () => { + expect(createWorkerPerformancePhaseAdapter(null)).toBeUndefined(); + }); + + it('delegates Xtream phases into the request capture with count-only metadata', async () => { + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'xtream-db-request', + runtime: createFakeRuntime().runtime, + }); + const adapter = createWorkerPerformancePhaseAdapter(capture); + const rows = [{ id: 1 }, { id: 2 }]; + + await expect( + adapter?.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + () => Promise.resolve(rows), + (result) => ({ itemCount: result.length }) + ) + ).resolves.toBe(rows); + + const result = await finishWorkerPerformanceCapture(capture); + expect(result?.phaseEvents).toEqual([ + expect.objectContaining({ + boundary: 'start', + metadata: undefined, + phase: 'sqlite.categories.read', + requestId: 'xtream-db-request', + }), + expect.objectContaining({ + boundary: 'end', + metadata: { itemCount: 2 }, + phase: 'sqlite.categories.read', + requestId: 'xtream-db-request', + }), + ]); + }); + + it('closes rejected phases without metadata and preserves error identity', async () => { + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'xtream-db-error', + runtime: createFakeRuntime().runtime, + }); + const adapter = createWorkerPerformancePhaseAdapter(capture); + const businessError = new Error('read failed'); + + await expect( + adapter?.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + () => Promise.reject(businessError), + () => ({ itemCount: 99 }) + ) + ).rejects.toBe(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.ts index 85fca3209..ae57d41da 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-phase.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.ts @@ -1,15 +1,16 @@ import { PERFORMANCE_PHASE_BOUNDARY, - type AppPlaylistPerformancePhase, + type DatabaseWorkerPerformancePhase, type PerformancePhaseEvent, type PerformancePhaseMetadata, } from '@iptvnator/shared/interfaces'; +import type { DatabaseOperationPerformancePhaseCapture } from '../database/operations/performance-phase-capture'; import type { WorkerPerformanceCapture } from './worker-performance-capture.model'; import { safeReadEpochMs } from './worker-performance-capture.runtime'; export function captureWorkerPerformancePhase( capture: WorkerPerformanceCapture | null, - phase: AppPlaylistPerformancePhase, + phase: DatabaseWorkerPerformancePhase, execute: () => TResult, metadata?: (result: TResult) => PerformancePhaseMetadata ): TResult { @@ -55,7 +56,7 @@ export function captureWorkerPerformancePhase( export function captureWorkerPerformancePhaseAsync( capture: WorkerPerformanceCapture | null, - phase: AppPlaylistPerformancePhase, + phase: DatabaseWorkerPerformancePhase, execute: () => Promise, metadata?: (result: TResult) => PerformancePhaseMetadata ): Promise { @@ -70,7 +71,7 @@ export function captureWorkerPerformancePhaseAsync( async function captureAsync( capture: WorkerPerformanceCapture, requestId: string, - phase: AppPlaylistPerformancePhase, + phase: DatabaseWorkerPerformancePhase, execute: () => Promise, metadata?: (result: TResult) => PerformancePhaseMetadata ): Promise { @@ -91,9 +92,7 @@ async function captureAsync( boundary: PERFORMANCE_PHASE_BOUNDARY.END, durationMs: safeDuration(endMonotonicMs - startMonotonicMs), epochMs: safeReadEpochMs(capture.runtime), - metadata: sanitizeMetadata( - safelyCreateMetadata(metadata, result) - ), + metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)), phase, requestId, }); @@ -162,7 +161,7 @@ function isCount(value: unknown): value is number { function appendPhaseEvent( capture: WorkerPerformanceCapture, - event: PerformancePhaseEvent + event: PerformancePhaseEvent ): void { try { capture.phaseEvents.push(event); @@ -170,3 +169,23 @@ function appendPhaseEvent( // Phase instrumentation is deliberately fail-neutral. } } + +export function createWorkerPerformancePhaseAdapter( + capture: WorkerPerformanceCapture | null +): DatabaseOperationPerformancePhaseCapture | undefined { + if (!capture?.requestId) { + return undefined; + } + + return { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + capture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase(capture, phase, execute, metadata), + }; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts new file mode 100644 index 000000000..14872eb63 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +function readCase(source: string, operation: string, nextOperation: string) { + return source.slice( + source.indexOf(`case '${operation}':`), + source.indexOf(`case '${nextOperation}':`) + ); +} + +describe('Xtream database performance phase wiring', () => { + const source = readFileSync( + join( + process.cwd(), + 'apps/electron-backend/src/app/workers/database.worker.ts' + ), + 'utf8' + ); + const executeRequestSource = source.slice( + source.indexOf('async function executeRequest') + ); + + it.each([ + ['DB_GET_CATEGORIES', 'DB_SAVE_CATEGORIES', 'getCategories'], + ['DB_SAVE_CATEGORIES', 'DB_GET_ALL_CATEGORIES', 'saveCategories'], + ['DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', 'getContent'], + ['DB_SAVE_CONTENT', 'DB_CLEAR_XTREAM_IMPORT_CACHE', 'saveContent'], + [ + 'DB_CLEAR_XTREAM_IMPORT_CACHE', + 'DB_GET_CONTENT_BY_XTREAM_ID', + 'clearXtreamImportCache', + ], + ['DB_SEARCH_CONTENT', 'DB_GLOBAL_SEARCH', 'searchContent'], + ['DB_DELETE_PLAYLIST', 'DB_GET_APP_STATE', 'deletePlaylist'], + [ + 'DB_DELETE_XTREAM_CONTENT', + 'DB_RESTORE_XTREAM_USER_DATA', + 'deleteXtreamContent', + ], + ])('passes the optional adapter through %s', (operation, next, call) => { + const branch = readCase(executeRequestSource, operation, next); + + expect(branch).toContain('createWorkerPerformancePhaseAdapter'); + expect(branch).toContain('performanceCapture'); + expect(branch).toContain(call); + }); + + it('leaves mixed global search uninstrumented', () => { + const branch = readCase( + executeRequestSource, + 'DB_GLOBAL_SEARCH', + 'DB_CREATE_PLAYLIST' + ); + + expect(branch).not.toContain('createWorkerPerformancePhaseAdapter'); + expect(branch).not.toContain('performanceCapture'); + }); +}); diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index d0d7096b1..8f2416235 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -24,20 +24,99 @@ pnpm run serve:marketing-demo:web --- +## Local Performance Mode + +The benchmark control plane is opt-in and binds only to an explicit loopback +address. Use a dedicated port; do not reuse the normal E2E server on `3211`. + +```bash +HOST=127.0.0.1 \ +PORT=3221 \ +IPTVNATOR_XTREAM_MOCK_CONTROL=1 \ +IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN=local-benchmark-token \ +pnpm nx run xtream-mock-server:serve +``` + +When enabled, every `/__control/*` request requires the exact +`x-iptvnator-performance-token` header, including `OPTIONS` preflight +requests. The server refuses non-loopback binds and an empty token. The control +routes do not exist when the flag is absent or is any value other than `1`. +The Nx serve targets preserve an explicit shell `PORT`; when it is omitted, the +server parser still defaults to `3211`. + +Prepare the fixed synthetic fixture before starting a capture: + +```bash +curl -X POST http://127.0.0.1:3221/__control/prepare \ + -H 'content-type: application/json' \ + -H 'x-iptvnator-performance-token: local-benchmark-token' \ + -d '{"scenario":"performance-100k"}' +``` + +The response contains only `epoch`, `scenario`, `seed`, `counts`, `bytes`, and +`catalogSha256`. `counts.categories` is `60/20/20` live/VOD/series (100 total) +and `counts.items` is `60000/20000/20000` (100,000 total). `bytes` and the +SHA-256 cover fixed-order UTF-8 JSON containing, in order, the live, VOD, and +series category arrays followed by the live, VOD, and series catalogs. The hash +input contains no credentials or server origin. + +Control endpoints: + +| Method | Path | Purpose | +| ------ | --------------------------------- | ---------------------------------------------------------------------- | +| POST | `/__control/prepare` | Materialize `performance-100k` and return its safe manifest | +| POST | `/__control/reset` | Reset `observations` or `all` state | +| POST | `/__control/barriers` | Add a one-shot, abort-aware request barrier | +| POST | `/__control/barriers/:id/release` | Release a request that reached a barrier | +| POST | `/__control/delays` | Add a one-shot delay of `0..5000` ms for control/smoke tests only | +| GET | `/__control/state` | Read bounded rules, held IDs, occurrences, lifecycle ledger, and epoch | + +Rules match the exact tuple +`(epoch, scenario, transport, action, categoryId, occurrence)`. Occurrences are +counted independently per tuple without `occurrence`, so parallel category +arrival order cannot change which rule matches. Empty Xtream actions are +recorded as `get_account_info`; unknown actions are recorded only as `unknown`. +Numeric category IDs use canonical decimal spelling: signs, whitespace, +leading zeroes, non-decimal notation, and unsafe integers are rejected. The +state never includes the token, credentials, raw URL/query, response payloads, +titles, or catalog arrays. + +`reset` with mode `observations` clears rules, occurrences, held requests, and +the ledger while preserving the prepared manifest and epoch. Mode `all` also +clears fixture caches and the prepared manifest, then increments the epoch. +Held clients are settled during either reset. The legacy unauthenticated +`POST /reset` returns `410` in performance mode so it cannot invalidate fixture +caches without also invalidating the prepared control manifest; use the +token-authenticated control reset instead. + +JSON bodies are strict and limited to 16 KiB. A state epoch accepts at most 32 +rules; the ledger retains 128 entries, and occurrence state has 512 slots for +the complete allowlisted identity domain without eviction or counter restart. +Duplicate IDs/matches, past occurrences, arbitrary scenarios/actions/category +IDs, unknown fields, and invalid delays are rejected. + +Performance mode is local-only: `/playlist.m3u` and every performance-fixture +stream/timeshift URL return `410` without redirecting or contacting external +media. Barriers and delays are coordination tools, not timing inputs. +**Formal benchmark captures must start with zero barrier and delay rules.** + +--- + ## Available Scenarios (credential pairs) -| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status | -| ----------- | ----------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- | -| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active | -| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | -| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active | -| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | -| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | -| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | -| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | -| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | -| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | -| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | +| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status | +| ------------- | ------------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- | +| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active | +| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | +| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active | +| `performance` | `performance` | performance-100k | 60 | 20 | 20 | 1,000 | active | +| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | +| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | +| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | +| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | +| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | +| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | +| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account). @@ -145,6 +224,9 @@ application code. - **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API) - **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests - **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing` +- **Performance fixture**: `performance:performance` returns exactly 100,000 + local-only catalog items from index-derived values; it does not use Faker, + `Date.now()`, `Math.random()`, external artwork, or external media URLs - **Timestamp precedence coverage**: The `epg:epg` scenario intentionally shifts raw `start` / `end` strings away from `start_timestamp` / `stop_timestamp` so UI tests can prove timestamps drive rendering - **Stream IDs**: Live 10,000+, VOD 20,000+, Series 30,000+ - **Category IDs**: Live 101+, VOD 201+, Series 301+ diff --git a/apps/xtream-mock-server/jest.config.ts b/apps/xtream-mock-server/jest.config.ts new file mode 100644 index 000000000..7e75dc2c3 --- /dev/null +++ b/apps/xtream-mock-server/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'xtream-mock-server', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/xtream-mock-server', +}; diff --git a/apps/xtream-mock-server/project.json b/apps/xtream-mock-server/project.json index f43ed7394..63793b31e 100644 --- a/apps/xtream-mock-server/project.json +++ b/apps/xtream-mock-server/project.json @@ -13,7 +13,6 @@ "cwd": "{workspaceRoot}", "color": true, "env": { - "PORT": "3211", "NODE_ENV": "development" } } @@ -26,13 +25,20 @@ "cwd": "{workspaceRoot}", "color": true, "env": { - "PORT": "3211", "NODE_ENV": "development" } } }, "lint": { "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/xtream-mock-server/jest.config.ts", + "tsConfig": "apps/xtream-mock-server/tsconfig.spec.json" + } } } } diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index c0b07513e..409cafa94 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -8,14 +8,29 @@ import { RawEpgListing, RawLiveStream, } from './generators/live.generator.js'; -import { generateVodStreams, generateVodDetails, RawVodStream, RawVodDetails } from './generators/vod.generator.js'; -import { generateSeriesItems, generateSeriesInfo, RawSeriesItem, RawSeriesInfo } from './generators/series.generator.js'; +import { + generateVodStreams, + generateVodDetails, + RawVodStream, + RawVodDetails, +} from './generators/vod.generator.js'; +import { + generateSeriesItems, + generateSeriesInfo, + RawSeriesItem, + RawSeriesInfo, +} from './generators/series.generator.js'; import { RawCategory } from './generators/categories.generator.js'; import { buildMarketingPortalFixture, buildMarketingSeriesInfo, buildMarketingVodDetails, } from './generators/marketing.generator.js'; +import { + buildPerformancePortalFixture, + buildPerformanceSeriesInfo, + buildPerformanceVodDetails, +} from './generators/performance.generator.js'; import { getScenario, ScenarioConfig } from './scenarios.js'; export interface PortalData { @@ -30,21 +45,31 @@ export interface PortalData { } const portalCache = new Map(); -const vodDetailsCache = new Map(); -const seriesInfoCache = new Map(); +const vodDetailsCache = new Map(); +const seriesInfoCache = new Map(); function generatePortalData(username: string, password: string): PortalData { const scenario = getScenario(username, password); + if (scenario.performanceFixture === 'catalog-100k') { + return { + scenario, + ...buildPerformancePortalFixture(), + }; + } faker.seed(scenario.seed); - - const { categoryCount, itemsPerCategory, seasonsPerSeries, episodesPerSeason } = scenario; - + const { + categoryCount, + itemsPerCategory, + seasonsPerSeries, + episodesPerSeason, + } = scenario; if (scenario.marketingFixture) { const marketingFixture = buildMarketingPortalFixture(); for (const s of marketingFixture.seriesItems) { - if (!seriesInfoCache.has(s.series_id)) { + const cacheKey = detailCacheKey(username, password, s.series_id); + if (!seriesInfoCache.has(cacheKey)) { seriesInfoCache.set( - s.series_id, + cacheKey, buildMarketingSeriesInfo( s, seasonsPerSeries, @@ -53,22 +78,18 @@ function generatePortalData(username: string, password: string): PortalData { ); } } - return { scenario, ...marketingFixture, }; } - let liveCategories = generateCategories('live', categoryCount.live); const vodCategories = generateCategories('vod', categoryCount.vod); const seriesCategories = generateCategories('series', categoryCount.series); - const epgListingsByStreamId = new Map(); let liveStreams = generateLiveStreams(liveCategories, itemsPerCategory); const vodStreams = generateVodStreams(vodCategories, itemsPerCategory); const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory); - if (scenario.epgFixture === 'timezone-focus') { const timezoneFixture = buildTimezoneFixture(); liveCategories = timezoneFixture.liveCategories; @@ -77,14 +98,18 @@ function generatePortalData(username: string, password: string): PortalData { epgListingsByStreamId.set(streamId, listings); }); } - // Pre-populate series info cache - for (const s of seriesItems) { - if (!seriesInfoCache.has(s.series_id)) { - seriesInfoCache.set(s.series_id, generateSeriesInfo(s, seasonsPerSeries, episodesPerSeason)); + if (!scenario.deferSeriesDetails) { + for (const s of seriesItems) { + const cacheKey = detailCacheKey(username, password, s.series_id); + if (!seriesInfoCache.has(cacheKey)) { + seriesInfoCache.set( + cacheKey, + generateSeriesInfo(s, seasonsPerSeries, episodesPerSeason) + ); + } } } - return { scenario, liveCategories, @@ -105,47 +130,60 @@ export function getPortalData(username: string, password: string): PortalData { return portalCache.get(key)!; } -export function getVodDetails(username: string, password: string, vodId: number): RawVodDetails | null { - if (!vodDetailsCache.has(vodId)) { +export function getVodDetails( + username: string, + password: string, + vodId: number +): RawVodDetails | null { + const cacheKey = detailCacheKey(username, password, vodId); + if (!vodDetailsCache.has(cacheKey)) { const data = getPortalData(username, password); - const stream = data.vodStreams.find(v => v.stream_id === vodId); + const stream = data.vodStreams.find((v) => v.stream_id === vodId); if (!stream) return null; if (data.scenario.vodDetailsFixture === 'empty-metadata') { - vodDetailsCache.set(vodId, { info: [] }); - return vodDetailsCache.get(vodId) ?? null; + vodDetailsCache.set(cacheKey, { info: [] }); + return vodDetailsCache.get(cacheKey) ?? null; } vodDetailsCache.set( - vodId, - data.scenario.marketingFixture - ? buildMarketingVodDetails(stream) - : generateVodDetails(stream) + cacheKey, + data.scenario.performanceFixture === 'catalog-100k' + ? buildPerformanceVodDetails(stream) + : data.scenario.marketingFixture + ? buildMarketingVodDetails(stream) + : generateVodDetails(stream) ); } - return vodDetailsCache.get(vodId) ?? null; + return vodDetailsCache.get(cacheKey) ?? null; } -export function getSeriesInfo(username: string, password: string, seriesId: number): RawSeriesInfo | null { - if (!seriesInfoCache.has(seriesId)) { +export function getSeriesInfo( + username: string, + password: string, + seriesId: number +): RawSeriesInfo | null { + const cacheKey = detailCacheKey(username, password, seriesId); + if (!seriesInfoCache.has(cacheKey)) { const data = getPortalData(username, password); - const series = data.seriesItems.find(s => s.series_id === seriesId); + const series = data.seriesItems.find((s) => s.series_id === seriesId); if (!series) return null; - const scenario = getScenario(username, password); seriesInfoCache.set( - seriesId, - scenario.marketingFixture - ? buildMarketingSeriesInfo( - series, - scenario.seasonsPerSeries, - scenario.episodesPerSeason - ) - : generateSeriesInfo( - series, - scenario.seasonsPerSeries, - scenario.episodesPerSeason - ) + cacheKey, + data.scenario.performanceFixture === 'catalog-100k' + ? buildPerformanceSeriesInfo(series) + : data.scenario.marketingFixture + ? buildMarketingSeriesInfo( + series, + data.scenario.seasonsPerSeries, + data.scenario.episodesPerSeason + ) + : generateSeriesInfo( + series, + data.scenario.seasonsPerSeries, + data.scenario.episodesPerSeason + ) ); } - return seriesInfoCache.get(seriesId) ?? null; + return seriesInfoCache.get(cacheKey) ?? null; } export function getEpgListings( @@ -183,6 +221,25 @@ export function resetAll(): void { seriesInfoCache.clear(); } +/** Test-only cache sizes; never exposes credential-bearing keys or fixture data. */ +export function getDetailCacheCardinalityForTesting(): Readonly<{ + vodDetails: number; + seriesInfo: number; +}> { + return { + vodDetails: vodDetailsCache.size, + seriesInfo: seriesInfoCache.size, + }; +} + +function detailCacheKey( + username: string, + password: string, + itemId: number +): string { + return `${username}:${password}:${itemId}`; +} + function buildTimezoneFixture(): Pick< PortalData, 'liveCategories' | 'liveStreams' | 'epgListingsByStreamId' @@ -258,42 +315,48 @@ function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] { { id: `${streamId}-past`, title: 'Earlier Bulletin', - description: 'Past schedule item used to anchor current-program detection.', + description: + 'Past schedule item used to anchor current-program detection.', startTimestamp: roundedNow - 75 * 60, stopTimestamp: roundedNow - 15 * 60, }, { id: `${streamId}-current`, title: 'Global Headlines', - description: 'Current program for list-row and detail EPG assertions.', + description: + 'Current program for list-row and detail EPG assertions.', startTimestamp: roundedNow - 15 * 60, stopTimestamp: roundedNow + 15 * 60, }, { id: `${streamId}-next`, title: 'Market Wrap', - description: 'Immediate next program for short-EPG preview assertions.', + description: + 'Immediate next program for short-EPG preview assertions.', startTimestamp: roundedNow + 15 * 60, stopTimestamp: roundedNow + 45 * 60, }, { id: `${streamId}-later`, title: 'Overnight Update', - description: 'Later same-day program for selected-channel list coverage.', + description: + 'Later same-day program for selected-channel list coverage.', startTimestamp: roundedNow + 45 * 60, stopTimestamp: roundedNow + 75 * 60, }, { id: `${streamId}-boundary-1`, title: 'Late Edition', - description: 'Boundary program spanning UTC midnight for timezone edge cases.', + description: + 'Boundary program spanning UTC midnight for timezone edge cases.', startTimestamp: nextUtcMidnight - 30 * 60, stopTimestamp: nextUtcMidnight + 30 * 60, }, { id: `${streamId}-boundary-2`, title: 'After Midnight', - description: 'Post-midnight program used for next-day navigation checks.', + description: + 'Post-midnight program used for next-day navigation checks.', startTimestamp: nextUtcMidnight + 30 * 60, stopTimestamp: nextUtcMidnight + 90 * 60, }, diff --git a/apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts b/apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts new file mode 100644 index 000000000..fb471d6ab --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts @@ -0,0 +1,388 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import type { Request, Response } from 'express'; +import * as dataStore from '../data-store.js'; +import { dispatchAction } from '../routes/dispatch.js'; +import { getScenario } from '../scenarios.js'; + +jest.mock('@faker-js/faker', () => { + let accessEnabled = false; + const fixedDate = new Date('2020-01-01T00:00:00.000Z'); + const fixedText = 'Fixture value'; + const deterministicFaker = { + seed: jest.fn(), + company: { + name: () => fixedText, + catchPhrase: () => fixedText, + }, + date: { + past: () => fixedDate, + recent: () => fixedDate, + }, + location: { country: () => fixedText }, + lorem: { + paragraph: () => fixedText, + sentence: () => fixedText, + words: () => fixedText, + }, + music: { + genre: () => fixedText, + songName: () => fixedText, + }, + number: { + int: ({ min = 0 }: { min?: number }) => min, + }, + person: { fullName: () => fixedText }, + }; + return { + faker: new Proxy(deterministicFaker, { + get(target, property, receiver) { + if (!accessEnabled) { + throw new Error( + `Performance fixture accessed Faker.${String(property)}` + ); + } + return Reflect.get(target, property, receiver); + }, + }), + setFakerAccessForTesting(enabled: boolean) { + accessEnabled = enabled; + }, + }; +}); +const [USERNAME, PASSWORD] = ['performance', 'performance']; +const LOOPBACK_ORIGIN = 'http://127.0.0.1:3211'; +jest.setTimeout(30_000); +type PortalData = ReturnType; +type DispatchResult = { + body: unknown; + jsonCalls: number; + statusCode: number; +}; +let initialPortal!: PortalData; +function setFakerAccess(enabled: boolean): void { + const fakerModule = jest.requireMock('@faker-js/faker') as { + setFakerAccessForTesting(value: boolean): void; + }; + fakerModule.setFakerAccessForTesting(enabled); +} +function withGenericFaker(run: () => void): void { + setFakerAccess(true); + try { + run(); + } finally { + dataStore.resetAll(); + setFakerAccess(false); + } +} +function portalFingerprint(data: PortalData): { + byteCount: number; + sha256: string; +} { + const serialized = JSON.stringify({ + scenario: data.scenario, + liveCategories: data.liveCategories, + vodCategories: data.vodCategories, + seriesCategories: data.seriesCategories, + liveStreams: data.liveStreams, + vodStreams: data.vodStreams, + seriesItems: data.seriesItems, + epgListingsByStreamId: [...data.epgListingsByStreamId.entries()], + }); + return { + byteCount: Buffer.byteLength(serialized), + sha256: createHash('sha256').update(serialized).digest('hex'), + }; +} +function dispatch(query: Record): DispatchResult { + let body: unknown; + let jsonCalls = 0; + let statusCode = 200; + const response = { + json(value: unknown) { + body = value; + jsonCalls += 1; + return response; + }, + status(value: number) { + statusCode = value; + return response; + }, + }; + dispatchAction( + { query } as unknown as Request, + response as unknown as Response + ); + return { body, jsonCalls, statusCode }; +} +function expectLocalOrEmpty(values: string[]): void { + for (const value of values) { + expect(value === '' || value.startsWith(`${LOOPBACK_ORIGIN}/`)).toBe( + true + ); + } +} +function expectOneThousandItemsPerCategory( + items: ReadonlyArray<{ category_id: number | string }> +): void { + const counts = new Map(); + for (const item of items) { + const categoryId = String(item.category_id); + counts.set(categoryId, (counts.get(categoryId) ?? 0) + 1); + } + expect([...counts.values()].every((count) => count === 1_000)).toBe(true); +} + +describe('performance Xtream fixture', () => { + beforeAll(() => { + dataStore.resetAll(); + initialPortal = dataStore.getPortalData(USERNAME, PASSWORD); + }); + afterAll(() => { + dataStore.resetAll(); + setFakerAccess(false); + }); + it('selects the committed performance scenario', () => { + expect(getScenario(USERNAME, PASSWORD)).toMatchObject({ + name: 'performance-100k', + description: 'Deterministic local-only 100k performance catalog', + seed: 91_001, + categoryCount: { live: 60, vod: 20, series: 20 }, + itemsPerCategory: 1_000, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + performanceFixture: 'catalog-100k', + deferSeriesDetails: true, + }); + }); + + it('contains exactly 100 categories and 100,000 catalog items', () => { + expect([ + initialPortal.liveCategories.length, + initialPortal.vodCategories.length, + initialPortal.seriesCategories.length, + ]).toEqual([60, 20, 20]); + expect(initialPortal.liveStreams).toHaveLength(60_000); + expect(initialPortal.vodStreams).toHaveLength(20_000); + expect(initialPortal.seriesItems).toHaveLength(20_000); + expectOneThousandItemsPerCategory(initialPortal.liveStreams); + expectOneThousandItemsPerCategory(initialPortal.vodStreams); + expectOneThousandItemsPerCategory(initialPortal.seriesItems); + }); + + it('uses unique item IDs and valid category references', () => { + const itemIds = [ + ...initialPortal.liveStreams.map((item) => item.stream_id), + ...initialPortal.vodStreams.map((item) => item.stream_id), + ...initialPortal.seriesItems.map((item) => item.series_id), + ]; + const liveCategoryIds = new Set( + initialPortal.liveCategories.map((item) => item.category_id) + ); + const vodCategoryIds = new Set( + initialPortal.vodCategories.map((item) => item.category_id) + ); + const seriesCategoryIds = new Set( + initialPortal.seriesCategories.map((item) => item.category_id) + ); + expect(new Set(itemIds).size).toBe(100_000); + expect( + initialPortal.liveStreams.every((item) => + liveCategoryIds.has(item.category_id) + ) + ).toBe(true); + expect( + initialPortal.vodStreams.every((item) => + vodCategoryIds.has(item.category_id) + ) + ).toBe(true); + expect( + initialPortal.seriesItems.every((item) => + seriesCategoryIds.has(String(item.category_id)) + ) + ).toBe(true); + }); + + it('rebuilds a distinct portal to the identical byte count and SHA-256', () => { + const source = readFileSync( + `${__dirname}/performance.generator.ts`, + 'utf8' + ); + expect(source).not.toMatch( + /@faker-js\/faker|\bfaker\b|Date\.now|Math\.random/ + ); + const dateNow = jest.spyOn(Date, 'now').mockImplementation(() => { + throw new Error('performance fixture used Date.now'); + }); + const mathRandom = jest.spyOn(Math, 'random').mockImplementation(() => { + throw new Error('performance fixture used Math.random'); + }); + try { + const firstFingerprint = portalFingerprint(initialPortal); + dataStore.resetAll(); + const rebuiltPortal = dataStore.getPortalData(USERNAME, PASSWORD); + + expect(rebuiltPortal).not.toBe(initialPortal); + expect(portalFingerprint(rebuiltPortal)).toEqual(firstFingerprint); + } finally { + dateNow.mockRestore(); + mathRandom.mockRestore(); + } + }); + + it('keeps catalog and detail URL fields local-only', () => { + const data = dataStore.getPortalData(USERNAME, PASSWORD); + const vodDetails = dataStore.getVodDetails( + USERNAME, + PASSWORD, + data.vodStreams[0].stream_id + ); + const seriesInfo = dataStore.getSeriesInfo( + USERNAME, + PASSWORD, + data.seriesItems[0].series_id + ); + + expect(vodDetails).not.toBeNull(); + expect(seriesInfo).not.toBeNull(); + expectLocalOrEmpty([ + ...data.liveStreams.flatMap((item) => [ + item.stream_icon, + item.direct_source, + ]), + ...data.vodStreams.flatMap((item) => [ + item.stream_icon, + item.direct_source, + ]), + ...data.seriesItems.flatMap((item) => [ + item.cover, + item.youtube_trailer, + ...item.backdrop_path, + ]), + ...(vodDetails && !Array.isArray(vodDetails.info) + ? [ + vodDetails.info.kinopoisk_url, + vodDetails.info.cover_big, + vodDetails.info.movie_image, + vodDetails.info.youtube_trailer, + ...vodDetails.info.backdrop_path, + vodDetails.movie_data?.direct_source ?? '', + ] + : []), + ...(seriesInfo + ? [ + seriesInfo.info.cover, + seriesInfo.info.youtube_trailer, + ...seriesInfo.info.backdrop_path, + ...seriesInfo.seasons.flatMap((season) => [ + season.cover, + season.cover_big, + ]), + ...Object.values(seriesInfo.episodes).flatMap( + (episodes) => + episodes.flatMap((episode) => [ + episode.info.movie_image, + episode.direct_source, + ]) + ), + ] + : []), + ]); + }); + + it('uses numeric fixed-epoch semantics for catalog timestamps', () => { + const firstSeries = initialPortal.seriesItems[0]; + + expect(initialPortal.liveStreams[0].added).toBe('1767225600'); + expect(initialPortal.vodStreams[0].added).toBe('1767225600'); + expect( + initialPortal.seriesItems.every((item) => + /^\d+$/.test(item.last_modified) + ) + ).toBe(true); + expect(firstSeries.last_modified).toBe('1767225600'); + expect( + new Date(Number(firstSeries.last_modified) * 1_000).toISOString() + ).toBe('2026-01-01T00:00:00.000Z'); + expect(firstSeries.releaseDate).toBe('2026-01-01'); + }); + + it('keeps account-info lazy and materializes one series detail through dispatch', () => { + dataStore.resetAll(); + const accountResult = dispatch({ + action: 'get_account_info', + username: USERNAME, + password: PASSWORD, + }); + + expect([accountResult.statusCode, accountResult.jsonCalls]).toEqual([ + 200, 1, + ]); + expect( + (accountResult.body as { user_info?: { username?: string } }) + .user_info?.username + ).toBe(USERNAME); + expect(dataStore.getDetailCacheCardinalityForTesting()).toEqual({ + vodDetails: 0, + seriesInfo: 0, + }); + + const seriesId = dataStore.getPortalData(USERNAME, PASSWORD) + .seriesItems[0].series_id; + const seriesResult = dispatch({ + action: 'get_series_info', + username: USERNAME, + password: PASSWORD, + series_id: String(seriesId), + }); + const seriesBody = seriesResult.body as { + seasons?: unknown[]; + episodes?: Record; + }; + + expect([seriesResult.statusCode, seriesResult.jsonCalls]).toEqual([ + 200, 1, + ]); + expect(seriesBody.seasons).toHaveLength(1); + expect(seriesBody.episodes?.['1']).toHaveLength(1); + expect(dataStore.getDetailCacheCardinalityForTesting()).toEqual({ + vodDetails: 0, + seriesInfo: 1, + }); + }); + + it('keys VOD and series details by both username and password', () => { + withGenericFaker(() => { + dataStore.resetAll(); + const portals = [ + ['emptyvod', 'emptyvod'], + ['emptyvod', 'alternate'], + ['alternate', 'emptyvod'], + ] as const; + const firstPortal = dataStore.getPortalData(...portals[0]); + const vodId = firstPortal.vodStreams[0].stream_id; + const seriesId = firstPortal.seriesItems[0].series_id; + const vodDetails = portals.map(([username, password]) => + dataStore.getVodDetails(username, password, vodId) + ); + const seriesDetails = portals.map(([username, password]) => + dataStore.getSeriesInfo(username, password, seriesId) + ); + expect(vodDetails[0]?.info).toEqual([]); + expect( + vodDetails.slice(1).every((item) => !Array.isArray(item?.info)) + ).toBe(true); + expect(seriesDetails.map((item) => item?.seasons.length)).toEqual([ + 2, 3, 3, + ]); + expect( + seriesDetails.map((item) => item?.episodes['1'].length) + ).toEqual([4, 8, 8]); + expect( + dataStore.getDetailCacheCardinalityForTesting().vodDetails + ).toBe(3); + }); + }); +}); diff --git a/apps/xtream-mock-server/src/app/generators/performance.generator.ts b/apps/xtream-mock-server/src/app/generators/performance.generator.ts new file mode 100644 index 000000000..20879833c --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/performance.generator.ts @@ -0,0 +1,294 @@ +import type { RawCategory } from './categories.generator.js'; +import type { RawEpgListing, RawLiveStream } from './live.generator.js'; +import type { + RawEpisode, + RawSeriesInfo, + RawSeriesItem, + RawSeason, +} from './series.generator.js'; +import type { RawVodDetails, RawVodStream } from './vod.generator.js'; + +interface PerformancePortalFixture { + liveCategories: RawCategory[]; + vodCategories: RawCategory[]; + seriesCategories: RawCategory[]; + liveStreams: RawLiveStream[]; + vodStreams: RawVodStream[]; + seriesItems: RawSeriesItem[]; + epgListingsByStreamId: Map; +} + +const FIXED_EPOCH_SECONDS = 1_767_225_600; +const DAY_SECONDS = 86_400; +const ITEMS_PER_CATEGORY = 1_000; +const LIVE_CATEGORY_COUNT = 60; +const VOD_CATEGORY_COUNT = 20; +const SERIES_CATEGORY_COUNT = 20; +const LIVE_CATEGORY_ID_BASE = 91_100; +const VOD_CATEGORY_ID_BASE = 92_100; +const SERIES_CATEGORY_ID_BASE = 93_100; +const LIVE_STREAM_ID_BASE = 1_000_000; +const VOD_STREAM_ID_BASE = 2_000_000; +const SERIES_ID_BASE = 3_000_000; +const EPISODE_ID_BASE = 4_000_000; +const CONTAINER_EXTENSIONS = ['mp4', 'mkv', 'avi'] as const; + +export function buildPerformancePortalFixture(): PerformancePortalFixture { + const liveCategories = buildCategories( + 'Live', + LIVE_CATEGORY_ID_BASE, + LIVE_CATEGORY_COUNT + ); + const vodCategories = buildCategories( + 'VOD', + VOD_CATEGORY_ID_BASE, + VOD_CATEGORY_COUNT + ); + const seriesCategories = buildCategories( + 'Series', + SERIES_CATEGORY_ID_BASE, + SERIES_CATEGORY_COUNT + ); + + return { + liveCategories, + vodCategories, + seriesCategories, + liveStreams: buildLiveStreams(liveCategories), + vodStreams: buildVodStreams(vodCategories), + seriesItems: buildSeriesItems(seriesCategories), + epgListingsByStreamId: new Map(), + }; +} + +export function buildPerformanceVodDetails( + stream: RawVodStream +): RawVodDetails { + const index = stream.stream_id - VOD_STREAM_ID_BASE; + const durationSeconds = 5_400 + (index % 1_800); + const hours = Math.floor(durationSeconds / 3_600); + const minutes = Math.floor((durationSeconds % 3_600) / 60); + + return { + info: { + kinopoisk_url: '', + tmdb_id: stream.stream_id, + name: stream.name, + o_name: stream.name, + cover_big: '', + movie_image: '', + releasedate: dateFromIndex(index), + episode_run_time: durationSeconds, + youtube_trailer: '', + director: `Performance Director ${formatIndex(index)}`, + actors: `Performance Actor ${formatIndex(index)}`, + cast: `Performance Actor ${formatIndex(index)}`, + description: `Synthetic movie ${formatIndex(index)}.`, + plot: `Synthetic movie ${formatIndex(index)}.`, + age: '12', + mpaa_rating: 'PG-13', + rating_count_kinopoisk: 1_000 + index, + country: 'Fixtureland', + genre: `Performance Genre ${index % 10}`, + backdrop_path: [], + duration_secs: durationSeconds, + duration: `${hours}h ${minutes}min`, + video: ['H.264'], + audio: ['AAC'], + bitrate: 2_000 + (index % 4_000), + rating: stream.rating, + rating_kinopoisk: stream.rating_imdb, + rating_imdb: stream.rating_imdb, + }, + movie_data: { + stream_id: stream.stream_id, + name: stream.name, + added: stream.added, + category_id: stream.category_id, + container_extension: stream.container_extension, + custom_sid: '', + direct_source: '', + }, + }; +} + +export function buildPerformanceSeriesInfo( + series: RawSeriesItem +): RawSeriesInfo { + const index = series.series_id - SERIES_ID_BASE; + const releaseDate = dateFromIndex(index); + const durationSeconds = 1_500 + (index % 1_800); + const season: RawSeason = { + air_date: releaseDate, + episode_count: 1, + id: series.series_id * 10 + 1, + name: 'Season 1', + overview: `Synthetic season for ${series.name}.`, + season_number: 1, + cover: '', + cover_big: '', + }; + const episode: RawEpisode = { + id: String(EPISODE_ID_BASE + index), + episode_num: 1, + title: `${series.name} S01E01`, + container_extension: 'mkv', + info: { + tmdb_id: series.series_id, + releasedate: releaseDate, + plot: `Synthetic episode for ${series.name}.`, + duration_secs: durationSeconds, + duration: `${Math.floor(durationSeconds / 60)}min`, + movie_image: '', + bitrate: 1_500 + (index % 5_000), + rating: ratingFromIndex(index), + }, + custom_sid: '', + added: addedFromIndex(index), + season: 1, + direct_source: '', + }; + + return { + seasons: [season], + info: { + name: series.name, + cover: '', + plot: series.plot, + cast: series.cast, + director: series.director, + genre: series.genre, + releaseDate: series.releaseDate, + last_modified: series.last_modified, + rating: series.rating, + rating_5based: series.rating_5based, + backdrop_path: [], + youtube_trailer: '', + episode_run_time: series.episode_run_time, + category_id: String(series.category_id), + }, + episodes: { '1': [episode] }, + }; +} + +function buildCategories( + label: string, + idBase: number, + count: number +): RawCategory[] { + return Array.from({ length: count }, (_, index) => ({ + category_id: String(idBase + index + 1), + category_name: `Performance ${label} ${String(index + 1).padStart( + 2, + '0' + )}`, + parent_id: 0, + })); +} + +function buildLiveStreams(categories: RawCategory[]): RawLiveStream[] { + const streams: RawLiveStream[] = []; + + for (const category of categories) { + for (let itemIndex = 0; itemIndex < ITEMS_PER_CATEGORY; itemIndex++) { + const index = streams.length; + const streamId = LIVE_STREAM_ID_BASE + index; + streams.push({ + num: index + 1, + name: `Performance Live ${formatIndex(index)}`, + stream_type: 'live', + stream_id: streamId, + stream_icon: '', + epg_channel_id: `performance-channel-${streamId}`, + added: addedFromIndex(index), + category_id: category.category_id, + custom_sid: '', + direct_source: '', + tv_archive: index % 2, + tv_archive_duration: index % 2 === 0 ? 0 : 3, + rating_imdb: ratingFromIndex(index).toFixed(1), + }); + } + } + + return streams; +} + +function buildVodStreams(categories: RawCategory[]): RawVodStream[] { + const streams: RawVodStream[] = []; + + for (const category of categories) { + for (let itemIndex = 0; itemIndex < ITEMS_PER_CATEGORY; itemIndex++) { + const index = streams.length; + const rating = ratingFromIndex(index); + streams.push({ + num: index + 1, + name: `Performance Movie ${formatIndex(index)}`, + stream_type: 'movie', + stream_id: VOD_STREAM_ID_BASE + index, + stream_icon: '', + added: addedFromIndex(index), + category_id: category.category_id, + custom_sid: '', + direct_source: '', + rating, + rating_5based: Number((rating / 2).toFixed(1)), + rating_imdb: rating.toFixed(1), + container_extension: + CONTAINER_EXTENSIONS[index % CONTAINER_EXTENSIONS.length], + type: 'movie', + }); + } + } + + return streams; +} + +function buildSeriesItems(categories: RawCategory[]): RawSeriesItem[] { + const items: RawSeriesItem[] = []; + + for (const category of categories) { + for (let itemIndex = 0; itemIndex < ITEMS_PER_CATEGORY; itemIndex++) { + const index = items.length; + const rating = ratingFromIndex(index); + items.push({ + num: index + 1, + name: `Performance Series ${formatIndex(index)}`, + series_id: SERIES_ID_BASE + index, + cover: '', + plot: `Synthetic series ${formatIndex(index)}.`, + cast: `Performance Actor ${formatIndex(index)}`, + director: `Performance Director ${formatIndex(index)}`, + genre: `Performance Genre ${index % 10}`, + releaseDate: dateFromIndex(index), + last_modified: addedFromIndex(index), + rating: rating.toFixed(1), + rating_5based: Number((rating / 2).toFixed(1)), + backdrop_path: [], + youtube_trailer: '', + episode_run_time: String(24 + (index % 37)), + category_id: Number(category.category_id), + }); + } + } + + return items; +} + +function addedFromIndex(index: number): string { + return String(FIXED_EPOCH_SECONDS - index); +} + +function dateFromIndex(index: number, dayRange = 3_650): string { + const timestampSeconds = + FIXED_EPOCH_SECONDS - (index % dayRange) * DAY_SECONDS; + return new Date(timestampSeconds * 1_000).toISOString().slice(0, 10); +} + +function formatIndex(index: number): string { + return String(index + 1).padStart(6, '0'); +} + +function ratingFromIndex(index: number): number { + return Number((6 + (index % 30) / 10).toFixed(1)); +} diff --git a/apps/xtream-mock-server/src/app/performance-category-validation.spec.ts b/apps/xtream-mock-server/src/app/performance-category-validation.spec.ts new file mode 100644 index 000000000..93855c955 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-category-validation.spec.ts @@ -0,0 +1,45 @@ +import { + isAllowedPerformanceCategory, + parseRuleBody, + PerformanceControlError, +} from './performance-control-validation.js'; + +describe('Xtream performance category validation', () => { + it.each([ + '091101', + '+91101', + ' 91101', + '91101 ', + '9.1101e4', + '9007199254740993', + '99999999999999999999999999999999999999999999999999', + ])('rejects the non-canonical category ID %p', (categoryId) => { + expect( + isAllowedPerformanceCategory('get_live_streams', categoryId) + ).toBe(false); + + expect(() => + parseRuleBody('barrier', { + id: 'non-canonical-category', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId, + }, + }) + ).toThrow(PerformanceControlError); + }); + + it.each([ + ['get_live_streams', '91101'], + ['get_live_streams', '91160'], + ['get_vod_streams', '92101'], + ['get_vod_streams', '92120'], + ['get_series', '93101'], + ['get_series', '93120'], + ] as const)('accepts canonical %s category %s', (action, categoryId) => { + expect(isAllowedPerformanceCategory(action, categoryId)).toBe(true); + }); +}); diff --git a/apps/xtream-mock-server/src/app/performance-control-abort.spec.ts b/apps/xtream-mock-server/src/app/performance-control-abort.spec.ts new file mode 100644 index 000000000..b5789c9a7 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-abort.spec.ts @@ -0,0 +1,149 @@ +import { resetAll } from './data-store.js'; +import type { PerformanceControlState } from './performance-control.js'; +import { createXtreamMockApp } from './server.js'; +import { + openAbortableGet, + postJson, + requestJson, + startLoopbackServer, + waitFor, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'delay-abort-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; + +describe('Xtream performance delay cancellation', () => { + it('aborts an active delay without later dispatch or response lifecycle', async () => { + resetAll(); + const running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + try { + const rule = await postJson( + running.origin, + '/__control/delays', + TOKEN, + { + id: 'abort-delay', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId: '91101', + }, + milliseconds: 100, + } + ); + expect(rule.response.status).toBe(201); + const request = openAbortableGet( + running.origin, + '/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101' + ); + await waitFor(async () => { + expect( + (await state(running.origin)).ledger.map( + (entry) => entry.status + ) + ).toEqual(expect.arrayContaining(['arrived', 'delayed'])); + }); + + request.abort(); + await request.completed; + await waitFor(async () => { + const snapshot = await state(running.origin); + expect(snapshot.heldCount).toBe(0); + expect(snapshot.rules).toEqual([]); + expect(snapshot.ledger.map((entry) => entry.status)).toEqual( + expect.arrayContaining(['aborted']) + ); + }); + await new Promise((resolve) => setTimeout(resolve, 125)); + const terminal = await state(running.origin); + expect( + terminal.ledger.some((entry) => entry.status === 'responded') + ).toBe(false); + expect(terminal.occurrences).toEqual([ + expect.objectContaining({ + action: 'get_live_streams', + categoryId: '91101', + count: 1, + }), + ]); + } finally { + await running.close(); + resetAll(); + } + }); + + it('settles an active delay when observations reset', async () => { + resetAll(); + const running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + try { + await postJson(running.origin, '/__control/delays', TOKEN, { + id: 'reset-delay', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId: '91101', + }, + milliseconds: 100, + }); + const delayedResponse = fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101` + ); + await waitFor(async () => { + expect( + (await state(running.origin)).ledger.map( + (entry) => entry.status + ) + ).toContain('delayed'); + }); + + const reset = await postJson( + running.origin, + '/__control/reset', + TOKEN, + { mode: 'observations' } + ); + expect(reset.response.status).toBe(200); + expect((await delayedResponse).status).toBe(409); + await new Promise((resolve) => setTimeout(resolve, 125)); + expect(await state(running.origin)).toMatchObject({ + heldCount: 0, + ledger: [], + occurrences: [], + rules: [], + }); + } finally { + await running.close(); + resetAll(); + } + }); +}); + +async function state(origin: string): Promise { + const result = await requestJson(origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} diff --git a/apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts b/apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts new file mode 100644 index 000000000..0ec9938e8 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'node:events'; +import type { Request, Response } from 'express'; +import { XtreamPerformanceController } from './performance-control.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +describe('Xtream performance controller lifecycle', () => { + it.each([ + ['observations', 1], + ['all', 2], + ] as const)( + 'invalidates an unmatched pre-reset response on %s reset', + async (mode, expectedEpoch) => { + const controller = new XtreamPerformanceController(); + const request = createRequest(); + const response = createResponse(); + + await controller.intercept( + request, + response as unknown as Response, + 'direct', + () => undefined + ); + expect(controller.snapshot().ledger).toHaveLength(1); + + controller.reset(mode); + + expect(response.listenerCount('finish')).toBe(0); + expect(response.listenerCount('close')).toBe(0); + expect(response.destroyed).toBe(true); + + response.writableFinished = true; + response.emit('finish'); + response.emit('close'); + + expect(controller.snapshot()).toMatchObject({ + epoch: expectedEpoch, + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + }); + } + ); +}); + +function createRequest(): Request { + return Object.assign(new EventEmitter(), { + aborted: false, + query: { + username: 'performance', + password: 'performance', + }, + }) as unknown as Request; +} + +function createResponse(): EventEmitter & { + destroyed: boolean; + headersSent: boolean; + writableFinished: boolean; + destroy(): void; + json(): unknown; + status(): unknown; +} { + const response = Object.assign(new EventEmitter(), { + destroyed: false, + headersSent: false, + writableFinished: false, + destroy() { + response.destroyed = true; + response.emit('close'); + }, + json() { + return response; + }, + status() { + return response; + }, + }); + return response; +} diff --git a/apps/xtream-mock-server/src/app/performance-control-routes.ts b/apps/xtream-mock-server/src/app/performance-control-routes.ts new file mode 100644 index 000000000..6c0df33eb --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-routes.ts @@ -0,0 +1,131 @@ +import { timingSafeEqual } from 'node:crypto'; +import cors from 'cors'; +import express, { + type ErrorRequestHandler, + type NextFunction, + type Request, + type Response, +} from 'express'; +import { + PERFORMANCE_CONTROL_LIMITS, + XtreamPerformanceController, +} from './performance-control.js'; +import { + assertSafeRuleId, + parsePrepareBody, + parseReleaseBody, + parseResetBody, + parseRuleBody, + PerformanceControlError, +} from './performance-control-validation.js'; + +const TOKEN_HEADER = 'x-iptvnator-performance-token'; + +export function installPerformanceControlRoutes( + app: express.Express, + controller: XtreamPerformanceController, + token: string +): void { + const router = express.Router(); + router.use(authorize(token)); + router.use(cors()); + router.use( + express.json({ + limit: PERFORMANCE_CONTROL_LIMITS.jsonBytes, + strict: true, + }) + ); + + router.post('/prepare', (request, response) => { + executeControl(response, () => { + parsePrepareBody(request.body); + response.json(controller.prepare()); + }); + }); + router.post('/reset', (request, response) => { + executeControl(response, () => { + const { mode } = parseResetBody(request.body); + response.json(controller.reset(mode)); + }); + }); + router.post('/barriers', (request, response) => { + executeControl(response, () => { + const rule = controller.addRule( + parseRuleBody('barrier', request.body) + ); + response.status(201).json(rule); + }); + }); + router.post('/barriers/:id/release', (request, response) => { + executeControl(response, () => { + const id = request.params['id'] ?? ''; + assertSafeRuleId(id); + parseReleaseBody(request.body); + if (!controller.releaseBarrier(id)) { + throw new PerformanceControlError( + 404, + 'held-barrier-not-found' + ); + } + response.json({ id, released: true }); + }); + }); + router.post('/delays', (request, response) => { + executeControl(response, () => { + const rule = controller.addRule( + parseRuleBody('delay', request.body) + ); + response.status(201).json(rule); + }); + }); + router.get('/state', (_request, response) => { + response.json(controller.snapshot()); + }); + router.use(jsonErrorHandler); + app.use('/__control', router); +} + +function authorize(token: string) { + const expected = Buffer.from(token, 'utf8'); + return (request: Request, response: Response, next: NextFunction) => { + const provided = request.get(TOKEN_HEADER); + if (!provided || !constantTimeEqual(expected, provided)) { + response.status(401).json({ error: 'unauthorized' }); + return; + } + next(); + }; +} + +function constantTimeEqual(expected: Buffer, provided: string): boolean { + const candidate = Buffer.from(provided, 'utf8'); + return ( + candidate.length === expected.length && + timingSafeEqual(candidate, expected) + ); +} + +function executeControl(response: Response, execute: () => void): void { + try { + execute(); + } catch (error) { + if (error instanceof PerformanceControlError) { + response.status(error.status).json({ error: error.code }); + return; + } + response.status(500).json({ error: 'control-internal-error' }); + } +} + +const jsonErrorHandler: ErrorRequestHandler = ( + error: Error & { status?: number; type?: string }, + _request, + response, + _next +) => { + void _next; + const tooLarge = error.status === 413 || error.type === 'entity.too.large'; + response + .status(tooLarge ? 413 : 400) + .json({ error: tooLarge ? 'control-json-too-large' : 'invalid-json' }); +}; diff --git a/apps/xtream-mock-server/src/app/performance-control-security.spec.ts b/apps/xtream-mock-server/src/app/performance-control-security.spec.ts new file mode 100644 index 000000000..8164a235d --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-security.spec.ts @@ -0,0 +1,129 @@ +import { getPortalData, resetAll } from './data-store.js'; +import { type PerformanceControlState } from './performance-control.js'; +import { createXtreamMockApp } from './server.js'; +import { + postJson, + requestJson, + startLoopbackServer, + type RunningTestServer, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'security-token'; +const ORIGIN = 'http://127.0.0.1:4200'; + +describe('Xtream performance control request boundary', () => { + let running: RunningTestServer; + + beforeEach(async () => { + resetAll(); + running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + }); + + afterEach(async () => { + await running.close(); + resetAll(); + }); + + it('requires the exact token before handling control preflight', async () => { + const missing = await controlOptions(running); + const wrong = await controlOptions(running, `${TOKEN}-wrong`); + const valid = await controlOptions(running, TOKEN); + + expect(missing.status).toBe(401); + expect(wrong.status).toBe(401); + expect(valid.status).toBe(204); + expect(valid.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('preserves unauthenticated CORS preflight for ordinary routes', async () => { + const response = await fetch(`${running.origin}/player_api.php`, { + method: 'OPTIONS', + headers: { + origin: ORIGIN, + 'access-control-request-method': 'GET', + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('rejects legacy reset without invalidating prepared control state', async () => { + const prepared = await postJson( + running.origin, + '/__control/prepare', + TOKEN, + { scenario: 'performance-100k' } + ); + const cachedBefore = getPortalData('performance', 'performance'); + + const reset = await requestJson(running.origin, '/reset', { + method: 'POST', + }); + const state = await requestJson(running.origin, '/__control/state', { + headers: { + 'x-iptvnator-performance-token': TOKEN, + }, + }); + + expect(reset.response.status).toBe(410); + expect(reset.body).toEqual({ + error: 'use-performance-control-reset', + }); + expect((state.body as PerformanceControlState).prepared).toEqual( + prepared.body + ); + expect(getPortalData('performance', 'performance')).toBe(cachedBefore); + }); +}); + +describe('Xtream normal reset compatibility', () => { + afterEach(() => resetAll()); + + it('keeps the legacy reset available outside control mode', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + const cachedBefore = getPortalData('performance', 'performance'); + try { + const reset = await requestJson(running.origin, '/reset', { + method: 'POST', + }); + + expect(reset.response.status).toBe(200); + expect(reset.body).toEqual({ status: 'reset' }); + expect(getPortalData('performance', 'performance')).not.toBe( + cachedBefore + ); + } finally { + await running.close(); + } + }); +}); + +function controlOptions( + running: RunningTestServer, + token?: string +): Promise { + return fetch(`${running.origin}/__control/state`, { + method: 'OPTIONS', + headers: { + origin: ORIGIN, + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'x-iptvnator-performance-token', + ...(token === undefined + ? {} + : { 'x-iptvnator-performance-token': token }), + }, + }); +} diff --git a/apps/xtream-mock-server/src/app/performance-control-validation.spec.ts b/apps/xtream-mock-server/src/app/performance-control-validation.spec.ts new file mode 100644 index 000000000..621c5cd54 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-validation.spec.ts @@ -0,0 +1,341 @@ +import { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceControlState, +} from './performance-control.js'; +import { resetAll } from './data-store.js'; +import { createXtreamMockApp } from './server.js'; +import { + postJson, + requestJson, + startLoopbackServer, + type RunningTestServer, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'validation-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; +jest.setTimeout(60_000); + +describe('Xtream performance control validation and bounds', () => { + let running: RunningTestServer; + + beforeEach(async () => { + resetAll(); + running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + }); + + afterEach(async () => { + await running.close(); + resetAll(); + }); + + it.each([ + ['/__control/prepare', {}, 400], + [ + '/__control/prepare', + { scenario: 'performance-100k', extra: true }, + 400, + ], + ['/__control/prepare', { scenario: 'large' }, 400], + ['/__control/reset', { mode: 'observations', extra: true }, 400], + ['/__control/reset', { mode: 'wrong' }, 400], + ['/__control/barriers', { id: '../unsafe', match: validMatch(1) }, 400], + [ + '/__control/barriers', + { + id: 'bad-scenario', + match: { ...validMatch(1), scenario: 'large' }, + }, + 400, + ], + [ + '/__control/barriers', + { + id: 'bad-transport', + match: { ...validMatch(1), transport: 'remote' }, + }, + 400, + ], + [ + '/__control/barriers', + { + id: 'bad-action', + match: { ...validMatch(1), action: 'delete_everything' }, + }, + 400, + ], + [ + '/__control/barriers', + { id: 'zero-occurrence', match: validMatch(0) }, + 400, + ], + [ + '/__control/barriers', + { id: 'fraction-occurrence', match: validMatch(1.5) }, + 400, + ], + [ + '/__control/barriers', + { + id: 'bad-category', + match: { ...validMatch(1), categoryId: '99999' }, + }, + 400, + ], + [ + '/__control/barriers', + { + id: 'unknown-field', + match: { ...validMatch(1), url: 'http://forbidden.invalid' }, + }, + 400, + ], + [ + '/__control/delays', + { id: 'negative-delay', match: validMatch(1), milliseconds: -1 }, + 400, + ], + [ + '/__control/delays', + { id: 'long-delay', match: validMatch(1), milliseconds: 5001 }, + 400, + ], + [ + '/__control/delays', + { + id: 'fraction-delay', + match: validMatch(1), + milliseconds: 1.5, + }, + 400, + ], + ])('rejects invalid body %#', async (path, body, status) => { + const result = await postJson(running.origin, path, TOKEN, body); + expect(result.response.status).toBe(status); + }); + + it('rejects malformed and oversized JSON safely', async () => { + const malformed = await fetch(`${running.origin}/__control/prepare`, { + method: 'POST', + headers: { + ...AUTH_HEADERS, + 'content-type': 'application/json', + }, + body: '{"scenario":', + }); + const oversized = await fetch(`${running.origin}/__control/prepare`, { + method: 'POST', + headers: { + ...AUTH_HEADERS, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + scenario: 'performance-100k', + padding: 'x'.repeat(PERFORMANCE_CONTROL_LIMITS.jsonBytes + 1), + }), + }); + + expect(malformed.status).toBe(400); + expect(oversized.status).toBe(413); + expect(await malformed.text()).not.toContain('scenario'); + expect(await oversized.text()).not.toContain('padding'); + }); + + it('rejects arbitrary release bodies before looking up a barrier', async () => { + const response = await fetch( + `${running.origin}/__control/barriers/not-held/release`, + { + method: 'POST', + headers: { + ...AUTH_HEADERS, + 'content-type': 'application/json', + }, + body: JSON.stringify({ url: 'http://forbidden.invalid' }), + } + ); + expect(response.status).toBe(400); + expect(await response.text()).not.toContain('forbidden'); + }); + + it('rejects duplicate IDs, duplicate matches, and past occurrences', async () => { + const first = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { id: 'first', match: validMatch(1) } + ); + const duplicateId = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'first', + match: { + ...validMatch(1), + categoryId: '91102', + }, + } + ); + const duplicateMatch = await postJson( + running.origin, + '/__control/delays', + TOKEN, + { id: 'same-match', match: validMatch(1), milliseconds: 0 } + ); + expect(first.response.status).toBe(201); + expect(duplicateId.response.status).toBe(409); + expect(duplicateMatch.response.status).toBe(409); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'observations', + }); + await fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101` + ); + const past = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { id: 'past', match: validMatch(1) } + ); + expect(past.response.status).toBe(409); + }); + + it('caps accepted rules and the serialized ledger', async () => { + for ( + let occurrence = 1; + occurrence <= PERFORMANCE_CONTROL_LIMITS.rules; + occurrence++ + ) { + const result = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: `rule-${occurrence}`, + match: validMatch(occurrence), + } + ); + expect(result.response.status).toBe(201); + } + const overflow = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'rule-overflow', + match: validMatch(PERFORMANCE_CONTROL_LIMITS.rules + 1), + } + ); + expect(overflow.response.status).toBe(409); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'observations', + }); + for ( + let index = 0; + index < PERFORMANCE_CONTROL_LIMITS.ledger; + index++ + ) { + await fetch( + `${running.origin}/player_api.php?username=u${index}&password=p${index}&action=unknown-${index}` + ); + } + const snapshot = await state(running); + expect(snapshot.ledger).toHaveLength(PERFORMANCE_CONTROL_LIMITS.ledger); + expect(snapshot.occurrences.length).toBeLessThanOrEqual( + PERFORMANCE_CONTROL_LIMITS.occurrences + ); + }); + + it('never serializes credentials, raw queries, URLs, or response payloads', async () => { + const username = 'SECRET_USER_MARKER'; + const password = 'SECRET_PASSWORD_MARKER'; + const rawUrl = 'http://RAW_URL_MARKER.invalid/private'; + const responseMarker = 'RESPONSE_PAYLOAD_MARKER'; + await postJson(running.origin, '/__control/prepare', TOKEN, { + scenario: 'performance-100k', + }); + const response = await fetch( + `${running.origin}/xtream?url=${encodeURIComponent( + rawUrl + )}&username=${username}&password=${password}&action=${responseMarker}&query=RAW_QUERY_MARKER` + ); + expect(response.status).toBe(400); + expect(await response.text()).toContain(responseMarker); + + const serialized = JSON.stringify(await state(running)); + for (const marker of [ + username, + password, + rawUrl, + 'RAW_URL_MARKER', + 'RAW_QUERY_MARKER', + responseMarker, + TOKEN, + 'Performance Live 000001', + ]) { + expect(serialized).not.toContain(marker); + } + }); + + it('accepts the legacy action alias but rejects arbitrary cardinalities', async () => { + const alias = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'legacy-alias', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_simple_date_table', + occurrence: 1, + categoryId: 'all', + }, + } + ); + const arbitraryCategory = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'arbitrary-category', + match: { ...validMatch(2), categoryId: '91161' }, + } + ); + expect(alias.response.status).toBe(201); + expect(arbitraryCategory.response.status).toBe(400); + }); +}); + +function validMatch(occurrence: number) { + return { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence, + categoryId: '91101', + }; +} + +async function state( + running: RunningTestServer +): Promise { + const result = await requestJson(running.origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} diff --git a/apps/xtream-mock-server/src/app/performance-control-validation.ts b/apps/xtream-mock-server/src/app/performance-control-validation.ts new file mode 100644 index 000000000..629cf5b92 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-validation.ts @@ -0,0 +1,185 @@ +import type { + PerformanceAction, + PerformanceRuleInput, + PerformanceRuleMatch, + PerformanceTransport, +} from './performance-control.types.js'; + +const RULE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const PERFORMANCE_SCENARIO = 'performance-100k'; +const TRANSPORTS: readonly PerformanceTransport[] = ['direct', 'proxy']; +const ACTIONS: readonly PerformanceAction[] = [ + 'get_account_info', + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', + 'get_vod_info', + 'get_series_info', + 'get_short_epg', + 'get_simple_data_table', + 'get_simple_date_table', +]; + +export class PerformanceControlError extends Error { + constructor( + readonly status: number, + readonly code: string + ) { + super(code); + } +} + +export function parsePrepareBody(value: unknown): { + scenario: typeof PERFORMANCE_SCENARIO; +} { + requireExactObject(value, ['scenario']); + if (value['scenario'] !== PERFORMANCE_SCENARIO) { + invalid(); + } + return { scenario: PERFORMANCE_SCENARIO }; +} + +export function parseResetBody(value: unknown): { + mode: 'all' | 'observations'; +} { + requireExactObject(value, ['mode']); + const mode = value['mode']; + if (mode !== 'all' && mode !== 'observations') invalid(); + return { mode }; +} + +export function parseRuleBody( + kind: 'barrier' | 'delay', + value: unknown +): PerformanceRuleInput { + const keys = + kind === 'barrier' ? ['id', 'match'] : ['id', 'match', 'milliseconds']; + requireExactObject(value, keys); + const id = value['id']; + if (typeof id !== 'string' || !RULE_ID_PATTERN.test(id)) invalid(); + const match = parseMatch(value['match']); + + if (kind === 'barrier') { + return { id, kind, match }; + } + const milliseconds = value['milliseconds']; + if ( + !Number.isInteger(milliseconds) || + (milliseconds as number) < 0 || + (milliseconds as number) > 5_000 + ) { + invalid(); + } + return { id, kind, match, milliseconds: milliseconds as number }; +} + +export function parseReleaseBody(value: unknown): void { + if (value === undefined) return; + requireExactObject(value, []); +} + +export function assertSafeRuleId(value: string): void { + if (!RULE_ID_PATTERN.test(value)) invalid(); +} + +export function isPerformanceAction(value: string): value is PerformanceAction { + return ACTIONS.includes(value as PerformanceAction); +} + +export function canonicalObservedAction( + value: unknown +): PerformanceAction | 'unknown' { + const raw = typeof value === 'string' ? value : ''; + if (raw === '') return 'get_account_info'; + return isPerformanceAction(raw) ? raw : 'unknown'; +} + +function parseMatch(value: unknown): PerformanceRuleMatch { + requireExactObject(value, [ + 'scenario', + 'transport', + 'action', + 'occurrence', + 'categoryId', + ]); + const scenario = value['scenario']; + const transport = value['transport']; + const action = value['action']; + const occurrence = value['occurrence']; + const categoryId = value['categoryId']; + + if (scenario !== PERFORMANCE_SCENARIO) invalid(); + if ( + typeof transport !== 'string' || + !TRANSPORTS.includes(transport as PerformanceTransport) + ) { + invalid(); + } + if (typeof action !== 'string' || !isPerformanceAction(action)) invalid(); + if (!Number.isInteger(occurrence) || (occurrence as number) <= 0) invalid(); + if ( + typeof categoryId !== 'string' || + !isAllowedPerformanceCategory(action, categoryId) + ) { + invalid(); + } + + return { + scenario, + transport: transport as PerformanceTransport, + action, + occurrence: occurrence as number, + categoryId, + }; +} + +export function isAllowedPerformanceCategory( + action: PerformanceAction, + categoryId: string +): boolean { + if (categoryId === 'all') return true; + if (!/^\d+$/.test(categoryId)) return false; + const numericId = Number(categoryId); + if (!Number.isSafeInteger(numericId) || categoryId !== String(numericId)) { + return false; + } + if (action === 'get_live_streams') { + return numericId >= 91_101 && numericId <= 91_160; + } + if (action === 'get_vod_streams') { + return numericId >= 92_101 && numericId <= 92_120; + } + if (action === 'get_series') { + return numericId >= 93_101 && numericId <= 93_120; + } + return false; +} + +function requireExactObject( + value: unknown, + expectedKeys: readonly string[] +): asserts value is Record { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + invalid(); + } + const actualKeys = Object.keys(value).sort(); + const sortedExpected = [...expectedKeys].sort(); + if ( + actualKeys.length !== sortedExpected.length || + actualKeys.some((key, index) => key !== sortedExpected[index]) + ) { + invalid(); + } +} + +function invalid(): never { + throw new PerformanceControlError(400, 'invalid-control-request'); +} diff --git a/apps/xtream-mock-server/src/app/performance-control.spec.ts b/apps/xtream-mock-server/src/app/performance-control.spec.ts new file mode 100644 index 000000000..d09273d5f --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control.spec.ts @@ -0,0 +1,380 @@ +import { resetAll } from './data-store.js'; +import { type PerformanceControlState } from './performance-control.js'; +import { createXtreamMockApp } from './server.js'; +import { + openAbortableGet, + postJson, + requestJson, + startLoopbackServer, + waitFor, + type RunningTestServer, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'benchmark-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; +jest.setTimeout(120_000); + +describe('Xtream performance control API', () => { + let running: RunningTestServer; + + beforeEach(async () => { + resetAll(); + running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + }); + + afterEach(async () => { + await running.close(); + resetAll(); + }); + + it('requires the exact token for every control route', async () => { + const missing = await fetch(`${running.origin}/__control/state`); + const incorrect = await fetch(`${running.origin}/__control/state`, { + headers: { + 'x-iptvnator-performance-token': `${TOKEN}-wrong`, + }, + }); + const valid = await fetch(`${running.origin}/__control/state`, { + headers: AUTH_HEADERS, + }); + + expect([missing.status, incorrect.status, valid.status]).toEqual([ + 401, 401, 200, + ]); + }); + + it('prepares the exact 100k fixture with a stable redacted manifest', async () => { + const first = await postJson( + running.origin, + '/__control/prepare', + TOKEN, + { scenario: 'performance-100k' } + ); + expect(first.response.status).toBe(200); + expect(Object.keys(first.body as object)).toEqual([ + 'epoch', + 'scenario', + 'seed', + 'counts', + 'bytes', + 'catalogSha256', + ]); + expect(first.body).toMatchObject({ + 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, + }, + }, + }); + expect((first.body as { bytes: number }).bytes).toBeGreaterThan(0); + expect((first.body as { catalogSha256: string }).catalogSha256).toMatch( + /^[a-f0-9]{64}$/ + ); + + const reset = await postJson( + running.origin, + '/__control/reset', + TOKEN, + { mode: 'all' } + ); + expect(reset.response.status).toBe(200); + const second = await postJson( + running.origin, + '/__control/prepare', + TOKEN, + { scenario: 'performance-100k' } + ); + expect(second.body).toMatchObject({ + epoch: 2, + bytes: (first.body as { bytes: number }).bytes, + catalogSha256: (first.body as { catalogSha256: string }) + .catalogSha256, + }); + }); + + it('distinguishes observation reset from all-state reset', async () => { + const prepared = await prepare(running); + await postJson(running.origin, '/__control/barriers', TOKEN, { + id: 'future-live', + match: liveMatch('direct', '91101', 1), + }); + await fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_account_info` + ); + const before = await state(running); + expect(before.prepared).toEqual(prepared); + expect(before.rules).toHaveLength(1); + expect(before.ledger.length).toBeGreaterThan(0); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'observations', + }); + const observationsReset = await state(running); + expect(observationsReset).toMatchObject({ + epoch: 1, + prepared, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + }); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'all', + }); + const allReset = await state(running); + expect(allReset).toMatchObject({ + epoch: 2, + prepared: null, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + }); + }); + + it('matches category barriers independently of parallel arrival order', async () => { + await prepare(running); + await createBarrier( + running, + 'category-a', + liveMatch('direct', '91101', 1) + ); + await createBarrier( + running, + 'category-b', + liveMatch('direct', '91102', 1) + ); + + const categoryB = fetch(catalogUrl(running, '91102', 'direct')); + await expectHeld(running, ['category-b']); + const categoryA = fetch(catalogUrl(running, '91101', 'direct')); + await expectHeld(running, ['category-a', 'category-b']); + await release(running, 'category-a'); + await release(running, 'category-b'); + + const [bodyA, bodyB] = await Promise.all([ + categoryA.then((response) => response.json()), + categoryB.then((response) => response.json()), + ]); + expect(bodyA).toHaveLength(1_000); + expect(bodyB).toHaveLength(1_000); + const snapshot = await state(running); + expect( + snapshot.occurrences.filter( + (entry) => + entry.action === 'get_live_streams' && + ['91101', '91102'].includes(entry.categoryId) + ) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ categoryId: '91101', count: 1 }), + expect.objectContaining({ categoryId: '91102', count: 1 }), + ]) + ); + }); + + it('releases a barrier and records the terminal response', async () => { + await prepare(running); + await createBarrier(running, 'account-gate', { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_account_info', + occurrence: 1, + categoryId: 'all', + }); + const request = fetch( + `${running.origin}/player_api.php?username=performance&password=performance` + ); + await expectHeld(running, ['account-gate']); + const releaseResponse = await release(running, 'account-gate'); + expect(releaseResponse.status).toBe(200); + expect((await request).status).toBe(200); + await waitFor(async () => { + const snapshot = await state(running); + expect(snapshot.heldCount).toBe(0); + expect(snapshot.ledger.map((entry) => entry.status)).toEqual( + expect.arrayContaining(['arrived', 'blocked', 'responded']) + ); + }); + }); + + it('settles an aborted held request without leaking it', async () => { + await prepare(running); + await createBarrier( + running, + 'abort-gate', + liveMatch('direct', '91101', 1) + ); + const request = openAbortableGet( + running.origin, + '/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101' + ); + await expectHeld(running, ['abort-gate']); + request.abort(); + await request.completed; + + await waitFor(async () => { + const snapshot = await state(running); + expect(snapshot.heldCount).toBe(0); + expect( + snapshot.ledger.some( + (entry) => + entry.action === 'get_live_streams' && + entry.status === 'aborted' + ) + ).toBe(true); + }); + }); + + it('settles held requests when observations are reset', async () => { + await prepare(running); + await createBarrier( + running, + 'reset-gate', + liveMatch('direct', '91101', 1) + ); + const heldResponse = fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101` + ); + await expectHeld(running, ['reset-gate']); + const reset = await postJson( + running.origin, + '/__control/reset', + TOKEN, + { mode: 'observations' } + ); + + expect(reset.response.status).toBe(200); + expect((await heldResponse).status).toBe(409); + expect(await state(running)).toMatchObject({ + epoch: 1, + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + rules: [], + }); + }); + + it('applies a configured short delay and records its lifecycle', async () => { + await prepare(running); + const created = await postJson( + running.origin, + '/__control/delays', + TOKEN, + { + id: 'short-delay', + match: liveMatch('proxy', '91101', 1), + milliseconds: 1, + } + ); + expect(created.response.status).toBe(201); + const response = await fetch(catalogUrl(running, '91101', 'proxy')); + expect(response.status).toBe(200); + const snapshot = await state(running); + expect(snapshot.heldCount).toBe(0); + expect(snapshot.ledger.map((entry) => entry.status)).toEqual( + expect.arrayContaining(['arrived', 'delayed', 'responded']) + ); + }); +}); + +async function prepare( + running: RunningTestServer +): Promise { + const result = await postJson(running.origin, '/__control/prepare', TOKEN, { + scenario: 'performance-100k', + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState['prepared']; +} + +async function state( + running: RunningTestServer +): Promise { + const result = await requestJson(running.origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} + +function liveMatch( + transport: 'direct' | 'proxy', + categoryId: string, + occurrence: number +) { + return { + scenario: 'performance-100k', + transport, + action: 'get_live_streams', + occurrence, + categoryId, + }; +} + +async function createBarrier( + running: RunningTestServer, + id: string, + match: ReturnType | Record +) { + const result = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { id, match } + ); + expect(result.response.status).toBe(201); +} + +async function release( + running: RunningTestServer, + id: string +): Promise { + return fetch(`${running.origin}/__control/barriers/${id}/release`, { + method: 'POST', + headers: AUTH_HEADERS, + }); +} + +async function expectHeld( + running: RunningTestServer, + ids: string[] +): Promise { + await waitFor(async () => { + const snapshot = await state(running); + expect(snapshot.heldIds.sort()).toEqual([...ids].sort()); + expect(snapshot.heldCount).toBe(ids.length); + }); +} + +function catalogUrl( + running: RunningTestServer, + categoryId: string, + transport: 'direct' | 'proxy' +): string { + const route = transport === 'direct' ? '/player_api.php' : '/xtream'; + return `${running.origin}${route}?url=ignored&username=performance&password=performance&action=get_live_streams&category_id=${categoryId}`; +} diff --git a/apps/xtream-mock-server/src/app/performance-control.ts b/apps/xtream-mock-server/src/app/performance-control.ts new file mode 100644 index 000000000..2a2362aed --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control.ts @@ -0,0 +1,299 @@ +import { performance } from 'node:perf_hooks'; +import type { Request, Response } from 'express'; +import { resetAll } from './data-store.js'; +import { PerformanceInterceptionLifecycle } from './performance-interception-lifecycle.js'; +import { buildPerformanceManifest } from './performance-manifest.js'; +import { PerformanceControlError } from './performance-control-validation.js'; +import { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceControlState, + type PerformanceIdentity, + type PerformanceLedgerEntry, + type PerformanceLifecycleStatus, + type PerformanceManifest, + type PerformanceRuleInput, + type PerformanceTransport, +} from './performance-control.types.js'; +import { + identityKey, + PerformanceOccurrenceTracker, + ruleMatchKey, +} from './performance-occurrences.js'; + +export { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceAction, + type PerformanceControlState, + type PerformanceLedgerEntry, + type PerformanceLifecycleStatus, + type PerformanceManifest, + type PerformanceOccurrenceState, + type PerformanceRuleInput, + type PerformanceRuleMatch, + type PerformanceTransport, +} from './performance-control.types.js'; + +type HeldOutcome = 'released' | 'aborted' | 'reset' | 'shutdown'; +interface PendingRequest { + readonly kind: 'barrier' | 'delay'; + settle(outcome: HeldOutcome): void; +} + +export class XtreamPerformanceController { + private epoch = 1; + private prepared: PerformanceManifest | null = null; + private readonly rules = new Map(); + private readonly matchKeys = new Set(); + private readonly seenRuleIds = new Set(); + private acceptedRuleCount = 0; + private readonly pending = new Map(); + private readonly interceptionLifecycle = + new PerformanceInterceptionLifecycle(); + private readonly occurrences = new PerformanceOccurrenceTracker(); + private readonly ledger: PerformanceLedgerEntry[] = []; + + prepare(): PerformanceManifest { + this.prepared ??= buildPerformanceManifest(this.epoch); + return this.prepared; + } + + reset(mode: 'all' | 'observations'): { epoch: number; mode: string } { + this.interceptionLifecycle.reset(); + this.settlePending('reset'); + this.clearObservations(); + if (mode === 'all') { + resetAll(); + this.prepared = null; + this.epoch += 1; + } + return { epoch: this.epoch, mode }; + } + + shutdown(): PerformanceControlState { + if (this.interceptionLifecycle.shutdown()) { + this.settlePending('shutdown'); + this.clearObservations(); + } + return this.snapshot(); + } + + addRule(input: PerformanceRuleInput): PerformanceRuleInput { + if (this.acceptedRuleCount >= PERFORMANCE_CONTROL_LIMITS.rules) { + conflict('control-rule-limit'); + } + if (this.seenRuleIds.has(input.id)) conflict('duplicate-rule-id'); + const matchKey = ruleMatchKey(input.match); + if (this.matchKeys.has(matchKey)) conflict('duplicate-rule-match'); + if (input.match.occurrence <= this.occurrences.count(input.match)) { + conflict('past-rule-occurrence'); + } + + this.acceptedRuleCount += 1; + this.seenRuleIds.add(input.id); + this.matchKeys.add(matchKey); + this.rules.set(input.id, input); + return input; + } + + releaseBarrier(id: string): boolean { + const pending = this.pending.get(id); + if (!pending || pending.kind !== 'barrier') return false; + this.settleOnePending(id, 'released'); + return true; + } + + snapshot(): PerformanceControlState { + const heldIds = [...this.pending.entries()] + .filter(([, request]) => request.kind === 'barrier') + .map(([id]) => id); + return { + epoch: this.epoch, + prepared: this.prepared, + rules: [...this.rules.values()], + heldIds, + heldCount: heldIds.length, + occurrences: this.occurrences.snapshot(), + ledger: [...this.ledger], + }; + } + + async intercept( + request: Request, + response: Response, + transport: PerformanceTransport, + dispatch: () => void | Promise + ): Promise { + if (this.interceptionLifecycle.shuttingDown) { + if (!response.destroyed) response.destroy(); + return; + } + const generation = this.interceptionLifecycle.generation; + const identity = this.occurrences.next(request, transport, this.epoch); + this.record(identity, 'arrived'); + const activeId = Symbol('performance-interception'); + let terminal = false; + let suppressTerminal = false; + let pendingId: string | undefined; + + const cleanup = () => { + request.off('aborted', onAborted); + response.off('finish', onFinished); + response.off('close', onClosed); + this.interceptionLifecycle.unregister(activeId); + }; + const finish = (status: 'responded' | 'aborted') => { + if (terminal) return; + terminal = true; + cleanup(); + if (pendingId) { + this.settleOnePending(pendingId, 'aborted'); + pendingId = undefined; + } + if ( + !suppressTerminal && + this.interceptionLifecycle.isCurrent(generation) + ) { + this.record(identity, status); + } + }; + const onAborted = () => finish('aborted'); + const onFinished = () => finish('responded'); + const onClosed = () => { + if (!response.writableFinished) finish('aborted'); + }; + request.once('aborted', onAborted); + response.once('finish', onFinished); + response.once('close', onClosed); + this.interceptionLifecycle.register(activeId, (abortHeldResponse) => { + if (terminal) return; + terminal = true; + suppressTerminal = true; + cleanup(); + if ( + (abortHeldResponse || pendingId === undefined) && + !response.writableFinished && + !response.destroyed + ) { + response.destroy(); + } + }); + + const rule = this.consumeRule(identity); + if (rule?.kind === 'barrier') { + this.record(identity, 'blocked'); + pendingId = rule.id; + const outcome = await new Promise((resolve) => { + this.pending.set(rule.id, { + kind: 'barrier', + settle: (value) => { + if (value === 'reset') { + suppressTerminal = true; + cleanup(); + } + resolve(value); + }, + }); + }); + pendingId = undefined; + if (this.finishControlledWait(outcome, response)) return; + } else if (rule?.kind === 'delay') { + this.record(identity, 'delayed'); + pendingId = rule.id; + const outcome = await new Promise((resolve) => { + const timer = setTimeout( + () => this.settleOnePending(rule.id, 'released'), + rule.milliseconds + ); + this.pending.set(rule.id, { + kind: 'delay', + settle: (value) => { + clearTimeout(timer); + if (value === 'reset') { + suppressTerminal = true; + cleanup(); + } + resolve(value); + }, + }); + }); + pendingId = undefined; + if (this.finishControlledWait(outcome, response)) return; + } + + if (request.aborted || response.destroyed) { + finish('aborted'); + return; + } + await dispatch(); + } + + private consumeRule( + identity: PerformanceIdentity + ): PerformanceRuleInput | undefined { + for (const rule of this.rules.values()) { + if (ruleMatchKey(rule.match) !== identityKey(identity)) continue; + this.rules.delete(rule.id); + this.matchKeys.delete(ruleMatchKey(rule.match)); + return rule; + } + return undefined; + } + + private record( + identity: PerformanceIdentity, + status: PerformanceLifecycleStatus + ): void { + this.ledger.push({ + ...identity, + status, + atMs: Number(performance.now().toFixed(3)), + }); + if (this.ledger.length > PERFORMANCE_CONTROL_LIMITS.ledger) { + this.ledger.splice( + 0, + this.ledger.length - PERFORMANCE_CONTROL_LIMITS.ledger + ); + } + } + + private finishControlledWait( + outcome: HeldOutcome, + response: Response + ): boolean { + if (outcome === 'released') return false; + if ( + outcome === 'reset' && + !response.headersSent && + !response.destroyed + ) { + response.status(409).json({ error: 'control-reset' }); + } + return true; + } + + private settlePending(outcome: HeldOutcome): void { + for (const id of [...this.pending.keys()]) { + this.settleOnePending(id, outcome); + } + } + + private settleOnePending(id: string, outcome: HeldOutcome): void { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + pending.settle(outcome); + } + + private clearObservations(): void { + this.rules.clear(); + this.matchKeys.clear(); + this.seenRuleIds.clear(); + this.acceptedRuleCount = 0; + this.occurrences.clear(); + this.ledger.length = 0; + } +} + +function conflict(code: string): never { + throw new PerformanceControlError(409, code); +} diff --git a/apps/xtream-mock-server/src/app/performance-control.types.ts b/apps/xtream-mock-server/src/app/performance-control.types.ts new file mode 100644 index 000000000..e11a5662a --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control.types.ts @@ -0,0 +1,98 @@ +export const PERFORMANCE_CONTROL_LIMITS = { + jsonBytes: 16 * 1024, + ledger: 128, + occurrences: 512, + rules: 32, +} as const; + +export type PerformanceTransport = 'direct' | 'proxy'; +export type PerformanceAction = + | 'get_account_info' + | 'get_live_categories' + | 'get_vod_categories' + | 'get_series_categories' + | 'get_live_streams' + | 'get_vod_streams' + | 'get_series' + | 'get_vod_info' + | 'get_series_info' + | 'get_short_epg' + | 'get_simple_data_table' + | 'get_simple_date_table'; +export type PerformanceLifecycleStatus = + 'arrived' | 'blocked' | 'delayed' | 'responded' | 'aborted'; + +export interface PerformanceRuleMatch { + readonly scenario: 'performance-100k'; + readonly transport: PerformanceTransport; + readonly action: PerformanceAction; + readonly occurrence: number; + readonly categoryId: string; +} + +export type PerformanceRuleInput = + | { + readonly id: string; + readonly kind: 'barrier'; + readonly match: PerformanceRuleMatch; + } + | { + readonly id: string; + readonly kind: 'delay'; + readonly match: PerformanceRuleMatch; + readonly milliseconds: number; + }; + +export interface PerformanceManifest { + readonly epoch: number; + readonly scenario: 'performance-100k'; + readonly seed: number; + readonly counts: { + readonly categories: { + readonly live: number; + readonly vod: number; + readonly series: number; + readonly total: number; + }; + readonly items: { + readonly live: number; + readonly vod: number; + readonly series: number; + readonly total: number; + }; + }; + readonly bytes: number; + readonly catalogSha256: string; +} + +export interface PerformanceIdentity { + readonly epoch: number; + readonly scenario: string; + readonly transport: PerformanceTransport; + readonly action: PerformanceAction | 'unknown'; + readonly categoryId: string; + readonly occurrence: number; +} + +export interface PerformanceOccurrenceState { + readonly scenario: string; + readonly transport: PerformanceTransport; + readonly action: PerformanceAction | 'unknown'; + readonly categoryId: string; + readonly count: number; +} + +export interface PerformanceLedgerEntry extends PerformanceIdentity { + readonly status: PerformanceLifecycleStatus; + readonly atMs: number; +} + +export interface PerformanceControlState { + readonly epoch: number; + readonly prepared: PerformanceManifest | null; + readonly rules: ReadonlyArray; + readonly heldIds: string[]; + readonly heldCount: number; + readonly occurrences: ReadonlyArray; + readonly ledger: ReadonlyArray; +} diff --git a/apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts b/apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts new file mode 100644 index 000000000..8bf169958 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts @@ -0,0 +1,45 @@ +type InvalidateInterception = (abortHeldResponse: boolean) => void; + +export class PerformanceInterceptionLifecycle { + private currentGeneration = 1; + private stopped = false; + private readonly active = new Map(); + + get generation(): number { + return this.currentGeneration; + } + + get shuttingDown(): boolean { + return this.stopped; + } + + isCurrent(generation: number): boolean { + return generation === this.currentGeneration; + } + + register(id: symbol, invalidate: InvalidateInterception): void { + this.active.set(id, invalidate); + } + + unregister(id: symbol): void { + this.active.delete(id); + } + + reset(): void { + this.invalidate(false); + } + + shutdown(): boolean { + if (this.stopped) return false; + this.stopped = true; + this.invalidate(true); + return true; + } + + private invalidate(abortHeldResponses: boolean): void { + this.currentGeneration += 1; + for (const invalidate of [...this.active.values()]) { + invalidate(abortHeldResponses); + } + } +} diff --git a/apps/xtream-mock-server/src/app/performance-manifest.ts b/apps/xtream-mock-server/src/app/performance-manifest.ts new file mode 100644 index 000000000..4af2cd847 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-manifest.ts @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto'; +import { getPortalData } from './data-store.js'; +import type { PerformanceManifest } from './performance-control.types.js'; + +const PERFORMANCE_USERNAME = 'performance'; +const PERFORMANCE_PASSWORD = 'performance'; + +/** + * The property insertion order is part of the fixture identity contract. + * Never add credentials, server origins, or response envelopes to this input. + */ +export function buildPerformanceManifest(epoch: number): PerformanceManifest { + const data = getPortalData(PERFORMANCE_USERNAME, PERFORMANCE_PASSWORD); + const catalog = { + liveCategories: data.liveCategories, + vodCategories: data.vodCategories, + seriesCategories: data.seriesCategories, + liveCatalog: data.liveStreams, + vodCatalog: data.vodStreams, + seriesCatalog: data.seriesItems, + }; + const serialized = JSON.stringify(catalog); + const categoryCounts = { + live: data.liveCategories.length, + vod: data.vodCategories.length, + series: data.seriesCategories.length, + }; + const itemCounts = { + live: data.liveStreams.length, + vod: data.vodStreams.length, + series: data.seriesItems.length, + }; + + return { + epoch, + scenario: 'performance-100k', + seed: data.scenario.seed, + counts: { + categories: { + ...categoryCounts, + total: + categoryCounts.live + + categoryCounts.vod + + categoryCounts.series, + }, + items: { + ...itemCounts, + total: itemCounts.live + itemCounts.vod + itemCounts.series, + }, + }, + bytes: Buffer.byteLength(serialized, 'utf8'), + catalogSha256: createHash('sha256') + .update(serialized, 'utf8') + .digest('hex'), + }; +} diff --git a/apps/xtream-mock-server/src/app/performance-occurrences.spec.ts b/apps/xtream-mock-server/src/app/performance-occurrences.spec.ts new file mode 100644 index 000000000..e53d2ac88 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-occurrences.spec.ts @@ -0,0 +1,108 @@ +import type { Request } from 'express'; +import { PerformanceOccurrenceTracker } from './performance-occurrences.js'; +import { PERFORMANCE_CONTROL_LIMITS } from './performance-control.types.js'; + +describe('PerformanceOccurrenceTracker', () => { + it('does not restart an old valid identity after more than 128 peers', () => { + const tracker = new PerformanceOccurrenceTracker(); + const firstRequest = requestFor('get_live_streams', '91101'); + + expect(tracker.next(firstRequest, 'direct', 1).occurrence).toBe(1); + for (let category = 91_102; category <= 91_160; category++) { + tracker.next( + requestFor('get_live_streams', String(category)), + 'direct', + 1 + ); + } + for (let category = 91_101; category <= 91_160; category++) { + tracker.next( + requestFor('get_live_streams', String(category)), + 'proxy', + 1 + ); + } + for (let category = 92_101; category <= 92_120; category++) { + tracker.next( + requestFor('get_vod_streams', String(category)), + 'direct', + 1 + ); + } + + expect(tracker.snapshot()).toHaveLength(140); + expect(tracker.next(firstRequest, 'direct', 1).occurrence).toBe(2); + }); + + it('collapses non-canonical category aliases into one bounded identity', () => { + const tracker = new PerformanceOccurrenceTracker(); + const canonical = tracker.next( + requestFor('get_live_streams', '91101'), + 'direct', + 1 + ); + const aliases = [ + '+91101', + ' 91101', + '91101 ', + '9.1101e4', + '9007199254740993', + ]; + + expect(canonical).toMatchObject({ + categoryId: '91101', + occurrence: 1, + }); + aliases.forEach((categoryId, index) => { + expect( + tracker.next( + requestFor('get_live_streams', categoryId), + 'direct', + 1 + ) + ).toMatchObject({ + categoryId: 'all', + occurrence: index + 1, + }); + }); + for ( + let zeroCount = 1; + zeroCount <= PERFORMANCE_CONTROL_LIMITS.occurrences + 1; + zeroCount++ + ) { + tracker.next( + requestFor('get_live_streams', `${'0'.repeat(zeroCount)}91101`), + 'direct', + 1 + ); + } + + expect(tracker.snapshot()).toHaveLength(2); + expect(tracker.snapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + categoryId: '91101', + count: 1, + }), + expect.objectContaining({ + categoryId: 'all', + count: + aliases.length + + PERFORMANCE_CONTROL_LIMITS.occurrences + + 1, + }), + ]) + ); + }); +}); + +function requestFor(action: string, categoryId: string): Request { + return { + query: { + username: 'performance', + password: 'performance', + action, + category_id: categoryId, + }, + } as unknown as Request; +} diff --git a/apps/xtream-mock-server/src/app/performance-occurrences.ts b/apps/xtream-mock-server/src/app/performance-occurrences.ts new file mode 100644 index 000000000..dfffe7a19 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-occurrences.ts @@ -0,0 +1,104 @@ +import type { Request } from 'express'; +import { + canonicalObservedAction, + isAllowedPerformanceCategory, + PerformanceControlError, +} from './performance-control-validation.js'; +import { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceAction, + type PerformanceIdentity, + type PerformanceOccurrenceState, + type PerformanceRuleMatch, + type PerformanceTransport, +} from './performance-control.types.js'; +import { getScenario } from './scenarios.js'; + +export class PerformanceOccurrenceTracker { + private readonly entries = new Map(); + + next( + request: Request, + transport: PerformanceTransport, + epoch: number + ): PerformanceIdentity { + const username = stringQuery(request.query['username']); + const password = stringQuery(request.query['password']); + const scenario = getScenario(username, password).name; + const action = canonicalObservedAction(request.query['action']); + const rawCategoryId = stringQuery(request.query['category_id']); + const categoryId = + scenario === 'performance-100k' && + action !== 'unknown' && + rawCategoryId !== 'all' && + isAllowedPerformanceCategory(action, rawCategoryId) + ? rawCategoryId + : 'all'; + const base = { scenario, transport, action, categoryId }; + const key = baseIdentityKey(base); + const previous = this.entries.get(key); + if ( + !previous && + this.entries.size >= PERFORMANCE_CONTROL_LIMITS.occurrences + ) { + throw new PerformanceControlError(503, 'control-occurrence-limit'); + } + const count = (previous?.count ?? 0) + 1; + this.entries.set(key, { ...base, count }); + return { epoch, ...base, occurrence: count }; + } + + count(match: PerformanceRuleMatch): number { + return this.entries.get(baseIdentityKey(match))?.count ?? 0; + } + + snapshot(): PerformanceOccurrenceState[] { + return [...this.entries.values()]; + } + + clear(): void { + this.entries.clear(); + } +} + +export function ruleMatchKey(match: PerformanceRuleMatch): string { + return JSON.stringify([ + match.scenario, + match.transport, + match.action, + match.categoryId, + match.occurrence, + ]); +} + +export function identityKey(identity: PerformanceIdentity): string { + return JSON.stringify([ + identity.scenario, + identity.transport, + identity.action, + identity.categoryId, + identity.occurrence, + ]); +} + +function baseIdentityKey( + identity: + | Omit + | { + scenario: string; + transport: PerformanceTransport; + action: PerformanceAction | 'unknown'; + categoryId: string; + } +): string { + return JSON.stringify([ + identity.scenario, + identity.transport, + identity.action, + identity.categoryId, + ]); +} + +function stringQuery(value: unknown): string { + return typeof value === 'string' ? value : ''; +} diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts new file mode 100644 index 000000000..ace0639fb --- /dev/null +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('Xtream mock Nx serve environment', () => { + it.each(['serve', 'serve-with-watch'])( + 'lets the caller select PORT for the %s target', + (targetName) => { + const project = JSON.parse( + readFileSync( + join(process.cwd(), 'apps/xtream-mock-server/project.json'), + 'utf8' + ) + ) as { + targets: Record< + string, + { options: { env?: Record } } + >; + }; + + expect(project.targets[targetName]?.options.env).not.toHaveProperty( + 'PORT' + ); + expect(project.targets[targetName]?.options.env).toMatchObject({ + NODE_ENV: 'development', + }); + } + ); +}); diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 94c683108..8212b9212 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -17,6 +17,10 @@ export interface ScenarioConfig { vodDetailsFixture?: 'empty-metadata'; /** Optional fictional release-marketing dataset with local demo artwork. */ marketingFixture?: true; + /** Optional deterministic catalog profile reserved for performance tests. */ + performanceFixture?: 'catalog-100k'; + /** Build series details on demand instead of during portal initialization. */ + deferSeriesDetails?: true; } /** @@ -59,9 +63,23 @@ export const SCENARIOS: Record = { accountStatus: 'Active', expiryDate: '2099-12-31', }, + 'performance:performance': { + name: 'performance-100k', + description: 'Deterministic local-only 100k performance catalog', + seed: 91001, + categoryCount: { live: 60, vod: 20, series: 20 }, + itemsPerCategory: 1000, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + performanceFixture: 'catalog-100k', + deferSeriesDetails: true, + }, 'series:series': { name: 'series-heavy', - description: 'Series-heavy — 15 series categories, 6 seasons × 10 episodes', + description: + 'Series-heavy — 15 series categories, 6 seasons × 10 episodes', seed: 2002, categoryCount: { live: 3, vod: 4, series: 15 }, itemsPerCategory: 30, @@ -147,11 +165,18 @@ export const SCENARIOS: Record = { /** Convert credential pair to a numeric seed for unknown credentials. */ export function credentialsToSeed(username: string, password: string): number { const str = `${username}:${password}`; - return str.split('').reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) | 0, 0) >>> 0; + return ( + str + .split('') + .reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) | 0, 0) >>> 0 + ); } /** Return the scenario config for a given username+password pair. */ -export function getScenario(username: string, password: string): ScenarioConfig { +export function getScenario( + username: string, + password: string +): ScenarioConfig { const key = `${username}:${password}`; if (SCENARIOS[key]) return SCENARIOS[key]; return { diff --git a/apps/xtream-mock-server/src/app/server-lifecycle.ts b/apps/xtream-mock-server/src/app/server-lifecycle.ts new file mode 100644 index 000000000..28ab336d8 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server-lifecycle.ts @@ -0,0 +1,55 @@ +import type { Server } from 'node:http'; + +const SHUTDOWN_DEADLINE_MS = 1_000; + +export interface XtreamMockShutdownApplication { + shutdown(): unknown; +} + +export function createXtreamMockServerShutdown( + server: Server, + application: XtreamMockShutdownApplication +): () => Promise { + let shutdownPromise: Promise | undefined; + return () => { + shutdownPromise ??= beginShutdown(server, application); + return shutdownPromise; + }; +} + +function beginShutdown( + server: Server, + application: XtreamMockShutdownApplication +): Promise { + try { + application.shutdown(); + } catch { + // HTTP shutdown must remain bounded even if an application hook fails. + } + return closeHttpServer(server); +} + +function closeHttpServer(server: Server): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(deadline); + resolve(); + }; + const deadline = setTimeout(() => { + server.closeAllConnections(); + finish(); + }, SHUTDOWN_DEADLINE_MS); + deadline.unref(); + + try { + server.close(() => finish()); + server.closeIdleConnections(); + server.closeAllConnections(); + } catch { + finish(); + } + }); +} diff --git a/apps/xtream-mock-server/src/app/server-shutdown.spec.ts b/apps/xtream-mock-server/src/app/server-shutdown.spec.ts new file mode 100644 index 000000000..c37324c85 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server-shutdown.spec.ts @@ -0,0 +1,160 @@ +import { type AddressInfo } from 'node:net'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { PerformanceControlState } from './performance-control.js'; +import { + createXtreamMockApp, + createXtreamMockServerShutdown, +} from './server.js'; +import { + openAbortableGet, + postJson, + requestJson, + waitFor, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'shutdown-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; + +describe('Xtream mock HTTP shutdown', () => { + it.each([ + ['barrier', 'blocked'], + ['delay', 'delayed'], + ] as const)( + 'settles an active %s and releases its listener', + async (kind, lifecycleStatus) => { + const app = createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }); + const server = createServer(app); + await listen(server); + const address = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${address.port}`; + let heldClient: ReturnType | undefined; + + try { + await createRule(origin, kind); + heldClient = openAbortableGet(origin, catalogPath()); + await waitFor(async () => { + const snapshot = await state(origin); + expect( + snapshot.ledger.map((entry) => entry.status) + ).toContain(lifecycleStatus); + }); + + let shutdownState: PerformanceControlState | null | undefined; + const applicationShutdown = app.shutdown.bind(app); + app.shutdown = () => { + shutdownState = applicationShutdown(); + return shutdownState; + }; + const shutdown = createXtreamMockServerShutdown(server, app); + const first = shutdown(); + const second = shutdown(); + + expect(second).toBe(first); + await within(first, 2_000); + await within(heldClient.completed, 2_000); + expect(shutdownState).toMatchObject({ + heldIds: [], + heldCount: 0, + rules: [], + occurrences: [], + ledger: [], + }); + + const rebound = createServer( + (_request, response: ServerResponse) => response.end('ok') + ); + try { + await listen(rebound, address.port); + } finally { + await close(rebound); + } + } finally { + heldClient?.abort(); + server.closeAllConnections(); + if (server.listening) await close(server); + } + } + ); +}); + +async function createRule( + origin: string, + kind: 'barrier' | 'delay' +): Promise { + const result = await postJson( + origin, + kind === 'barrier' ? '/__control/barriers' : '/__control/delays', + TOKEN, + { + id: `shutdown-${kind}`, + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId: '91101', + }, + ...(kind === 'delay' ? { milliseconds: 5_000 } : {}), + } + ); + expect(result.response.status).toBe(201); +} + +async function state(origin: string): Promise { + const result = await requestJson(origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} + +function catalogPath(): string { + return '/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101'; +} + +function listen(server: Server, port = 0): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} + +async function within( + promise: Promise, + milliseconds: number +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('operation exceeded deadline')), + milliseconds + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts new file mode 100644 index 000000000..a4e899137 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -0,0 +1,225 @@ +import { connect } from 'node:net'; +import { resetAll } from './data-store.js'; +import { + createXtreamMockApp, + parseXtreamMockServerEnvironment, +} from './server.js'; +import { startLoopbackServer } from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +jest.setTimeout(60_000); + +describe('Xtream mock server factory', () => { + afterEach(() => { + resetAll(); + jest.restoreAllMocks(); + }); + + it('keeps performance controls absent unless explicitly enabled', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const response = await fetch(`${running.origin}/__control/state`); + expect(response.status).toBe(404); + } finally { + await running.close(); + } + }); + + it('reports the configured port and preserves direct/PWA responses', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const health = await fetch(`${running.origin}/health`).then((r) => + r.json() + ); + const direct = await fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_categories` + ); + const directBody = await direct.json(); + const proxy = await fetch( + `${running.origin}/xtream?url=http%3A%2F%2Fignored.invalid&username=performance&password=performance&action=get_live_categories` + ); + const proxyBody = await proxy.json(); + + expect(health).toEqual({ + status: 'ok', + server: 'xtream-mock-server', + port: 0, + }); + expect(direct.status).toBe(200); + expect(directBody).toHaveLength(60); + expect(proxy.status).toBe(200); + expect(proxyBody).toEqual({ + payload: directBody, + action: 'get_live_categories', + }); + } finally { + await running.close(); + } + }); + + it('preserves the basic M3U fixture and ordinary stream redirects', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const playlist = await fetch(`${running.origin}/playlist.m3u`); + const stream = await fetch( + `${running.origin}/live/user1/pass1/10000.m3u8`, + { redirect: 'manual' } + ); + + expect(playlist.status).toBe(200); + expect(playlist.headers.get('content-type')).toContain( + 'audio/x-mpegurl' + ); + expect(await playlist.text()).toContain('#EXTM3U'); + expect(stream.status).toBe(302); + expect(stream.headers.get('location')).toBe( + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8' + ); + } finally { + await running.close(); + } + }); + + it('rejects non-loopback performance binds before opening a listener', () => { + expect(() => + createXtreamMockApp({ + control: { enabled: true, token: 'test-token' }, + host: '0.0.0.0', + port: 0, + }) + ).toThrow(/loopback/i); + }); + + it('makes local-only performance media routes terminal without outbound requests', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: 'test-token' }, + host: '127.0.0.1', + port: 0, + }) + ); + try { + const httpModule = + jest.requireActual('node:http'); + const httpsModule = + jest.requireActual('node:https'); + const httpRequest = jest.spyOn(httpModule, 'request'); + const httpsRequest = jest.spyOn(httpsModule, 'request'); + const globalFetch = jest.spyOn(globalThis, 'fetch'); + const mediaResponses = await Promise.all( + [ + '/playlist.m3u', + '/live/performance/performance/1000000.m3u8', + '/live/performance/performance/1000000.ts', + '/movie/performance/performance/2000000.mkv', + '/series/performance/performance/3000000.mkv', + '/timeshift/performance/performance/60/start/1000000.ts', + '/streaming/timeshift.php?username=performance&password=performance', + ].map((path) => rawLoopbackGet(running.origin, path)) + ); + + expect(mediaResponses.map(({ status }) => status)).toEqual( + Array(mediaResponses.length).fill(410) + ); + expect( + mediaResponses.every( + ({ headers }) => !headers.includes('location:') + ) + ).toBe(true); + expect(httpRequest).not.toHaveBeenCalled(); + expect(httpsRequest).not.toHaveBeenCalled(); + expect(globalFetch).not.toHaveBeenCalled(); + } finally { + await running.close(); + } + }); +}); + +describe('Xtream mock environment parsing', () => { + it('uses safe defaults and enables control only for the exact flag', () => { + expect(parseXtreamMockServerEnvironment({})).toEqual({ + host: '0.0.0.0', + port: 3211, + }); + expect( + parseXtreamMockServerEnvironment({ + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + }) + ).toEqual({ + control: { enabled: true, token: 'secret-token' }, + host: '127.0.0.1', + port: 3211, + }); + expect( + parseXtreamMockServerEnvironment({ + HOST: '::1', + PORT: '4321', + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + }) + ).toEqual({ + control: { enabled: true, token: 'secret-token' }, + host: '::1', + port: 4321, + }); + expect( + parseXtreamMockServerEnvironment({ + IPTVNATOR_XTREAM_MOCK_CONTROL: 'true', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored', + }) + ).toEqual({ host: '0.0.0.0', port: 3211 }); + }); + + it.each([ + [{ PORT: '-1' }, /port/i], + [{ PORT: '12x' }, /port/i], + [{ PORT: '65536' }, /port/i], + [{ IPTVNATOR_XTREAM_MOCK_CONTROL: '1' }, /token/i], + [ + { + HOST: '192.0.2.1', + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + }, + /loopback/i, + ], + ])('rejects invalid environment %#', (environment, message) => { + expect(() => parseXtreamMockServerEnvironment(environment)).toThrow( + message + ); + }); +}); + +async function rawLoopbackGet( + origin: string, + path: string +): Promise<{ headers: string; status: number }> { + const url = new URL(origin); + return new Promise((resolve, reject) => { + const socket = connect(Number(url.port), url.hostname); + let raw = ''; + socket.setEncoding('utf8'); + socket.once('error', reject); + socket.on('data', (chunk) => (raw += chunk)); + socket.once('end', () => { + const [headers = ''] = raw.split('\r\n\r\n'); + const status = Number(headers.split(' ')[1]); + resolve({ headers: headers.toLowerCase(), status }); + }); + socket.once('connect', () => { + socket.write( + `GET ${path} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n` + ); + }); + }); +} diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts new file mode 100644 index 000000000..a5aebc464 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server.ts @@ -0,0 +1,274 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import cors from 'cors'; +import express, { + type NextFunction, + type Request, + type Response, +} from 'express'; +import { resetAll } from './data-store.js'; +import { renderMarketingAssetSvg } from './generators/marketing.generator.js'; +import { installPerformanceControlRoutes } from './performance-control-routes.js'; +import { + type PerformanceControlState, + XtreamPerformanceController, +} from './performance-control.js'; +import { dispatchAction } from './routes/dispatch.js'; + +export { createXtreamMockServerShutdown } from './server-lifecycle.js'; + +export interface XtreamMockServerOptions { + readonly control?: { + readonly enabled: boolean; + readonly token: string; + }; + readonly host: string; + readonly port: number; +} + +export interface XtreamMockApplication extends express.Express { + shutdown(): PerformanceControlState | null; +} + +const M3U_FIXTURE = `#EXTM3U +#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 +https://example.channels/path-to-file/1.m3u8 +#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV +https://example.channels/path-to-file/2.m3u8 +#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX +https://example.channels/path-to-file/3.m3u8 +#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV +https://example.channels/path-to-file/4.m3u8 +`; +const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; +const DEFAULT_PORT = 3211; +const DEFAULT_NORMAL_HOST = '0.0.0.0'; +const DEFAULT_CONTROL_HOST = '127.0.0.1'; +const PERFORMANCE_USERNAME = 'performance'; +const PERFORMANCE_PASSWORD = 'performance'; +const marketingRasterAssetRoot = join( + process.cwd(), + 'apps/xtream-mock-server/public/marketing' +); + +export function createXtreamMockApp( + options: XtreamMockServerOptions +): XtreamMockApplication { + validateServerOptions(options); + const app = express() as XtreamMockApplication; + const controlEnabled = options.control?.enabled === true; + const controller = controlEnabled + ? new XtreamPerformanceController() + : undefined; + app.shutdown = () => controller?.shutdown() ?? null; + + if (controller && options.control) { + installPerformanceControlRoutes(app, controller, options.control.token); + } + app.use(cors()); + app.use(express.json()); + + app.get('/health', (_request, response) => { + response.json({ + status: 'ok', + server: 'xtream-mock-server', + port: options.port, + }); + }); + app.post('/reset', (_request, response) => { + if (controlEnabled) { + response + .status(410) + .json({ error: 'use-performance-control-reset' }); + return; + } + resetAll(); + response.json({ status: 'reset' }); + }); + app.get('/playlist.m3u', (_request, response) => { + if (controlEnabled) { + response.status(410).json({ error: 'performance-media-disabled' }); + return; + } + response + .type('audio/x-mpegurl') + .set('Cache-Control', 'no-store') + .send(M3U_FIXTURE); + }); + installMarketingAssetRoute(app); + + app.get('/player_api.php', (request, response, next) => { + dispatchWithControl(controller, request, response, next, 'direct', () => + dispatchAction(request, response) + ); + }); + installStreamRoutes(app, controlEnabled); + app.get('/xtream', (request, response, next) => { + dispatchWithControl(controller, request, response, next, 'proxy', () => + dispatchProxyAction(request, response) + ); + }); + + return app; +} + +export function parseXtreamMockServerEnvironment( + environment: NodeJS.ProcessEnv +): XtreamMockServerOptions { + const rawPort = environment['PORT'] ?? String(DEFAULT_PORT); + if (!/^\d+$/.test(rawPort)) { + throw new Error('Xtream mock port must be an integer'); + } + const port = Number(rawPort); + const controlEnabled = environment['IPTVNATOR_XTREAM_MOCK_CONTROL'] === '1'; + const host = + environment['HOST'] ?? + (controlEnabled ? DEFAULT_CONTROL_HOST : DEFAULT_NORMAL_HOST); + const token = environment['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ?? ''; + const options: XtreamMockServerOptions = { + ...(controlEnabled + ? { control: { enabled: true, token } as const } + : {}), + host, + port, + }; + validateServerOptions(options); + return options; +} + +function validateServerOptions(options: XtreamMockServerOptions): void { + if ( + !Number.isInteger(options.port) || + options.port < 0 || + options.port > 65_535 + ) { + throw new Error('Xtream mock port must be in 0..65535'); + } + if (!options.host) throw new Error('Xtream mock host is required'); + if (!options.control?.enabled) return; + if (options.control.token.trim().length === 0) { + throw new Error('Xtream performance control token is required'); + } + if (options.host !== '127.0.0.1' && options.host !== '::1') { + throw new Error( + 'Xtream performance control requires a loopback bind host' + ); + } +} + +function dispatchWithControl( + controller: XtreamPerformanceController | undefined, + request: Request, + response: Response, + next: NextFunction, + transport: 'direct' | 'proxy', + dispatch: () => void +): void { + if (!controller) { + dispatch(); + return; + } + void controller + .intercept(request, response, transport, dispatch) + .catch(next); +} + +function dispatchProxyAction(request: Request, response: Response): void { + const { url, action, ...rest } = request.query as Record; + void url; + const syntheticRequest = { + query: { action, ...rest }, + headers: request.headers, + params: {}, + } as unknown as Request; + let payload: unknown; + let statusCode = 200; + const syntheticResponse = { + json(data: unknown) { + payload = data; + return this; + }, + status(code: number) { + statusCode = code; + return this; + }, + } as unknown as Response; + + dispatchAction(syntheticRequest, syntheticResponse); + if (statusCode !== 200) { + response.status(statusCode).json({ error: payload }); + return; + } + response.json({ payload, action }); +} + +function installMarketingAssetRoute(app: express.Express): void { + app.get('/assets/marketing/:kind/:slug', (request, response) => { + const kind = request.params['kind'] as + 'backdrop' | 'episode' | 'logo' | 'poster' | 'season'; + const slug = request.params['slug'] ?? ''; + const size = + typeof request.query['size'] === 'string' + ? request.query['size'] + : undefined; + if ( + !['backdrop', 'episode', 'logo', 'poster', 'season'].includes(kind) + ) { + response.status(404).send('Unknown marketing asset kind'); + return; + } + const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); + const rasterPath = join( + marketingRasterAssetRoot, + kind, + `${rasterSlug}.png` + ); + if (existsSync(rasterPath)) { + response + .type('image/png') + .set('Cache-Control', 'public, max-age=3600') + .send(readFileSync(rasterPath)); + return; + } + response + .type('image/svg+xml') + .set('Cache-Control', 'public, max-age=3600') + .send(renderMarketingAssetSvg(kind, slug, size)); + }); +} + +function installStreamRoutes( + app: express.Express, + controlEnabled: boolean +): void { + const streamResponse = (request: Request, response: Response) => { + if (isPerformanceMediaRequest(request, controlEnabled)) { + response.status(410).json({ error: 'performance-media-disabled' }); + return; + } + response.redirect(HLS_STUB); + }; + app.get('/live/:username/:password/:streamId.m3u8', streamResponse); + app.get('/live/:username/:password/:streamId.ts', streamResponse); + app.get('/movie/:username/:password/:streamId.:ext', streamResponse); + app.get('/series/:username/:password/:streamId.:ext', streamResponse); + app.all( + '/timeshift/:username/:password/:duration/:start/:streamId.ts', + streamResponse + ); + app.all('/streaming/timeshift.php', streamResponse); +} + +function isPerformanceMediaRequest( + request: Request, + controlEnabled: boolean +): boolean { + if (!controlEnabled) return false; + const username = + request.params['username'] ?? request.query['username'] ?? ''; + const password = + request.params['password'] ?? request.query['password'] ?? ''; + return ( + username === PERFORMANCE_USERNAME && password === PERFORMANCE_PASSWORD + ); +} diff --git a/apps/xtream-mock-server/src/app/testing/http-server.fixture.ts b/apps/xtream-mock-server/src/app/testing/http-server.fixture.ts new file mode 100644 index 000000000..6629f1a53 --- /dev/null +++ b/apps/xtream-mock-server/src/app/testing/http-server.fixture.ts @@ -0,0 +1,126 @@ +import { type AddressInfo } from 'node:net'; +import { + createServer, + get as httpGet, + type Server, + type ServerResponse, +} from 'node:http'; +import type express from 'express'; + +export interface RunningTestServer { + readonly origin: string; + close(): Promise; +} + +export interface AbortableRequest { + readonly completed: Promise; + abort(): void; +} + +export async function startLoopbackServer( + app: express.Express +): Promise { + const server = createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => closeServer(server), + }; +} + +export async function requestJson( + origin: string, + path: string, + init: RequestInit = {} +): Promise<{ body: unknown; response: Response }> { + const response = await fetch(`${origin}${path}`, init); + const body = await response.json(); + return { body, response }; +} + +export async function postJson( + origin: string, + path: string, + token: string, + body?: unknown +): Promise<{ body: unknown; response: Response }> { + return requestJson(origin, path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-iptvnator-performance-token': token, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +export function openAbortableGet( + origin: string, + path: string, + headers: Record = {} +): AbortableRequest { + let settled = false; + let settle!: () => void; + const completed = new Promise((resolve) => { + settle = resolve; + }); + const request = httpGet(`${origin}${path}`, { headers }, (response) => { + drain(response, () => { + settled = true; + settle(); + }); + }); + request.once('error', () => { + settled = true; + settle(); + }); + + return { + completed, + abort() { + if (!settled) request.destroy(); + }, + }; +} + +export async function waitFor( + assertion: () => Promise, + timeoutMs = 2_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + throw lastError; +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); +} + +function drain( + response: ServerResponse | NodeJS.ReadableStream, + done: () => void +) { + response.on('data', () => undefined); + response.once('end', done); + response.once('error', done); +} diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 648a15c34..fbfaf3ed9 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -1,201 +1,73 @@ -import http from 'http'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import express from 'express'; -import cors from 'cors'; -import { dispatchAction } from './app/routes/dispatch.js'; -import { resetAll } from './app/data-store.js'; -import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js'; +import { createServer } from 'node:http'; +import { + createXtreamMockServerShutdown, + createXtreamMockApp, + parseXtreamMockServerEnvironment, +} from './app/server.js'; -const app = express(); -const PORT = parseInt(process.env['PORT'] ?? '3211', 10); -const M3U_FIXTURE = `#EXTM3U -#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 -https://example.channels/path-to-file/1.m3u8 -#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV -https://example.channels/path-to-file/2.m3u8 -#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX -https://example.channels/path-to-file/3.m3u8 -#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV -https://example.channels/path-to-file/4.m3u8 -`; -const marketingRasterAssetRoot = join( - process.cwd(), - 'apps/xtream-mock-server/public/marketing' -); - -app.use(cors()); -app.use(express.json()); - -// ─── Health check ────────────────────────────────────────────────────────────── -app.get('/health', (_req, res) => { - res.json({ status: 'ok', server: 'xtream-mock-server', port: PORT }); -}); - -// ─── Reset all cached data ───────────────────────────────────────────────────── -app.post('/reset', (_req, res) => { - resetAll(); - res.json({ status: 'reset' }); -}); - -// ─── M3U fixture endpoint for self-hosted PWA URL import tests ─────────────── -app.get('/playlist.m3u', (_req, res) => { - res.type('audio/x-mpegurl') - .set('Cache-Control', 'no-store') - .send(M3U_FIXTURE); -}); - -// ─── Local fictional artwork for release screenshots ────────────────────────── -app.get('/assets/marketing/:kind/:slug', (req, res) => { - const kind = req.params['kind'] as - | 'backdrop' - | 'episode' - | 'logo' - | 'poster' - | 'season'; - const slug = req.params['slug'] ?? ''; - const size = - typeof req.query['size'] === 'string' ? req.query['size'] : undefined; - - if (!['backdrop', 'episode', 'logo', 'poster', 'season'].includes(kind)) { - res.status(404).send('Unknown marketing asset kind'); - return; +function start(): void { + let options; + try { + options = parseXtreamMockServerEnvironment(process.env); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Invalid configuration'; + console.error(`[xtream-mock] ${message}`); + process.exit(1); } - const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); - const rasterPath = join( - marketingRasterAssetRoot, - kind, - `${rasterSlug}.png` - ); + const application = createXtreamMockApp(options); + const server = createServer(application); + const shutdownServer = createXtreamMockServerShutdown(server, application); + const displayHost = options.host === '::1' ? '[::1]' : options.host; + let shuttingDown = false; - if (existsSync(rasterPath)) { - res.type('image/png') - .set('Cache-Control', 'public, max-age=3600') - .send(readFileSync(rasterPath)); - return; - } + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + console.error( + `[xtream-mock] Port ${options.port} is already in use.` + ); + } else { + console.error('[xtream-mock] Server error:', error.message); + } + process.exit(1); + }); - res.type('image/svg+xml') - .set('Cache-Control', 'public, max-age=3600') - .send(renderMarketingAssetSvg(kind, slug, size)); -}); + server.listen(options.port, options.host, () => { + const origin = `http://${displayHost}:${options.port}`; + console.log(`[xtream-mock] Listening on ${origin}`); + console.log(`[xtream-mock] Direct API: ${origin}/player_api.php`); + console.log(`[xtream-mock] PWA proxy: ${origin}/xtream`); + console.log(`[xtream-mock] Health: ${origin}/health`); + if (options.control?.enabled) { + console.log('[xtream-mock] Performance control enabled'); + } + }); -// ─── Direct Xtream player_api.php endpoint ───────────────────────────────────── -app.get('/player_api.php', dispatchAction); + const shutdown = () => { + if (!shuttingDown) { + shuttingDown = true; + console.log('\n[xtream-mock] Shutting down...'); + } + void shutdownServer().then(() => process.exit(0)); + }; -// ─── Stream stub endpoints (Xtream stream URLs reference these) ──────────────── -// Returns a valid HLS playlist redirect for any stream type. -// In production these would be actual video streams. -const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + process.on('uncaughtException', (error) => { + console.error('[xtream-mock] Uncaught exception:', error.message); + process.exit(1); + }); + process.on('unhandledRejection', (reason) => { + const message = + reason instanceof Error ? reason.message : 'Unknown rejection'; + console.error('[xtream-mock] Unhandled rejection:', message); + process.exit(1); + }); + process.stdin.resume(); + process.stdin.on('end', () => { + /* The HTTP server handle intentionally keeps this process alive. */ + }); +} -app.get('/live/:username/:password/:streamId.m3u8', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.get('/live/:username/:password/:streamId.ts', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.get('/movie/:username/:password/:streamId.:ext', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.get('/series/:username/:password/:streamId.:ext', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.all( - '/timeshift/:username/:password/:duration/:start/:streamId.ts', - (_req, res) => { - res.redirect(HLS_STUB); - } -); - -app.all('/streaming/timeshift.php', (_req, res) => { - res.redirect(HLS_STUB); -}); - -// ─── PWA CORS proxy endpoint ──────────────────────────────────────────────────── -// IPTVnator PWA routes Xtream calls through: -// GET /xtream?url=&action=&username=X&password=Y -// and expects: { payload: , action: } -app.get('/xtream', async (req, res) => { - const { url: _url, action, ...rest } = req.query as Record; - - const syntheticReq = { - query: { action, ...rest }, - headers: req.headers, - params: {}, - } as unknown as express.Request; - - // Capture response via monkey-patched json method - let payload: unknown; - let statusCode = 200; - - const syntheticRes = { - json(data: unknown) { - payload = data; - return this; - }, - status(code: number) { - statusCode = code; - return this; - }, - } as unknown as express.Response; - - dispatchAction(syntheticReq, syntheticRes); - - if (statusCode !== 200) { - res.status(statusCode).json({ error: payload }); - return; - } - - res.json({ payload, action }); -}); - -// ─── Server lifecycle ────────────────────────────────────────────────────────── -const server = http.createServer(app); - -server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - console.error(`[xtream-mock] Port ${PORT} is already in use.`); - } else { - console.error('[xtream-mock] Server error:', err.message); - } - process.exit(1); -}); - -server.listen(PORT, () => { - console.log(`[xtream-mock] Listening on http://localhost:${PORT}`); - console.log( - `[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info` - ); - console.log( - `[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info` - ); - console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`); -}); - -const shutdown = () => { - console.log('\n[xtream-mock] Shutting down...'); - server.close(() => process.exit(0)); -}; - -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); -process.on('uncaughtException', (err) => { - console.error('[xtream-mock] Uncaught exception:', err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error('[xtream-mock] Unhandled rejection:', reason); - process.exit(1); -}); - -// When Nx (or any process manager) closes stdin, prevent auto-exit. -// The HTTP server handle is what keeps the process alive. -process.stdin.resume(); -process.stdin.on('end', () => { - /* ignore stdin close */ -}); +start(); diff --git a/apps/xtream-mock-server/tsconfig.spec.json b/apps/xtream-mock-server/tsconfig.spec.json new file mode 100644 index 000000000..f1a044453 --- /dev/null +++ b/apps/xtream-mock-server/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"], + "strict": true + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 56504eb66..f8006d8e6 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -172,6 +172,54 @@ explicit sum is `ipcStructuredCloneProxyMs`. See [M3U Playlist Module Architecture](./m3u-playlist-module.md#initial-url-import-performance-benchmark-electron) for the complete cross-process attribution. +The same request envelope exposes count-only Xtream database phases for these +operations: + +- `DB_GET_CATEGORIES`: `sqlite.categories.read` +- `DB_SAVE_CATEGORIES`: `normalize.categories`, then + `sqlite.categories.write-transactions` +- `DB_GET_CONTENT`: `sqlite.content.read` +- `DB_SAVE_CONTENT`: `sqlite.content.category-map-read`, then + `normalize.content`, then `sqlite.content.write-transactions` +- `DB_CLEAR_XTREAM_IMPORT_CACHE`: + `sqlite.xtream-cache-clear.write-transactions` +- `DB_SEARCH_CONTENT`: `sqlite.search.query`, then `normalize.search-rank` +- `DB_DELETE_XTREAM_CONTENT`: + `sqlite.xtream-delete.collect-user-data`, then + `sqlite.xtream-delete.write-transactions` +- `DB_DELETE_PLAYLIST`: `sqlite.playlist-delete.collect-ids`, then + `sqlite.playlist-delete.write-transactions` + +Read and query phases cover the exact awaited Drizzle query. Normalization +phases cover the existing synchronous transforms. The content-write phase is +one aggregate pair around every existing 100-row transaction, cancellation +checkpoint, and progress callback; it is not an exact measurement of SQLite +commit time. Cache clear similarly uses one aggregate pair around all existing +content and category chunk loops and transactions, including their JavaScript +and autocommit overhead. A zero-category cache clear still emits one pair with +`itemCount: 0` and performs no extra SQL. + +The Xtream-delete collection span includes its existing ordered category, +favorite, recently-viewed, and content-ID work. Its `itemCount` deliberately +counts only content and category deletion candidates; favorite, +recently-viewed, and hidden-category user data is timed but is not added to +that count. The matching write count uses the same deletion-candidate +definition. Playlist-delete collection counts every collected favorite, +recently-viewed, playback-position, download, content, and category ID. Its +write count adds the final playlist row. Both write spans include every +existing cooperative checkpoint, 100-row transaction, progress callback, and, +for playlist deletion, the final playlist-row autocommit; they are not exact +SQLite commit-time measurements. + +Successful end markers carry only row/item counts. Error or cancellation still +closes the active phase without metadata and preserves the original error. +Disabled profiling passes no adapter into the operations, so it performs no +phase-event or metadata-callback allocation. Worker concurrency, SQL, chunk +sizes, transaction boundaries, progress ordering, and cancellation checkpoints +are unchanged. `DB_GLOBAL_SEARCH` is deliberately not instrumented: it +interleaves queries and ranking across sources, so these single-query phases +would be misleading. + Formal initial-import comparison also requires both request-scoped captures in every measured run to have coherent event-loop delay, event-loop utilization, and worker-thread CPU values with no unavailable or invalid reason. Summary @@ -266,6 +314,15 @@ If a worker operation is canceled: Cancellation is cooperative and chunk-based. Already committed SQLite batches stay committed. +With the exact `IPTVNATOR_PERF_WORKER_PROFILING=1` opt-in, receipt of a cancel +for a correlated active request also emits a +`performance-cancel-received` worker diagnostic containing only safe +operation/request IDs and its epoch. The functional cancellation flag is set +first, this receipt remains distinct from the later authoritative +`cancelled` event, and `DatabaseWorkerClient` ignores it without settling or +exposing the pending request. Disabled profiling performs no receipt clock or +transport work, and `DatabaseWorkerClient.cancel()` remains fire-and-return. + ## Renderer Contract The preload bridge keeps the existing database methods but adds scoped worker diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 6a96fd7b0..21450390d 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -6,7 +6,9 @@ Xtream Codes API protocol. It is used for: - **Local development** — run a full portal without a real Xtream subscription -- **E2E testing** — Playwright spins it up alongside the Angular dev server +- **E2E testing** — Playwright uses it for both web and Electron workflows +- **Performance investigation** — a locked-down loopback control plane + prepares and identifies a fixed synthetic 100k catalog before capture --- @@ -20,6 +22,9 @@ credentialsToSeed(u, p) ←── deterministic polynomial hash │ ▼ faker.seed(seed) ←── all faker calls use same seed per credentials + │ + ├── performance:performance? + │ └── index-derived generator (no Faker/runtime clock) │ ▼ generateCategories() ←── live / vod / series categories @@ -43,19 +48,28 @@ Re-requesting with the same credentials returns the exact same data until ``` apps/xtream-mock-server/ -├── project.json ← Nx targets: serve (port 3211), serve-with-watch +├── project.json ← Nx serve, watch, lint, and test targets ├── public/ │ └── marketing/ ← committed fictional release artwork PNGs ├── tsconfig.json └── src/ - ├── main.ts ← Express app bootstrap, all routes wired up + ├── main.ts ← env validation, HTTP lifecycle, safe logging └── app/ + ├── server.ts ← side-effect-free Express app factory/routes + ├── server-lifecycle.ts ← bounded, idempotent HTTP shutdown ├── scenarios.ts ← Credential → ScenarioConfig mapping ├── data-store.ts ← Lazy cache, per-credentials generation + ├── performance-control.ts ← bounded request lifecycle controller + ├── performance-interception-lifecycle.ts ← reset/shutdown generation + ├── performance-control-routes.ts ← token-gated control HTTP API + ├── performance-control-validation.ts ← strict request validation + ├── performance-control.types.ts ← safe manifest/state contracts + ├── performance-manifest.ts ← fixed-order catalog hash/counts ├── generators/ │ ├── categories.generator.ts │ ├── live.generator.ts ← Live streams + EPG listings │ ├── marketing.generator.ts ← Fictional release screenshot fixture + │ ├── performance.generator.ts ← local-only deterministic 100k fixture │ ├── vod.generator.ts ← VOD streams + VodDetails │ └── series.generator.ts ← Series items + SeriesInfo ├── handlers/ @@ -95,6 +109,134 @@ Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same Angular service code works in both environments. +### Performance control endpoint + +The control plane is absent by default. It is mounted only when +`IPTVNATOR_XTREAM_MOCK_CONTROL=1`; startup then requires a non-empty +`IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST` +(`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value +in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests. +Configuration is validated before the HTTP listener opens. Normal development +mode preserves the legacy wildcard bind when `HOST` is unset; control mode +instead defaults to `127.0.0.1` and rejects an explicitly configured +non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell +value reaches the parser; its no-value default remains `3211`. + +Use a dedicated port rather than the normal `3211` E2E server: + +```bash +HOST=127.0.0.1 \ +PORT=3221 \ +IPTVNATOR_XTREAM_MOCK_CONTROL=1 \ +IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN=local-benchmark-token \ +pnpm nx run xtream-mock-server:serve +``` + +The strict control API is: + +```text +POST /__control/prepare +POST /__control/reset +POST /__control/barriers +POST /__control/barriers/:id/release +POST /__control/delays +GET /__control/state +``` + +`prepare` accepts exactly `{"scenario":"performance-100k"}` and materializes +the fixed `performance:performance` fixture before application capture. It +returns only this manifest: + +```json +{ + "epoch": 1, + "scenario": "performance-100k", + "seed": 91001, + "counts": { + "categories": { "live": 60, "vod": 20, "series": 20, "total": 100 }, + "items": { + "live": 60000, + "vod": 20000, + "series": 20000, + "total": 100000 + } + }, + "bytes": 123, + "catalogSha256": "64-lowercase-hex-characters" +} +``` + +`bytes` is the UTF-8 byte length and `catalogSha256` is the SHA-256 of one JSON +object whose property order is fixed as: + +1. live categories +2. VOD categories +3. series categories +4. live catalog +5. VOD catalog +6. series catalog + +Credentials, server origins, response envelopes, and EPG/detail caches are not +part of the hash input. + +`reset` accepts exactly `{"mode":"observations"}` or `{"mode":"all"}`. +Observation reset clears active/held rules, per-identity occurrences, and the +ledger while retaining the prepared manifest and epoch. All-state reset also +calls the data-store reset, clears the manifest, and increments the epoch. +Either mode first advances an internal observation generation and detaches all +pre-reset response listeners, so a late `finish` or `close` cannot repopulate +the freshly cleared ledger. Held barrier/delay clients retain the safe `409` +reset response where possible; unmatched active responses are aborted. + +Barrier and delay rules have an ID plus the exact match tuple: + +```text +(epoch, scenario, transport, canonicalAction, categoryId, occurrence) +``` + +`transport` is `direct` or `proxy`. Empty actions canonicalize to +`get_account_info`; the dispatcher's legacy `get_simple_date_table` alias is +allowlisted. Occurrences are counted per tuple excluding occurrence, so +parallel requests for different categories cannot consume each other's rule. +Numeric category IDs must use canonical decimal spelling. Signs, whitespace, +leading zeroes, non-decimal notation, unsafe integers, and values outside the +closed scenario ranges are rejected; invalid incoming aliases collapse to the +single `all` observation identity and cannot expand the bounded state map. +Rules are one-shot and match before dispatch or JSON serialization. Barrier +lifecycle is `arrived → blocked → responded|aborted`; delay lifecycle is +`arrived → delayed → responded|aborted`. Real request abort events release held +state; a normally consumed request's `close` event is not treated as an abort. + +The state response contains only epoch, the safe manifest, active rules, held +IDs/count, bounded occurrence counts, and bounded lifecycle entries with +monotonic timestamps. It never stores or returns the token, username, password, +raw URL/query, request/response payload, titles, or catalog arrays. Unknown +incoming actions are represented only as `unknown`. + +Control JSON is limited to 16 KiB. Bodies reject unknown/missing/wrong-type +fields. Each observation epoch accepts at most 32 rules, and the serialized +ledger retains at most 128 entries. Occurrence state has 512 slots: enough for +the complete closed scenario/action/category/transport identity domain without +eviction or counter restart; an unexpected overflow fails closed. IDs, +scenarios, transports, actions, categories, occurrences, and delays +(`0..5000` ms) use closed validation. Duplicate IDs/matches and already-past +occurrences fail closed. + +The legacy unauthenticated `POST /reset` remains available in normal mode. It +returns `410` while the control plane is enabled, preventing cache invalidation +that would leave the controller's prepared manifest stale. Performance runs +must use the token-authenticated `/__control/reset`. + +Barriers and delays exist for deterministic coordination and smoke tests only. +Formal performance captures must prove that both rule sets are empty and must +never add an artificial delay to the timed application path. + +`SIGINT` and `SIGTERM` use one bounded, idempotent shutdown path. The +application hook invalidates active observations, settles barriers and delays, +and clears their timers/listeners before the HTTP listener closes. The shared +HTTP helper then closes idle and active connections, so an unreleased control +rule cannot keep the mock process alive. + ### M3U fixture endpoint ``` @@ -113,8 +255,12 @@ GET /movie///. GET /series///. ``` -All redirect to a publicly available HLS test stream -(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). +In normal mode all redirect to a publicly available HLS test stream +(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). With the control plane +enabled, every stream/timeshift route for the performance credentials returns +`410` without a redirect or outbound request. `/playlist.m3u` is also `410` in +that mode. This keeps formal performance fixtures local-only; normal +control-disabled E2E behavior is unchanged. --- @@ -242,19 +388,20 @@ sometimes only respond to that misspelled action. ## Scenarios -| Key (`username:password`) | Seed | Categories | Items/cat | Account status | -| ------------------------- | ---- | ------------------------ | --------- | -------------- | -| `user1:pass1` | 1001 | 8 each | 40 | active | -| `large:large` | 9999 | 20 each | 200 | active | -| `stress:stress` | 7777 | 16 each | 120 | active | -| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | -| `minimal:minimal` | 3003 | 2 each | 5 | active | -| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | -| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active | -| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active | -| `expired:expired` | 4004 | 4 each | 10 | Expired | -| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | -| `` | hash | 6 each | 30 | active | +| Key (`username:password`) | Seed | Categories | Items/cat | Account status | +| ------------------------- | ----- | -------------------------- | --------- | -------------- | +| `user1:pass1` | 1001 | 8 each | 40 | active | +| `large:large` | 9999 | 20 each | 200 | active | +| `stress:stress` | 7777 | 16 each | 120 | active | +| `performance:performance` | 91001 | live:60, vod:20, series:20 | 1,000 | active | +| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | +| `minimal:minimal` | 3003 | 2 each | 5 | active | +| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | +| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active | +| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active | +| `expired:expired` | 4004 | 4 each | 10 | Expired | +| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | +| `` | hash | 6 each | 30 | active | ### `epg:epg` fixture details @@ -296,6 +443,91 @@ This scenario is reserved for release screenshots and marketing materials: - the SVG renderer in `marketing.generator.ts` remains the fallback for missing assets, live logos, season covers, and episode thumbnails +### `performance:performance` fixture details + +This scenario is reserved for local performance captures: + +- exactly 60 live categories/60,000 channels, 20 VOD categories/20,000 movies, + and 20 series categories/20,000 series +- exactly 1,000 catalog items in every category +- fixed IDs, timestamps, ratings, names, and one lazy season/episode generated + from indexes rather than Faker, `Date.now()`, or `Math.random()` +- empty/local-only artwork and direct-source fields +- VOD and series detail records are materialized only on request +- preparation and manifest hashing happen before the timed application request + +### Electron Xtream performance benchmark + +Run a formal benchmark from a clean worktree with the standard CDP endpoint +available at `127.0.0.1:9222`: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=baseline \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +The target starts an isolated control-enabled mock server on +`127.0.0.1:3221` and uses only the synthetic `performance:performance` +credentials and fixture. It covers initial import, refresh, deletion, +cancellation during import, and navigation/search/UI interaction during a +background operation. Every scenario has one warm-up, five measured +iterations, and one diagnostic iteration. Only the five measured iterations +of a clean formal run can have `validForComparison: true`. + +Capture setup and operation measurement are separate boundaries. The harness +first installs and arms the main, renderer, and worker capture infrastructure; +it then starts the main measurement and renderer operation window immediately +before triggering the user operation. Profiler attachment, fixture preparation, +and other setup outside that operation window are not application work. Because +`database.worker` is persistent but created lazily, the harness awaits the +read-only `window.electron.dbGetAppPlaylists()` preload call immediately after +installing main capture. Its result is discarded: this pre-arm only guarantees +that the exact worker can be profiled and happens before seed or measured +capture. + +Diagnostic profiles are evidence envelopes, not comparison totals. Starting +Chromium tracing and the CPU profilers can add a short pre-trigger setup segment +that contains no application workload; the raw boundary timestamps preserve +that segment explicitly. Formal summaries compare only the five measured runs, +never warm-up or diagnostic totals. + +CPU scopes in the summary are intentionally not interchangeable. Electron main +CPU uses `process.threadCpuUsage()` and is labeled +`electron-main-thread`. Database-worker CPU is the sum of thread CPU from valid, +non-overlapping request work and is labeled +`sum-valid-request-thread-cpu`; it is neither process-wide CPU nor a whole-worker +sample. + +Scenarios that need an existing portal seed it outside the measured iteration. +Seed completion is authoritative only after a successful +`store.xtream-import-terminal` renderer phase marker, the matching terminal DOM +gate, and two consecutive painted `requestAnimationFrame` frames. The harness +then verifies main-process settlement before rolling over to the measured +capture; merely seeing the catalog or loading overlay disappear is not a seed +terminal. + +For an end-to-end wiring check, run smoke mode with one comparison-ineligible +measured iteration per scenario. An unused loopback CDP port may be selected +when the formal port is occupied: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream-smoke" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=smoke \ +IPTVNATOR_PERF_SMOKE=1 \ +IPTVNATOR_PERF_CDP_PORT=9322 \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +The manifest, summary, per-iteration JSON, traces, CPU profiles, and heap +snapshots stay under the git-ignored `dist/performance/` tree. Never commit +those artifacts, and never substitute real credentials, portal URLs, or +provider data. + --- ## Playwright Integration @@ -337,4 +569,5 @@ await page.route('**/localhost:3000/xtream**', async (route) => { `OPENAI_API_KEY=... pnpm release:artwork:generate`, inspect the generated PNGs, and finish with `pnpm release:artwork:validate` - **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario -- **Custom stream URLs**: Edit the HLS stub redirect in `main.ts` +- **Custom stream URLs**: Edit the normal-mode HLS stub redirect in + `app/server.ts`; never enable it for the performance fixture diff --git a/docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md b/docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md new file mode 100644 index 000000000..de9cf69ab --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md @@ -0,0 +1,1042 @@ +# Xtream Performance Benchmark Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a reproducible, fail-closed Electron benchmark for large Xtream initial import, refresh, deletion, cancellation, and concurrent UI interaction, with process-separated profiles and count-only phase attribution, without changing normal production behavior. + +**Architecture:** A performance-only 100,000-item Xtream mock fixture is materialized and hashed before capture. An opt-in, fail-neutral instrumentation layer emits sanitized renderer, preload, Electron-main, and database-worker markers. A Playwright orchestrator runs five scenarios in fresh Electron processes with exactly one warm-up, five measured iterations, and one diagnostic iteration per scenario, writes only to the ignored `dist/performance/` tree, and rejects incomplete or contaminated measurements. The existing M3U capture implementation remains behaviorally unchanged and supplies stable process/worker primitives through small, tested extensions rather than a broad rewrite. + +**Tech Stack:** Angular 21 signals and router, Electron 41 IPC/preload, Chrome DevTools Protocol, Node `inspector`/`perf_hooks`/Worker APIs, Axios, better-sqlite3, Express, Jest, Playwright, TypeScript, Nx. + +--- + +## Non-Negotiable Measurement Contract + +- Formal runs use only a clean tracked worktree and exact CDP endpoint + `127.0.0.1:9222`. An alternate loopback CDP port is smoke-only and makes the + run ineligible for before/after claims. +- The mock binds to `127.0.0.1`, uses only the committed + `performance-100k` fixture, and rejects arbitrary provider URLs, + credentials, payloads, and cardinalities. No response field used by the + benchmark may reference a non-loopback URL. +- Every formal scenario has exactly: + `warmup-01`, `run-01` through `run-05`, and `diagnostic`. + Warm-up and diagnostic values never enter headline distributions. +- Each iteration gets a fresh Electron process and data directory. Stateful + refresh/delete/UI iterations seed the portal before capture in that same + process, verify database idleness, and atomically roll capture into the + measured operation. +- Renderer, Electron main, and `database.worker` metrics remain separate. + Main RSS is labelled process-wide and includes worker/native/SQLite memory; + worker RSS is unavailable because a Node Worker is a thread. Native/SQLite + memory is never inferred by subtracting JS heaps. +- Xtream does not instantiate `playlist-refresh.worker`. Report it as + `N/A: not-instantiated-by-xtream-flow` only after observing zero matching + workers and artifacts. Any instance invalidates the iteration. +- IPC boundary deltas are named structured-clone proxies because they include + queueing/scheduling. SQLite transaction spans include commit. Index creation + is `N/A: schema-indexes-pre-exist; operation-creates-none`. +- Cancellation acknowledgement is the terminal database `cancelled` event. + Also retain click-to-preload return, main HTTP abort, DB cancel dispatch, and + dev-only worker-receipt timestamps as separate diagnostics. +- Instrumentation is active only when `IPTVNATOR_PERF_CAPTURE=1` and/or + `IPTVNATOR_PERF_WORKER_PROFILING=1`, is count/identifier-only, never logs + credentials/URLs/titles/search text/payloads, and is fail-neutral. +- No production bottleneck fix belongs in this branch. This branch establishes + the benchmark and instrumentation required to prove a later fix. + +## Scenario Matrix + +| Scenario ID | Seed state | Measured trigger | Authoritative terminal | +| ----------------------------- | ----------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `xtream-initial-import-large` | Empty DB, prepared mock | Final Add button | All three import states completed, overlay absent, expected DB counts, two painted frames | +| `xtream-refresh-large` | Complete 100k import | Refresh-confirm button | Delete/reimport/empty restore complete, exact counts, no duplicate or stale rows | +| `xtream-delete-large` | Complete 100k import | Delete-confirm button | `DB_DELETE_PLAYLIST` completed, source row absent, playlist/category/content counts zero | +| `xtream-cancel-import` | Empty DB, prepared mock | Final Add; cancel at first real live save progress threshold | Worker receipt plus authoritative DB `cancelled`, visible cancelled state painted, no later VOD/series request | +| `xtream-background-ui` | Complete 100k import | Refresh-confirm button | Fixed navigation/search/action sequence paints while DB operation is active, then normal refresh terminal | + +The fixture contains 60 live categories/items groups, 20 VOD groups, and 20 +series groups with 1,000 items each: exactly 60,000 live + 20,000 VOD + +20,000 series = 100,000 imported catalog rows and 100 categories. + +## Task 0: Reconfirm Branch, Baseline, And Safety Preconditions + +**Files:** + +- Verify only: `AGENTS.md` +- Verify only: `package.json` +- Verify only: `pnpm-lock.yaml` +- Verify only: `apps/electron-backend-e2e/project.json` + +- [ ] **Step 1: Confirm the linked worktree and branch** + +Run: + +```bash +git status --short --branch +git rev-parse --show-toplevel +git rev-parse --git-common-dir +git log -1 --oneline +``` + +Expected: branch `agent/perf-xtream-harness`, clean tracked tree, repository root +`/Users/4gray/.codex/worktrees/05cf/iptvnator`, and base commit +`24f0dee6f033613148aeab4b699e9db667041927`. + +- [ ] **Step 2: Verify dependencies and Nx discovery** + +Run: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects +pnpm nx show projects --withTarget test +pnpm nx show projects --withTarget e2e +``` + +Expected: all commands exit 0 and the project list includes +`electron-backend`, `electron-backend-e2e`, `portal-xtream-data-access`, +`portal-xtream-feature`, and `xtream-mock-server`. + +- [ ] **Step 3: Record the pre-change regression baseline** + +Run: + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +pnpm nx test portal-xtream-data-access --skip-nx-cache +pnpm nx test portal-xtream-feature --skip-nx-cache +``` + +Expected baseline: performance harness 156/156, Xtream data-access 162 tests, +Xtream feature 144 tests. Stop and use `systematic-debugging` if the baseline +changes before editing code. + +- [ ] **Step 4: Preserve the CDP blocker as an external precondition** + +Run: + +```bash +lsof -nP -iTCP:9222 -sTCP:LISTEN || true +``` + +Do not terminate an unrelated listener without explicit user authorization. +Harness implementation and unit tests may continue; no formal M3U or Xtream +run may start while `9222` is occupied. + +## Task 1: Add A Deterministic, Local-Only 100k Xtream Fixture + +**Files:** + +- Create: `apps/xtream-mock-server/jest.config.ts` +- Create: `apps/xtream-mock-server/tsconfig.spec.json` +- Create: `apps/xtream-mock-server/src/app/generators/performance.generator.ts` +- Create: `apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts` +- Modify: `apps/xtream-mock-server/project.json` +- Modify: `apps/xtream-mock-server/src/app/scenarios.ts` +- Modify: `apps/xtream-mock-server/src/app/data-store.ts` + +- [ ] **Step 1: Add the mock-server Jest target** + +Mirror the established `stalker-mock-server` Node/Jest configuration: + +```ts +export default { + displayName: 'xtream-mock-server', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/xtream-mock-server', +}; +``` + +Add an `@nx/jest:jest` `test` target with `jestConfig` and `tsConfig` paths. + +- [ ] **Step 2: Write failing fixture identity tests** + +The tests must require: + +- scenario key `performance:performance`, name `performance-100k`, seed + `91001`; +- exactly 100 categories and 100,000 catalog items with the 60k/20k/20k split; +- unique stream/series IDs and valid category references; +- two full resets/rebuilds serialize to identical byte counts and SHA-256; +- every artwork/backdrop/stream URL field is empty or begins with the exact + supplied `http://127.0.0.1:` origin; +- the account-info call does not eagerly materialize 20,000 series-detail + objects; a detail is built lazily on `getSeriesInfo`; +- VOD/series detail caches are keyed by portal plus item ID, not global ID. + +Run and preserve the red result: + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +``` + +Expected: failure because the fixture and test target do not yet exist. + +- [ ] **Step 3: Implement the dedicated deterministic generator** + +Add this scenario shape without changing `large`, `stress`, or other existing +fixtures: + +```ts +'performance:performance': { + name: 'performance-100k', + description: 'Deterministic local-only 100k performance catalog', + seed: 91001, + categoryCount: { live: 60, vod: 20, series: 20 }, + itemsPerCategory: 1000, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + performanceFixture: 'catalog-100k', + deferSeriesDetails: true, +}, +``` + +`performance.generator.ts` must use no Faker date clock, `Math.random`, or +`Date.now`. Derive names, ratings, timestamps, extensions, IDs, and ordering +from stable integer indices plus fixed epoch `1_767_225_600` (2026-01-01 UTC). +Use empty artwork/backdrop fields for the list payloads. Do not create stream +URLs in catalog objects. + +- [ ] **Step 4: Make performance series details lazy and cache-safe** + +Branch only the performance fixture in `data-store.ts`; leave every other +scenario's output unchanged. Use `${username}:${password}:${id}` cache keys for +VOD/series details, and skip prepopulation when `deferSeriesDetails` is true. + +- [ ] **Step 5: Run the green mock tests and lint** + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +pnpm nx lint xtream-mock-server --skip-nx-cache +``` + +Expected: all fixture identity, local-only, lazy-detail, existing mock tests, +and lint checks pass. + +## Task 2: Add A Locked-Down Mock Performance Control Plane + +**Files:** + +- Create: `apps/xtream-mock-server/src/app/performance-control.ts` +- Create: `apps/xtream-mock-server/src/app/performance-control.spec.ts` +- Create: `apps/xtream-mock-server/src/app/server.ts` +- Create: `apps/xtream-mock-server/src/app/server.spec.ts` +- Modify: `apps/xtream-mock-server/src/main.ts` +- Modify: `apps/xtream-mock-server/src/app/data-store.ts` +- Modify: `apps/xtream-mock-server/README.md` +- Modify: `docs/architecture/xtream-mock-server.md` + +- [ ] **Step 1: Write failing control-plane tests** + +Use a real loopback HTTP server on port `0`; do not add `supertest`. +Cover: + +- controls are absent unless `IPTVNATOR_XTREAM_MOCK_CONTROL=1`; +- performance mode rejects non-loopback bind hosts; +- every `/__control/*` request requires the exact per-process token header; +- JSON bodies reject unknown fields, arbitrary scenarios/cardinalities/URLs, + duplicate rules, past occurrences, and delays outside integer `0..5000`; +- `prepare` materializes `performance-100k` and returns only + `{epoch, scenario, seed, counts, bytes, catalogSha256}`; +- `reset observations` clears ledger/rules but preserves the prepared fixture; + `reset all` also clears fixture caches and increments epoch; +- request identity is + `(epoch,scenario,transport,canonicalAction,categoryId|'all',occurrence)`, + so parallel category arrival order does not affect matching; +- barrier release and request-abort both settle and remove held responses; +- ledger entries contain only safe action/scenario/count/status/timestamps and + never password, username, raw URL, query, or response payload; +- performance stream and `/playlist.m3u` endpoints return `410`, while no + non-loopback request is made. + +Run and preserve the red result: + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +``` + +- [ ] **Step 2: Extract an Express app factory** + +Move route wiring, without behavior changes, into: + +```ts +export interface XtreamMockServerOptions { + readonly control?: { + readonly enabled: boolean; + readonly token: string; + }; + readonly host: string; + readonly port: number; +} + +export function createXtreamMockApp( + options: XtreamMockServerOptions +): express.Express; +``` + +Keep `main.ts` responsible only for validated environment parsing, HTTP +startup, logging, and signal/error shutdown. Normal `serve` behavior remains +port 3211 and existing routes remain compatible. + +- [ ] **Step 3: Implement bounded observations, barriers, and delays** + +Use an exact allowlist for canonical Xtream actions. Cap ledger and rule counts. +A barrier matches before dispatch/serialization, is one-shot, and records +`arrived -> blocked -> responded|aborted`. A delay is abort-aware and exists +only for deterministic smoke/control tests; formal scenario manifests require +zero delay rules. + +- [ ] **Step 4: Add fixture preparation and manifest hashing** + +Hash stable UTF-8 JSON for the three category arrays and three catalog arrays +in a documented fixed order. The hash input contains no credentials or server +origin. Materialization happens before capture, so mock generation is excluded +from application acquisition time. + +- [ ] **Step 5: Run focused tests and docs checks** + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +pnpm nx lint xtream-mock-server --skip-nx-cache +pnpm exec prettier --check apps/xtream-mock-server/README.md docs/architecture/xtream-mock-server.md +``` + +Expected: green. Docs explain the opt-in control flag, loopback/token +requirements, fixed scenario, prepare/reset/ledger semantics, and that delays +are forbidden in formal timing. + +## Task 3: Add Sanitized Xtream IPC And Main-Process Phase Markers + +**Files:** + +- Create: `libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts` +- Create: `apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts` +- Create: `apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts` +- Create: `apps/electron-backend/src/app/events/xtream-performance.ts` +- Create: `apps/electron-backend/src/app/events/xtream-performance.spec.ts` +- Modify: `libs/shared/interfaces/src/index.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts` +- Modify: `apps/electron-backend/src/app/events/xtream.events.ts` +- Modify: `apps/electron-backend/src/app/events/xtream.events.spec.ts` + +- [ ] **Step 1: Write failing schema, gating, redaction, and lifecycle tests** + +Target only these bridge methods: + +```text +xtreamRequest +xtreamCancelSession +dbGetCategories +dbSaveCategories +dbGetContent +dbSaveContent +dbDeleteXtreamContent +dbDeletePlaylist +dbRestoreXtreamUserData +dbSearchContent +dbGlobalSearch +dbCancelOperation +``` + +Markers may include only schema version, IPC call ID, method, boundary/outcome, +epoch, playlist/operation/session identifiers, Xtream action, content/category +type, and item count. Tests must reject or prove absence of URL, username, +password, params, titles, search term, arrays, and result bodies. Prove exact +pass-through result identity, error propagation, no marker work when disabled, +and fail-neutral behavior when the marker sink throws. + +Run the red tests: + +```bash +pnpm nx test electron-backend --skip-nx-cache --testPathPatterns='xtream-preload-performance-capture|main.preload.performance|xtream.events|xtream-performance' +``` + +- [ ] **Step 2: Implement a separate Xtream preload marker protocol** + +Do not overload `preload-performance-correlation.ts`, whose state machine is +M3U refresh-specific. Add a separate channel and schema. Extract safe identity +from arguments without cloning/traversing payload arrays. Emit start and +success/error around the existing proxied call only when +`IPTVNATOR_PERF_CAPTURE=1`. + +- [ ] **Step 3: Instrument main HTTP phases without changing normal Axios behavior** + +Emit count-only main phases: + +```text +xtream.network.total +xtream.json.transform +xtream.response.ready +xtream.cancel-session +``` + +Under capture only, wrap Axios's existing default `transformResponse` chain and +invoke the same functions with the same context. Do not switch response type or +introduce a separate manual `JSON.parse`. `network.total` contains the nested +transform; the report derives acquisition as total minus JSON transform. +Every success, error, and abort path must close its phase exactly once. + +- [ ] **Step 4: Keep debug-copy overhead explicit** + +Electron imports always create a portal-debug request ID, but performance +builds use the production environment and disable portal-debug sanitize/send +before the payload is copied. Record that work as +`N/A: production-debug-send-disabled`; do not infer that the request ID is +absent. If a future diagnostic build enables the send path, instrument +redaction/send separately and never fold that development-only copy into +production acquisition. + +`ElectronService.forwardXtreamRequest()` also posts the full returned payload +through `window.postMessage()` after the preload call completes. Keep that +second renderer clone unchanged until a baseline proves it is the selected +bottleneck; attribute it through the preload-end to store-marker gap and the +renderer CPU profile. + +- [ ] **Step 5: Run focused backend tests and lint** + +```bash +pnpm nx test electron-backend --skip-nx-cache --testPathPatterns='xtream-preload-performance-capture|main.preload.performance|xtream.events|xtream-performance' +pnpm nx lint electron-backend --skip-nx-cache +``` + +Expected: green with no changes to normal IPC payloads or Axios results. + +## Task 4: Add Database-Worker Phase And Cancellation-Receipt Markers + +**Files:** + +- Modify: `libs/shared/interfaces/src/lib/performance-phase.interface.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-capture.model.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-phase.ts` +- Modify: `apps/electron-backend/src/app/workers/database.worker.ts` +- Modify: `apps/electron-backend/src/app/database/operations/category.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/content.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/xtream.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/playlist.operations.ts` +- Modify: `apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts` +- Modify: `apps/electron-backend/src/app/services/database-worker-client.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts` +- Modify: `apps/electron-backend/src/app/database/operations/category.operations.spec.ts` +- Modify: `apps/electron-backend/src/app/database/operations/content.operations.spec.ts` +- Create: `apps/electron-backend/src/app/database/operations/xtream.operations.spec.ts` +- Modify: `apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts` + +- [ ] **Step 1: Write failing phase-placement and cancellation tests** + +Require one ordered pair per aggregate phase, count-only metadata, closure on +success/error/cancel, and no calls when worker profiling is off. Required phase +names: + +```text +sqlite.categories.read +normalize.categories +sqlite.categories.write-transactions +sqlite.content.read +sqlite.content.category-map-read +normalize.content +sqlite.content.write-transactions +sqlite.xtream-cache-clear.write-transactions +sqlite.xtream-delete.collect-user-data +sqlite.xtream-delete.write-transactions +sqlite.playlist-delete.collect-ids +sqlite.playlist-delete.write-transactions +sqlite.search.query +normalize.search-rank +``` + +Wrap the complete batch loop in one aggregate `write-transactions` span; +metadata reports total items. This avoids ambiguous repeated phase pairs. +Tests must state that the span includes all transaction commits. + +Cancellation cleanup runs `DB_CLEAR_XTREAM_IMPORT_CACHE` after an aborted +import. Measure its complete delete loops with the dedicated cache-clear phase +instead of hiding that work in the request envelope or mislabelling it as a +content-save phase. Synthetic benchmark fixtures keep favorites/recent data +empty, so restore-user-data remains explicitly `N/A: no-user-data`. + +Require a dev-only `performance-cancel-received` worker message containing only +operation/request IDs and epoch. It must not change +`DatabaseWorkerClient.cancel()` into a wait or alter normal cancellation order. + +- [ ] **Step 2: Extend the worker phase union generically** + +Change the capture helper/model to accept the union of existing app-playlist +phases and new Xtream database phases. Preserve existing M3U phase strings and +tests exactly. + +- [ ] **Step 3: Add operation adapters without changing SQL or concurrency** + +Pass the optional capture adapter from `database.worker.ts` into the existing +operations. Measure around existing normalization, query, and transaction-loop +blocks. Do not reorder statements, change batch size, add indexes, serialize +parallel requests, or alter transaction boundaries. + +- [ ] **Step 4: Preserve overlap invalidation** + +`registerDatabaseWorkerPerformanceCapture()` must continue marking overlapping +request CPU/ELU/event-loop-delay data invalid. Tests must prove no hidden +serialization is introduced. Whole-worker profiles remain valid diagnostics; +request-scoped overlapping metrics remain explicitly invalid. + +- [ ] **Step 5: Run focused and full backend tests** + +```bash +pnpm nx test electron-backend --skip-nx-cache --testPathPatterns='worker-performance|worker-ipc-contract|database-worker-client|category.operations|content.operations|xtream.operations|playlist.operations' +pnpm nx test electron-backend --skip-nx-cache +pnpm nx lint electron-backend --skip-nx-cache +``` + +Expected: green and no SQL/business-result changes. + +## Task 5: Add Renderer Store Markers Without Equating Them To Paint + +**Files:** + +- Modify: `libs/shared/logging/src/lib/renderer-performance-phase.ts` +- Modify: `libs/shared/logging/src/lib/renderer-performance-phase.spec.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts` +- Modify: `libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts` +- Modify: `libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts` +- Modify: `libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts` +- Modify: `libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts` + +- [ ] **Step 1: Write failing opt-in/fail-neutral store-marker tests** + +Add count-only phase names: + +```text +store.xtream-publish-categories +store.xtream-publish-live +store.xtream-publish-vod +store.xtream-publish-series +store.xtream-import-terminal +store.xtream-search-results +store.xtream-refresh-meta +store.xtream-delete-row +``` + +Tests must prove the marker wraps only the synchronous `patchState` or NgRx +dispatch, emits one start/end pair, preserves thrown errors/results, performs +no metadata work without a hook, and ignores hook failures. + +- [ ] **Step 2: Wrap the exact existing publication sites** + +Use `measureRendererPerformancePhase` around the category publication at +`with-content.feature.ts:965`, live/VOD/series publications at +`:1047/:1083/:1119`, import-terminal publication, search result publication, +refresh metadata dispatch, and delete-row dispatch. Do not change fetch order, +signals, routes, or visible behavior. + +- [ ] **Step 3: Keep Angular paint external to store timing** + +Document in code comments and benchmark contracts that a synchronous store +span is not render time. The Playwright renderer probe will observe a +scenario-specific DOM sentinel and two `requestAnimationFrame` callbacks after +the marker end. + +- [ ] **Step 4: Run affected project tests** + +```bash +pnpm nx test shared-logging --skip-nx-cache +pnpm nx test portal-xtream-data-access --skip-nx-cache +pnpm nx test playlist-shared-ui --skip-nx-cache +``` + +If the exact project names differ, discover them with: + +```bash +pnpm nx show projects --withTarget test +``` + +Then run the owning targets; do not guess a project name. + +## Task 6: Extend The Existing Process Capture For Xtream Attribution + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts` +- Modify: `apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts` +- Modify: `apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts` +- Modify: `apps/electron-backend-e2e/src/performance/worker-request-performance.ts` +- Modify: `apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts` +- Modify: `apps/electron-backend-e2e/src/performance/performance-phase-events.ts` +- Modify: `apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts` + +- [ ] **Step 1: Write failing parsing/correlation tests** + +Require: + +- exact schema validation and count-only fields; +- ordered start/end pairs and no unclosed/duplicate boundaries; +- FIFO correlation by safe method/playlist/type/action/operation key; +- quarantine rather than guessing when the same key overlaps; +- handler/preload/worker timestamps remain monotonic; +- acquisition = `network.total - json.transform`, never negative; +- main-to-worker and worker-to-main deltas are named IPC proxies; +- worker cancel receipt and terminal cancel are separate; +- existing M3U parsers produce byte-equivalent normalized output. + +- [ ] **Step 2: Parameterize main capture phase subscriptions** + +Extend `installMainCapture()` to subscribe to both existing M3U and new Xtream +diagnostics channels. Keep the old state key and exported API for compatibility; +do not rename or copy the 1,500-line injected protocol in this task. Add only +the minimum channel allowlists and timeline record shapes. + +- [ ] **Step 3: Capture Xtream preload and main-handler boundaries** + +Listen for sanitized preload markers in main and record handler receive/ready +phases. Match DB worker requests using safe operation ID where supplied and +FIFO/quarantine otherwise. Never attach raw IPC payloads to the timeline. + +- [ ] **Step 4: Record true worker cancellation receipt** + +Recognize `performance-cancel-received` without counting it as a normal +response. Store the epoch/IDs in the current capture generation. Late or +cross-generation markers invalidate cancellation attribution. + +- [ ] **Step 5: Run the complete performance-harness unit suite** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +Expected: all old M3U tests and new Xtream contracts pass. + +## Task 7: Build Xtream Scenario, Summary, And Validity Contracts + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-control-client.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts` + +- [ ] **Step 1: Write failing schedule and safety tests** + +Require the five exact scenario IDs and formal iteration schedule: + +```ts +[ + { runId: 'warmup-01', kind: 'warmup' }, + { runId: 'run-01', kind: 'measured' }, + { runId: 'run-02', kind: 'measured' }, + { runId: 'run-03', kind: 'measured' }, + { runId: 'run-04', kind: 'measured' }, + { runId: 'run-05', kind: 'measured' }, + { runId: 'diagnostic', kind: 'diagnostic' }, +]; +``` + +Smoke may reduce measured runs to one but must set `eligibleForComparison=false`. +Reject non-loopback origin, unexpected fixture name/hash/counts, any credential +field in serialized artifacts, nonzero formal delay/barrier residue, and any +unexpected mock action. + +- [ ] **Step 2: Define the request and phase contract** + +After observations reset, initial/reimport expects exactly seven application +actions: + +```text +get_account_info +get_live_categories +get_vod_categories +get_series_categories +get_live_streams +get_vod_streams +get_series +``` + +Category order is irrelevant; content order is live -> VOD -> series. Cancel +requires account + categories + live only, with no VOD/series request after the +authoritative cancellation point. + +- [ ] **Step 3: Define process-separated result types** + +Each result stores: + +- total and named phase durations; +- renderer peak/post-GC heap, long-task count/max, frame-gap p95/max, + heartbeat p95/max, store-to-paint, navigation/search/action latency; +- main CPU, peak/post-GC heap, process-wide peak/post-GC RSS, ELD + p95/p99/max, ELU/unavailable reason, BrowserWindow + unresponsive/responsive events; +- database-worker CPU, peak/post-GC heap, external memory, ELD p95/p99/max, + ELU, request validity, profile paths, and RSS `N/A` reason; +- playlist-refresh worker `N/A` reason; +- cancel dispatch/receipt/terminal/paint latencies; +- explicit index, exact-commit, native-memory, and debug-copy N/A/proxy labels. + +- [ ] **Step 4: Fail closed on comparison validity** + +Formal summaries require five valid measured values for every required metric; +never silently filter nulls. Require one renderer identity, one current database +worker, zero playlist-refresh workers, no late DB request after capture cutoff, +complete action/DB phase contracts, exact catalog invariants, no console errors, +and valid regular contained diagnostic artifacts. + +- [ ] **Step 5: Run the harness unit suite** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +## Task 8: Build The Renderer Probe And Initial/Cancel Drivers + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts` + +- [ ] **Step 1: Write failing probe lifecycle tests** + +Mirror the proven M3U capture lifecycle and require: + +- Long Tasks clipped to operation boundaries; +- continuous rAF gaps and a 50 ms deadline-based UI heartbeat; +- 20 ms renderer heap sampling plus explicit GC/post-GC heap; +- route, store marker, DOM sentinel, and two-rAF paint timestamps; +- idempotent stop/dispose after partial startup failure; +- CPU/trace/heap snapshot only in diagnostic; +- no profile path in measured/warm-up runs. + +- [ ] **Step 2: Implement initial-import trigger and terminal** + +Prepare the Xtream dialog with only the fixed synthetic name, loopback origin, +and committed synthetic credential pair. Start clocks immediately before the +final Add button activation. Terminal requires overlay absent, all three +import-status values completed, exact 100k content/100 category DB counts, and +two rendered frames after the final store marker. + +- [ ] **Step 3: Implement cancellation at real DB progress** + +Install the DB-event listener before triggering import. On the first live +`save-content` progress event at or above +`max(100, floor(total * 0.10))`, synchronously record the click epoch and +activate the visible cancel button. Use no timeout/delay as the trigger. +Require worker receipt, terminal DB `cancelled`, painted cancelled state, no +later success, no automatic retry, and no VOD/series mock requests. + +- [ ] **Step 4: Add interaction-free smoke driver tests** + +Use deterministic mocked Page/CDP/main-capture adapters to prove trigger order, +terminal checks, cleanup, and that credential strings never enter result, +manifest, console label, or artifact filenames. + +- [ ] **Step 5: Run the complete harness tests** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +## Task 9: Build Refresh, Delete, And Background-UI Drivers + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts` +- Modify: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts` + +- [ ] **Step 1: Write failing seed/rollover tests** + +Require a complete seed import outside measured capture, an idle DB worker, +zero pending requests, and atomic main-capture rollover before trigger. Store +seed metrics separately as `seed-main-capture.json`; never include them in +measured summaries. + +- [ ] **Step 2: Implement refresh** + +Start at the refresh confirmation button. Include +`DB_DELETE_XTREAM_CONTENT`, playback/meta parallel work, navigation, the seven +mock actions, three category writes, three content writes, empty user-data +restore, final store publication, and paint. Assert exact counts and no +duplicate/stale rows. + +- [ ] **Step 3: Implement full source deletion** + +Start at the delete confirmation button. Require started/progress/completed DB +events, source row removal, zero playlist/category/content rows, and no stale +active-store route. Capture all delete transaction phases and final dispatch +to paint. + +- [ ] **Step 4: Implement the fixed background UI sequence** + +Start a natural delay-zero refresh. Only after proving the DB delete operation +active, perform: + +1. Sources filter input and result paint; +2. navigation to Dashboard and two-rAF route paint; +3. navigation back to Sources; +4. source search input and result paint; +5. one stable, non-destructive UI action. + +Record input/click to paint latency for every action and require the background +operation to be active both before and after each action. If it finishes too +early, invalidate the iteration and increase the fixed fixture cardinality in +a separately reviewed change; never add sleeps or mock delay to formal timing. + +- [ ] **Step 5: Run harness and existing responsiveness tests** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-responsiveness.e2e.ts --skip-nx-cache +``` + +The existing E2E may keep its deliberate worker batch delay because it is a +functional regression test. The benchmark must always set +`IPTVNATOR_DB_WORKER_BATCH_DELAY_MS=0`. + +## Task 10: Add The Orchestrator, Nx Target, And Artifact Validation + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts` +- Create: `apps/electron-backend-e2e/src/xtream.performance.ts` +- Create: `apps/electron-backend-e2e/playwright.xtream-performance.config.ts` +- Modify: `apps/electron-backend-e2e/project.json` +- Modify: `apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts` + +- [ ] **Step 1: Write failing lifecycle and artifact tests** + +Require: + +- preflight before directory creation; +- mock prepare before capture and observations reset immediately before trigger; +- fresh app/data dir per iteration and guaranteed cleanup; +- raw iteration JSON written before summary validity throws; +- diagnostic regular/non-symlink/nonempty JSON trace, CPU profile, and heap + snapshot for renderer/main/database worker; +- no playlist-refresh worker artifacts; +- source-map/build hash manifest sufficient to map profiles after a rebuild; +- output contained by a fresh absolute + `dist/performance//` path; +- no `.cpuprofile`, `.heapsnapshot`, trace, or generated summary staged by Git. + +- [ ] **Step 2: Implement the serial orchestrator** + +For each scenario, run all seven definitions serially. Launch Electron with: + +```text +--js-flags=--expose-gc +--remote-debugging-address=127.0.0.1 +--remote-debugging-port=9222 +--user-data-dir= +``` + +Set: + +```text +IPTVNATOR_DB_WORKER_BATCH_DELAY_MS=0 +IPTVNATOR_PERF_CAPTURE=1 +IPTVNATOR_PERF_WORKER_PROFILING=1 +IPTVNATOR_TRACE_RENDERER_CONSOLE=0 +``` + +Start renderer/main capture before trigger, wait for authoritative terminal and +DB settlement, stop capture, write raw capture/result JSON, validate artifacts, +then build the scenario summary. + +- [ ] **Step 3: Add the isolated mock web server** + +`playwright.xtream-performance.config.ts` starts exactly one control-enabled +mock on a dedicated loopback port with a per-process token, `reuseExistingServer: +false`, one Playwright worker, no retries, and a 30-minute test timeout. The +token is passed to the control client but never serialized. + +- [ ] **Step 4: Add the Nx target** + +Add `benchmark-xtream` with: + +```json +{ + "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" + } +} +``` + +- [ ] **Step 5: Run all harness tests** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +Expected: old M3U and new Xtream lifecycle/contract tests pass. + +## Task 11: Run A Non-Claim Smoke And Close The Harness PR + +**Files:** + +- Modify: `docs/architecture/xtream-mock-server.md` +- Modify: `docs/architecture/sqlite-db-worker.md` +- Modify if command/environment text changed: `AGENTS.md` +- Modify if command/environment text changed: `CLAUDE.md` +- Verify only: `.gitignore` +- Verify only: `.changes/README.md` + +- [ ] **Step 1: Run the full validation ladder** + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache +pnpm nx test portal-xtream-data-access --skip-nx-cache +pnpm nx test portal-xtream-feature --skip-nx-cache +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-responsiveness.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/sources.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts --skip-nx-cache +pnpm nx affected -t lint --base=24f0dee6f033613148aeab4b699e9db667041927 --head=HEAD --skip-nx-cache +pnpm run release:notes:validate +``` + +- [ ] **Step 2: Run a smoke benchmark only when a CDP port is available** + +If `9222` remains occupied, use an unused alternate loopback port only for +smoke: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream-smoke" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=smoke \ +IPTVNATOR_PERF_SMOKE=1 \ +IPTVNATOR_PERF_CDP_PORT=9322 \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +Validate that every smoke summary says `eligibleForComparison=false`. Never +quote smoke values as baseline or improvement evidence. + +- [ ] **Step 3: Update canonical docs** + +Document the future formal command, fixture identity, exact run schedule, +process/RSS/heap distinctions, Xtream worker topology, control-plane safety, +artifact layout, validity gates, and before/after rules. Keep `AGENTS.md` and +`CLAUDE.md` mirrored if their tracing/benchmark environment section changes. + +- [ ] **Step 4: Test-impact and release-note decision** + +This PR changes only opt-in development/test instrumentation and synthetic mock +tooling; normal user-visible behavior is unchanged. Do not add a `.changes` +note. Apply `no-release-note` when opening the PR because runtime paths under +`apps/**` and `libs/**` are touched. + +- [ ] **Step 5: Verify tracked scope and ignored raw artifacts** + +```bash +git status --short +git check-ignore -v dist/performance +git diff --check +git diff --stat 24f0dee6f033613148aeab4b699e9db667041927 +git ls-files 'dist/performance/**' +``` + +Expected: tracked source/tests/docs only; `git ls-files` prints nothing. + +- [ ] **Step 6: Commit, push, open the PR, and run the review loop** + +Use focused conventional commits, push `agent/perf-xtream-harness`, open a +ready PR with validation and smoke caveats, apply `no-release-note`, trigger +`@codex review`, and inspect all CI/review threads. Do not merge until every +actionable item is fixed, re-tested, independently reviewed, and current-head +CI is green. + +## Task 12: Run The Formal Xtream Baseline After Harness Merge + +**Files:** + +- Generated only, ignored: `dist/performance/-xtream/baseline/**` + +- [ ] **Step 1: Start from the exact clean merged commit** + +```bash +git switch master +git pull --ff-only +git status --short +lsof -nP -iTCP:9222 -sTCP:LISTEN +``` + +Expected: clean tree and port 9222 free. If an unrelated listener remains, stop +and request authorization rather than killing it. + +- [ ] **Step 2: Run the formal benchmark** + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=baseline \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +Do not set smoke or alternate-port flags. + +- [ ] **Step 3: Validate and analyze raw profiles** + +Verify every scenario has one warm-up, five measured, one diagnostic, valid +process identities, exact fixture/action hashes, zero external requests, and +all required artifacts. Run the ignored CPU/heap analyzers sequentially, +preserve source-mapped hotspot and retaining-path JSON beside the raw profiles, +and hash the analyzer/source-map inputs. + +- [ ] **Step 4: Name and prove three bottlenecks** + +For each candidate, cite: + +- scenario and measured phase distribution; +- renderer/main/database-worker CPU stack; +- retaining path and retained/self sizes with process scope; +- corroborating event-loop/long-task/frame/heartbeat evidence; +- explicit confounders and attribution gaps. + +Choose only the strongest candidate. Add a regression test that fails at the +merged baseline, implement the smallest production change, rerun all affected +tests, and repeat the identical formal benchmark into a fresh `after` +directory. + +- [ ] **Step 5: Claim improvement only after matched five-run evidence** + +Report before/after medians and p95 where appropriate, percentage change, +memory/process separation, variance, unchanged/counter-regressed metrics, and +tradeoffs. If the matched results do not improve, revert or describe the change +as unconfirmed; never call it an optimization. diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 2ba8bb51e..b87054a3d 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -23,9 +23,26 @@ import { PlaylistMeta, SECURITY_ERROR_PREFIX, } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistRefreshActionService } from './playlist-refresh-action.service'; +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -233,6 +250,7 @@ describe('PlaylistRefreshActionService', () => { }); afterEach(() => { + setPerformanceHook(null); jest.restoreAllMocks(); localStorage.clear(); }); @@ -382,6 +400,11 @@ describe('PlaylistRefreshActionService', () => { it('stores Xtream restore data before updating playlist meta and navigating', async () => { const item = createPlaylistMeta(); const executionOrder: string[] = []; + const performanceEvents: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => { + performanceEvents.push(event); + executionOrder.push(event.boundary); + }); let confirmPromise: Promise | undefined; const dateNowSpy = jest .spyOn(Date, 'now') @@ -456,7 +479,34 @@ describe('PlaylistRefreshActionService', () => { 'xtreams', item._id, ]); - expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + expect(executionOrder).toEqual([ + 'setItem', + 'start', + 'dispatch', + 'end', + 'navigate', + ]); + expect( + performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 1, + outcome: undefined, + phase: 'store.xtream-refresh-meta', + }, + { + boundary: 'end', + items: 1, + outcome: 'success', + phase: 'store.xtream-refresh-meta', + }, + ]); setItemSpy.mockRestore(); dateNowSpy.mockRestore(); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 43aed4ab6..214cc380d 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -23,6 +23,10 @@ import { PLAYLIST_UPDATE, PlaylistMeta, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; export interface XtreamRefreshPreparationState { @@ -159,10 +163,15 @@ export class PlaylistRefreshActionService { playbackPositions, }); - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { ...item, updateDate }, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META, + () => + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate }, + }) + ), + () => ({ items: 1 }) ); await this.router.navigate([ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index dca8d0fa0..b9de1ca97 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -27,8 +27,25 @@ import { SortService, } from '@iptvnator/services'; import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; import { RecentPlaylistsComponent } from './recent-playlists.component'; +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -233,11 +250,14 @@ describe('RecentPlaylistsComponent busy state', () => { }); afterEach(() => { + setPerformanceHook(null); jest.restoreAllMocks(); localStorage.clear(); }); it('tracks delete progress and clears the busy row after completion', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const item = createPlaylistMeta({ _id: 'playlist-delete-1' }); const deletion = createDeferred(); @@ -290,9 +310,32 @@ describe('RecentPlaylistsComponent busy state', () => { undefined, { duration: 2000 } ); + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 1, + outcome: undefined, + phase: 'store.xtream-delete-row', + }, + { + boundary: 'end', + items: 1, + outcome: 'success', + phase: 'store.xtream-delete-row', + }, + ]); }); it('delegates playlist deletion and updates local UI state after success', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const item = createPlaylistMeta({ _id: 'pwa-playlist-1', serverUrl: undefined, @@ -316,6 +359,43 @@ describe('RecentPlaylistsComponent busy state', () => { undefined, { duration: 2000 } ); + expect(events).toEqual([]); + }); + + it('records dispatch failure and still clears delete-row busy state', async () => { + const events: RendererPerformancePhaseEvent[] = []; + const item = createPlaylistMeta({ _id: 'dispatch-failure' }); + const dispatchError = new Error('dispatch failed'); + setPerformanceHook((event) => events.push(event)); + (store.dispatch as jest.Mock).mockImplementation(() => { + throw dispatchError; + }); + + await expect(component.removePlaylist(item)).rejects.toBe( + dispatchError + ); + + expect(component.isDeletePending(item._id)).toBe(false); + expect(component.getBusyProgress(item._id)).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect( + events.map(({ boundary, outcome, phase }) => ({ + boundary, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + outcome: undefined, + phase: 'store.xtream-delete-row', + }, + { + boundary: 'end', + outcome: 'error', + phase: 'store.xtream-delete-row', + }, + ]); }); it('tracks Xtream refresh progress and clears the busy row after abort', async () => { @@ -388,6 +468,11 @@ describe('RecentPlaylistsComponent busy state', () => { const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); let confirmPromise: Promise | undefined; const executionOrder: string[] = []; + const performanceEvents: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => { + performanceEvents.push(event); + executionOrder.push(event.boundary); + }); const dateNowSpy = jest .spyOn(Date, 'now') .mockReturnValue(1712145600000); @@ -476,7 +561,38 @@ describe('RecentPlaylistsComponent busy state', () => { 'xtreams', item._id, ]); - expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + expect(executionOrder).toEqual([ + 'setItem', + 'start', + 'dispatch', + 'end', + 'navigate', + ]); + expect(performanceEvents).toHaveLength(2); + expect(performanceEvents[0]?.phaseId).toBe( + performanceEvents[1]?.phaseId + ); + expect( + performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + metadata, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + metadata: { items: 1 }, + outcome: undefined, + phase: 'store.xtream-refresh-meta', + }, + { + boundary: 'end', + metadata: { items: 1 }, + outcome: 'success', + phase: 'store.xtream-refresh-meta', + }, + ]); setItemSpy.mockRestore(); dateNowSpy.mockRestore(); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index bbada321d..45d88f585 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -46,6 +46,10 @@ import { PlaylistMeta, PlaylistRefreshEvent, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { EmptyStateComponent } from './empty-state/empty-state.component'; import type { PlaylistType } from '../add-playlist-menu/playlist-type'; @@ -271,9 +275,24 @@ export class RecentPlaylistsComponent { } ); if (deleted) { - this.store.dispatch( - PlaylistActions.removePlaylist({ playlistId: item._id }) - ); + if (item.serverUrl) { + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW, + () => + this.store.dispatch( + PlaylistActions.removePlaylist({ + playlistId: item._id, + }) + ), + () => ({ items: 1 }) + ); + } else { + this.store.dispatch( + PlaylistActions.removePlaylist({ + playlistId: item._id, + }) + ); + } this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS' @@ -389,10 +408,15 @@ export class RecentPlaylistsComponent { }); // Update the timestamp in NgRx / IndexedDB - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { ...item, updateDate }, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META, + () => + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate }, + }) + ), + () => ({ items: 1 }) ); // Navigate to the playlist to trigger re-import diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index a8f1bac48..a7d9a4da6 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -1,6 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { DatabaseService } from '@iptvnator/services'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; import { XTREAM_DATA_SOURCE, XtreamPlaylistData, @@ -28,6 +32,18 @@ const PLAYLIST: XtreamPlaylistData = { password: 'secret', type: 'xtream', }; +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} let checkPortalStatusMock: jest.Mock, []>; @@ -120,14 +136,16 @@ describe('withContent import state', () => { databaseService = { clearXtreamImportCache: jest.fn().mockResolvedValue(true), cancelOperation: jest.fn().mockResolvedValue(true), - createOperationId: jest.fn().mockImplementation((prefix?: string) => { - if (prefix === 'xtream-import-session') { - return 'xtream-import-session'; - } + createOperationId: jest + .fn() + .mockImplementation((prefix?: string) => { + if (prefix === 'xtream-import-session') { + return 'xtream-import-session'; + } - operationCounter += 1; - return `${prefix ?? 'db-op'}-${operationCounter}`; - }), + operationCounter += 1; + return `${prefix ?? 'db-op'}-${operationCounter}`; + }), getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), setXtreamImportStatus: jest.fn().mockResolvedValue(true), supportsDbOperationCancellation: jest.fn().mockReturnValue(true), @@ -159,9 +177,111 @@ describe('withContent import state', () => { }); afterEach(() => { + setPerformanceHook(null); localStorage.clear(); }); + it('emits ordered count-only publication markers for a successful import', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); + const categories = { + live: [{ category_id: '1' }], + vod: [{ category_id: '2' }, { category_id: '3' }], + series: [{ category_id: '4' }], + }; + const content = { + live: [{ xtream_id: 1 }, { xtream_id: 2 }], + movie: [{ xtream_id: 3 }], + series: [{ xtream_id: 4 }, { xtream_id: 5 }], + }; + dataSource.getCategories.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: keyof typeof categories + ) => Promise.resolve(categories[type]) + ); + dataSource.getContent.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: keyof typeof content + ) => Promise.resolve(content[type]) + ); + + await store.initializeContent(); + + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 4, + outcome: undefined, + phase: 'store.xtream-publish-categories', + }, + { + boundary: 'end', + items: 4, + outcome: 'success', + phase: 'store.xtream-publish-categories', + }, + { + boundary: 'start', + items: 2, + outcome: undefined, + phase: 'store.xtream-publish-live', + }, + { + boundary: 'end', + items: 2, + outcome: 'success', + phase: 'store.xtream-publish-live', + }, + { + boundary: 'start', + items: 1, + outcome: undefined, + phase: 'store.xtream-publish-vod', + }, + { + boundary: 'end', + items: 1, + outcome: 'success', + phase: 'store.xtream-publish-vod', + }, + { + boundary: 'start', + items: 2, + outcome: undefined, + phase: 'store.xtream-publish-series', + }, + { + boundary: 'end', + items: 2, + outcome: 'success', + phase: 'store.xtream-publish-series', + }, + { + boundary: 'start', + items: 5, + outcome: undefined, + phase: 'store.xtream-import-terminal', + }, + { + boundary: 'end', + items: 5, + outcome: 'success', + phase: 'store.xtream-import-terminal', + }, + ]); + }); + it('tracks aggregated import progress while content is loading', async () => { const pendingCategories = { live: createDeferred(), @@ -350,11 +470,8 @@ describe('withContent import state', () => { ) => pendingCategories[type].promise ); dataSource.getContent.mockImplementation( - ( - _playlistId: string, - _credentials: unknown, - type: ContentType - ) => pending[type].promise + (_playlistId: string, _credentials: unknown, type: ContentType) => + pending[type].promise ); const initialization = store.initializeContent(); @@ -741,6 +858,8 @@ describe('withContent import state', () => { }); it('ignores concurrent initializeContent calls while an import is already running', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const pendingCategories = { live: createDeferred(), vod: createDeferred(), @@ -772,6 +891,11 @@ describe('withContent import state', () => { expect(dataSource.getCategories).toHaveBeenCalledTimes(3); expect(dataSource.getContent).toHaveBeenCalledTimes(3); + expect( + events.filter( + ({ phase }) => phase === 'store.xtream-import-terminal' + ) + ).toHaveLength(2); }); it('blocks auto restart after cancelling during category loading until retry is explicit', async () => { @@ -936,6 +1060,8 @@ describe('withContent import state', () => { }); it('cancels active imports and clears stale progress after worker aborts', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const pendingCategories = { live: createDeferred(), vod: createDeferred(), @@ -1042,5 +1168,17 @@ describe('withContent import state', () => { expect(store.activeImportTotalCount()).toBe(0); expect(store.importCount()).toBe(0); expect(store.itemsToImport()).toBe(0); + expect( + events + .filter(({ phase }) => phase === 'store.xtream-import-terminal') + .map(({ boundary, metadata, outcome }) => ({ + boundary, + items: metadata?.items, + outcome, + })) + ).toEqual([ + { boundary: 'start', items: 0, outcome: undefined }, + { boundary: 'end', items: 0, outcome: 'success' }, + ]); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index f1ab104a7..4ba66c065 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -12,6 +12,10 @@ import { XtreamSerieItem, XtreamVodStream, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { createLogger } from '@iptvnator/portal/shared/util'; import { DatabaseService, @@ -861,18 +865,28 @@ export function withContent() { logger.error('Error initializing content', error); } } finally { - patchState(store, { - isImporting: false, - isCancellingImport: false, - importCount: 0, - importPhase: null, - itemsToImport: 0, - activeImportContentType: null, - activeImportCurrentCount: 0, - activeImportTotalCount: 0, - activeImportSessionId: null, - activeImportOperationIds: [], - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_IMPORT_TERMINAL, + () => + patchState(store, { + isImporting: false, + isCancellingImport: false, + importCount: 0, + importPhase: null, + itemsToImport: 0, + activeImportContentType: null, + activeImportCurrentCount: 0, + activeImportTotalCount: 0, + activeImportSessionId: null, + activeImportOperationIds: [], + }), + () => ({ + items: + store.liveStreams().length + + store.vodStreams().length + + store.serialStreams().length, + }) + ); } }; @@ -962,12 +976,21 @@ export function withContent() { ), ]); - patchState(store, { - liveCategories: live, - vodCategories: vod, - serialCategories: series, - isLoadingCategories: false, - }); + // This span ends after synchronous store publication; + // the renderer probe owns paint observation. + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES, + () => + patchState(store, { + liveCategories: live, + vodCategories: vod, + serialCategories: series, + isLoadingCategories: false, + }), + () => ({ + items: live.length + vod.length + series.length, + }) + ); } catch (error) { if (!isDbAbortError(error)) { logger.error('Error fetching categories', error); @@ -1044,9 +1067,14 @@ export function withContent() { 'completed' ); options?.completedTypes?.add('live'); - patchState(store, { - liveStreams: live, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_LIVE, + () => + patchState(store, { + liveStreams: live, + }), + () => ({ items: live.length }) + ); updateContentTypeLoadState('live', 'ready'); throwIfImportCancelled(options?.importSessionId); @@ -1080,9 +1108,14 @@ export function withContent() { 'completed' ); options?.completedTypes?.add('vod'); - patchState(store, { - vodStreams: vod, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_VOD, + () => + patchState(store, { + vodStreams: vod, + }), + () => ({ items: vod.length }) + ); updateContentTypeLoadState('vod', 'ready'); throwIfImportCancelled(options?.importSessionId); @@ -1116,10 +1149,15 @@ export function withContent() { 'completed' ); options?.completedTypes?.add('series'); - patchState(store, { - serialStreams: series, - isLoadingContent: false, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_SERIES, + () => + patchState(store, { + serialStreams: series, + isLoadingContent: false, + }), + () => ({ items: series.length }) + ); updateContentTypeLoadState('series', 'ready'); } catch (error) { if (!isDbAbortError(error)) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts new file mode 100644 index 000000000..77ca625d2 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts @@ -0,0 +1,116 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore, withState } from '@ngrx/signals'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface'; +import { withSearch } from './with-search.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ error: jest.fn() }), +})); + +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); +const TestSearchStore = signalStore( + withState({ playlistId: 'playlist-1' }), + withSearch() +); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + +describe('withSearch renderer performance markers', () => { + let searchContent: jest.Mock; + let store: InstanceType; + + beforeEach(() => { + searchContent = jest.fn(); + TestBed.configureTestingModule({ + providers: [ + TestSearchStore, + { + provide: XTREAM_DATA_SOURCE, + useValue: { searchContent }, + }, + ], + }); + store = TestBed.inject(TestSearchStore); + }); + + afterEach(() => setPerformanceHook(null)); + + it('marks only the authoritative non-stale result publication', async () => { + const events: RendererPerformancePhaseEvent[] = []; + const first = createDeferred(); + const second = createDeferred(); + setPerformanceHook((event) => events.push(event)); + searchContent + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const staleSearch = store.searchContent('old', ['live']); + const authoritativeSearch = store.searchContent('new', ['live']); + second.resolve([{ xtream_id: 2 }, { xtream_id: 3 }]); + await authoritativeSearch; + first.resolve([{ xtream_id: 1 }]); + await staleSearch; + + expect(store.searchResults()).toEqual([ + { xtream_id: 2 }, + { xtream_id: 3 }, + ]); + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 2, + outcome: undefined, + phase: 'store.xtream-search-results', + }, + { + boundary: 'end', + items: 2, + outcome: 'success', + phase: 'store.xtream-search-results', + }, + ]); + }); + + it('does not mark an error-state reset', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); + searchContent.mockRejectedValue(new Error('search failed')); + + await expect(store.searchContent('query', ['movie'])).resolves.toEqual( + [] + ); + + expect(store.searchResults()).toEqual([]); + expect(events).toEqual([]); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index 895c360a2..fc769057b 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -6,6 +6,10 @@ import { withState, } from '@ngrx/signals'; import { GlobalSearchResult } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { XTREAM_DATA_SOURCE, XtreamContentItem, @@ -127,10 +131,15 @@ export function withSearch() { return results; } - patchState(store, { - searchResults: results, - isSearching: false, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_SEARCH_RESULTS, + () => + patchState(store, { + searchResults: results, + isSearching: false, + }), + () => ({ items: results.length }) + ); return results; } catch (error) { diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index e8bf168f0..b8592bf20 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -52,6 +52,7 @@ export * from './lib/xtream-response.interface'; export * from './lib/xtream-portal.utils'; export * from './lib/xtream-restore-state.util'; export * from './lib/xtream-recently-added.utils'; +export * from './lib/xtream-performance-marker.interface'; export * from './lib/xtream-serie-details.interface'; export * from './lib/xtream-serie-item.interface'; export * from './lib/xtream-vod-details.interface'; diff --git a/libs/shared/interfaces/src/lib/performance-phase.interface.ts b/libs/shared/interfaces/src/lib/performance-phase.interface.ts index bbca5daf3..45a6d5a2e 100644 --- a/libs/shared/interfaces/src/lib/performance-phase.interface.ts +++ b/libs/shared/interfaces/src/lib/performance-phase.interface.ts @@ -35,8 +35,35 @@ export type AppPlaylistGetPerformancePhase = (typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE]; export type AppPlaylistPerformancePhase = - | AppPlaylistGetPerformancePhase - | AppPlaylistUpsertPerformancePhase; + AppPlaylistGetPerformancePhase | AppPlaylistUpsertPerformancePhase; + +export const XTREAM_DATABASE_PERFORMANCE_PHASE = { + NORMALIZE_CATEGORIES: 'normalize.categories', + NORMALIZE_CONTENT: 'normalize.content', + NORMALIZE_SEARCH_RANK: 'normalize.search-rank', + SQLITE_CATEGORIES_READ: 'sqlite.categories.read', + SQLITE_CATEGORIES_WRITE_TRANSACTIONS: + 'sqlite.categories.write-transactions', + SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read', + SQLITE_CONTENT_READ: 'sqlite.content.read', + SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions', + SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids', + SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS: + 'sqlite.playlist-delete.write-transactions', + SQLITE_SEARCH_QUERY: 'sqlite.search.query', + SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS: + 'sqlite.xtream-cache-clear.write-transactions', + SQLITE_XTREAM_DELETE_COLLECT_USER_DATA: + 'sqlite.xtream-delete.collect-user-data', + SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS: + 'sqlite.xtream-delete.write-transactions', +} as const; + +export type XtreamDatabasePerformancePhase = + (typeof XTREAM_DATABASE_PERFORMANCE_PHASE)[keyof typeof XTREAM_DATABASE_PERFORMANCE_PHASE]; + +export type DatabaseWorkerPerformancePhase = + AppPlaylistPerformancePhase | XtreamDatabasePerformancePhase; export interface PerformancePhaseMetadata { readonly byteCount?: number; diff --git a/libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts b/libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts new file mode 100644 index 000000000..175787b45 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts @@ -0,0 +1,58 @@ +import { XtreamCodeActions } from './xtream-code-actions'; + +export const XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL = + 'IPTVNATOR_XTREAM_MAIN_PERFORMANCE_PHASE_EVENT'; + +export const XTREAM_MAIN_PERFORMANCE_PHASE = { + CANCEL_SESSION: 'xtream.cancel-session', + JSON_TRANSFORM: 'xtream.json.transform', + NETWORK_TOTAL: 'xtream.network.total', + RESPONSE_READY: 'xtream.response.ready', +} as const; + +export type XtreamMainPerformancePhase = + (typeof XTREAM_MAIN_PERFORMANCE_PHASE)[keyof typeof XTREAM_MAIN_PERFORMANCE_PHASE]; + +export const XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL = + 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER'; + +export const XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION = 1 as const; + +export const XTREAM_PRELOAD_PERFORMANCE_METHOD = { + DB_CANCEL_OPERATION: 'dbCancelOperation', + DB_DELETE_PLAYLIST: 'dbDeletePlaylist', + DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent', + DB_GET_CATEGORIES: 'dbGetCategories', + DB_GET_CONTENT: 'dbGetContent', + DB_GLOBAL_SEARCH: 'dbGlobalSearch', + DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData', + DB_SAVE_CATEGORIES: 'dbSaveCategories', + DB_SAVE_CONTENT: 'dbSaveContent', + DB_SEARCH_CONTENT: 'dbSearchContent', + XTREAM_CANCEL_SESSION: 'xtreamCancelSession', + XTREAM_REQUEST: 'xtreamRequest', +} as const; + +export type XtreamPreloadPerformanceMethod = + (typeof XTREAM_PRELOAD_PERFORMANCE_METHOD)[keyof typeof XTREAM_PRELOAD_PERFORMANCE_METHOD]; + +export type XtreamPreloadPerformanceBoundary = 'start' | 'end'; +export type XtreamPreloadPerformanceOutcome = 'success' | 'error' | null; +export type XtreamPreloadPerformanceCategoryType = 'live' | 'movies' | 'series'; +export type XtreamPreloadPerformanceContentType = 'live' | 'movie' | 'series'; + +export interface XtreamPreloadPerformanceMarker { + schemaVersion: typeof XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION; + sourceEpochMs: number; + ipcCallId: number; + method: XtreamPreloadPerformanceMethod; + boundary: XtreamPreloadPerformanceBoundary; + outcome: XtreamPreloadPerformanceOutcome; + playlistId: string | null; + operationId: string | null; + sessionId: string | null; + action: XtreamCodeActions | null; + categoryType: XtreamPreloadPerformanceCategoryType | null; + contentType: XtreamPreloadPerformanceContentType | null; + itemCount: number | null; +} diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts index ded5179e4..a9202e4b1 100644 --- a/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts +++ b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts @@ -1,7 +1,16 @@ import * as sharedLogging from '../index'; type RendererPerformancePhase = - 'store.m3u-import-dispatch' | 'store.m3u-publish-channels'; + | 'store.m3u-import-dispatch' + | 'store.m3u-publish-channels' + | 'store.xtream-delete-row' + | 'store.xtream-import-terminal' + | 'store.xtream-publish-categories' + | 'store.xtream-publish-live' + | 'store.xtream-publish-series' + | 'store.xtream-publish-vod' + | 'store.xtream-refresh-meta' + | 'store.xtream-search-results'; type RendererPerformancePhaseOutcome = 'error' | 'success'; interface RendererPerformancePhaseEvent { @@ -28,11 +37,20 @@ interface RendererPerformancePhaseModule { readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{ M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch'; M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels'; + XTREAM_DELETE_ROW: 'store.xtream-delete-row'; + XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal'; + XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories'; + XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live'; + XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series'; + XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod'; + XTREAM_REFRESH_META: 'store.xtream-refresh-meta'; + XTREAM_SEARCH_RESULTS: 'store.xtream-search-results'; }>; readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string; } -const rendererPerformance = sharedLogging as RendererPerformancePhaseModule; +const rendererPerformance = + sharedLogging as unknown as RendererPerformancePhaseModule; const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1'; const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY); @@ -175,6 +193,14 @@ describe('renderer performance phases', () => { expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({ M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', + XTREAM_DELETE_ROW: 'store.xtream-delete-row', + XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal', + XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories', + XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live', + XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series', + XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod', + XTREAM_REFRESH_META: 'store.xtream-refresh-meta', + XTREAM_SEARCH_RESULTS: 'store.xtream-search-results', }); const events: RendererPerformancePhaseEvent[] = []; diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.ts b/libs/shared/logging/src/lib/renderer-performance-phase.ts index d9a8b64ae..4671db8d6 100644 --- a/libs/shared/logging/src/lib/renderer-performance-phase.ts +++ b/libs/shared/logging/src/lib/renderer-performance-phase.ts @@ -4,6 +4,14 @@ export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY = export const RENDERER_PERFORMANCE_PHASE = { M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', + XTREAM_DELETE_ROW: 'store.xtream-delete-row', + XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal', + XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories', + XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live', + XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series', + XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod', + XTREAM_REFRESH_META: 'store.xtream-refresh-meta', + XTREAM_SEARCH_RESULTS: 'store.xtream-search-results', } as const; export type RendererPerformancePhase =