Merge remote-tracking branch 'origin/master' into agent/shared-marketing-poster-catalog

This commit is contained in:
4gray committed 2026-07-27 00:21:02 +02:00
commit 8a902d2e25
114 files changed
+14115 -1418

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
type: fix
area: electron
---
The desktop remote-control server now blocks crafted static paths from escaping
bundled web files on Windows.
+12 -12
View File
@@ -56,7 +56,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -322,7 +322,7 @@ jobs:
test -s "${RUNTIME_ROOT}/source-archive-binding.json"
- name: Upload staged Linux runtime
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-embedded-mpv-runtime
path: vendor/embedded-mpv/linux-x64
@@ -330,7 +330,7 @@ jobs:
retention-days: 7
- name: Upload Linux runtime source compliance
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-frame-copy-runtime-sources
path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz
@@ -370,7 +370,7 @@ jobs:
steps: &electron-build-steps
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -424,7 +424,7 @@ jobs:
- name: Download pinned Linux Embedded MPV runtime
if: matrix.os == 'linux'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: linux-embedded-mpv-runtime
path: vendor/embedded-mpv/linux-x64
@@ -1187,7 +1187,7 @@ jobs:
- name: Upload artifacts (macOS)
if: matrix.os == 'macos'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: macos-${{ matrix.arch }}-artifacts
path: |
@@ -1199,7 +1199,7 @@ jobs:
- name: Upload system-runtime Linux artifacts
if: matrix.os == 'linux' && matrix.linux_profile == 'system'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-system-artifacts
path: |
@@ -1211,7 +1211,7 @@ jobs:
- name: Upload portable-runtime Linux artifacts
if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-portable-artifacts
path: |
@@ -1223,7 +1223,7 @@ jobs:
- name: Upload Flatpak-runtime Linux artifacts
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: linux-flatpak-artifacts
path: |
@@ -1232,7 +1232,7 @@ jobs:
- name: Upload artifacts (Windows)
if: matrix.os == 'windows'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: windows-artifacts
path: |
@@ -1294,10 +1294,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
path: artifacts
+5 -5
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
# Image pinned by digest (tag 1.7.12). False positives are
# suppressed in .github/actionlint.yaml; shellcheck runs at
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
# The gate scripts are dependency-free Node, so this job skips
# pnpm install entirely and stays cheap.
@@ -92,7 +92,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
# nx affected needs the merge-base with the PR target branch.
fetch-depth: 0
@@ -133,7 +133,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -167,7 +167,7 @@ jobs:
- name: Upload unit coverage artifact
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: unit-coverage
path: |
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
# Initializes the CodeQL tools for scanning. PR runs analyze the merge
# commit checked out above (the modern default); JavaScript is
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Prepare Docker metadata
id: docker-meta
+4 -4
View File
@@ -49,7 +49,7 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -87,7 +87,7 @@ jobs:
- name: Upload Electron Test Results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report-electron-${{ matrix.os }}
path: |
@@ -103,7 +103,7 @@ jobs:
NX_SKIP_NX_CACHE: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -131,7 +131,7 @@ jobs:
- name: Upload Web Test Results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: playwright-report-web-ubuntu
path: |
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout released tooling
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
@@ -121,7 +121,7 @@ jobs:
printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}"
- name: Transfer verified release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: verified-snap-release-assets
path: /var/lib/iptvnator-snap-release/assets
@@ -139,7 +139,7 @@ jobs:
steps:
- name: Download verified release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
with:
name: verified-snap-release-assets
path: ${{ runner.temp }}/verified-snap-release-assets
+2 -1
View File
@@ -84,7 +84,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_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; 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
+2 -1
View File
@@ -141,7 +141,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_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses; 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
+9 -2
View File
@@ -12,14 +12,21 @@
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
},
"test-performance-harness": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/performance/*.spec.ts"
}
},
"benchmark-m3u-refresh-cancellation": {
"dependsOn": ["electron-backend:build-e2e"],
"dependsOn": ["electron-backend:build-performance"],
"executor": "nx:run-commands",
"cache": false,
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.performance.config.ts"
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts"
}
},
"packaged-frame-copy-smoke": {
@@ -0,0 +1,229 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import {
installDatabaseRequestIdentityCaptureInMain,
type DatabaseRequestIdentityCaptureApi,
} from './database-request-identity-capture';
const CHANNEL = 'IPTVNATOR_PERF_CAPTURE_MARKER';
let nextStateId = 1;
function createCapture(): {
api: DatabaseRequestIdentityCaptureApi;
ipcMain: EventEmitter;
} {
const ipcMain = new EventEmitter();
const stateKey = `__identityCapture${nextStateId}`;
nextStateId += 1;
installDatabaseRequestIdentityCaptureInMain({ ipcMain } as never, stateKey);
const api = (globalThis as unknown as Record<string, unknown>)[
stateKey
] as DatabaseRequestIdentityCaptureApi;
api.start();
return { api, ipcMain };
}
function marker(overrides: Record<string, unknown> = {}) {
return {
correlationState: 'correlated',
invalidReason: null,
ipcCallId: 2,
method: 'dbGetAppPlaylist',
operationId: 'refresh-operation-1',
phase: 'start',
playlistId: 'playlist-1',
sourceEpochMs: 100,
...overrides,
};
}
test('correlates the real preload marker to a DB request whose payload omits operationId', () => {
const { api, ipcMain } = createCapture();
ipcMain.emit(CHANNEL, {}, marker());
const identity = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-1',
type: 'request',
});
assert.deepEqual(identity, {
operationId: 'refresh-operation-1',
operationIdUnavailableReason: null,
});
});
test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => {
const { api, ipcMain } = createCapture();
const identity = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-before-marker',
type: 'request',
});
assert.equal(
identity.operationIdUnavailableReason,
'preload-performance-marker-missing'
);
const responseOutcome = { identity };
ipcMain.emit(CHANNEL, {}, marker());
assert.deepEqual(responseOutcome.identity, {
operationId: 'refresh-operation-1',
operationIdUnavailableReason: null,
});
api.stop();
ipcMain.emit(CHANNEL, {}, marker({ operationId: 'marker-while-stopped' }));
api.start();
const afterReset = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-after-reset',
type: 'request',
});
assert.deepEqual(afterReset, {
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-missing',
});
});
test('fails closed for missing and ambiguous preload markers', () => {
const missingCapture = createCapture();
const missing = missingCapture.api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-missing',
type: 'request',
});
assert.deepEqual(missing, {
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-missing',
});
const ambiguousCapture = createCapture();
ambiguousCapture.ipcMain.emit(CHANNEL, {}, marker());
ambiguousCapture.ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 3,
operationId: 'refresh-operation-2',
})
);
const ambiguous = ambiguousCapture.api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-ambiguous',
type: 'request',
});
assert.deepEqual(ambiguous, {
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
});
});
test('quarantines a key after response-before-marker ambiguity', () => {
const { api, ipcMain } = createCapture();
const requestA = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-a',
type: 'request',
});
const requestB = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-b',
type: 'request',
});
ipcMain.emit(
CHANNEL,
{},
marker({ ipcCallId: 2, operationId: 'refresh-operation-a' })
);
ipcMain.emit(
CHANNEL,
{},
marker({ ipcCallId: 3, operationId: 'refresh-operation-b' })
);
const requestC = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'request-c',
type: 'request',
});
ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 4,
operationId: 'other-playlist-operation',
playlistId: 'playlist-2',
})
);
const otherPlaylist = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-2' },
requestId: 'other-playlist-request',
type: 'request',
});
ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 5,
method: 'dbUpsertAppPlaylist',
operationId: 'other-db-operation',
})
);
const otherOperation = api.matchDatabaseRequest({
operation: 'DB_UPSERT_APP_PLAYLIST',
payload: { _id: 'playlist-1' },
requestId: 'other-operation-request',
type: 'request',
});
const ambiguousIdentity = {
operationId: null,
operationIdUnavailableReason: 'preload-performance-marker-ambiguous',
};
assert.deepEqual(requestA, ambiguousIdentity);
assert.deepEqual(requestB, ambiguousIdentity);
assert.deepEqual(requestC, ambiguousIdentity);
assert.deepEqual(otherPlaylist, {
operationId: 'other-playlist-operation',
operationIdUnavailableReason: null,
});
assert.deepEqual(otherOperation, {
operationId: 'other-db-operation',
operationIdUnavailableReason: null,
});
api.stop();
api.start();
ipcMain.emit(
CHANNEL,
{},
marker({
ipcCallId: 6,
operationId: 'fresh-capture-operation',
})
);
const freshCaptureIdentity = api.matchDatabaseRequest({
operation: 'DB_GET_APP_PLAYLIST',
payload: { playlistId: 'playlist-1' },
requestId: 'fresh-capture-request',
type: 'request',
});
assert.deepEqual(freshCaptureIdentity, {
operationId: 'fresh-capture-operation',
operationIdUnavailableReason: null,
});
});
@@ -0,0 +1,270 @@
import type { ElectronApplication } from '@playwright/test';
export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY =
'__iptvnatorM3uRefreshDatabaseRequestIdentityCapture';
export interface DatabaseRequestIdentity {
operationId: string | null;
operationIdUnavailableReason: string | null;
}
export interface DatabaseRequestIdentityCaptureApi {
matchDatabaseRequest(message: unknown): DatabaseRequestIdentity;
start(): void;
stop(): void;
}
interface DatabaseRequestIdentityElectron {
readonly ipcMain: {
on(
channel: string,
listener: (event: unknown, marker: unknown) => void
): void;
};
}
export function installDatabaseRequestIdentityCaptureInMain(
{ ipcMain }: DatabaseRequestIdentityElectron,
stateKey: string
): void {
type JsonRecord = Record<string, unknown>;
interface PendingMarker {
readonly ipcCallId: number;
readonly operation: string;
readonly operationId: string;
readonly playlistId: string;
}
interface PendingRequest {
readonly identity: DatabaseRequestIdentity;
readonly operation: string;
readonly playlistId: string;
}
interface QuarantinedKey {
readonly operation: string;
readonly playlistId: string;
}
const target = globalThis as unknown as Record<string, unknown>;
if (target[stateKey] !== undefined) {
return;
}
const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER';
const markerMethodToOperation: Readonly<Record<string, string>> = {
dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST',
dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST',
};
let active = false;
let pendingMarkers: PendingMarker[] = [];
let pendingRequests: PendingRequest[] = [];
let quarantinedKeys: QuarantinedKey[] = [];
const readRecord = (value: unknown): JsonRecord | null =>
typeof value === 'object' && value !== null
? (value as JsonRecord)
: null;
const readPlaylistId = (
operation: string,
payload: unknown
): string | null => {
const value = readRecord(payload);
if (!value) {
return null;
}
const playlistId =
operation === 'DB_UPSERT_APP_PLAYLIST'
? value['_id']
: value['playlistId'];
return typeof playlistId === 'string' && playlistId.length > 0
? playlistId
: null;
};
const matches = (
candidate: { operation: string; playlistId: string },
operation: string,
playlistId: string
): boolean =>
candidate.operation === operation &&
candidate.playlistId === playlistId;
const markAmbiguous = (requests: PendingRequest[]): void => {
for (const request of requests) {
request.identity.operationId = null;
request.identity.operationIdUnavailableReason =
'preload-performance-marker-ambiguous';
}
};
const isQuarantined = (operation: string, playlistId: string): boolean =>
quarantinedKeys.some((key) => matches(key, operation, playlistId));
const quarantine = (operation: string, playlistId: string): void => {
if (!isQuarantined(operation, playlistId)) {
quarantinedKeys.push({ operation, playlistId });
}
const requestCandidates = pendingRequests.filter((request) =>
matches(request, operation, playlistId)
);
markAmbiguous(requestCandidates);
pendingRequests = pendingRequests.filter(
(request) => !matches(request, operation, playlistId)
);
pendingMarkers = pendingMarkers.filter(
(marker) => !matches(marker, operation, playlistId)
);
};
ipcMain.on(markerChannel, (_event, input) => {
if (!active) {
return;
}
const marker = readRecord(input);
if (
!marker ||
marker['phase'] !== 'start' ||
marker['correlationState'] !== 'correlated' ||
marker['invalidReason'] !== null
) {
return;
}
const operation =
typeof marker['method'] === 'string'
? markerMethodToOperation[marker['method']]
: undefined;
const operationId = marker['operationId'];
const playlistId = marker['playlistId'];
const ipcCallId = marker['ipcCallId'];
if (
operation === undefined ||
typeof operationId !== 'string' ||
operationId.length === 0 ||
typeof playlistId !== 'string' ||
playlistId.length === 0 ||
!Number.isSafeInteger(ipcCallId) ||
Number(ipcCallId) < 1
) {
return;
}
if (isQuarantined(operation, playlistId)) {
return;
}
const requestCandidates = pendingRequests.filter((request) =>
matches(request, operation, playlistId)
);
if (requestCandidates.length === 1) {
const request = requestCandidates[0];
if (!request) {
return;
}
request.identity.operationId = operationId;
request.identity.operationIdUnavailableReason = null;
pendingRequests = pendingRequests.filter(
(candidate) => candidate !== request
);
return;
}
if (requestCandidates.length > 1) {
quarantine(operation, playlistId);
return;
}
pendingMarkers.push({
ipcCallId: Number(ipcCallId),
operation,
operationId,
playlistId,
});
pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId);
});
const api: DatabaseRequestIdentityCaptureApi = {
matchDatabaseRequest: (input) => {
const message = readRecord(input);
const operation = message?.['operation'];
const playlistId =
typeof operation === 'string'
? readPlaylistId(operation, message?.['payload'])
: null;
if (
!active ||
(operation !== 'DB_GET_APP_PLAYLIST' &&
operation !== 'DB_UPSERT_APP_PLAYLIST') ||
playlistId === null
) {
return {
operationId: null,
operationIdUnavailableReason:
'preload-performance-marker-not-applicable',
};
}
if (isQuarantined(operation, playlistId)) {
return {
operationId: null,
operationIdUnavailableReason:
'preload-performance-marker-ambiguous',
};
}
const markerCandidates = pendingMarkers.filter((marker) =>
matches(marker, operation, playlistId)
);
if (markerCandidates.length === 1) {
const marker = markerCandidates[0];
if (!marker) {
return {
operationId: null,
operationIdUnavailableReason:
'preload-performance-marker-missing',
};
}
pendingMarkers = pendingMarkers.filter(
(candidate) => candidate !== marker
);
return {
operationId: marker.operationId,
operationIdUnavailableReason: null,
};
}
if (markerCandidates.length > 1) {
quarantine(operation, playlistId);
return {
operationId: null,
operationIdUnavailableReason:
'preload-performance-marker-ambiguous',
};
}
const identity: DatabaseRequestIdentity = {
operationId: null,
operationIdUnavailableReason:
'preload-performance-marker-missing',
};
pendingRequests.push({
identity,
operation,
playlistId,
});
return identity;
},
start: () => {
pendingMarkers = [];
pendingRequests = [];
quarantinedKeys = [];
active = true;
},
stop: () => {
active = false;
pendingMarkers = [];
pendingRequests = [];
quarantinedKeys = [];
},
};
target[stateKey] = api;
}
export async function installDatabaseRequestIdentityCapture(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
installDatabaseRequestIdentityCaptureInMain,
DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY
);
}
@@ -48,6 +48,29 @@ export interface MainTimelineRecord {
readonly type: string;
}
export interface WorkerRequestPerformanceMetrics {
readonly eventLoopDelay: EventLoopDelayMetrics | null;
readonly eventLoopDelayUnavailableReason: string | null;
readonly eventLoopUtilization: number | null;
readonly eventLoopUtilizationUnavailableReason: string | null;
readonly histogramFlushedEpochMs: number | null;
readonly invalidReason: string | null;
readonly operation: string | null;
readonly operationId: string | null;
readonly operationIdUnavailableReason: string | null;
readonly performanceCaptureUnavailableReason: string | null;
readonly playlistId: string | null;
readonly requestId: string | null;
readonly requestReceivedEpochMs: number | null;
readonly responseEpochMs: number;
readonly success: boolean;
readonly threadCpuSystemMicros: number | null;
readonly threadCpuUnavailableReason: string | null;
readonly threadCpuUserMicros: number | null;
readonly workEndedEpochMs: number | null;
readonly workStartedEpochMs: number | null;
}
export interface WorkerCaptureMetrics {
readonly cancelPostedEpochMs: number | null;
readonly cpuSystemMicros: number | null;
@@ -62,6 +85,7 @@ export interface WorkerCaptureMetrics {
readonly playlistId: string | null;
readonly postGcHeapUsedBytes: number | null;
readonly profilePath: string | null;
readonly requests: readonly WorkerRequestPerformanceMetrics[];
readonly responseEpochMs: number | null;
readonly snapshotPath: string | null;
readonly terminatedEpochMs: number | null;
@@ -176,13 +200,15 @@ export interface CancellationBenchmarkSummary {
readonly cancelToDurableTerminalMs: NumericDistribution;
readonly cancelToWorkerTerminatedMs: NumericDistribution;
readonly cancelTransportLatencyMs: NumericDistribution;
readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution;
readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution;
readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution;
readonly databaseWorkerEventLoopUtilization: NumericDistribution;
readonly databaseWorkerExternalPeakBytes: NumericDistribution;
readonly databaseWorkerHeapPeakBytes: NumericDistribution;
readonly databaseWorkerPostGcHeapBytes: NumericDistribution;
readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution;
readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution;
readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution;
readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution;
readonly databaseWorkerRequestThreadCpuSystemMicros: NumericDistribution;
readonly databaseWorkerRequestThreadCpuUserMicros: NumericDistribution;
readonly dataFetchMs: NumericDistribution;
readonly dbCommitProxyMs: NumericDistribution;
readonly ipcStoreDispatchProxyMs: NumericDistribution;
@@ -196,13 +222,15 @@ export interface CancellationBenchmarkSummary {
readonly mainRssPostGcBytes: NumericDistribution;
readonly parseNormalizeCloneProxyMs: NumericDistribution;
readonly persistencePreparationProxyMs: NumericDistribution;
readonly playlistWorkerEventLoopUtilization: NumericDistribution;
readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution;
readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution;
readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution;
readonly playlistWorkerExternalPeakBytes: NumericDistribution;
readonly playlistWorkerHeapPeakBytes: NumericDistribution;
readonly playlistWorkerPostGcHeapBytes: NumericDistribution;
readonly playlistWorkerRequestEventLoopDelayMaxMs: NumericDistribution;
readonly playlistWorkerRequestEventLoopDelayP95Ms: NumericDistribution;
readonly playlistWorkerRequestEventLoopDelayP99Ms: NumericDistribution;
readonly playlistWorkerRequestEventLoopUtilization: NumericDistribution;
readonly playlistWorkerRequestThreadCpuSystemMicros: NumericDistribution;
readonly playlistWorkerRequestThreadCpuUserMicros: NumericDistribution;
readonly rendererFrameGapMs: NumericDistribution;
readonly rendererHeartbeatDelayMs: NumericDistribution;
readonly rendererHeapPeakBytes: NumericDistribution;
@@ -9,6 +9,7 @@ import type {
NumericDistribution,
PerformanceWorkerKind,
RendererCaptureMetrics,
WorkerRequestPerformanceMetrics,
} from './m3u-refresh-cancellation-contract';
import {
PERFORMANCE_ITERATION_KIND,
@@ -63,15 +64,28 @@ export function createCancellationBenchmarkSummary(
return worker ? select(worker) : null;
})
);
const workerEventLoopDelayMetric = (
const workerRequestMetric = (
kind: PerformanceWorkerKind,
select: (request: WorkerRequestPerformanceMetrics) => number | null
): NumericDistribution =>
summarizeNumbers(
measured.flatMap((iteration) =>
iteration.main.workers
.filter((worker) => worker.kind === kind)
.flatMap((worker) =>
worker.requests.map((request) => select(request))
)
)
);
const workerRequestEventLoopDelayMetric = (
kind: PerformanceWorkerKind,
percentile: keyof NonNullable<
MainCaptureMetrics['workers'][number]['eventLoopDelay']
WorkerRequestPerformanceMetrics['eventLoopDelay']
>
): NumericDistribution =>
workerMetric(
workerRequestMetric(
kind,
(worker) => worker.eventLoopDelay?.[percentile] ?? null
(request) => request.eventLoopDelay?.[percentile] ?? null
);
return Object.freeze({
@@ -105,22 +119,6 @@ export function createCancellationBenchmarkSummary(
(iteration) => iteration.phases.cancelTransportLatencyMs
)
),
databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'maxMs'
),
databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'p95Ms'
),
databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'p99Ms'
),
databaseWorkerEventLoopUtilization: workerMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(worker) => worker.eventLoopUtilization
),
databaseWorkerExternalPeakBytes: workerMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(worker) => worker.peakExternalBytes
@@ -133,6 +131,33 @@ export function createCancellationBenchmarkSummary(
PERFORMANCE_WORKER_KIND.DATABASE,
(worker) => worker.postGcHeapUsedBytes
),
databaseWorkerRequestEventLoopDelayMaxMs:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'maxMs'
),
databaseWorkerRequestEventLoopDelayP95Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'p95Ms'
),
databaseWorkerRequestEventLoopDelayP99Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
'p99Ms'
),
databaseWorkerRequestEventLoopUtilization: workerRequestMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(request) => request.eventLoopUtilization
),
databaseWorkerRequestThreadCpuSystemMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(request) => request.threadCpuSystemMicros
),
databaseWorkerRequestThreadCpuUserMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.DATABASE,
(request) => request.threadCpuUserMicros
),
dataFetchMs: summarizeNumbers(
measured.map((iteration) => iteration.phases.dataFetchMs)
),
@@ -185,22 +210,6 @@ export function createCancellationBenchmarkSummary(
iteration.phases.persistencePreparationProxyMs
)
),
playlistWorkerEventLoopUtilization: workerMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(worker) => worker.eventLoopUtilization
),
playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'maxMs'
),
playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'p95Ms'
),
playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'p99Ms'
),
playlistWorkerExternalPeakBytes: workerMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(worker) => worker.peakExternalBytes
@@ -213,6 +222,33 @@ export function createCancellationBenchmarkSummary(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(worker) => worker.postGcHeapUsedBytes
),
playlistWorkerRequestEventLoopDelayMaxMs:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'maxMs'
),
playlistWorkerRequestEventLoopDelayP95Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'p95Ms'
),
playlistWorkerRequestEventLoopDelayP99Ms:
workerRequestEventLoopDelayMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
'p99Ms'
),
playlistWorkerRequestEventLoopUtilization: workerRequestMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(request) => request.eventLoopUtilization
),
playlistWorkerRequestThreadCpuSystemMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(request) => request.threadCpuSystemMicros
),
playlistWorkerRequestThreadCpuUserMicros: workerRequestMetric(
PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
(request) => request.threadCpuUserMicros
),
rendererFrameGapMs: summarizeNumbers(
flattenRenderer(
(iteration) => iteration.renderer.probe.frameGapsMs
@@ -166,6 +166,7 @@ async function runIteration(
],
env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0',
IPTVNATOR_PERF_CAPTURE: '1',
IPTVNATOR_PERF_WORKER_PROFILING: '1',
IPTVNATOR_TRACE_RENDERER_CONSOLE:
process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0',
@@ -1,7 +1,17 @@
/* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */
import type { ElectronApplication } from '@playwright/test';
import {
DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY,
installDatabaseRequestIdentityCapture,
type DatabaseRequestIdentity,
type DatabaseRequestIdentityCaptureApi,
} from './database-request-identity-capture';
import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract';
import {
selectMainCaptureGeneration,
type MainCaptureGenerationTransport,
} from './worker-request-performance';
const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture';
@@ -23,7 +33,13 @@ export interface MainCaptureStatus {
export async function installMainCapture(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => {
await installDatabaseRequestIdentityCapture(electronApp);
const captureStateKeys = {
databaseRequestIdentityStateKey:
DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY,
stateKey: MAIN_CAPTURE_STATE_KEY,
};
await electronApp.evaluate(async ({ app, BrowserWindow }, input) => {
type JsonRecord = Record<string, unknown>;
type WorkerTransferable = import('node:worker_threads').Transferable;
interface CpuProfileHandle {
@@ -42,6 +58,15 @@ export async function installMainCapture(
idle: number;
utilization: number;
}
interface WorkerRequestPerformance {
identity: DatabaseRequestIdentity;
operation: string | null;
performanceCapture: unknown;
playlistId: string | null;
requestId: string | null;
responseEpochMs: number;
success: boolean;
}
interface InstrumentedWorker {
cpuUsage?(): Promise<WorkerCpuUsage>;
getHeapSnapshot?(): Promise<NodeJS.ReadableStream>;
@@ -62,15 +87,11 @@ export async function installMainCapture(
}
interface WorkerRecord {
cancelPostedEpochMs: number | null;
captureGeneration: number | null;
cpuFirst: WorkerCpuUsage | null;
cpuLast: WorkerCpuUsage | null;
elu: number | null;
eluStart: WorkerElu | null;
eventLoopDelay: {
maxMs: number;
p95Ms: number;
p99Ms: number;
} | null;
externalPeak: number;
finalized: boolean;
finalizing: Promise<void> | null;
@@ -81,6 +102,7 @@ export async function installMainCapture(
postGcHeapUsed: number | null;
profileHandle: Promise<CpuProfileHandle> | null;
profilePath: string | null;
requestPerformance: WorkerRequestPerformance[];
responseEpochMs: number | null;
sampleBusy: boolean;
sampleTimer: NodeJS.Timeout | null;
@@ -99,9 +121,12 @@ export async function installMainCapture(
}
const target = globalThis as unknown as Record<string, unknown>;
if (target[stateKey] !== undefined) {
if (target[input.stateKey] !== undefined) {
return;
}
const databaseRequestIdentityCapture = target[
input.databaseRequestIdentityStateKey
] as DatabaseRequestIdentityCaptureApi;
const runtimeProcess = process as typeof process & {
getBuiltinModule(id: string): unknown;
@@ -132,6 +157,7 @@ export async function installMainCapture(
const dbRequests = new Map<
string,
{
identity: DatabaseRequestIdentity;
operation: string;
playlistId: string | null;
record: WorkerRecord;
@@ -140,6 +166,7 @@ export async function installMainCapture(
const state = {
active: false,
captureGeneration: 0,
cpuStart: null as NodeJS.CpuUsage | null,
diagnostic: false,
eventLoopDelay: null as ReturnType<
@@ -200,6 +227,9 @@ export async function installMainCapture(
const playlistId = value['playlistId'] ?? value['_id'];
return typeof playlistId === 'string' ? playlistId : null;
};
const isCurrentCaptureRecord = (record: WorkerRecord): boolean =>
state.active &&
record.captureGeneration === state.captureGeneration;
const createWorkerRecord = (
worker: InstrumentedWorker,
kind: WorkerRecord['kind']
@@ -210,11 +240,11 @@ export async function installMainCapture(
}
const record: WorkerRecord = {
cancelPostedEpochMs: null,
captureGeneration: null,
cpuFirst: null,
cpuLast: null,
elu: null,
eluStart: null,
eventLoopDelay: null,
externalPeak: 0,
finalized: false,
finalizing: null,
@@ -225,6 +255,7 @@ export async function installMainCapture(
postGcHeapUsed: null,
profileHandle: null,
profilePath: null,
requestPerformance: [],
responseEpochMs: null,
sampleBusy: false,
sampleTimer: null,
@@ -238,30 +269,13 @@ export async function installMainCapture(
return;
}
const message = incoming as JsonRecord;
const performanceCapture = readWorkerPerformance(message);
if (performanceCapture) {
record.eventLoopDelay = record.eventLoopDelay
? {
maxMs: Math.max(
record.eventLoopDelay.maxMs,
performanceCapture.eventLoopDelay.maxMs
),
p95Ms: Math.max(
record.eventLoopDelay.p95Ms,
performanceCapture.eventLoopDelay.p95Ms
),
p99Ms: Math.max(
record.eventLoopDelay.p99Ms,
performanceCapture.eventLoopDelay.p99Ms
),
}
: performanceCapture.eventLoopDelay;
if (!isCurrentCaptureRecord(record)) {
return;
}
if (record.kind === 'playlist-refresh.worker') {
if (message['type'] === 'event') {
const event = message['event'] as
| JsonRecord
| undefined;
JsonRecord | undefined;
recordTimeline({
operationId: record.operationId ?? undefined,
playlistId: record.playlistId ?? undefined,
@@ -270,7 +284,20 @@ export async function installMainCapture(
)}:${String(event?.['phase'] ?? 'none')}`,
});
} else if (message['type'] === 'response') {
record.responseEpochMs = nowEpochMs();
const responseEpochMs = nowEpochMs();
record.responseEpochMs = responseEpochMs;
record.requestPerformance.push({
identity: {
operationId: record.operationId,
operationIdUnavailableReason: null,
},
operation: null,
performanceCapture: message['performance'] ?? null,
playlistId: record.playlistId,
requestId: null,
responseEpochMs,
success: message['success'] === true,
});
recordTimeline({
operationId: record.operationId ?? undefined,
playlistId: record.playlistId ?? undefined,
@@ -285,10 +312,23 @@ export async function installMainCapture(
typeof message['requestId'] === 'string'
) {
const request = dbRequests.get(message['requestId']);
if (request) {
record.requestPerformance.push({
identity: request.identity,
operation: request.operation,
performanceCapture: message['performance'] ?? null,
playlistId: request.playlistId,
requestId: message['requestId'],
responseEpochMs: nowEpochMs(),
success: message['success'] === true,
});
}
if (request) {
dbRequests.delete(message['requestId']);
recordTimeline({
operation: request.operation,
operationId:
request.identity.operationId ?? undefined,
playlistId: request.playlistId ?? undefined,
requestId: message['requestId'],
success: message['success'] === true,
@@ -299,46 +339,6 @@ export async function installMainCapture(
});
return record;
};
const readWorkerPerformance = (
message: JsonRecord
): {
eventLoopDelay: {
maxMs: number;
p95Ms: number;
p99Ms: number;
};
} | null => {
const performanceCapture = message['performance'];
if (
typeof performanceCapture !== 'object' ||
performanceCapture === null
) {
return null;
}
const eventLoopDelay = (performanceCapture as JsonRecord)[
'eventLoopDelay'
];
if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) {
return null;
}
const value = eventLoopDelay as JsonRecord;
const maxMs = value['maxMs'];
const p95Ms = value['p95Ms'];
const p99Ms = value['p99Ms'];
if (
typeof maxMs !== 'number' ||
!Number.isFinite(maxMs) ||
typeof p95Ms !== 'number' ||
!Number.isFinite(p95Ms) ||
typeof p99Ms !== 'number' ||
!Number.isFinite(p99Ms)
) {
return null;
}
return {
eventLoopDelay: { maxMs, p95Ms, p99Ms },
};
};
const sampleWorker = async (record: WorkerRecord): Promise<void> => {
if (record.sampleBusy || record.finalized) {
return;
@@ -373,14 +373,45 @@ export async function installMainCapture(
record.sampleBusy = false;
}
};
const resetWorkerForCapture = (record: WorkerRecord): void => {
if (record.sampleTimer) {
clearInterval(record.sampleTimer);
}
record.cancelPostedEpochMs = null;
record.captureGeneration = state.captureGeneration;
record.cpuFirst = null;
record.cpuLast = null;
record.elu = null;
record.eluStart = null;
record.externalPeak = 0;
record.finalized = false;
record.finalizing = null;
record.heapPeak = 0;
record.operationId = null;
record.playlistId = null;
record.postGcHeapUsed = null;
record.profileHandle = null;
record.profilePath = null;
record.requestPerformance = [];
record.responseEpochMs = null;
record.sampleBusy = false;
record.sampleTimer = null;
record.snapshotPath = null;
record.terminatedEpochMs = null;
};
const startWorker = (record: WorkerRecord): void => {
if (!state.active || record.sampleTimer !== null) {
if (!state.active) {
return;
}
if (record.captureGeneration !== state.captureGeneration) {
resetWorkerForCapture(record);
}
if (record.sampleTimer !== null) {
return;
}
record.elu = null;
record.eluStart =
record.worker.performance?.eventLoopUtilization() ?? null;
record.eventLoopDelay = null;
void sampleWorker(record);
record.sampleTimer = setInterval(
() => void sampleWorker(record),
@@ -456,7 +487,11 @@ export async function installMainCapture(
const kind = classifyRequest(value);
if (kind) {
const record = createWorkerRecord(this, kind);
if (kind === 'playlist-refresh.worker') {
startWorker(record);
if (
isCurrentCaptureRecord(record) &&
kind === 'playlist-refresh.worker'
) {
const payload = value['payload'] as JsonRecord;
record.operationId = String(payload['operationId']);
record.playlistId = String(payload['playlistId']);
@@ -467,23 +502,47 @@ export async function installMainCapture(
type: 'playlist-request',
});
} else if (
isCurrentCaptureRecord(record) &&
typeof value['requestId'] === 'string' &&
typeof value['operation'] === 'string'
) {
const payload = value['payload'];
const payloadOperationId =
typeof payload === 'object' &&
payload !== null &&
typeof (payload as JsonRecord)[
'operationId'
] === 'string' &&
String((payload as JsonRecord)['operationId'])
.length > 0
? String(
(payload as JsonRecord)['operationId']
)
: null;
const identity =
payloadOperationId === null
? databaseRequestIdentityCapture.matchDatabaseRequest(
value
)
: {
operationId: payloadOperationId,
operationIdUnavailableReason: null,
};
dbRequests.set(value['requestId'], {
identity,
operation: value['operation'],
playlistId: readDatabasePlaylistId(value),
record,
});
recordTimeline({
operation: value['operation'],
operationId: identity.operationId ?? undefined,
playlistId:
readDatabasePlaylistId(value) ?? undefined,
requestId: value['requestId'],
type: 'db-request',
});
}
startWorker(record);
}
} else if (
value['type'] === 'cancel' &&
@@ -505,7 +564,7 @@ export async function installMainCapture(
this: InstrumentedWorker
): Promise<number> {
const record = records.get(this);
if (!record || !state.active) {
if (!record || !isCurrentCaptureRecord(record)) {
return originalTerminate.call(this);
}
const markTerminated = (code: number): number => {
@@ -627,6 +686,7 @@ export async function installMainCapture(
).length,
playlistTerminated: [...records.values()].filter(
(record) =>
isCurrentCaptureRecord(record) &&
record.kind === 'playlist-refresh.worker' &&
record.terminatedEpochMs !== null
).length,
@@ -645,7 +705,11 @@ export async function installMainCapture(
).length,
}),
start: async (options: MainCaptureStartOptions): Promise<void> => {
state.captureGeneration += 1;
state.active = true;
databaseRequestIdentityCapture.start();
dbRequests.clear();
operationWorkers.clear();
state.diagnostic = options.diagnostic;
state.outputDirectory = options.outputDirectory;
state.timeline = [];
@@ -682,7 +746,7 @@ export async function installMainCapture(
);
}
},
stop: async (): Promise<MainCaptureMetrics> => {
stop: async (): Promise<MainCaptureGenerationTransport> => {
if (state.sampleTimer) {
clearInterval(state.sampleTimer);
state.sampleTimer = null;
@@ -705,6 +769,7 @@ export async function installMainCapture(
const delay = state.eventLoopDelay;
const databaseRecords = [...records.values()].filter(
(record) =>
record.captureGeneration === state.captureGeneration &&
record.kind === 'database.worker' &&
record.sampleTimer !== null
);
@@ -756,78 +821,91 @@ export async function installMainCapture(
}
state.windowListeners = [];
state.active = false;
databaseRequestIdentityCapture.stop();
const workers = [...records.values()]
.filter(
(record) =>
record.kind === 'playlist-refresh.worker' ||
record.heapPeak > 0
record.heapPeak > 0 ||
record.requestPerformance.length > 0
)
.map((record) => ({
cancelPostedEpochMs: record.cancelPostedEpochMs,
cpuSystemMicros:
record.cpuFirst && record.cpuLast
? record.cpuLast.system - record.cpuFirst.system
: null,
cpuUserMicros:
record.cpuFirst && record.cpuLast
? record.cpuLast.user - record.cpuFirst.user
: null,
eventLoopDelay: record.eventLoopDelay,
eventLoopDelayUnavailableReason:
record.eventLoopDelay === null
? record.terminatedEpochMs !== null
? 'worker-terminated-before-profile-flush'
: 'worker-self-profile-result-unavailable'
: null,
eventLoopUtilization: record.elu,
kind: record.kind,
operationId: record.operationId,
peakExternalBytes: record.externalPeak,
peakHeapUsedBytes: record.heapPeak,
playlistId: record.playlistId,
postGcHeapUsedBytes: record.postGcHeapUsed,
profilePath: record.profilePath,
responseEpochMs: record.responseEpochMs,
snapshotPath: record.snapshotPath,
terminatedEpochMs: record.terminatedEpochMs,
captureGeneration: record.captureGeneration,
metrics: {
cancelPostedEpochMs: record.cancelPostedEpochMs,
cpuSystemMicros:
record.cpuFirst && record.cpuLast
? record.cpuLast.system -
record.cpuFirst.system
: null,
cpuUserMicros:
record.cpuFirst && record.cpuLast
? record.cpuLast.user - record.cpuFirst.user
: null,
eventLoopUtilization: record.elu,
kind: record.kind,
operationId: record.operationId,
peakExternalBytes: record.externalPeak,
peakHeapUsedBytes: record.heapPeak,
playlistId: record.playlistId,
postGcHeapUsedBytes: record.postGcHeapUsed,
profilePath: record.profilePath,
responseEpochMs: record.responseEpochMs,
snapshotPath: record.snapshotPath,
terminatedEpochMs: record.terminatedEpochMs,
},
requests: record.requestPerformance.map((request) => ({
operation: request.operation,
operationId: request.identity.operationId,
operationIdUnavailableReason:
request.identity.operationIdUnavailableReason,
performanceCapture: request.performanceCapture,
playlistId: request.playlistId,
requestId: request.requestId,
responseEpochMs: request.responseEpochMs,
success: request.success,
})),
}));
return {
cpuProfilePath: state.mainProfilePath,
cpuSystemMicros: cpu.system,
cpuUserMicros: cpu.user,
eventLoopDelay: {
maxMs: Number(delay?.max ?? 0) / 1e6,
p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6,
p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6,
captureGeneration: state.captureGeneration,
metrics: {
cpuProfilePath: state.mainProfilePath,
cpuSystemMicros: cpu.system,
cpuUserMicros: cpu.user,
eventLoopDelay: {
maxMs: Number(delay?.max ?? 0) / 1e6,
p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6,
p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6,
},
// Electron's Chromium-owned main message pump currently
// leaves Node's libuv ELU counters at zero. Preserve that
// as unavailable instead of reporting a misleading 0%.
eventLoopUtilization,
eventLoopUtilizationUnavailableReason:
eventLoopUtilization === null
? 'electron-main-embedded-event-loop'
: null,
heapSnapshotPath: state.mainSnapshotPath,
memory: {
peakHeapUsedBytes: state.mainPeakHeap,
peakRssBytes: state.mainPeakRss,
postGcHeapUsedBytes: state.postGcHeap,
postGcRssBytes: state.postGcRss,
},
rendererPeakRssBytes: state.rendererPeakRss,
responsiveEvents: state.responsiveEvents,
rssScope:
'electron-main-process-including-worker-threads-and-native-memory',
timeline: state.timeline,
unresponsiveEvents: state.unresponsiveEvents,
},
// Electron's Chromium-owned main message pump currently
// leaves Node's libuv ELU counters at zero. Preserve that
// as unavailable instead of reporting a misleading 0%.
eventLoopUtilization,
eventLoopUtilizationUnavailableReason:
eventLoopUtilization === null
? 'electron-main-embedded-event-loop'
: null,
heapSnapshotPath: state.mainSnapshotPath,
memory: {
peakHeapUsedBytes: state.mainPeakHeap,
peakRssBytes: state.mainPeakRss,
postGcHeapUsedBytes: state.postGcHeap,
postGcRssBytes: state.postGcRss,
},
rendererPeakRssBytes: state.rendererPeakRss,
responsiveEvents: state.responsiveEvents,
rssScope:
'electron-main-process-including-worker-threads-and-native-memory',
timeline: state.timeline,
unresponsiveEvents: state.unresponsiveEvents,
workers,
};
},
};
target[stateKey] = api;
}, MAIN_CAPTURE_STATE_KEY);
target[input.stateKey] = api;
}, captureStateKeys);
}
export async function startMainCapture(
@@ -861,11 +939,15 @@ export async function readMainCaptureStatus(
export async function stopMainCapture(
electronApp: ElectronApplication
): Promise<MainCaptureMetrics> {
return electronApp.evaluate(async (_electron, stateKey) => {
const target = globalThis as unknown as Record<string, unknown>;
const api = target[stateKey] as {
stop(): Promise<MainCaptureMetrics>;
};
return api.stop();
}, MAIN_CAPTURE_STATE_KEY);
const transport = await electronApp.evaluate(
async (_electron, stateKey) => {
const target = globalThis as unknown as Record<string, unknown>;
const api = target[stateKey] as {
stop(): Promise<MainCaptureGenerationTransport>;
};
return api.stop();
},
MAIN_CAPTURE_STATE_KEY
);
return selectMainCaptureGeneration(transport);
}
@@ -0,0 +1,259 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { join } from 'node:path';
interface TargetConfiguration {
configurations?: Record<string, Record<string, unknown>>;
dependsOn?: unknown;
executor?: unknown;
options?: Record<string, unknown>;
}
interface ProjectConfiguration {
targets: Record<string, TargetConfiguration>;
}
interface NxGraphTask {
outputs?: string[];
target: {
project: string;
target: string;
};
}
interface NxGraph {
tasks: {
tasks: Record<string, NxGraphTask>;
};
}
const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url));
function readProject(relativePath: string): ProjectConfiguration {
return JSON.parse(
readFileSync(join(workspaceRoot, relativePath), 'utf8')
) as ProjectConfiguration;
}
function readResolvedWebBuildTask(): NxGraphTask | undefined {
const environment = {
...process.env,
FORCE_COLOR: '0',
NX_DAEMON: 'false',
};
delete environment['NO_COLOR'];
const graph = JSON.parse(
execFileSync(
process.execPath,
[
join(workspaceRoot, 'node_modules/nx/dist/bin/nx.js'),
'run',
'web:build',
'--graph=stdout',
],
{
cwd: workspaceRoot,
encoding: 'utf8',
env: environment,
}
)
) as NxGraph;
return Object.values(graph.tasks.tasks).find(
(task) =>
task.target.project === 'web' && task.target.target === 'build'
);
}
const e2eProject = readProject('apps/electron-backend-e2e/project.json');
const electronProject = readProject('apps/electron-backend/project.json');
const remoteControlProject = readProject(
'apps/remote-control-web/project.json'
);
const webProject = readProject('apps/web/project.json');
test('the performance harness target runs only Node performance specs', () => {
const target = e2eProject.targets['test-performance-harness'];
assert.ok(
target,
'electron-backend-e2e must define test-performance-harness'
);
assert.equal(target.executor, 'nx:run-commands');
assert.equal(target.options?.['cwd'], 'apps/electron-backend-e2e');
assert.equal(
target.options?.['command'],
'pnpm exec tsx --test src/performance/*.spec.ts'
);
});
test('the web performance build keeps production renderer behavior with profiling source maps', () => {
const build = webProject.targets['build'];
const production = build.configurations?.['production'];
const performance = build.configurations?.['electron-performance'];
assert.ok(production, 'web:build must define production');
assert.ok(performance, 'web:build must define electron-performance');
assert.equal(performance['baseHref'], './');
assert.equal(performance['serviceWorker'], false);
assert.deepEqual(performance['optimization'], production['optimization']);
assert.equal(performance['outputHashing'], production['outputHashing']);
assert.deepEqual(
performance['fileReplacements'],
production['fileReplacements']
);
assert.equal(performance['sourceMap'], true);
});
test('the resolved web build cache output is the renderer directory', () => {
const task = readResolvedWebBuildTask();
assert.ok(task, 'the Nx graph must include web:build');
assert.deepEqual(task.outputs, ['dist/apps/web']);
});
test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => {
const build = remoteControlProject.targets['build'];
const production = build.configurations?.['production'];
const performance = build.configurations?.['electron-performance'];
assert.ok(production, 'remote-control-web:build must define production');
assert.ok(
performance,
'remote-control-web:build must define electron-performance'
);
assert.equal(performance['optimization'], true);
assert.equal(performance['outputHashing'], production['outputHashing']);
assert.equal(performance['sourceMap'], true);
});
test('the Electron performance build keeps production main-process behavior with profiling source maps', () => {
const build = electronProject.targets['build'];
const production = build.configurations?.['production'];
const performance = build.configurations?.['electron-performance'];
assert.ok(production, 'electron-backend:build must define production');
assert.ok(
performance,
'electron-backend:build must define electron-performance'
);
assert.equal(performance['optimization'], production['optimization']);
assert.equal(performance['inspect'], production['inspect']);
assert.deepEqual(
performance['fileReplacements'],
production['fileReplacements']
);
assert.equal(performance['sourceMap'], true);
});
test('the regular Electron build keeps its existing renderer dependency contract', () => {
const build = electronProject.targets['build'];
const dependencies = build.dependsOn as Array<
Record<string, unknown> | string
>;
const rendererBuild = dependencies.find(
(dependency): dependency is Record<string, unknown> =>
typeof dependency === 'object' &&
dependency !== null &&
dependency['target'] === 'build'
);
assert.deepEqual(rendererBuild, {
projects: ['web', 'remote-control-web'],
target: 'build',
});
});
test('the web performance wrapper selects the profiling configuration', () => {
const target = webProject.targets['build-performance'];
assert.ok(target, 'web must define build-performance');
assert.equal(target.executor, 'nx:run-commands');
assert.equal(
target.options?.['command'],
'pnpm nx run web:build:electron-performance'
);
});
test('the remote-control performance wrapper selects the profiling configuration', () => {
const target = remoteControlProject.targets['build-performance'];
assert.ok(target, 'remote-control-web must define build-performance');
assert.equal(target.executor, 'nx:run-commands');
assert.equal(
target.options?.['command'],
'pnpm nx run remote-control-web:build:electron-performance'
);
});
test('the Electron performance wrapper owns build dependencies before its profiled build', () => {
const target = electronProject.targets['build-performance'];
assert.ok(target, 'electron-backend must define build-performance');
assert.equal(target.executor, 'nx:run-commands');
assert.deepEqual(target.dependsOn, [
'electron-backend:build-worker-performance',
'electron-backend:build-embedded-mpv',
{
projects: ['web', 'remote-control-web'],
target: 'build-performance',
},
]);
assert.equal(
target.options?.['command'],
'pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies'
);
});
test('the Electron performance wrapper builds optimized source-mapped workers', () => {
const target = electronProject.targets['build-worker-performance'];
const source = readFileSync(
join(workspaceRoot, 'apps/electron-backend/build-worker.js'),
'utf8'
);
assert.ok(target, 'electron-backend must define build-worker-performance');
assert.equal(target.executor, 'nx:run-commands');
assert.equal(
target.options?.['command'],
'node apps/electron-backend/build-worker.js --performance'
);
assert.match(
source,
/const isPerformance = process\.argv\.includes\('--performance'\)/
);
assert.match(source, /minify: isProduction \|\| isPerformance/);
assert.match(source, /sourcemap: isPerformance \|\| !isProduction/);
});
test('the cancellation benchmark uses the production-equivalent performance build', () => {
const target = e2eProject.targets['benchmark-m3u-refresh-cancellation'];
assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']);
});
test('the cancellation benchmark command is pinned to its Playwright test file', () => {
const target = e2eProject.targets['benchmark-m3u-refresh-cancellation'];
assert.equal(
target.options?.['command'],
'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts'
);
});
test('the cancellation benchmark enables preload performance capture', () => {
const source = readFileSync(
join(
workspaceRoot,
'apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts'
),
'utf8'
);
assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/);
});
@@ -0,0 +1,172 @@
/* 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 type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract';
import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance';
const validCapture = {
eventLoopDelay: { maxMs: 6, p95Ms: 5, p99Ms: 5.5 },
eventLoopDelayUnavailableReason: null,
eventLoopUtilization: 0.5,
eventLoopUtilizationUnavailableReason: null,
histogramFlushedEpochMs: 1_050,
invalidReason: null,
requestReceivedEpochMs: 1_000,
threadCpuSystemMicros: 50,
threadCpuUnavailableReason: null,
threadCpuUserMicros: 100,
workEndedEpochMs: 1_040,
workStartedEpochMs: 1_010,
} as const;
function normalize(performanceCapture: unknown) {
return normalizeWorkerRequestPerformanceOutcome({
operation: 'DB_GET_APP_PLAYLIST',
operationId: 'operation-42',
operationIdUnavailableReason: null,
performanceCapture,
playlistId: 'playlist-1',
requestId: 'request-1',
responseEpochMs: 1_060,
success: true,
});
}
function assertClosedCapture(
capture: WorkerRequestPerformanceMetrics,
reason: string
): void {
assert.equal(capture.performanceCaptureUnavailableReason, reason);
assert.equal(capture.operationId, 'operation-42');
for (const value of [
capture.eventLoopDelay,
capture.eventLoopDelayUnavailableReason,
capture.eventLoopUtilization,
capture.eventLoopUtilizationUnavailableReason,
capture.histogramFlushedEpochMs,
capture.invalidReason,
capture.requestReceivedEpochMs,
capture.threadCpuSystemMicros,
capture.threadCpuUnavailableReason,
capture.threadCpuUserMicros,
capture.workEndedEpochMs,
capture.workStartedEpochMs,
]) {
assert.equal(value, null);
}
}
test('absent captures are missing while present non-record captures are invalid', () => {
for (const missing of [undefined, null]) {
assertClosedCapture(
normalize(missing),
'worker-performance-capture-missing'
);
}
for (const malformed of [42, [], 'capture']) {
assertClosedCapture(
normalize(malformed),
'worker-performance-capture-invalid'
);
}
});
test('malformed nested fields and cross-field invariants fail the whole capture closed', () => {
const malformed = [
{ ...validCapture, eventLoopDelay: [] },
{
...validCapture,
eventLoopDelay: { maxMs: 6, p95Ms: 'bad', p99Ms: 5.5 },
},
{
...validCapture,
eventLoopDelay: { maxMs: 5, p95Ms: 6, p99Ms: 5.5 },
},
{ ...validCapture, eventLoopDelayUnavailableReason: 42 },
{
...validCapture,
eventLoopDelay: null,
eventLoopDelayUnavailableReason: null,
histogramFlushedEpochMs: null,
},
{
...validCapture,
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
'event-loop-delay-capture-unavailable',
},
{ ...validCapture, eventLoopUtilization: 1.01 },
{
...validCapture,
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason: null,
},
{ ...validCapture, threadCpuSystemMicros: null },
{
...validCapture,
threadCpuSystemMicros: null,
threadCpuUnavailableReason: null,
threadCpuUserMicros: null,
},
{ ...validCapture, threadCpuUnavailableReason: 'unavailable' },
{ ...validCapture, histogramFlushedEpochMs: null },
{
...validCapture,
eventLoopDelay: null,
eventLoopDelayUnavailableReason: 'event-loop-delay-invalid',
histogramFlushedEpochMs: null,
},
{ ...validCapture, histogramFlushedEpochMs: 1_039 },
{ ...validCapture, workStartedEpochMs: 999 },
{
...validCapture,
invalidReason: 'overlapping-database-worker-requests',
},
{ ...validCapture, threadCpuUserMicros: undefined },
];
for (const performanceCapture of malformed) {
assertClosedCapture(
normalize(performanceCapture),
'worker-performance-capture-invalid'
);
}
});
test('coherent metric-unavailable and invalid worker results remain valid raw captures', () => {
const unavailable = {
...validCapture,
eventLoopDelay: null,
eventLoopDelayUnavailableReason: 'event-loop-delay-capture-unavailable',
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason:
'event-loop-utilization-unavailable',
histogramFlushedEpochMs: null,
threadCpuSystemMicros: null,
threadCpuUnavailableReason: 'thread-cpu-usage-unavailable',
threadCpuUserMicros: null,
};
for (const capture of [
unavailable,
{
...validCapture,
eventLoopDelay: null,
eventLoopDelayUnavailableReason: 'event-loop-delay-invalid',
},
]) {
const accepted = normalize(capture);
assert.equal(accepted.performanceCaptureUnavailableReason, null);
assert.equal(accepted.requestReceivedEpochMs, 1_000);
}
const invalidReason = 'overlapping-database-worker-requests';
const legitimateInvalid = normalize({
...unavailable,
eventLoopDelayUnavailableReason: invalidReason,
eventLoopUtilizationUnavailableReason: invalidReason,
invalidReason,
threadCpuUnavailableReason: invalidReason,
});
assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null);
assert.equal(legitimateInvalid.invalidReason, invalidReason);
});
@@ -0,0 +1,302 @@
/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
type CancellationBenchmarkManifest,
type CancellationIterationResult,
PERFORMANCE_ITERATION_KIND,
PERFORMANCE_WORKER_KIND,
type WorkerCaptureMetrics,
type WorkerRequestPerformanceMetrics,
} from './m3u-refresh-cancellation-contract';
import {
normalizeWorkerRequestPerformanceOutcome,
selectMainCaptureGeneration,
} from './worker-request-performance';
import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report';
type RequestPerformanceFixture = WorkerRequestPerformanceMetrics;
function requestPerformance(
requestId: string,
p95Ms: number,
threadCpuUserMicros: number
): RequestPerformanceFixture {
return normalizeWorkerRequestPerformanceOutcome(
requestTransport(
{
eventLoopDelay: {
maxMs: p95Ms + 1,
p95Ms,
p99Ms: p95Ms + 0.5,
},
eventLoopDelayUnavailableReason: null,
eventLoopUtilization: p95Ms / 100,
eventLoopUtilizationUnavailableReason: null,
histogramFlushedEpochMs: 1_050 + p95Ms,
invalidReason: null,
requestReceivedEpochMs: 1_000,
threadCpuSystemMicros: threadCpuUserMicros / 2,
threadCpuUnavailableReason: null,
threadCpuUserMicros,
workEndedEpochMs: 1_040,
workStartedEpochMs: 1_010,
},
requestId
)
);
}
function requestTransport(
performanceCapture: unknown,
requestId = 'request-1'
) {
return {
operation: 'DB_GET_APP_PLAYLIST',
operationId: 'operation-42',
operationIdUnavailableReason: null,
performanceCapture,
playlistId: 'playlist-1',
requestId,
responseEpochMs: 1_060,
success: true,
} as const;
}
function assertClosedCapture(
capture: WorkerRequestPerformanceMetrics,
reason: string
): void {
assert.equal(capture.performanceCaptureUnavailableReason, reason);
assert.equal(capture.operationId, 'operation-42');
for (const value of [
capture.eventLoopDelay,
capture.eventLoopDelayUnavailableReason,
capture.eventLoopUtilization,
capture.eventLoopUtilizationUnavailableReason,
capture.histogramFlushedEpochMs,
capture.invalidReason,
capture.requestReceivedEpochMs,
capture.threadCpuSystemMicros,
capture.threadCpuUnavailableReason,
capture.threadCpuUserMicros,
capture.workEndedEpochMs,
capture.workStartedEpochMs,
]) {
assert.equal(value, null);
}
}
function databaseWorker(
requests: readonly RequestPerformanceFixture[]
): WorkerCaptureMetrics {
return {
kind: PERFORMANCE_WORKER_KIND.DATABASE,
operationId: null,
peakExternalBytes: 0,
peakHeapUsedBytes: 0,
playlistId: null,
postGcHeapUsedBytes: null,
requests,
responseEpochMs: null,
terminatedEpochMs: null,
} as WorkerCaptureMetrics;
}
function playlistWorker(
request: RequestPerformanceFixture,
operationId: string,
terminatedEpochMs: number
): WorkerCaptureMetrics {
return {
...databaseWorker([request]),
kind: PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH,
operationId,
playlistId: request.playlistId,
responseEpochMs: request.responseEpochMs,
terminatedEpochMs,
};
}
function measuredIteration(
workers: readonly WorkerCaptureMetrics[]
): CancellationIterationResult {
return {
cancellationEffectObserved: true,
kind: PERFORMANCE_ITERATION_KIND.MEASURED,
main: {
eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 },
eventLoopUtilization: null,
memory: {
peakHeapUsedBytes: 0,
peakRssBytes: 0,
postGcHeapUsedBytes: null,
postGcRssBytes: null,
},
rendererPeakRssBytes: 0,
responsiveEvents: 0,
unresponsiveEvents: 0,
workers,
} as CancellationIterationResult['main'],
phases: {} as CancellationIterationResult['phases'],
renderer: {
peakHeapUsedBytes: 0,
postGcHeapUsedBytes: null,
probe: {
frameGapsMs: [],
heartbeatDelaysMs: [],
longTasksMs: [],
},
} as CancellationIterationResult['renderer'],
runId: 'measured-1',
};
}
test('summary distributions use every request capture instead of a worker-level percentile maximum', () => {
const requests = [
requestPerformance('request-1', 5, 100),
requestPerformance('request-2', 15, 300),
];
const summary = createCancellationBenchmarkSummary(
{} as CancellationBenchmarkManifest,
[measuredIteration([databaseWorker(requests)])]
);
const measured = summary.measured as unknown as Record<
string,
{ count: number; max: number | null; min: number | null }
>;
assert.deepEqual(
summary.measured.databaseWorkerRequestEventLoopDelayP95Ms,
{
count: 2,
max: 15,
mean: 10,
median: 10,
min: 5,
p95: 14.5,
p99: 14.9,
}
);
assert.deepEqual(measured['databaseWorkerRequestThreadCpuUserMicros'], {
count: 2,
max: 300,
mean: 200,
median: 200,
min: 100,
p95: 290,
p99: 298,
});
assert.equal(
summary.iterations[0]?.main.workers[0]?.requests[0]?.operationId,
'operation-42'
);
assert.equal(
'databaseWorkerEventLoopDelayP95Ms' in summary.measured,
false,
'request-percentile distributions must not look like an operation-wide percentile'
);
});
test('main capture retains raw request identity, timestamps, metrics, and reasons', () => {
const source = readFileSync(
new URL('./m3u-refresh-main-capture.ts', import.meta.url),
'utf8'
);
assert.match(source, /requestPerformance: \[\]/);
assert.match(source, /record\.requestPerformance\.length > 0/);
assert.match(source, /record\.requestPerformance\.map/);
assert.match(source, /operationId: request\.identity\.operationId/);
assert.match(source, /identity: request\.identity/);
assert.match(source, /performanceCapture:\s+message\['performance'\]/);
assert.match(source, /captureGeneration: state\.captureGeneration/);
assert.doesNotMatch(
source,
/record\.eventLoopDelay = record\.eventLoopDelay/
);
});
test('capture-generation selection excludes a pre-start seed worker', () => {
const seed = playlistWorker(
requestPerformance('seed-request', 999, 999),
'seed-operation',
1_100
);
const measured = playlistWorker(
{
...requestPerformance('measured-request', 8, 80),
success: false,
},
'measured-cancel-operation',
2_100
);
const mainMetrics = measuredIteration([]).main;
const toTransportWorker = (
captureGeneration: number | null,
worker: WorkerCaptureMetrics
) => ({
captureGeneration,
metrics: worker,
requests: worker.requests.map((request) => ({
...requestTransport(request, request.requestId ?? undefined),
success: request.success,
})),
});
const selected = selectMainCaptureGeneration({
captureGeneration: 2,
metrics: mainMetrics,
workers: [
toTransportWorker(null, seed),
toTransportWorker(2, measured),
],
});
assert.equal(selected.workers.length, 1);
assert.equal(
selected.workers[0]?.requests[0]?.requestId,
'measured-request'
);
assert.equal(selected.workers[0]?.operationId, 'measured-cancel-operation');
assert.equal(selected.workers[0]?.terminatedEpochMs, 2_100);
});
test('raw outcomes retain missing and malformed captures while summaries exclude their null metrics', () => {
const valid = normalizeWorkerRequestPerformanceOutcome(
requestTransport(requestPerformance('request-1', 5, 100))
);
const missing = normalizeWorkerRequestPerformanceOutcome(
requestTransport(null, 'request-2')
);
const malformed = normalizeWorkerRequestPerformanceOutcome(
requestTransport(
{
requestReceivedEpochMs: 'invalid',
workEndedEpochMs: 1_080,
workStartedEpochMs: 1_075,
},
'request-3'
)
);
const worker = databaseWorker([valid, missing, malformed]);
const summary = createCancellationBenchmarkSummary(
{} as CancellationBenchmarkManifest,
[measuredIteration([worker])]
);
assert.equal(worker.requests.length, 3);
assertClosedCapture(
worker.requests[1] as WorkerRequestPerformanceMetrics,
'worker-performance-capture-missing'
);
assertClosedCapture(
worker.requests[2] as WorkerRequestPerformanceMetrics,
'worker-performance-capture-invalid'
);
assert.equal(
summary.measured.databaseWorkerRequestEventLoopDelayP95Ms.count,
1
);
assert.equal(
summary.measured.databaseWorkerRequestThreadCpuUserMicros.count,
1
);
});
@@ -0,0 +1,302 @@
import type {
EventLoopDelayMetrics,
MainCaptureMetrics,
WorkerCaptureMetrics,
WorkerRequestPerformanceMetrics,
} from './m3u-refresh-cancellation-contract';
export interface WorkerRequestPerformanceOutcomeTransport {
readonly operation: string | null;
readonly operationId: string | null;
readonly operationIdUnavailableReason: string | null;
readonly performanceCapture: unknown;
readonly playlistId: string | null;
readonly requestId: string | null;
readonly responseEpochMs: number;
readonly success: boolean;
}
export interface MainCaptureGenerationTransport {
readonly captureGeneration: number;
readonly metrics: Omit<MainCaptureMetrics, 'workers'>;
readonly workers: readonly {
readonly captureGeneration: number | null;
readonly metrics: Omit<
WorkerCaptureMetrics,
'eventLoopDelay' | 'eventLoopDelayUnavailableReason' | 'requests'
>;
readonly requests: readonly WorkerRequestPerformanceOutcomeTransport[];
}[];
}
type ParsedWorkerPerformanceCapture = Pick<
WorkerRequestPerformanceMetrics,
| 'eventLoopDelay'
| 'eventLoopDelayUnavailableReason'
| 'eventLoopUtilization'
| 'eventLoopUtilizationUnavailableReason'
| 'histogramFlushedEpochMs'
| 'invalidReason'
| 'requestReceivedEpochMs'
| 'threadCpuSystemMicros'
| 'threadCpuUnavailableReason'
| 'threadCpuUserMicros'
| 'workEndedEpochMs'
| 'workStartedEpochMs'
>;
const INVALID_REASONS = new Set(['overlapping-database-worker-requests']);
const EVENT_LOOP_DELAY_REASONS = new Set([
...INVALID_REASONS,
'event-loop-delay-arm-timeout',
'event-loop-delay-capture-unavailable',
'event-loop-delay-flush-timeout',
'event-loop-delay-invalid',
]);
const EVENT_LOOP_UTILIZATION_REASONS = new Set([
...INVALID_REASONS,
'event-loop-utilization-unavailable',
]);
const THREAD_CPU_REASONS = new Set([
...INVALID_REASONS,
'thread-cpu-usage-invalid',
'thread-cpu-usage-unavailable',
]);
function isRecord(input: unknown): input is Record<string, unknown> {
return typeof input === 'object' && input !== null && !Array.isArray(input);
}
function isFiniteNonNegativeNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
function isNullableFiniteNonNegativeNumber(
value: unknown
): value is number | null {
return value === null || isFiniteNonNegativeNumber(value);
}
function isNullableReason(
value: unknown,
allowed: ReadonlySet<string>
): value is string | null {
return value === null || (typeof value === 'string' && allowed.has(value));
}
function parseEventLoopDelay(
input: unknown
): EventLoopDelayMetrics | null | undefined {
if (input === null) {
return null;
}
if (!isRecord(input)) {
return undefined;
}
const maxMs = input['maxMs'];
const p95Ms = input['p95Ms'];
const p99Ms = input['p99Ms'];
if (
!isFiniteNonNegativeNumber(maxMs) ||
!isFiniteNonNegativeNumber(p95Ms) ||
!isFiniteNonNegativeNumber(p99Ms) ||
p95Ms > p99Ms ||
p99Ms > maxMs
) {
return undefined;
}
return { maxMs, p95Ms, p99Ms };
}
function parseWorkerPerformanceCapture(
input: unknown
): ParsedWorkerPerformanceCapture | null {
if (!isRecord(input)) {
return null;
}
const eventLoopDelay = parseEventLoopDelay(input['eventLoopDelay']);
const eventLoopDelayUnavailableReason =
input['eventLoopDelayUnavailableReason'];
const eventLoopUtilization = input['eventLoopUtilization'];
const eventLoopUtilizationUnavailableReason =
input['eventLoopUtilizationUnavailableReason'];
const histogramFlushedEpochMs = input['histogramFlushedEpochMs'];
const invalidReason = input['invalidReason'];
const requestReceivedEpochMs = input['requestReceivedEpochMs'];
const threadCpuSystemMicros = input['threadCpuSystemMicros'];
const threadCpuUnavailableReason = input['threadCpuUnavailableReason'];
const threadCpuUserMicros = input['threadCpuUserMicros'];
const workEndedEpochMs = input['workEndedEpochMs'];
const workStartedEpochMs = input['workStartedEpochMs'];
if (
eventLoopDelay === undefined ||
!isNullableReason(
eventLoopDelayUnavailableReason,
EVENT_LOOP_DELAY_REASONS
) ||
!isNullableFiniteNonNegativeNumber(eventLoopUtilization) ||
(eventLoopUtilization !== null && eventLoopUtilization > 1) ||
!isNullableReason(
eventLoopUtilizationUnavailableReason,
EVENT_LOOP_UTILIZATION_REASONS
) ||
!isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) ||
!isNullableReason(invalidReason, INVALID_REASONS) ||
!isFiniteNonNegativeNumber(requestReceivedEpochMs) ||
!isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) ||
!isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) ||
!isNullableFiniteNonNegativeNumber(threadCpuUserMicros) ||
!isFiniteNonNegativeNumber(workEndedEpochMs) ||
!isFiniteNonNegativeNumber(workStartedEpochMs)
) {
return null;
}
const timestampsAreOrdered =
requestReceivedEpochMs <= workStartedEpochMs &&
workStartedEpochMs <= workEndedEpochMs &&
(histogramFlushedEpochMs === null ||
workEndedEpochMs <= histogramFlushedEpochMs);
const eventLoopDelayIsCoherent =
eventLoopDelay === null
? eventLoopDelayUnavailableReason !== null &&
(eventLoopDelayUnavailableReason === 'event-loop-delay-invalid'
? histogramFlushedEpochMs !== null
: histogramFlushedEpochMs === null)
: eventLoopDelayUnavailableReason === null &&
histogramFlushedEpochMs !== null;
const eventLoopUtilizationIsCoherent =
eventLoopUtilization === null
? eventLoopUtilizationUnavailableReason !== null
: eventLoopUtilizationUnavailableReason === null;
const threadCpuIsCoherent =
threadCpuSystemMicros === null && threadCpuUserMicros === null
? threadCpuUnavailableReason !== null
: threadCpuSystemMicros !== null &&
threadCpuUserMicros !== null &&
threadCpuUnavailableReason === null;
const invalidCaptureIsCoherent =
invalidReason === null ||
(eventLoopDelay === null &&
eventLoopDelayUnavailableReason === invalidReason &&
eventLoopUtilization === null &&
eventLoopUtilizationUnavailableReason === invalidReason &&
histogramFlushedEpochMs === null &&
threadCpuSystemMicros === null &&
threadCpuUnavailableReason === invalidReason &&
threadCpuUserMicros === null);
if (
!timestampsAreOrdered ||
!eventLoopDelayIsCoherent ||
!eventLoopUtilizationIsCoherent ||
!threadCpuIsCoherent ||
!invalidCaptureIsCoherent
) {
return null;
}
return {
eventLoopDelay,
eventLoopDelayUnavailableReason,
eventLoopUtilization,
eventLoopUtilizationUnavailableReason,
histogramFlushedEpochMs,
invalidReason,
requestReceivedEpochMs,
threadCpuSystemMicros,
threadCpuUnavailableReason,
threadCpuUserMicros,
workEndedEpochMs,
workStartedEpochMs,
};
}
export function normalizeWorkerRequestPerformanceOutcome(
input: WorkerRequestPerformanceOutcomeTransport
): WorkerRequestPerformanceMetrics {
const identity = {
operation: input.operation,
operationId: input.operationId,
operationIdUnavailableReason: input.operationIdUnavailableReason,
playlistId: input.playlistId,
requestId: input.requestId,
responseEpochMs: input.responseEpochMs,
success: input.success,
};
const unavailable = (
reason:
| 'worker-performance-capture-invalid'
| 'worker-performance-capture-missing'
): WorkerRequestPerformanceMetrics => ({
eventLoopDelay: null,
eventLoopDelayUnavailableReason: null,
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason: null,
histogramFlushedEpochMs: null,
invalidReason: null,
...identity,
performanceCaptureUnavailableReason: reason,
requestReceivedEpochMs: null,
threadCpuSystemMicros: null,
threadCpuUnavailableReason: null,
threadCpuUserMicros: null,
workEndedEpochMs: null,
workStartedEpochMs: null,
});
if (
input.performanceCapture === undefined ||
input.performanceCapture === null
) {
return unavailable('worker-performance-capture-missing');
}
const performanceCapture = parseWorkerPerformanceCapture(
input.performanceCapture
);
if (!performanceCapture) {
return unavailable('worker-performance-capture-invalid');
}
return {
...performanceCapture,
...identity,
performanceCaptureUnavailableReason: null,
};
}
export function selectMainCaptureGeneration(
input: MainCaptureGenerationTransport
): MainCaptureMetrics {
const workers = input.workers
.filter(
(worker) => worker.captureGeneration === input.captureGeneration
)
.map((worker): WorkerCaptureMetrics => {
const requests = worker.requests.map(
normalizeWorkerRequestPerformanceOutcome
);
const onlyRequest =
requests.length === 1 ? (requests[0] ?? null) : null;
return {
...worker.metrics,
eventLoopDelay: onlyRequest?.eventLoopDelay ?? null,
eventLoopDelayUnavailableReason: onlyRequest
? (onlyRequest.eventLoopDelayUnavailableReason ??
onlyRequest.performanceCaptureUnavailableReason ??
'worker-self-profile-result-unavailable')
: requests.length > 1
? 'multiple-request-scoped-captures'
: worker.metrics.terminatedEpochMs !== null
? 'worker-terminated-before-profile-flush'
: 'worker-self-profile-result-unavailable',
requests,
};
});
return {
...input.metrics,
workers,
};
}
+9 -3
View File
@@ -18,6 +18,12 @@ const nativeModules = [
];
const isProduction = process.env.NODE_ENV === 'production';
const isPerformance = process.argv.includes('--performance');
const buildMode = isPerformance
? 'performance'
: isProduction
? 'production'
: 'development';
async function buildWorker() {
try {
@@ -59,7 +65,7 @@ async function buildWorker() {
for (const worker of workers) {
console.log(
`Building ${worker.label} with esbuild (${isProduction ? 'production' : 'development'})...`
`Building ${worker.label} with esbuild (${buildMode})...`
);
await esbuild.build({
@@ -74,8 +80,8 @@ async function buildWorker() {
...nodeBuiltins,
...nativeModules,
],
sourcemap: !isProduction,
minify: isProduction,
sourcemap: isPerformance || !isProduction,
minify: isProduction || isPerformance,
alias: {
'@iptvnator/shared/interfaces': path.join(
__dirname,
+33
View File
@@ -12,6 +12,13 @@
"command": "node apps/electron-backend/build-worker.js"
}
},
"build-worker-performance": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"],
"options": {
"command": "node apps/electron-backend/build-worker.js --performance"
}
},
"build-embedded-mpv": {
"executor": "nx:run-commands",
"cache": false,
@@ -81,9 +88,35 @@
"with": "apps/electron-backend/src/environments/environment.prod.ts"
}
]
},
"electron-performance": {
"optimization": true,
"extractLicenses": true,
"inspect": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/electron-backend/src/environments/environment.ts",
"with": "apps/electron-backend/src/environments/environment.prod.ts"
}
]
}
}
},
"build-performance": {
"dependsOn": [
"electron-backend:build-worker-performance",
"electron-backend:build-embedded-mpv",
{
"projects": ["web", "remote-control-web"],
"target": "build-performance"
}
],
"executor": "nx:run-commands",
"options": {
"command": "pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies"
}
},
"build-e2e": {
"dependsOn": [
"electron-backend:build-worker",
@@ -0,0 +1,343 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_MARKER_CHANNEL,
PRELOAD_PERFORMANCE_METHOD,
type Playlist,
type PreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import {
createPlaylist,
createPreloadPerformanceHarness,
createRefreshPayload,
expectFixedMarkers,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
describe('main preload performance marker contract', () => {
let harness: PreloadPerformanceHarness;
beforeEach(() => {
harness = createPreloadPerformanceHarness();
});
afterEach(() => {
harness.cleanup();
});
async function loadPerformancePreload(): Promise<void> {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
}
it('preserves calls while emitting ordered, correlated, fixed, redacted markers', async () => {
const secrets = {
favorite: 'sentinel-favorite-secret',
filePath: '/sentinel/private/playlist.m3u',
host: 'sentinel.private.example',
item: 'sentinel-item-secret',
result: 'sentinel-result-secret',
title: 'sentinel-title-secret',
url: 'https://sentinel.example/private.m3u?token=secret',
};
const payload = createRefreshPayload({
filePath: secrets.filePath,
title: secrets.title,
trustedInsecureTlsHosts: [secrets.host],
url: secrets.url,
});
const refreshedPlaylist = createPlaylist(payload.playlistId, {
favorites: [secrets.favorite],
items: [{ name: secrets.item }],
title: secrets.title,
url: secrets.url,
});
const storedPlaylist = createPlaylist(payload.playlistId, {
filePath: secrets.filePath,
title: secrets.title,
});
const upsertPlaylist = createPlaylist(payload.playlistId, {
favorites: [secrets.favorite],
items: [{ name: secrets.item }],
title: secrets.title,
});
const upsertResult = {
secretResult: secrets.result,
success: true,
};
await loadPerformancePreload();
harness.ipcRenderer.invoke
.mockResolvedValueOnce(refreshedPlaylist)
.mockResolvedValueOnce(storedPlaylist)
.mockResolvedValueOnce(upsertResult);
const api = harness.getApi();
await expect(api.refreshPlaylist(payload)).resolves.toBe(
refreshedPlaylist
);
await expect(
api.dbGetAppPlaylist(payload.playlistId, payload.operationId)
).resolves.toBe(storedPlaylist);
await expect(
api.dbUpsertAppPlaylist(upsertPlaylist, payload.operationId)
).resolves.toBe(upsertResult);
expect(harness.ipcRenderer.invoke.mock.calls).toEqual([
['PLAYLIST:REFRESH', payload],
['DB_GET_APP_PLAYLIST', payload.playlistId],
['DB_UPSERT_APP_PLAYLIST', upsertPlaylist],
]);
const markers = harness.getMarkers();
expect(markers).toHaveLength(6);
expect(
markers.map(({ ipcCallId, method, phase }) => [
ipcCallId,
method,
phase,
])
).toEqual([
[1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'start'],
[1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'success'],
[2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'start'],
[2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'success'],
[3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'start'],
[3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'success'],
]);
expect(markers.map(({ correlationState }) => correlationState)).toEqual(
[
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
]
);
expect(markers.map(({ operationId }) => operationId)).toEqual(
Array(6).fill(payload.operationId)
);
expect(markers.map(({ playlistId }) => playlistId)).toEqual(
Array(6).fill(payload.playlistId)
);
expect(markers.map(({ invalidReason }) => invalidReason)).toEqual(
Array(6).fill(null)
);
expectFixedMarkers(markers);
const markerSendOrders = harness.ipcRenderer.send.mock.calls.flatMap(
([channel], index) =>
channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL
? [harness.ipcRenderer.send.mock.invocationCallOrder[index]]
: []
);
const invokeOrders =
harness.ipcRenderer.invoke.mock.invocationCallOrder;
for (const [index, invokeOrder] of invokeOrders.entries()) {
expect(markerSendOrders[index * 2]).toBeLessThan(invokeOrder);
}
const serializedMarkers = JSON.stringify(markers);
for (const secret of Object.values(secrets)) {
expect(serializedMarkers).not.toContain(secret);
}
});
it('preserves rejection identity and emits a redacted error', async () => {
const payload = createRefreshPayload({
title: 'sentinel-rejection-title',
url: 'https://sentinel.example/rejected.m3u',
});
const rejection = new Error('sentinel-rejection-error');
await loadPerformancePreload();
harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection);
await expect(harness.getApi().refreshPlaylist(payload)).rejects.toBe(
rejection
);
const markers = harness.getMarkers();
expect(markerPhases(markers)).toEqual([
[1, 'start'],
[1, 'error'],
]);
expect(markers[1]).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR,
operationId: payload.operationId,
playlistId: payload.playlistId,
});
expect(JSON.stringify(markers)).not.toContain(rejection.message);
expect(JSON.stringify(markers)).not.toContain(payload.title);
expect(JSON.stringify(markers)).not.toContain(payload.url);
});
it('preserves synchronous throw identity and emits a redacted error', async () => {
const synchronousError = new Error('sentinel-synchronous-error');
const payload = createRefreshPayload();
await loadPerformancePreload();
harness.ipcRenderer.invoke.mockImplementationOnce(() => {
throw synchronousError;
});
let caughtError: unknown;
try {
harness.getApi().refreshPlaylist(payload);
} catch (error) {
caughtError = error;
}
expect(caughtError).toBe(synchronousError);
const markers = harness.getMarkers();
expect(markerPhases(markers)).toEqual([
[1, 'start'],
[1, 'error'],
]);
expect(markers[1]).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR,
});
expect(JSON.stringify(markers)).not.toContain(synchronousError.message);
});
it('closes a structured refresh cancellation with a fixed reason', async () => {
const payload = createRefreshPayload();
const cancelledResult = {
operationId: payload.operationId,
type: 'playlist-refresh-cancelled',
} as const;
await loadPerformancePreload();
harness.ipcRenderer.invoke
.mockResolvedValueOnce(cancelledResult)
.mockResolvedValueOnce(null);
const api = harness.getApi();
await expect(api.refreshPlaylist(payload)).resolves.toBe(
cancelledResult
);
await api.dbGetAppPlaylist(payload.playlistId);
const markers = harness.getMarkers();
expect(markers[1]).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED,
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: payload.operationId,
phase: 'success',
playlistId: payload.playlistId,
});
expect(markers[2]).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: null,
phase: 'start',
});
});
it('uses the exact upsert _id and keeps one malformed reason in both phases', async () => {
const malformedPlaylist = {
...createPlaylist('placeholder'),
_id: {
secret: 'sentinel-malformed-playlist-id',
},
} as unknown as Playlist;
const result = { success: true };
await loadPerformancePreload();
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
await expect(
harness.getApi().dbUpsertAppPlaylist(malformedPlaylist)
).resolves.toBe(result);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith(
'DB_UPSERT_APP_PLAYLIST',
malformedPlaylist
);
const markers = harness.getMarkers();
expect(
markers.map(
({ correlationState, invalidReason, phase, playlistId }) => ({
correlationState,
invalidReason,
phase,
playlistId,
})
)
).toEqual([
{
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER,
phase: 'start',
playlistId: null,
},
{
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER,
phase: 'success',
playlistId: null,
},
]);
expect(markers[0].ipcCallId).toBe(markers[1].ipcCallId);
expect(JSON.stringify(markers)).not.toContain(
'sentinel-malformed-playlist-id'
);
});
it('poisons correlation after the first marker delivery failure', async () => {
const payload = createRefreshPayload();
const result = createPlaylist(payload.playlistId);
await loadPerformancePreload();
harness.ipcRenderer.invoke
.mockResolvedValueOnce(result)
.mockResolvedValueOnce(result)
.mockResolvedValueOnce({ success: true });
harness.ipcRenderer.send.mockImplementationOnce((channel: string) => {
if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) {
throw new Error('marker-send-failed');
}
});
await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe(
result
);
await expect(
harness
.getApi()
.dbGetAppPlaylist(payload.playlistId, payload.operationId)
).resolves.toBe(result);
await expect(
harness.getApi().dbUpsertAppPlaylist(result, payload.operationId)
).resolves.toEqual({ success: true });
expect(
harness.ipcRenderer.send.mock.calls.filter(
([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL
)
).toHaveLength(1);
expect(harness.getMarkers()).toHaveLength(1);
expect(harness.getMarkers()[0]).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
phase: 'start',
});
expect(
harness
.getMarkers()
.some(
({ correlationState }) =>
correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE
)
).toBe(false);
});
});
function markerPhases(
markers: PreloadPerformanceMarker[]
): [number, PreloadPerformanceMarker['phase']][] {
return markers.map(({ ipcCallId, phase }) => [ipcCallId, phase]);
}
@@ -0,0 +1,247 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_MARKER_CHANNEL,
PRELOAD_PERFORMANCE_METHOD,
type Settings,
Theme,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace';
import {
createPreloadPerformanceHarness,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
describe('main preload performance marker gates', () => {
let harness: PreloadPerformanceHarness;
beforeEach(() => {
harness = createPreloadPerformanceHarness();
});
afterEach(() => {
harness.cleanup();
jest.restoreAllMocks();
});
it.each([undefined, '', '0', 'false', 'no', 'off'])(
'emits no marker when the performance flag is %s',
async (flagValue) => {
await harness.load(
flagValue === undefined
? {}
: {
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue,
}
);
await harness.getApi().dbGetAppPlaylist('playlist-1');
expect(harness.getMarkers()).toEqual([]);
}
);
it.each(['1', ' TRUE ', 'yes', 'On'])(
'accepts the existing true flag value %s',
async (flagValue) => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue,
});
await harness.getApi().dbGetAppPlaylist('playlist-1');
expect(
harness.getMarkers().map(({ correlationState, phase }) => ({
correlationState,
phase,
}))
).toEqual([
{
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
phase: 'start',
},
{
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
phase: 'success',
},
]);
}
);
it('keeps trace-only calls on the existing debug channel', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
await harness.getApi().dbGetAppPlaylist('playlist-1');
expect(
harness
.getSentPayloads<
Record<string, unknown>
>(DEBUG_TRACE_EVENT_CHANNEL)
.map(({ method, phase }) => ({ method, phase }))
).toEqual([
{
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
phase: 'start',
},
{
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
phase: 'success',
},
]);
expect(harness.getMarkers()).toEqual([]);
});
it('keeps debug tracing unchanged when both gates are enabled', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
await harness.getApi().dbGetAppPlaylist('playlist-1');
expect(
harness
.getSentPayloads<
Record<string, unknown>
>(DEBUG_TRACE_EVENT_CHANNEL)
.map(({ method, phase }) => ({ method, phase }))
).toEqual([
{
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
phase: 'start',
},
{
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
phase: 'success',
},
]);
expect(
harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL).length
).toBe(2);
});
it('does not inspect or mark non-target calls in performance-only mode', async () => {
const result = { success: true };
const settings = Object.defineProperty({}, 'sentinel', {
enumerable: true,
get: () => {
throw new Error('non-target arguments must stay opaque');
},
}) as Partial<Settings>;
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
await expect(harness.getApi().updateSettings(settings)).resolves.toBe(
result
);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith(
'SETTINGS_UPDATE',
settings
);
expect(harness.getMarkers()).toEqual([]);
});
it('preserves the full legacy trace payload for non-target success', async () => {
const settings = {
password: 'sentinel-argument-secret',
theme: Theme.DarkTheme,
} as Partial<Settings>;
const result = {
password: 'sentinel-result-secret',
success: true,
};
await harness.load({
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
jest.spyOn(globalThis.performance, 'now')
.mockReturnValueOnce(10)
.mockReturnValueOnce(12.34);
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
await expect(harness.getApi().updateSettings(settings)).resolves.toBe(
result
);
const traces = harness.getSentPayloads<Record<string, unknown>>(
DEBUG_TRACE_EVENT_CHANNEL
);
expect(traces).toEqual([
{
args: {
items: [
{
password: 'sentinel-argument-secret',
theme: Theme.DarkTheme,
},
],
length: 1,
type: 'array',
},
method: 'updateSettings',
phase: 'start',
},
{
durationMs: 2.3,
method: 'updateSettings',
phase: 'success',
result,
},
]);
const redacted = JSON.stringify(redactSensitiveData(traces));
expect(redacted).not.toContain('sentinel-argument-secret');
expect(redacted).not.toContain('sentinel-result-secret');
expect(harness.getMarkers()).toEqual([]);
});
it('preserves the full legacy trace payload for non-target errors', async () => {
const rejection = new Error('token=sentinel-error-secret');
await harness.load({
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
jest.spyOn(globalThis.performance, 'now')
.mockReturnValueOnce(20)
.mockReturnValueOnce(23.26);
harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection);
await expect(
harness.getApi().updateSettings({ theme: Theme.LightTheme })
).rejects.toBe(rejection);
const traces = harness.getSentPayloads<Record<string, unknown>>(
DEBUG_TRACE_EVENT_CHANNEL
);
expect(traces).toEqual([
{
args: {
items: [{ theme: Theme.LightTheme }],
length: 1,
type: 'array',
},
method: 'updateSettings',
phase: 'start',
},
{
durationMs: 3.3,
error: {
message: rejection.message,
name: 'Error',
},
method: 'updateSettings',
phase: 'error',
},
]);
expect(JSON.stringify(redactSensitiveData(traces))).not.toContain(
'sentinel-error-secret'
);
expect(harness.getMarkers()).toEqual([]);
});
});
@@ -0,0 +1,79 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
createPlaylist,
createPreloadPerformanceHarness,
createRefreshPayload,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
describe('main preload performance marker identities', () => {
let harness: PreloadPerformanceHarness;
beforeEach(async () => {
harness = createPreloadPerformanceHarness();
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
});
afterEach(() => {
harness.cleanup();
});
it('completes only for database calls carrying the refresh operation ID', async () => {
const payload = createRefreshPayload();
const playlist = createPlaylist(payload.playlistId);
harness.ipcRenderer.invoke.mockResolvedValue(playlist);
const api = harness.getApi();
await api.refreshPlaylist(payload);
await api.dbGetAppPlaylist(payload.playlistId);
await api.dbUpsertAppPlaylist(playlist);
await api.dbGetAppPlaylist(payload.playlistId, 'other-operation');
await api.dbUpsertAppPlaylist(playlist, 'other-operation');
expect(
harness
.getMarkers()
.slice(2)
.every(
({ correlationState }) =>
correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED
)
).toBe(true);
expect(
harness
.getMarkers()
.some(
({ correlationState }) =>
correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE
)
).toBe(false);
await api.dbGetAppPlaylist(payload.playlistId, payload.operationId);
await api.dbUpsertAppPlaylist(playlist, payload.operationId);
const markers = harness.getMarkers();
expect(markers.at(-1)).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: payload.operationId,
playlistId: payload.playlistId,
});
expect(harness.ipcRenderer.invoke.mock.calls).toEqual([
['PLAYLIST:REFRESH', payload],
['DB_GET_APP_PLAYLIST', payload.playlistId],
['DB_UPSERT_APP_PLAYLIST', playlist],
['DB_GET_APP_PLAYLIST', payload.playlistId],
['DB_UPSERT_APP_PLAYLIST', playlist],
['DB_GET_APP_PLAYLIST', payload.playlistId],
['DB_UPSERT_APP_PLAYLIST', playlist],
]);
});
});
@@ -0,0 +1,162 @@
import type {
ElectronBridgeApi,
Playlist,
PlaylistRefreshPayload,
PreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
export const PRELOAD_ENVIRONMENT = {
PERF_CAPTURE: 'IPTVNATOR_PERF_CAPTURE',
TRACE_IPC: 'IPTVNATOR_TRACE_IPC',
TRACE_STARTUP: 'IPTVNATOR_TRACE_STARTUP',
} as const;
type PreloadEnvironmentName =
(typeof PRELOAD_ENVIRONMENT)[keyof typeof PRELOAD_ENVIRONMENT];
type PreloadEnvironment = Partial<Record<PreloadEnvironmentName, string>>;
export type ExposedElectronApi = ElectronBridgeApi &
Record<string, (...args: unknown[]) => unknown>;
export interface MockIpcRenderer {
invoke: jest.Mock;
off: jest.Mock;
on: jest.Mock;
send: jest.Mock;
}
export interface PreloadPerformanceHarness {
cleanup: () => void;
getApi: () => ExposedElectronApi;
getMarkers: () => PreloadPerformanceMarker[];
getSentPayloads: <T>(channel: string) => T[];
ipcRenderer: MockIpcRenderer;
load: (environment?: PreloadEnvironment) => Promise<void>;
}
const MARKER_KEYS = [
'correlationState',
'invalidReason',
'ipcCallId',
'method',
'operationId',
'phase',
'playlistId',
'sourceEpochMs',
] as const;
export function createPlaylist(
playlistId: string,
overrides: Partial<Playlist> = {}
): Playlist {
return {
_id: playlistId,
autoRefresh: false,
count: 1,
importDate: '2026-07-26T00:00:00.000Z',
lastUsage: '2026-07-26T00:00:00.000Z',
title: 'Performance fixture',
...overrides,
};
}
export function createRefreshPayload(
overrides: Partial<PlaylistRefreshPayload> = {}
): PlaylistRefreshPayload {
return {
operationId: 'operation-1',
playlistId: 'playlist-1',
title: 'Performance fixture',
...overrides,
};
}
export function expectFixedMarkers(markers: PreloadPerformanceMarker[]): 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
);
}
}
}
export function createPreloadPerformanceHarness(): PreloadPerformanceHarness {
const originalEnvironment = new Map<
PreloadEnvironmentName,
string | undefined
>(
Object.values(PRELOAD_ENVIRONMENT).map((name) => [
name,
process.env[name],
])
);
let exposedApi: ExposedElectronApi | null = null;
const ipcRenderer: MockIpcRenderer = {
invoke: jest.fn(),
off: jest.fn(),
on: jest.fn(),
send: jest.fn(),
};
function getSentPayloads<T>(channel: string): T[] {
return ipcRenderer.send.mock.calls
.filter(([sentChannel]) => sentChannel === channel)
.map(([, payload]) => payload as T);
}
return {
cleanup: () => {
for (const [name, value] of originalEnvironment) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
jest.dontMock('electron');
},
getApi: () => {
if (!exposedApi) {
throw new Error('Expected preload API to be exposed');
}
return exposedApi;
},
getMarkers: () =>
getSentPayloads<PreloadPerformanceMarker>(
'IPTVNATOR_PERF_CAPTURE_MARKER'
),
getSentPayloads,
ipcRenderer,
load: async (environment = {}) => {
jest.resetModules();
for (const name of Object.values(PRELOAD_ENVIRONMENT)) {
delete process.env[name];
}
Object.assign(process.env, environment);
exposedApi = null;
ipcRenderer.invoke.mockReset().mockResolvedValue({ success: true });
ipcRenderer.off.mockReset();
ipcRenderer.on.mockReset();
ipcRenderer.send.mockReset();
jest.doMock('electron', () => ({
contextBridge: {
exposeInMainWorld: jest.fn(
(_name: string, api: ExposedElectronApi) => {
exposedApi = api;
}
),
},
ipcRenderer,
webUtils: {
getPathForFile: jest.fn(),
},
}));
await import('./main.preload');
},
};
}
@@ -11,6 +11,10 @@ import {
attachEmbeddedMpvFrameView,
detachEmbeddedMpvFrameView,
} from './embedded-mpv-frame-pump';
import {
createPreloadPerformanceCapture,
toPreloadPerformanceTargetMethod,
} from './preload-performance-capture';
import type {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
@@ -48,6 +52,7 @@ import type {
} from '@iptvnator/shared/interfaces';
import {
DEBUG_TRACE_EVENT_CHANNEL,
isPerformanceCaptureEnabled,
isRendererApiTraceEnabled,
roundTraceDuration,
summarizeForTrace,
@@ -72,6 +77,11 @@ const dbSaveContentProgressListeners = new Set<
>();
const shouldTraceRendererApi = isRendererApiTraceEnabled();
const shouldCapturePerformance = isPerformanceCaptureEnabled();
const preloadPerformanceCapture = createPreloadPerformanceCapture(
shouldCapturePerformance,
(channel, marker) => ipcRenderer.send(channel, marker)
);
function emitRendererTrace(payload: {
method: string;
@@ -89,7 +99,7 @@ function emitRendererTrace(payload: {
}
function wrapElectronApi<T extends object>(api: T): T {
if (!shouldTraceRendererApi) {
if (!shouldTraceRendererApi && !shouldCapturePerformance) {
return api;
}
@@ -98,7 +108,9 @@ function wrapElectronApi<T extends object>(api: T): T {
if (
typeof value !== 'function' ||
name.startsWith('on') ||
name.startsWith('remove')
name.startsWith('remove') ||
(!shouldTraceRendererApi &&
!toPreloadPerformanceTargetMethod(name))
) {
return [name, value];
}
@@ -108,13 +120,20 @@ function wrapElectronApi<T extends object>(api: T): T {
return [
name,
(...args: unknown[]) => {
const startedAt =
globalThis.performance?.now?.() ?? Date.now();
emitRendererTrace({
args: summarizeForTrace(args),
method: name,
phase: 'start',
});
const startedAt = shouldTraceRendererApi
? (globalThis.performance?.now?.() ?? Date.now())
: 0;
if (shouldTraceRendererApi) {
emitRendererTrace({
args: summarizeForTrace(args),
method: name,
phase: 'start',
});
}
const performanceCall = preloadPerformanceCapture.start(
name,
args
);
try {
const result = original(...args);
@@ -126,54 +145,74 @@ function wrapElectronApi<T extends object>(api: T): T {
) {
return (result as Promise<unknown>)
.then((resolvedValue) => {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(
resolvedValue
),
});
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(
resolvedValue
),
});
}
preloadPerformanceCapture.success(
performanceCall,
resolvedValue
);
return resolvedValue;
})
.catch((error: unknown) => {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
}
preloadPerformanceCapture.error(
performanceCall
);
throw error;
});
}
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(result),
});
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
method: name,
phase: 'success',
result: summarizeForTrace(result),
});
}
preloadPerformanceCapture.success(
performanceCall,
result
);
return result;
} catch (error) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
if (shouldTraceRendererApi) {
emitRendererTrace({
durationMs: roundTraceDuration(
(globalThis.performance?.now?.() ??
Date.now()) - startedAt
),
error: summarizeForTrace(error),
method: name,
phase: 'error',
});
}
preloadPerformanceCapture.error(performanceCall);
throw error;
}
},
@@ -530,8 +569,16 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }),
getEpgMappingsBatch: (channelKeys: string[]) =>
ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }),
setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) =>
ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }),
setEpgMapping: (
channelKey: string,
epgChannelId: string,
playlistId?: string
) =>
ipcRenderer.invoke('EPG_MAPPING_SET', {
channelKey,
epgChannelId,
playlistId,
}),
deleteEpgMapping: (channelKey: string) =>
ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }),
searchEpgChannels: (searchTerm: string, limit?: number) =>
@@ -571,14 +618,14 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist),
dbGetPlaylist: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId),
dbUpsertAppPlaylist: (playlist: Playlist) =>
dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist),
dbUpsertAppPlaylists: (playlists: Playlist[]) =>
ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists),
dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'),
dbGetAppPlaylistMetas: () =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'),
dbGetAppPlaylist: (playlistId: string) =>
dbGetAppPlaylist: (playlistId: string, _operationId?: string) =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId),
dbGetAppPlaylistFavoriteChannels: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId),
@@ -0,0 +1,50 @@
import {
PRELOAD_PERFORMANCE_MARKER_CHANNEL,
PRELOAD_PERFORMANCE_METHOD,
type PreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import { createPreloadPerformanceCapture } from './preload-performance-capture';
describe('preload performance capture timestamps', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('uses fractional monotonic time on top of the epoch origin', () => {
const markers: PreloadPerformanceMarker[] = [];
const monotonicNow = jest
.spyOn(globalThis.performance, 'now')
.mockReturnValueOnce(10.125)
.mockReturnValueOnce(10.375);
const capture = createPreloadPerformanceCapture(
true,
(channel, marker) => {
expect(channel).toBe(PRELOAD_PERFORMANCE_MARKER_CHANNEL);
markers.push(marker);
}
);
const call = capture.start(
PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
[
{
operationId: 'operation-1',
playlistId: 'playlist-1',
},
]
);
capture.success(call, {});
expect(monotonicNow).toHaveBeenCalledTimes(2);
expect(markers).toHaveLength(2);
expect(
markers[0].sourceEpochMs - globalThis.performance.timeOrigin
).toBeCloseTo(10.125, 3);
expect(
markers[1].sourceEpochMs - globalThis.performance.timeOrigin
).toBeCloseTo(10.375, 3);
expect(markers[1].sourceEpochMs).toBeGreaterThan(
markers[0].sourceEpochMs
);
});
});
@@ -0,0 +1,197 @@
import {
isPlaylistRefreshCancelledResult,
PRELOAD_PERFORMANCE_MARKER_CHANNEL,
PRELOAD_PERFORMANCE_METHOD,
type PreloadPerformanceMarker,
type PreloadPerformanceMethod,
type PreloadPerformancePhase,
} from '@iptvnator/shared/interfaces';
import {
advancePreloadPerformanceCorrelation,
createPreloadPerformanceCorrelationState,
normalizePreloadPerformanceIdentifier,
type PreloadPerformanceCorrelationState,
} from './preload-performance-correlation';
export interface PreloadPerformanceCallContext {
ipcCallId: number;
method: PreloadPerformanceMethod;
operationId: string | null;
playlistId: string | null;
}
export interface PreloadPerformanceCapture {
error: (call: PreloadPerformanceCallContext | null) => void;
start: (
methodName: string,
args: readonly unknown[]
) => PreloadPerformanceCallContext | null;
success: (
call: PreloadPerformanceCallContext | null,
result: unknown
) => void;
}
type MarkerSender = (
channel: typeof PRELOAD_PERFORMANCE_MARKER_CHANNEL,
marker: PreloadPerformanceMarker
) => void;
function readProperty(value: unknown, property: string): unknown {
if (
(typeof value !== 'object' || value === null) &&
typeof value !== 'function'
) {
return undefined;
}
try {
return Reflect.get(value, property);
} catch {
return undefined;
}
}
export function toPreloadPerformanceTargetMethod(
methodName: string
): PreloadPerformanceMethod | null {
switch (methodName) {
case PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST:
case PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST:
case PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST:
return methodName;
default:
return null;
}
}
function readHighResolutionEpochMs(): number {
const timeOrigin = globalThis.performance?.timeOrigin;
const monotonicNow = globalThis.performance?.now?.();
const highResolutionEpoch =
typeof timeOrigin === 'number' && typeof monotonicNow === 'number'
? timeOrigin + monotonicNow
: Number.NaN;
return Number.isFinite(highResolutionEpoch)
? highResolutionEpoch
: Date.now();
}
function extractIdentifiers(
method: PreloadPerformanceMethod,
args: readonly unknown[]
): Pick<PreloadPerformanceCallContext, 'operationId' | 'playlistId'> {
if (method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) {
const payload = args[0];
return {
operationId: normalizePreloadPerformanceIdentifier(
readProperty(payload, 'operationId')
),
playlistId: normalizePreloadPerformanceIdentifier(
readProperty(payload, 'playlistId')
),
};
}
if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) {
return {
operationId: normalizePreloadPerformanceIdentifier(args[1]),
playlistId: normalizePreloadPerformanceIdentifier(args[0]),
};
}
return {
operationId: normalizePreloadPerformanceIdentifier(args[1]),
playlistId: normalizePreloadPerformanceIdentifier(
readProperty(args[0], '_id')
),
};
}
export function createPreloadPerformanceCapture(
enabled: boolean,
sendMarker: MarkerSender
): PreloadPerformanceCapture {
let correlationState: PreloadPerformanceCorrelationState =
createPreloadPerformanceCorrelationState();
let markerDeliveryFailed = false;
let lastSourceEpochMs = 0;
let nextIpcCallId = 1;
function emit(
call: PreloadPerformanceCallContext,
phase: PreloadPerformancePhase,
refreshCancelled = false
): void {
if (markerDeliveryFailed) {
return;
}
try {
const transition = advancePreloadPerformanceCorrelation(
correlationState,
{
...call,
phase,
refreshCancelled,
}
);
const sourceEpochMs = Math.max(
readHighResolutionEpochMs(),
lastSourceEpochMs
);
sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, {
...transition.marker,
sourceEpochMs,
});
correlationState = transition.state;
lastSourceEpochMs = sourceEpochMs;
} catch {
markerDeliveryFailed = true;
// Performance instrumentation must never affect the bridge call.
}
}
return {
error: (call) => {
if (call) {
emit(call, 'error');
}
},
start: (methodName, args) => {
if (!enabled) {
return null;
}
const method = toPreloadPerformanceTargetMethod(methodName);
if (!method) {
return null;
}
const call: PreloadPerformanceCallContext = {
ipcCallId: nextIpcCallId,
method,
...extractIdentifiers(method, args),
};
nextIpcCallId += 1;
emit(call, 'start');
return call;
},
success: (call, result) => {
if (!call) {
return;
}
let refreshCancelled = false;
if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) {
try {
refreshCancelled = isPlaylistRefreshCancelledResult(result);
} catch {
refreshCancelled = false;
}
}
emit(call, 'success', refreshCancelled);
},
};
}
@@ -0,0 +1,246 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
PRELOAD_PERFORMANCE_SEQUENCE_STAGE,
closeInvalidPreloadPerformanceSequenceWhenSettled,
createPreloadPerformanceMarkerMetadata,
invalidatePreloadPerformanceSequence,
type PreloadPerformanceCall,
type PreloadPerformanceCorrelationEvent,
type PreloadPerformanceMarkerMetadata,
type PreloadPerformanceSequence,
} from './preload-performance-correlation.model';
function eventForRememberedCall(
event: PreloadPerformanceCorrelationEvent,
call: PreloadPerformanceCall
): PreloadPerformanceCorrelationEvent {
return {
...event,
method: call.method,
operationId: call.operationId,
playlistId: call.playlistId,
};
}
function invalidCompletion(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
event: PreloadPerformanceCorrelationEvent,
call: PreloadPerformanceCall
): PreloadPerformanceMarkerMetadata {
const rememberedEvent = eventForRememberedCall(event, call);
const invalidSequence =
call.playlistId === null
? undefined
: invalidatePreloadPerformanceSequence(
calls,
sequences,
call.playlistId,
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
calls.delete(event.ipcCallId);
closeInvalidPreloadPerformanceSequenceWhenSettled(
calls,
sequences,
call.playlistId
);
return createPreloadPerformanceMarkerMetadata(
rememberedEvent,
call.playlistId,
invalidSequence?.operationId ?? call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidSequence?.invalidReason ??
call.invalidReason ??
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
}
function completeCorrelatedCall(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
event: PreloadPerformanceCorrelationEvent,
call: PreloadPerformanceCall & {
operationId: string;
playlistId: string;
}
): PreloadPerformanceMarkerMetadata {
const sequence = sequences.get(call.playlistId);
const expectedStage =
call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST
? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED
: call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST
? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED
: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED;
if (
!sequence ||
sequence.operationId !== call.operationId ||
sequence.stage !== expectedStage
) {
return invalidCompletion(calls, sequences, event, call);
}
if (event.phase === 'error') {
invalidatePreloadPerformanceSequence(
calls,
sequences,
call.playlistId,
PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR
);
calls.delete(event.ipcCallId);
closeInvalidPreloadPerformanceSequenceWhenSettled(
calls,
sequences,
call.playlistId
);
return createPreloadPerformanceMarkerMetadata(
event,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR
);
}
if (
call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST &&
event.refreshCancelled === true
) {
calls.delete(event.ipcCallId);
sequences.delete(call.playlistId);
return createPreloadPerformanceMarkerMetadata(
event,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED
);
}
calls.delete(event.ipcCallId);
if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) {
sequences.set(call.playlistId, {
...sequence,
stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED,
});
return createPreloadPerformanceMarkerMetadata(
event,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
null
);
}
if (call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) {
sequences.set(call.playlistId, {
...sequence,
stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED,
});
return createPreloadPerformanceMarkerMetadata(
event,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
null
);
}
sequences.delete(call.playlistId);
return createPreloadPerformanceMarkerMetadata(
event,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
null
);
}
export function completePreloadPerformanceCall(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
event: PreloadPerformanceCorrelationEvent,
playlistId: string | null,
operationId: string | null
): PreloadPerformanceMarkerMetadata {
const call = calls.get(event.ipcCallId);
if (!call) {
const invalidSequence =
playlistId === null
? undefined
: invalidatePreloadPerformanceSequence(
calls,
sequences,
playlistId,
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
closeInvalidPreloadPerformanceSequenceWhenSettled(
calls,
sequences,
playlistId
);
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
invalidSequence?.operationId ?? operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidSequence?.invalidReason ??
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
}
const identityMatches =
call.method === event.method &&
call.operationId === operationId &&
call.playlistId === playlistId;
if (!identityMatches) {
return invalidCompletion(calls, sequences, event, call);
}
const rememberedEvent = eventForRememberedCall(event, call);
if (
call.correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED
) {
calls.delete(event.ipcCallId);
return createPreloadPerformanceMarkerMetadata(
rememberedEvent,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
null
);
}
if (
call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID
) {
calls.delete(event.ipcCallId);
closeInvalidPreloadPerformanceSequenceWhenSettled(
calls,
sequences,
call.playlistId
);
return createPreloadPerformanceMarkerMetadata(
rememberedEvent,
call.playlistId,
call.operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
call.invalidReason ??
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
}
return completeCorrelatedCall(
calls,
sequences,
rememberedEvent,
call as PreloadPerformanceCall & {
operationId: string;
playlistId: string;
}
);
}
@@ -0,0 +1,311 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
createCorrelationHarness,
OPERATION_ONE,
OPERATION_TWO,
PLAYLIST_ONE,
PLAYLIST_TWO,
type CorrelationHarness,
} from './preload-performance-correlation.test-helpers';
function completeDatabaseSequence(
advance: CorrelationHarness['advance'],
firstCallId: number,
playlistId: string,
operationId: string
) {
return [
advance({
ipcCallId: firstCallId,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId,
phase: 'start',
playlistId,
}),
advance({
ipcCallId: firstCallId,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId,
phase: 'success',
playlistId,
}),
advance({
ipcCallId: firstCallId + 1,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId,
phase: 'start',
playlistId,
}),
advance({
ipcCallId: firstCallId + 1,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId,
phase: 'success',
playlistId,
}),
];
}
describe('preload performance marker correlation completions', () => {
let advance: CorrelationHarness['advance'];
beforeEach(() => {
({ advance } = createCorrelationHarness());
});
it.each(['success', 'error'] as const)(
'invalidates only the matching playlist after an unknown call ID %s',
(phase) => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 10,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_TWO,
});
const unknownCompletion = advance({
ipcCallId: 99,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase,
playlistId: PLAYLIST_ONE,
});
const matchingCompletion = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const samePlaylistPersistence = completeDatabaseSequence(
advance,
20,
PLAYLIST_ONE,
OPERATION_ONE
);
const unrelatedRefreshCompletion = advance({
ipcCallId: 10,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'success',
playlistId: PLAYLIST_TWO,
});
const unrelatedPersistence = completeDatabaseSequence(
advance,
30,
PLAYLIST_TWO,
OPERATION_TWO
);
expect(unknownCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
});
expect(matchingCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER,
});
expect(
[matchingCompletion, ...samePlaylistPersistence].some(
({ correlationState }) =>
correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE
)
).toBe(false);
expect(unrelatedRefreshCompletion.correlationState).toBe(
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED
);
expect(unrelatedPersistence.at(-1)).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
operationId: OPERATION_TWO,
playlistId: PLAYLIST_TWO,
});
}
);
it('invalidates persistence after a duplicate completion without touching another playlist', () => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 10,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_TWO,
});
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const duplicateCompletion = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const samePlaylistPersistence = completeDatabaseSequence(
advance,
20,
PLAYLIST_ONE,
OPERATION_ONE
);
advance({
ipcCallId: 10,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'success',
playlistId: PLAYLIST_TWO,
});
const unrelatedPersistence = completeDatabaseSequence(
advance,
30,
PLAYLIST_TWO,
OPERATION_TWO
);
expect(duplicateCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
});
expect(
samePlaylistPersistence.some(
({ correlationState }) =>
correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE
)
).toBe(false);
expect(unrelatedPersistence.at(-1)).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
operationId: OPERATION_TWO,
playlistId: PLAYLIST_TWO,
});
});
it('invalidates a correlated sequence when a target call errors', () => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const error = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'error',
playlistId: PLAYLIST_ONE,
});
const laterUpsert = advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const restart = advance({
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
expect(error).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR,
operationId: OPERATION_ONE,
});
expect(laterUpsert).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
invalidReason: null,
operationId: OPERATION_ONE,
});
expect(restart.correlationState).toBe(
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED
);
});
it('closes a cancelled refresh without awaiting database persistence', () => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const cancelled = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
refreshCancelled: true,
});
const laterGet = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
expect(cancelled).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED,
operationId: OPERATION_ONE,
});
expect(laterGet).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
invalidReason: null,
operationId: OPERATION_ONE,
});
});
});
@@ -0,0 +1,183 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
createCorrelationHarness,
OPERATION_ONE,
OPERATION_TWO,
PLAYLIST_ONE,
PLAYLIST_TWO,
} from './preload-performance-correlation.test-helpers';
describe('preload performance correlation identities', () => {
it('advances only database calls tagged with the refresh operation', () => {
const { advance } = createCorrelationHarness();
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const unrelated = [
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: null,
phase: 'start',
playlistId: PLAYLIST_ONE,
}),
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: null,
phase: 'success',
playlistId: PLAYLIST_ONE,
}),
advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_ONE,
}),
advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'success',
playlistId: PLAYLIST_ONE,
}),
];
expect(
unrelated.every(
({ correlationState }) =>
correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED
)
).toBe(true);
for (const event of [
{
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
phase: 'start',
},
{
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
phase: 'success',
},
{
ipcCallId: 5,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
phase: 'start',
},
] as const) {
advance({
...event,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
});
}
const complete = advance({
ipcCallId: 5,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
expect(complete).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
});
});
it.each([
{
label: 'operation',
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_TWO,
playlistId: PLAYLIST_ONE,
},
{
label: 'playlist',
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_TWO,
},
{
label: 'method',
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
},
])(
'fails closed when the completion changes the started $label',
({ method, operationId, playlistId }) => {
const { advance } = createCorrelationHarness();
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const completion = advance({
ipcCallId: 2,
method,
operationId,
phase: 'success',
playlistId,
});
const laterUpsert = advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
expect(completion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
});
expect(laterUpsert).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
invalidReason: null,
});
}
);
});
@@ -0,0 +1,173 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
type PreloadPerformanceCorrelationStateName,
type PreloadPerformanceInvalidReason,
type PreloadPerformanceMarker,
type PreloadPerformanceMethod,
type PreloadPerformancePhase,
} from '@iptvnator/shared/interfaces';
const MAX_IDENTIFIER_LENGTH = 128;
const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
export const PRELOAD_PERFORMANCE_SEQUENCE_STAGE = {
DB_GET_STARTED: 'db-get-started',
DB_GET_SUCCEEDED: 'db-get-succeeded',
DB_UPSERT_STARTED: 'db-upsert-started',
INVALID: 'invalid',
REFRESH_STARTED: 'refresh-started',
REFRESH_SUCCEEDED: 'refresh-succeeded',
} as const;
export type PreloadPerformanceSequenceStage =
(typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE];
export interface PreloadPerformanceSequence {
invalidReason: PreloadPerformanceInvalidReason | null;
operationId: string;
stage: PreloadPerformanceSequenceStage;
}
export interface PreloadPerformanceCall {
correlationState: PreloadPerformanceCorrelationStateName;
invalidReason: PreloadPerformanceInvalidReason | null;
method: PreloadPerformanceMethod;
operationId: string | null;
playlistId: string | null;
}
export interface PreloadPerformanceCorrelationEvent {
ipcCallId: number;
method: PreloadPerformanceMethod;
operationId: unknown;
phase: PreloadPerformancePhase;
playlistId: unknown;
refreshCancelled?: boolean;
}
export interface PreloadPerformanceCorrelationState {
readonly calls: ReadonlyMap<number, PreloadPerformanceCall>;
readonly sequences: ReadonlyMap<string, PreloadPerformanceSequence>;
}
export interface PreloadPerformanceCorrelationResult {
marker: PreloadPerformanceMarkerMetadata;
state: PreloadPerformanceCorrelationState;
}
export type PreloadPerformanceMarkerMetadata = Omit<
PreloadPerformanceMarker,
'sourceEpochMs'
>;
export function normalizePreloadPerformanceIdentifier(
value: unknown
): string | null {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_IDENTIFIER_LENGTH ||
value !== value.trim() ||
!SAFE_IDENTIFIER_PATTERN.test(value)
) {
return null;
}
return value;
}
export function createPreloadPerformanceMarkerMetadata(
event: PreloadPerformanceCorrelationEvent,
playlistId: string | null,
operationId: string | null,
correlationState: PreloadPerformanceCorrelationStateName,
invalidReason: PreloadPerformanceInvalidReason | null
): PreloadPerformanceMarkerMetadata {
return {
correlationState,
invalidReason,
ipcCallId: event.ipcCallId,
method: event.method,
operationId,
phase: event.phase,
playlistId,
};
}
export function createInvalidPreloadPerformanceCall(
event: PreloadPerformanceCorrelationEvent,
playlistId: string | null,
operationId: string | null,
invalidReason: PreloadPerformanceInvalidReason
): PreloadPerformanceCall {
return {
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason,
method: event.method,
operationId,
playlistId,
};
}
export function invalidatePreloadPerformanceSequence(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
playlistId: string,
reason: PreloadPerformanceInvalidReason
): PreloadPerformanceSequence | undefined {
const sequence = sequences.get(playlistId);
if (!sequence) {
return undefined;
}
const invalidReason =
sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID
? (sequence.invalidReason ?? reason)
: reason;
const invalidSequence: PreloadPerformanceSequence = {
...sequence,
invalidReason,
stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID,
};
sequences.set(playlistId, invalidSequence);
for (const [callId, call] of calls) {
if (
call.playlistId === playlistId &&
call.correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED
) {
calls.set(callId, {
...call,
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason,
});
}
}
return invalidSequence;
}
export function closeInvalidPreloadPerformanceSequenceWhenSettled(
calls: ReadonlyMap<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
playlistId: string | null
): void {
if (
playlistId === null ||
sequences.get(playlistId)?.stage !==
PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID
) {
return;
}
const hasInvalidCall = Array.from(calls.values()).some(
(call) =>
call.playlistId === playlistId &&
call.correlationState ===
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID
);
if (!hasInvalidCall) {
sequences.delete(playlistId);
}
}
@@ -0,0 +1,338 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
createCorrelationHarness,
OPERATION_ONE,
OPERATION_TWO,
PLAYLIST_ONE,
PLAYLIST_TWO,
type CorrelationEvent,
type CorrelationHarness,
} from './preload-performance-correlation.test-helpers';
describe('preload performance marker correlation sequences', () => {
let advance: CorrelationHarness['advance'];
let reset: CorrelationHarness['reset'];
beforeEach(() => {
({ advance, reset } = createCorrelationHarness());
});
it('correlates the successful refresh persistence sequence and propagates its operation ID', () => {
const refreshStart = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const refreshSuccess = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const getStart = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const getSuccess = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const upsertStart = advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const upsertSuccess = advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
expect(refreshStart).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
invalidReason: null,
operationId: OPERATION_ONE,
playlistId: PLAYLIST_ONE,
});
expect(refreshSuccess).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
operationId: OPERATION_ONE,
});
expect(getStart.operationId).toBe(OPERATION_ONE);
expect(getSuccess.operationId).toBe(OPERATION_ONE);
expect(upsertStart.operationId).toBe(OPERATION_ONE);
expect(upsertSuccess).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE,
invalidReason: null,
operationId: OPERATION_ONE,
});
});
it('fails closed for wrong-order and duplicate targeted database calls', () => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const wrongOrder = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
expect(wrongOrder).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER,
operationId: OPERATION_ONE,
});
reset();
advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const duplicate = advance({
ipcCallId: 5,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const originalCompletion = advance({
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
expect(duplicate.invalidReason).toBe(
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
expect(originalCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER,
});
});
it('invalidates concurrent refreshes for the same playlist', () => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const concurrentStart = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const firstCompletion = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const concurrentCompletion = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
const restart = advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: 'operation-3',
phase: 'start',
playlistId: PLAYLIST_ONE,
});
expect(concurrentStart).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH,
operationId: OPERATION_TWO,
});
expect(concurrentCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH,
operationId: OPERATION_TWO,
});
expect(restart.correlationState).toBe(
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED
);
expect(firstCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH,
});
});
it('keeps different playlists independent when one sequence invalidates', () => {
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_TWO,
});
advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'success',
playlistId: PLAYLIST_TWO,
});
advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const playlistTwoGet = advance({
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_TWO,
});
expect(playlistTwoGet).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
invalidReason: null,
operationId: OPERATION_TWO,
playlistId: PLAYLIST_TWO,
});
});
it('allows a new refresh after the prior sequence completes', () => {
const events: CorrelationEvent[] = [
{
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
},
{
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
},
{
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
},
{
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
},
{
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
},
{
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: PLAYLIST_ONE,
},
];
events.forEach((event) => advance(event));
const restart = advance({
ipcCallId: 4,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_TWO,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
expect(restart).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
invalidReason: null,
operationId: OPERATION_TWO,
});
});
});
@@ -0,0 +1,235 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
PRELOAD_PERFORMANCE_SEQUENCE_STAGE,
createInvalidPreloadPerformanceCall,
createPreloadPerformanceMarkerMetadata,
invalidatePreloadPerformanceSequence,
type PreloadPerformanceCall,
type PreloadPerformanceCorrelationEvent,
type PreloadPerformanceMarkerMetadata,
type PreloadPerformanceSequence,
} from './preload-performance-correlation.model';
function rememberUncorrelatedCall(
calls: Map<number, PreloadPerformanceCall>,
event: PreloadPerformanceCorrelationEvent,
playlistId: string,
operationId: string | null
): PreloadPerformanceMarkerMetadata {
calls.set(event.ipcCallId, {
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
invalidReason: null,
method: event.method,
operationId,
playlistId,
});
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
null
);
}
function startRefresh(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
event: PreloadPerformanceCorrelationEvent,
playlistId: string,
operationId: string
): PreloadPerformanceMarkerMetadata {
const existing = sequences.get(playlistId);
if (existing) {
const reason =
existing.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID
? (existing.invalidReason ??
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER)
: PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH;
const invalidSequence = invalidatePreloadPerformanceSequence(
calls,
sequences,
playlistId,
reason
);
const invalidReason = invalidSequence?.invalidReason ?? reason;
calls.set(
event.ipcCallId,
createInvalidPreloadPerformanceCall(
event,
playlistId,
operationId,
invalidReason
)
);
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason
);
}
sequences.set(playlistId, {
invalidReason: null,
operationId,
stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED,
});
calls.set(event.ipcCallId, {
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
invalidReason: null,
method: event.method,
operationId,
playlistId,
});
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
null
);
}
function startDatabaseCall(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
event: PreloadPerformanceCorrelationEvent,
playlistId: string,
operationId: string | null
): PreloadPerformanceMarkerMetadata {
if (operationId === null) {
return rememberUncorrelatedCall(calls, event, playlistId, operationId);
}
const sequence = sequences.get(playlistId);
if (!sequence || sequence.operationId !== operationId) {
return rememberUncorrelatedCall(calls, event, playlistId, operationId);
}
if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) {
const invalidReason =
sequence.invalidReason ??
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER;
calls.set(
event.ipcCallId,
createInvalidPreloadPerformanceCall(
event,
playlistId,
operationId,
invalidReason
)
);
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason
);
}
const isGet =
event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST;
const expectedStage = isGet
? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED
: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED;
const startedStage = isGet
? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED
: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED;
if (sequence.stage !== expectedStage) {
const invalidSequence = invalidatePreloadPerformanceSequence(
calls,
sequences,
playlistId,
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER
);
const invalidReason =
invalidSequence?.invalidReason ??
PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER;
calls.set(
event.ipcCallId,
createInvalidPreloadPerformanceCall(
event,
playlistId,
operationId,
invalidReason
)
);
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason
);
}
sequences.set(playlistId, {
...sequence,
stage: startedStage,
});
calls.set(event.ipcCallId, {
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
invalidReason: null,
method: event.method,
operationId,
playlistId,
});
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
operationId,
PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED,
null
);
}
export function startPreloadPerformanceCall(
calls: Map<number, PreloadPerformanceCall>,
sequences: Map<string, PreloadPerformanceSequence>,
event: PreloadPerformanceCorrelationEvent,
playlistId: string | null,
operationId: string | null
): PreloadPerformanceMarkerMetadata {
if (
playlistId === null ||
(event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST &&
operationId === null)
) {
if (playlistId !== null) {
invalidatePreloadPerformanceSequence(
calls,
sequences,
playlistId,
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER
);
}
calls.set(
event.ipcCallId,
createInvalidPreloadPerformanceCall(
event,
playlistId,
null,
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER
)
);
return createPreloadPerformanceMarkerMetadata(
event,
playlistId,
null,
PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER
);
}
return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST
? startRefresh(calls, sequences, event, playlistId, operationId)
: startDatabaseCall(calls, sequences, event, playlistId, operationId);
}
@@ -0,0 +1,29 @@
import {
advancePreloadPerformanceCorrelation,
createPreloadPerformanceCorrelationState,
type PreloadPerformanceCorrelationEvent,
} from './preload-performance-correlation';
export const PLAYLIST_ONE = 'playlist-1';
export const PLAYLIST_TWO = 'playlist-2';
export const OPERATION_ONE = 'operation-1';
export const OPERATION_TWO = 'operation-2';
export type CorrelationEvent = PreloadPerformanceCorrelationEvent;
export function createCorrelationHarness() {
let state = createPreloadPerformanceCorrelationState();
return {
advance: (event: PreloadPerformanceCorrelationEvent) => {
const result = advancePreloadPerformanceCorrelation(state, event);
state = result.state;
return result.marker;
},
reset: () => {
state = createPreloadPerformanceCorrelationState();
},
};
}
export type CorrelationHarness = ReturnType<typeof createCorrelationHarness>;
@@ -0,0 +1,58 @@
import { completePreloadPerformanceCall } from './preload-performance-correlation.complete';
import {
normalizePreloadPerformanceIdentifier,
type PreloadPerformanceCorrelationEvent,
type PreloadPerformanceCorrelationResult,
type PreloadPerformanceCorrelationState,
} from './preload-performance-correlation.model';
import { startPreloadPerformanceCall } from './preload-performance-correlation.start';
export type {
PreloadPerformanceCorrelationEvent,
PreloadPerformanceCorrelationResult,
PreloadPerformanceCorrelationState,
} from './preload-performance-correlation.model';
export { normalizePreloadPerformanceIdentifier } from './preload-performance-correlation.model';
export function createPreloadPerformanceCorrelationState(): PreloadPerformanceCorrelationState {
return {
calls: new Map(),
sequences: new Map(),
};
}
export function advancePreloadPerformanceCorrelation(
state: PreloadPerformanceCorrelationState,
event: PreloadPerformanceCorrelationEvent
): PreloadPerformanceCorrelationResult {
const calls = new Map(state.calls);
const sequences = new Map(state.sequences);
const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId);
const operationId = normalizePreloadPerformanceIdentifier(
event.operationId
);
const marker =
event.phase === 'start'
? startPreloadPerformanceCall(
calls,
sequences,
event,
playlistId,
operationId
)
: completePreloadPerformanceCall(
calls,
sequences,
event,
playlistId,
operationId
);
return {
marker,
state: {
calls,
sequences,
},
};
}
@@ -0,0 +1,117 @@
import {
PRELOAD_PERFORMANCE_CORRELATION_STATE,
PRELOAD_PERFORMANCE_INVALID_REASON,
PRELOAD_PERFORMANCE_METHOD,
} from '@iptvnator/shared/interfaces';
import {
createCorrelationHarness,
OPERATION_ONE,
OPERATION_TWO,
PLAYLIST_ONE,
type CorrelationHarness,
} from './preload-performance-correlation.test-helpers';
describe('preload performance marker correlation validation', () => {
let advance: CorrelationHarness['advance'];
beforeEach(() => {
({ advance } = createCorrelationHarness());
});
it('uses null identifiers and a fixed reason for malformed inputs', () => {
const malformedPlaylist = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: 'https://secret.example/private.m3u',
});
const malformedOperation = advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: { secret: OPERATION_TWO },
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const malformedDbId = advance({
ipcCallId: 3,
method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST,
operationId: null,
phase: 'start',
playlistId: undefined,
});
const malformedPlaylistCompletion = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'success',
playlistId: 'https://secret.example/private.m3u',
});
expect(malformedPlaylist).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER,
operationId: null,
playlistId: null,
});
expect(malformedOperation).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER,
operationId: null,
playlistId: PLAYLIST_ONE,
});
expect(malformedDbId).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER,
operationId: null,
playlistId: null,
});
expect(malformedPlaylistCompletion).toMatchObject({
correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID,
invalidReason:
PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER,
operationId: null,
playlistId: null,
});
});
it('keeps targeted database calls outside a sequence visible and uncorrelated', () => {
const getStart = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: null,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
advance({
ipcCallId: 2,
method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST,
operationId: OPERATION_ONE,
phase: 'start',
playlistId: PLAYLIST_ONE,
});
const getSuccess = advance({
ipcCallId: 1,
method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST,
operationId: null,
phase: 'success',
playlistId: PLAYLIST_ONE,
});
expect(getStart).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
invalidReason: null,
operationId: null,
});
expect(getSuccess).toMatchObject({
correlationState:
PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED,
invalidReason: null,
operationId: null,
});
});
});
@@ -7,6 +7,20 @@ const mockBroadcastDownloadUpdate = jest.fn();
const mockRemovePartialDownloadFile = jest.fn();
const mockPauseDownload = jest.fn();
const mockResumeDownloadRequest = jest.fn();
const mockExistsSync = jest.fn();
const mockOpenPath = jest.fn();
const mockShowItemInFolder = jest.fn();
const mockEq = jest.fn();
let downloadsFilePathColumn: unknown;
const MANAGED_PATH_STATE = {
ERROR: 'error',
MANAGED: 'managed',
UNMANAGED: 'unmanaged',
} as const;
type ManagedPathState =
(typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE];
function getHandler(channel: string): IpcHandler {
const handler = mockRegisteredHandlers.get(channel);
@@ -34,7 +48,24 @@ describe('downloads events', () => {
mockRemovePartialDownloadFile.mockReset();
mockPauseDownload.mockReset();
mockResumeDownloadRequest.mockReset();
mockExistsSync.mockReset();
mockOpenPath.mockReset().mockResolvedValue('');
mockShowItemInFolder.mockReset();
mockEq.mockReset();
jest.doMock('node:fs', () => ({
...jest.requireActual<typeof import('node:fs')>('node:fs'),
existsSync: mockExistsSync,
}));
jest.doMock('drizzle-orm', () => {
const actual =
jest.requireActual<typeof import('drizzle-orm')>('drizzle-orm');
mockEq.mockImplementation(actual.eq);
return {
...actual,
eq: mockEq,
};
});
jest.doMock('electron', () => ({
app: {
getPath: jest.fn((name: string) =>
@@ -50,8 +81,8 @@ describe('downloads events', () => {
}),
},
shell: {
openPath: jest.fn(),
showItemInFolder: jest.fn(),
openPath: mockOpenPath,
showItemInFolder: mockShowItemInFolder,
},
}));
jest.doMock('../../database/connection', () => ({
@@ -77,8 +108,45 @@ describe('downloads events', () => {
}));
await import('./downloads.events');
const schema = await import('../../database/schema');
downloadsFilePathColumn = schema.downloads.filePath;
});
function mockManagedPath(state: ManagedPathState) {
const limit = jest.fn(() => {
if (state === MANAGED_PATH_STATE.ERROR) {
return Promise.reject(new Error('database unavailable'));
}
return Promise.resolve(
state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : []
);
});
const where = jest.fn((_predicate: unknown) => ({ limit }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const db = { select };
mockGetDatabase.mockResolvedValue(db);
return { from, limit, select, where };
}
function expectManagedPathLookup(
lookup: ReturnType<typeof mockManagedPath>,
filePath: string
) {
expect(mockGetDatabase).toHaveBeenCalledTimes(1);
expect(lookup.select).toHaveBeenCalledTimes(1);
expect(lookup.from).toHaveBeenCalledTimes(1);
expect(lookup.where).toHaveBeenCalledTimes(1);
expect(mockEq).toHaveBeenCalledTimes(1);
expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true);
expect(mockEq.mock.calls[0][1]).toBe(filePath);
expect(
lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value
).toBe(true);
expect(lookup.limit).toHaveBeenCalledTimes(1);
expect(lookup.limit).toHaveBeenCalledWith(1);
}
function mockDownloadRow(row: { filePath: string | null; status: string }) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const db = {
@@ -101,7 +169,9 @@ describe('downloads events', () => {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const selectWhere = jest
.fn()
.mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row })));
.mockResolvedValue(
rows.map((row, index) => ({ id: index + 1, ...row }))
);
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
@@ -117,9 +187,11 @@ describe('downloads events', () => {
it('removes queued resumed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
success: true,
});
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual(
{
success: true,
}
);
expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42);
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
@@ -127,7 +199,9 @@ describe('downloads events', () => {
);
expect(
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]);
).toBeLessThan(
mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]
);
expect(
mockRemovePartialDownloadFile.mock.invocationCallOrder[0]
).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]);
@@ -137,9 +211,11 @@ describe('downloads events', () => {
it('removes completed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('completed'));
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({
success: true,
});
await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual(
{
success: true,
}
);
expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith(
'/downloads/resume.mp4'
@@ -328,4 +404,127 @@ describe('downloads events', () => {
expect.anything()
);
});
describe.each([
{
channel: 'DOWNLOADS_REVEAL_FILE',
filePath: '/downloads/reveal-boundary.mp4',
operation: 'reveal',
},
{
channel: 'DOWNLOADS_PLAY_FILE',
filePath: '/downloads/play-boundary.mp4',
operation: 'play',
},
])('$operation managed-path boundary', ({ channel, filePath }) => {
it('rejects an unmanaged database path before accessing the filesystem', async () => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED);
mockExistsSync.mockReturnValue(true);
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
error: 'File not found',
success: false,
});
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).not.toHaveBeenCalled();
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
it('rejects a managed database path that is missing from disk', async () => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockExistsSync.mockReturnValue(false);
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
error: 'File not found',
success: false,
});
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
it('fails closed when the managed-path database query rejects', async () => {
const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR);
mockExistsSync.mockReturnValue(true);
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
await expect(
getHandler(channel)(null, filePath)
).resolves.toEqual({
error: 'File not found',
success: false,
});
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
'Error verifying managed download path:',
expect.objectContaining({
message: 'database unavailable',
})
);
} finally {
consoleError.mockRestore();
}
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).not.toHaveBeenCalled();
expect(mockOpenPath).not.toHaveBeenCalled();
expect(mockShowItemInFolder).not.toHaveBeenCalled();
});
});
it('reveals a managed file that exists on disk', async () => {
const filePath = '/downloads/reveal-success.mp4';
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockExistsSync.mockReturnValue(true);
await expect(
getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath)
).resolves.toEqual({ success: true });
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockShowItemInFolder).toHaveBeenCalledTimes(1);
expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).not.toHaveBeenCalled();
});
it('waits for the native shell before reporting a managed file as played', async () => {
const filePath = '/downloads/play-success.mp4';
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
mockExistsSync.mockReturnValue(true);
let resolveOpenPath!: (value: string) => void;
const openPathResult = new Promise<string>((resolve) => {
resolveOpenPath = resolve;
});
mockOpenPath.mockReturnValue(openPathResult);
let responseSettled = false;
const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then(
(result) => {
responseSettled = true;
return result;
}
);
await new Promise<void>((resolve) => setImmediate(resolve));
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).toHaveBeenCalledTimes(1);
expect(mockOpenPath).toHaveBeenCalledWith(filePath);
expect(mockShowItemInFolder).not.toHaveBeenCalled();
expect(responseSettled).toBe(false);
resolveOpenPath('');
await expect(response).resolves.toEqual({ success: true });
});
});
@@ -0,0 +1,588 @@
import type * as http from 'node:http';
import { PassThrough } from 'node:stream';
type HttpHandler = (
request: http.IncomingMessage,
response: http.ServerResponse
) => void;
type IpcCallback = (...args: unknown[]) => unknown;
interface RemoteControlSettings {
enabled: boolean;
port: number;
}
interface ResponseSnapshot {
statusCode: number;
headers: http.OutgoingHttpHeaders;
body: string;
}
interface ResponseRecorder {
response: http.ServerResponse;
completed: Promise<ResponseSnapshot>;
}
interface RequestInvocation {
response: ResponseSnapshot;
request: PassThrough;
requestClosed: Promise<void>;
requestErrors: Error[];
}
interface InvokeOptions {
method: string;
body?: string | Buffer;
}
const REMOTE_CONTROL_PATHS = {
STATUS: '/api/remote-control/status',
SELECT_NUMBER: '/api/remote-control/channel/select-number',
CHANNEL_UP: '/api/remote-control/channel/up',
CHANNEL_DOWN: '/api/remote-control/channel/down',
VOLUME_UP: '/api/remote-control/volume/up',
VOLUME_DOWN: '/api/remote-control/volume/down',
VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute',
} as const;
const JSON_HEADERS = { 'Content-Type': 'application/json' } as const;
const SUCCESS_RESPONSE = {
statusCode: 200,
headers: JSON_HEADERS,
body: JSON.stringify({ success: true }),
} as const;
const METHOD_NOT_ALLOWED_RESPONSE = {
statusCode: 405,
headers: JSON_HEADERS,
body: JSON.stringify({ error: 'Method not allowed' }),
} as const;
const mockHttpHandlers = new Map<string, HttpHandler>();
const mockIpcHandlers = new Map<string, IpcCallback>();
const mockIpcListeners = new Map<string, IpcCallback>();
const mockRegisterRemoteControlHandler = jest.fn(
(path: string, handler: HttpHandler) => {
mockHttpHandlers.set(path, handler);
}
);
const mockStartHttpServer = jest.fn();
const mockStoreGet = jest.fn();
const mockFirstRendererSend = jest.fn();
const mockSecondRendererSend = jest.fn();
const mockGetAllWindows = jest.fn();
const mockIpcHandle = jest.fn((channel: string, handler: IpcCallback) => {
mockIpcHandlers.set(channel, handler);
});
const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => {
mockIpcListeners.set(channel, listener);
});
jest.mock('electron', () => ({
BrowserWindow: {
getAllWindows: mockGetAllWindows,
},
ipcMain: {
handle: mockIpcHandle,
on: mockIpcOn,
},
}));
jest.mock('../server/http-server', () => ({
httpServer: {
registerRemoteControlHandler: mockRegisterRemoteControlHandler,
start: mockStartHttpServer,
},
}));
jest.mock('../services/store.service', () => ({
store: {
get: mockStoreGet,
},
}));
import { RemoteControlEvents } from './remote-control.events';
function createResponseRecorder(): ResponseRecorder {
let statusCode = 0;
let headers: http.OutgoingHttpHeaders = {};
let response: http.ServerResponse;
let responseEnded = false;
let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => {
throw new Error('Response completion promise is not initialized');
};
const completed = new Promise<ResponseSnapshot>((resolve) => {
resolveCompleted = resolve;
});
response = {
writeHead: (
nextStatusCode: number,
nextHeaders?: http.OutgoingHttpHeaders
) => {
statusCode = nextStatusCode;
headers = { ...nextHeaders };
return response;
},
end: (chunk?: string | Uint8Array) => {
if (responseEnded) {
throw new Error('Response ended more than once');
}
responseEnded = true;
const body =
typeof chunk === 'string'
? chunk
: chunk
? Buffer.from(chunk).toString('utf8')
: '';
resolveCompleted({ statusCode, headers, body });
return response;
},
} as unknown as http.ServerResponse;
return { response, completed };
}
function getHttpHandler(path: string): HttpHandler {
const handler = mockHttpHandlers.get(path);
if (!handler) {
throw new Error(`Expected HTTP handler for ${path}`);
}
return handler;
}
function getIpcHandler(channel: string): IpcCallback {
const handler = mockIpcHandlers.get(channel);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
function getIpcListener(channel: string): IpcCallback {
const listener = mockIpcListeners.get(channel);
if (!listener) {
throw new Error(`Expected IPC listener for ${channel}`);
}
return listener;
}
function bootstrapRemoteControl(
settings: RemoteControlSettings = { enabled: false, port: 8765 }
): RemoteControlEvents {
mockStoreGet.mockImplementation(
(key: string, fallbackValue: unknown): unknown => {
if (key === 'remoteControl') {
return settings.enabled;
}
if (key === 'remoteControlPort') {
return settings.port;
}
return fallbackValue;
}
);
const events = new RemoteControlEvents();
events.bootstrapRemoteControlEvents();
return events;
}
async function invokeHttpHandler(
path: string,
options: InvokeOptions
): Promise<RequestInvocation> {
const request = new PassThrough();
const requestErrors: Error[] = [];
request.on('error', (error: Error) => {
requestErrors.push(error);
});
const requestClosed = new Promise<void>((resolve) => {
request.once('close', resolve);
});
const incomingMessage = Object.assign(request, {
method: options.method,
url: path,
}) as unknown as http.IncomingMessage;
const recorder = createResponseRecorder();
getHttpHandler(path)(incomingMessage, recorder.response);
request.end(options.body);
return {
response: await recorder.completed,
request,
requestClosed,
requestErrors,
};
}
function createBodyAtByteLength(byteLength: number): string {
const prefix = '{"number":7,"padding":"';
const suffix = '"}';
const paddingLength =
byteLength -
Buffer.byteLength(prefix, 'utf8') -
Buffer.byteLength(suffix, 'utf8');
if (paddingLength < 0) {
throw new Error(`Cannot create a JSON body at ${byteLength} bytes`);
}
const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`;
if (Buffer.byteLength(body, 'utf8') !== byteLength) {
throw new Error(`Expected a ${byteLength}-byte JSON body`);
}
return body;
}
describe('RemoteControlEvents', () => {
let consoleLog: jest.SpyInstance;
let consoleWarn: jest.SpyInstance;
beforeEach(() => {
mockHttpHandlers.clear();
mockIpcHandlers.clear();
mockIpcListeners.clear();
mockRegisterRemoteControlHandler.mockClear();
mockStartHttpServer.mockReset();
mockStoreGet.mockReset();
mockFirstRendererSend.mockReset();
mockSecondRendererSend.mockReset();
mockGetAllWindows.mockReset();
mockIpcHandle.mockClear();
mockIpcOn.mockClear();
mockGetAllWindows.mockReturnValue([
{ webContents: { send: mockFirstRendererSend } },
{ webContents: { send: mockSecondRendererSend } },
]);
consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
consoleWarn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.useRealTimers();
consoleLog.mockRestore();
consoleWarn.mockRestore();
});
it('registers exactly the seven remote-control HTTP endpoints', () => {
bootstrapRemoteControl();
expect(mockRegisterRemoteControlHandler).toHaveBeenCalledTimes(7);
expect([...mockHttpHandlers.keys()].sort()).toEqual(
Object.values(REMOTE_CONTROL_PATHS).sort()
);
});
it('starts the HTTP server once with the stored port when enabled', () => {
bootstrapRemoteControl({ enabled: true, port: 9987 });
expect(mockStoreGet).toHaveBeenNthCalledWith(1, 'remoteControl', false);
expect(mockStoreGet).toHaveBeenNthCalledWith(
2,
'remoteControlPort',
8765
);
expect(mockStartHttpServer).toHaveBeenCalledTimes(1);
expect(mockStartHttpServer).toHaveBeenCalledWith(9987);
});
it('does not start the HTTP server when remote control is disabled', () => {
bootstrapRemoteControl({ enabled: false, port: 9987 });
expect(mockStartHttpServer).not.toHaveBeenCalled();
});
it('registers and dispatches both main-app channel IPC handlers', () => {
bootstrapRemoteControl();
expect(mockIpcHandle).toHaveBeenCalledTimes(2);
expect([...mockIpcHandlers.keys()].sort()).toEqual([
'REMOTE_CONTROL_CHANNEL_DOWN',
'REMOTE_CONTROL_CHANNEL_UP',
]);
const ipcEventPlaceholder = {};
expect(
getIpcHandler('REMOTE_CONTROL_CHANNEL_UP')(ipcEventPlaceholder)
).toBeUndefined();
expect(
getIpcHandler('REMOTE_CONTROL_CHANNEL_DOWN')(ipcEventPlaceholder)
).toBeUndefined();
expect(mockFirstRendererSend).toHaveBeenCalledTimes(2);
expect(mockFirstRendererSend).toHaveBeenNthCalledWith(
1,
'CHANNEL_CHANGE',
{ direction: 'up' }
);
expect(mockFirstRendererSend).toHaveBeenNthCalledWith(
2,
'CHANNEL_CHANGE',
{ direction: 'down' }
);
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it.each([
{
path: REMOTE_CONTROL_PATHS.CHANNEL_UP,
channel: 'CHANNEL_CHANGE',
payload: { direction: 'up' },
},
{
path: REMOTE_CONTROL_PATHS.CHANNEL_DOWN,
channel: 'CHANNEL_CHANGE',
payload: { direction: 'down' },
},
{
path: REMOTE_CONTROL_PATHS.VOLUME_UP,
channel: 'REMOTE_CONTROL_COMMAND',
payload: { type: 'volume-up' },
},
{
path: REMOTE_CONTROL_PATHS.VOLUME_DOWN,
channel: 'REMOTE_CONTROL_COMMAND',
payload: { type: 'volume-down' },
},
{
path: REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE,
channel: 'REMOTE_CONTROL_COMMAND',
payload: { type: 'volume-toggle-mute' },
},
] as const)(
'dispatches POST $path to the first renderer',
async ({ path, channel, payload }) => {
bootstrapRemoteControl();
const result = await invokeHttpHandler(path, { method: 'POST' });
expect(result.response).toEqual(SUCCESS_RESPONSE);
expect(mockFirstRendererSend).toHaveBeenCalledTimes(1);
expect(mockFirstRendererSend).toHaveBeenCalledWith(
channel,
payload
);
expect(mockSecondRendererSend).not.toHaveBeenCalled();
}
);
it.each([
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
REMOTE_CONTROL_PATHS.CHANNEL_UP,
REMOTE_CONTROL_PATHS.CHANNEL_DOWN,
REMOTE_CONTROL_PATHS.VOLUME_UP,
REMOTE_CONTROL_PATHS.VOLUME_DOWN,
REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE,
])('rejects GET on POST-only endpoint %s', async (path) => {
bootstrapRemoteControl();
const result = await invokeHttpHandler(path, { method: 'GET' });
expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE);
expect(mockFirstRendererSend).not.toHaveBeenCalled();
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it('rejects POST on the status endpoint', async () => {
bootstrapRemoteControl();
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
method: 'POST',
});
expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE);
expect(mockFirstRendererSend).not.toHaveBeenCalled();
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it('floors a finite positive channel number and dispatches it', async () => {
bootstrapRemoteControl();
const result = await invokeHttpHandler(
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
{
method: 'POST',
body: JSON.stringify({ number: 7.9 }),
}
);
expect(result.response).toEqual(SUCCESS_RESPONSE);
expect(mockFirstRendererSend).toHaveBeenCalledWith(
'REMOTE_CONTROL_COMMAND',
{
type: 'channel-select-number',
number: 7,
}
);
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it.each([
{ label: 'missing', body: '{}' },
{ label: 'zero', body: '{"number":0}' },
{ label: 'negative', body: '{"number":-2}' },
{ label: 'infinite', body: '{"number":1e309}' },
{ label: 'nonnumeric', body: '{"number":"seven"}' },
])('rejects a $label channel number', async ({ body }) => {
bootstrapRemoteControl();
const result = await invokeHttpHandler(
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
{
method: 'POST',
body,
}
);
expect(result.response).toEqual({
statusCode: 400,
headers: JSON_HEADERS,
body: JSON.stringify({ error: 'Invalid channel number' }),
});
expect(mockGetAllWindows).not.toHaveBeenCalled();
expect(mockFirstRendererSend).not.toHaveBeenCalled();
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it('rejects malformed JSON without dispatching', async () => {
bootstrapRemoteControl();
const result = await invokeHttpHandler(
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
{
method: 'POST',
body: '{"number":',
}
);
expect(result.response).toEqual({
statusCode: 400,
headers: JSON_HEADERS,
body: JSON.stringify({ error: 'Invalid JSON payload' }),
});
expect(mockGetAllWindows).not.toHaveBeenCalled();
expect(mockFirstRendererSend).not.toHaveBeenCalled();
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it('accepts a valid JSON request at the 10,240-byte body limit', async () => {
bootstrapRemoteControl();
const body = createBodyAtByteLength(10 * 1024);
const result = await invokeHttpHandler(
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
{
method: 'POST',
body,
}
);
expect(Buffer.byteLength(body, 'utf8')).toBe(10_240);
expect(result.response).toEqual(SUCCESS_RESPONSE);
expect(mockFirstRendererSend).toHaveBeenCalledWith(
'REMOTE_CONTROL_COMMAND',
{
type: 'channel-select-number',
number: 7,
}
);
});
it('rejects and destroys a 10,241-byte request without dispatching', async () => {
bootstrapRemoteControl();
const body = Buffer.alloc(10 * 1024 + 1, 'x');
const result = await invokeHttpHandler(
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
{
method: 'POST',
body,
}
);
await result.requestClosed;
expect(body.byteLength).toBe(10_241);
expect(result.response).toEqual({
statusCode: 413,
headers: JSON_HEADERS,
body: JSON.stringify({ error: 'Payload too large' }),
});
expect(result.request.destroyed).toBe(true);
expect(result.requestErrors).toEqual([]);
expect(mockGetAllWindows).not.toHaveBeenCalled();
expect(mockFirstRendererSend).not.toHaveBeenCalled();
expect(mockSecondRendererSend).not.toHaveBeenCalled();
});
it('merges partial status updates and refreshes updatedAt', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z'));
bootstrapRemoteControl();
const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE');
jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z'));
updateStatus(
{},
{
portal: 'm3u',
isLiveView: true,
channelName: 'News',
volume: 35,
}
);
jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z'));
updateStatus({}, { channelName: 'Sports', muted: true });
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
method: 'GET',
});
expect(result.response).toEqual({
statusCode: 200,
headers: JSON_HEADERS,
body: JSON.stringify({
portal: 'm3u',
isLiveView: true,
supportsVolume: false,
updatedAt: '2026-07-25T10:02:00.000Z',
channelName: 'Sports',
volume: 35,
muted: true,
}),
});
});
it.each([
{
path: REMOTE_CONTROL_PATHS.CHANNEL_UP,
warning: 'No browser windows found to send channel change',
},
{
path: REMOTE_CONTROL_PATHS.VOLUME_UP,
warning: 'No browser windows found to send remote command',
},
])(
'returns success and warns instead of throwing when $path has no renderer',
async ({ path, warning }) => {
mockGetAllWindows.mockReturnValue([]);
bootstrapRemoteControl();
await expect(
invokeHttpHandler(path, { method: 'POST' })
).resolves.toMatchObject({
response: SUCCESS_RESPONSE,
});
expect(consoleWarn).toHaveBeenCalledWith(warning);
expect(mockFirstRendererSend).not.toHaveBeenCalled();
expect(mockSecondRendererSend).not.toHaveBeenCalled();
}
);
});
@@ -26,7 +26,7 @@ interface RemoteControlStatus {
updatedAt?: string;
}
class RemoteControlEvents {
export class RemoteControlEvents {
private remoteControlStatus: RemoteControlStatus = {
portal: 'unknown',
isLiveView: false,
@@ -1,45 +1,196 @@
const handlers = new Map<string, (...args: unknown[]) => unknown>();
import type { Settings } from '@iptvnator/shared/interfaces';
type ExternalPlayerArgumentsSetting = string | readonly unknown[];
type SettingsUpdatePayload = Omit<
Partial<Settings>,
| 'embeddedMpvFrameCopy'
| 'language'
| 'mpvPlayerArguments'
| 'vlcPlayerArguments'
> & {
embeddedMpvFrameCopy?: boolean | number;
language?: string;
mpvPlayerArguments?: ExternalPlayerArgumentsSetting;
vlcPlayerArguments?: ExternalPlayerArgumentsSetting;
};
type SettingsUpdateHandler = (
event: unknown,
settings: SettingsUpdatePayload
) => unknown;
const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
const STORE_KEYS = {
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
} as const;
const handlers = new Map<string, SettingsUpdateHandler>();
const mockStoreGet = jest.fn();
const mockStoreSet = jest.fn();
const mockUpdateSettings = jest.fn();
const mockIpcHandle = jest.fn(
(channel: string, handler: SettingsUpdateHandler): void => {
handlers.set(channel, handler);
}
);
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
handlers.set(channel, handler);
}
),
handle: mockIpcHandle,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments',
MPV_REUSE_INSTANCE: 'mpvReuseInstance',
VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments',
VLC_REUSE_INSTANCE: 'vlcReuseInstance',
store: { get: jest.fn(), set: jest.fn() },
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
store: {
get: mockStoreGet,
set: mockStoreSet,
},
}));
jest.mock('../server/http-server', () => ({
httpServer: { updateSettings: jest.fn() },
httpServer: {
updateSettings: mockUpdateSettings,
},
}));
describe('SETTINGS_UPDATE logging', () => {
describe('SETTINGS_UPDATE', () => {
let consoleLogSpy: jest.SpiedFunction<typeof console.log>;
let settingsUpdateHandler: SettingsUpdateHandler;
beforeEach(async () => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
handlers.clear();
jest.resetModules();
mockIpcHandle.mockClear();
mockStoreGet.mockReset();
mockStoreSet.mockReset();
mockUpdateSettings.mockReset();
mockStoreGet.mockImplementation(
(_key: string, fallbackValue: unknown): unknown => fallbackValue
);
consoleLogSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
await import('./settings.events');
const registeredHandler = handlers.get(SETTINGS_UPDATE);
if (!registeredHandler) {
throw new Error(`Missing ipcMain handler for ${SETTINGS_UPDATE}`);
}
settingsUpdateHandler = registeredHandler;
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
consoleLogSpy.mockRestore();
});
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
settingsUpdateHandler(
{},
{
mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '],
mpvReuseInstance: false,
vlcPlayerArguments:
' --network-caching=1000 \r\n\n --fullscreen ',
vlcReuseInstance: false,
}
);
expect(mockStoreSet.mock.calls).toEqual([
[STORE_KEYS.MPV_PLAYER_ARGUMENTS, '--screen=1\n--hwdec=auto-safe'],
[
STORE_KEYS.VLC_PLAYER_ARGUMENTS,
'--network-caching=1000\n--fullscreen',
],
[STORE_KEYS.MPV_REUSE_INSTANCE, false],
[STORE_KEYS.VLC_REUSE_INSTANCE, false],
]);
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it('does not persist or reconcile an empty settings update', () => {
settingsUpdateHandler({}, {});
expect(mockStoreGet).not.toHaveBeenCalled();
expect(mockStoreSet).not.toHaveBeenCalled();
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it('coerces an enabled embedded-MPV frame-copy value to boolean true', () => {
settingsUpdateHandler({}, { embeddedMpvFrameCopy: 1 });
expect(mockStoreSet).toHaveBeenCalledTimes(1);
expect(mockStoreSet).toHaveBeenCalledWith(
STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
true
);
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it('reconciles an enabled remote-control update with the stored port', () => {
mockStoreGet.mockImplementation(
(key: string, fallbackValue: unknown): unknown =>
key === 'remoteControlPort' ? 9988 : fallbackValue
);
settingsUpdateHandler({}, { remoteControl: true });
expect(mockStoreGet.mock.calls).toEqual([['remoteControlPort', 8765]]);
expect(mockStoreSet.mock.calls).toEqual([['remoteControl', true]]);
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988);
});
it('reconciles a remote-control port update with the stored enabled state', () => {
mockStoreGet.mockImplementation(
(key: string, fallbackValue: unknown): unknown =>
key === 'remoteControl' ? true : fallbackValue
);
settingsUpdateHandler({}, { remoteControlPort: 9123 });
expect(mockStoreGet.mock.calls).toEqual([['remoteControl', false]]);
expect(mockStoreSet.mock.calls).toEqual([['remoteControlPort', 9123]]);
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123);
});
it('preserves explicit false and zero remote-control values', () => {
settingsUpdateHandler(
{},
{ remoteControl: false, remoteControlPort: 0 }
);
expect(mockStoreGet).not.toHaveBeenCalled();
expect(mockStoreSet.mock.calls).toEqual([
['remoteControl', false],
['remoteControlPort', 0],
]);
expect(mockUpdateSettings).toHaveBeenCalledTimes(1);
expect(mockUpdateSettings).toHaveBeenCalledWith(false, 0);
});
it('does not print a TMDB apiKey while retaining useful fields', () => {
const apiKey = 'tmdb-settings-api-key-secret';
const handler = handlers.get('SETTINGS_UPDATE');
expect(handler).toBeDefined();
handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } });
settingsUpdateHandler(
{},
{
language: 'de',
tmdb: { apiKey, enabled: true },
}
);
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
const output = JSON.stringify(consoleLogSpy.mock.calls);
expect(output).not.toContain(apiKey);
expect(output).toContain('language');
expect(output).toContain('de');
@@ -49,10 +200,8 @@ describe('SETTINGS_UPDATE logging', () => {
it('does not print credentials embedded in external player arguments', () => {
const authorizationSecret = 'player-authorization-secret';
const cookieSecret = 'player-cookie-secret';
const handler = handlers.get('SETTINGS_UPDATE');
expect(handler).toBeDefined();
handler?.(
settingsUpdateHandler(
{},
{
language: 'de',
@@ -61,7 +210,7 @@ describe('SETTINGS_UPDATE logging', () => {
}
);
const output = JSON.stringify((console.log as jest.Mock).mock.calls);
const output = JSON.stringify(consoleLogSpy.mock.calls);
expect(output).not.toContain(authorizationSecret);
expect(output).not.toContain(cookieSecret);
expect(output).toContain('language');
@@ -0,0 +1,448 @@
import { once } from 'node:events';
import {
mkdirSync,
mkdtempSync,
rmSync,
unlinkSync,
writeFileSync,
} from 'node:fs';
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join, posix, win32 } from 'node:path';
import { HttpServer, resolveStaticFilePath } from './http-server';
interface CapturedServer {
listening: Promise<void>;
listeningEvents: number;
server: http.Server;
}
interface TestResponse {
body: Buffer;
contentType: string | undefined;
statusCode: number | undefined;
}
const INDEX_BODY = Buffer.from('<main>IPTVnator remote control</main>');
const ASSETS = {
'/app.js': {
body: Buffer.from('globalThis.remoteControlLoaded = true;'),
contentType: 'application/javascript',
},
'/styles.css': {
body: Buffer.from('main { color: rebeccapurple; }'),
contentType: 'text/css',
},
'/data.json': {
body: Buffer.from('{"ready":true}'),
contentType: 'application/json',
},
'/asset.unknown': {
body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]),
contentType: 'application/octet-stream',
},
} as const;
jest.setTimeout(15_000);
function request(port: number, path: string): Promise<TestResponse> {
return new Promise((resolve, reject) => {
const clientRequest = http.request(
{
host: '127.0.0.1',
method: 'GET',
path,
port,
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
response.on('end', () => {
const contentType = response.headers['content-type'];
resolve({
body: Buffer.concat(chunks),
contentType:
typeof contentType === 'string'
? contentType
: undefined,
statusCode: response.statusCode,
});
});
response.on('error', reject);
}
);
clientRequest.on('error', reject);
clientRequest.end();
});
}
function getAddress(server: http.Server): AddressInfo {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Expected the HTTP server to have an IP address');
}
return address;
}
async function closeServer(server: http.Server): Promise<void> {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
async function getAvailablePort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((resolve, reject) => {
probe.once('error', reject);
probe.listen(0, '127.0.0.1', resolve);
});
const port = getAddress(probe).port;
await closeServer(probe);
return port;
}
describe('resolveStaticFilePath', () => {
const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control';
const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control';
it('rejects a Windows double-leading-slash traversal', () => {
expect(
resolveStaticFilePath(
WINDOWS_STATIC_ROOT,
'//../../outside-secret.txt',
win32
)
).toBeNull();
});
it('rejects encoded traversal segments after one decode', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/%2e%2e/outside-secret.txt',
posix
)
).toBeNull();
});
it('fails closed for malformed percent encoding without throwing', () => {
expect(
resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix)
).toBeNull();
});
it('rejects decoded NUL bytes', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/assets/%00secret.js',
posix
)
).toBeNull();
});
it('resolves a valid Windows-style asset inside the static root', () => {
expect(
resolveStaticFilePath(
WINDOWS_STATIC_ROOT,
'/assets\\app.js?version=1',
win32
)
).toBe('C:\\iptvnator\\remote-control\\assets\\app.js');
});
it('ignores query and fragment data for filesystem resolution', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/data.json?cache=1#ignored',
posix
)
).toBe('/opt/iptvnator/remote-control/data.json');
});
it('decodes the URL pathname exactly once', () => {
expect(
resolveStaticFilePath(
POSIX_STATIC_ROOT,
'/%252e%252e/asset.js',
posix
)
).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js');
});
});
describe('HttpServer', () => {
let capturedServers: CapturedServer[];
let distPath: string;
let server: HttpServer;
let tempRoot: string;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation(() => undefined);
capturedServers = [];
tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-'));
distPath = join(tempRoot, 'static', 'app');
mkdirSync(distPath, { recursive: true });
writeFileSync(join(distPath, 'index.html'), INDEX_BODY);
for (const [assetPath, asset] of Object.entries(ASSETS)) {
writeFileSync(join(distPath, assetPath), asset.body);
}
writeFileSync(
join(tempRoot, 'outside-secret.txt'),
'must-not-leave-the-static-directory'
);
server = new HttpServer({
createServer: (requestListener) => {
const nodeServer = http.createServer(requestListener);
const captured: CapturedServer = {
listening: once(nodeServer, 'listening').then(
() => undefined
),
listeningEvents: 0,
server: nodeServer,
};
nodeServer.on('listening', () => {
captured.listeningEvents += 1;
});
capturedServers.push(captured);
return nodeServer;
},
distPath,
});
});
afterEach(async () => {
let closeError: unknown;
try {
await Promise.all(
capturedServers.map(({ server: nodeServer }) =>
closeServer(nodeServer)
)
);
} catch (error) {
closeError = error;
} finally {
rmSync(tempRoot, { force: true, recursive: true });
jest.restoreAllMocks();
}
if (closeError) {
throw closeError;
}
});
async function startServer(): Promise<CapturedServer> {
const serverIndex = capturedServers.length;
server.start(0);
const captured = capturedServers[serverIndex];
if (!captured) {
throw new Error('Expected HttpServer.start() to create a server');
}
await captured.listening;
return captured;
}
it('serves the exact index document at the root', async () => {
const captured = await startServer();
const response = await request(getAddress(captured.server).port, '/');
expect(response).toEqual({
body: INDEX_BODY,
contentType: 'text/html',
statusCode: 200,
});
});
it('asks the operating system for an ephemeral port when started with zero', async () => {
const captured = await startServer();
expect(getAddress(captured.server).port).not.toBe(8765);
});
it.each(Object.entries(ASSETS))(
'serves %s with its expected MIME type',
async (assetPath, asset) => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
assetPath
);
expect(response).toEqual({
body: asset.body,
contentType: asset.contentType,
statusCode: 200,
});
}
);
it('falls back to the exact index document for a client route', async () => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
'/channels/favorites'
);
expect(response).toEqual({
body: INDEX_BODY,
contentType: 'text/html',
statusCode: 200,
});
});
it('returns a plain-text 404 when the index document is missing', async () => {
unlinkSync(join(distPath, 'index.html'));
const captured = await startServer();
const response = await request(getAddress(captured.server).port, '/');
expect(response).toEqual({
body: Buffer.from('404 Not Found'),
contentType: 'text/plain',
statusCode: 404,
});
});
it('dispatches a registered API request without serving a static file', async () => {
let receivedRequest: http.IncomingMessage | undefined;
server.registerRemoteControlHandler(
'/api/remote-control/status',
(incomingRequest, response) => {
receivedRequest = incomingRequest;
response.writeHead(200, { 'Content-Type': 'application/json' });
response.end('{"source":"api-handler"}');
}
);
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
'/api/remote-control/status'
);
expect(receivedRequest?.url).toBe('/api/remote-control/status');
expect(response).toEqual({
body: Buffer.from('{"source":"api-handler"}'),
contentType: 'application/json',
statusCode: 200,
});
expect(response.body).not.toEqual(INDEX_BODY);
});
it('returns a JSON 404 for an unknown remote-control API endpoint', async () => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
'/api/remote-control/missing'
);
expect(response).toEqual({
body: Buffer.from('{"error":"Endpoint not found"}'),
contentType: 'application/json',
statusCode: 404,
});
});
it.each([
'/../../outside-secret.txt',
'//../../outside-secret.txt',
'/%2e%2e/%2e%2e/outside-secret.txt',
'/%E0%A4%A',
])(
'rejects the invalid static request target %s without serving an outside file',
async (requestTarget) => {
const captured = await startServer();
const response = await request(
getAddress(captured.server).port,
requestTarget
);
expect(response).toEqual({
body: Buffer.from('404 Not Found'),
contentType: 'text/plain',
statusCode: 404,
});
expect(response.body.toString()).not.toContain(
'must-not-leave-the-static-directory'
);
}
);
it('creates and listens on only one server when started twice', async () => {
server.start(0);
server.start(0);
expect(capturedServers).toHaveLength(1);
await capturedServers[0].listening;
expect(capturedServers[0].listeningEvents).toBe(1);
});
it('stops the running server when disabled', async () => {
const captured = await startServer();
const closed = once(captured.server, 'close');
server.updateSettings(false, 0);
await closed;
expect(captured.server.listening).toBe(false);
expect(capturedServers).toHaveLength(1);
});
it('stops once and starts once on the new port when the port changes', async () => {
const first = await startServer();
const newPort = await getAvailablePort();
let closeEvents = 0;
first.server.on('close', () => {
closeEvents += 1;
});
const firstClosed = once(first.server, 'close');
server.updateSettings(true, newPort);
const second = capturedServers[1];
if (!second) {
throw new Error('Expected a replacement HTTP server');
}
await Promise.all([firstClosed, second.listening]);
expect(capturedServers).toHaveLength(2);
expect(closeEvents).toBe(1);
expect(first.server.listening).toBe(false);
expect(second.listeningEvents).toBe(1);
expect(getAddress(second.server).port).toBe(newPort);
await expect(request(newPort, '/')).resolves.toEqual({
body: INDEX_BODY,
contentType: 'text/html',
statusCode: 200,
});
});
});
@@ -3,10 +3,61 @@ import * as fs from 'fs';
import * as http from 'http';
import * as path from 'path';
type HttpServerFactory = (requestListener: http.RequestListener) => http.Server;
interface HttpServerOptions {
createServer?: HttpServerFactory;
distPath?: string;
}
/**
* Resolve an HTTP request target within the configured static root.
* The path implementation is injectable so platform-specific semantics remain testable.
*/
export function resolveStaticFilePath(
staticRoot: string,
requestTarget: string,
pathImplementation: path.PlatformPath = path
): string | null {
const separatorIndex = requestTarget.search(/[?#]/);
const encodedPathname =
separatorIndex === -1
? requestTarget
: requestTarget.slice(0, separatorIndex);
let pathname: string;
try {
pathname = decodeURIComponent(encodedPathname);
} catch {
return null;
}
if (pathname.includes('\0')) {
return null;
}
const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html';
const resolvedRoot = pathImplementation.resolve(staticRoot);
const candidate = pathImplementation.resolve(
resolvedRoot,
relativeCandidate
);
if (
candidate === resolvedRoot ||
candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`)
) {
return candidate;
}
return null;
}
/**
* HTTP server for serving the remote control web app and providing REST API endpoints
*/
export class HttpServer {
private readonly createServer: HttpServerFactory;
private server: http.Server | null = null;
private port = 8765;
private isEnabled = false;
@@ -16,6 +67,11 @@ export class HttpServer {
(req: http.IncomingMessage, res: http.ServerResponse) => void
> = new Map();
constructor(options: HttpServerOptions = {}) {
this.createServer = options.createServer ?? http.createServer;
this.distPath = options.distPath ?? null;
}
/**
* Get the path to the remote-control-web static files.
* Lazily computed to avoid calling Electron APIs before app is ready.
@@ -43,11 +99,7 @@ export class HttpServer {
} else {
// Production mode - files are bundled with the app
// electron-builder copies remote-control-web/**/* directly to app root
this.distPath = path.join(
appPath,
'remote-control-web',
'browser'
);
this.distPath = path.join(appPath, 'remote-control-web', 'browser');
}
console.log('[HTTP Server] Serving from:', this.distPath);
@@ -58,7 +110,7 @@ export class HttpServer {
* Start the HTTP server
*/
start(port?: number): void {
if (port) {
if (port !== undefined) {
this.port = port;
}
@@ -67,7 +119,7 @@ export class HttpServer {
return;
}
this.server = http.createServer((req, res) => {
this.server = this.createServer((req, res) => {
this.handleRequest(req, res);
});
@@ -153,18 +205,21 @@ export class HttpServer {
* Serve static files
*/
private serveStaticFile(url: string, res: http.ServerResponse): void {
// Default to index.html for root path
let filePath = url === '/' ? '/index.html' : url;
// Security: prevent directory traversal
filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, '');
const fullPath = path.join(this.getDistPath(), filePath);
const distPath = this.getDistPath();
const fullPath = resolveStaticFilePath(distPath, url);
if (!fullPath) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 Not Found');
return;
}
fs.readFile(fullPath, (err, data) => {
if (err) {
// If file not found, try serving index.html (for Angular routing)
if (err.code === 'ENOENT' && filePath !== '/index.html') {
if (
err.code === 'ENOENT' &&
fullPath !== path.resolve(distPath, 'index.html')
) {
this.serveStaticFile('/', res);
return;
}
@@ -52,6 +52,10 @@ export function isRendererApiTraceEnabled(): boolean {
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC');
}
export function isPerformanceCaptureEnabled(): boolean {
return readFlag('IPTVNATOR_PERF_CAPTURE');
}
export function isDbTraceEnabled(): boolean {
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB');
}
@@ -90,8 +90,11 @@ import {
} from '../database/operations/xtream.operations';
import {
armWorkerPerformanceCapture,
finishWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
registerDatabaseWorkerPerformanceCapture,
releaseDatabaseWorkerPerformanceCapture,
startWorkerPerformanceCapture,
type WorkerPerformanceCapture,
} from './worker-performance-capture';
const loggerLabel = '[DB Worker]';
@@ -104,6 +107,11 @@ type ActiveOperationState = {
cancelled: boolean;
};
type PreRegisteredActiveOperation = {
operationId: string;
state: ActiveOperationState;
};
type OperationController = {
control: {
checkpoint: () => Promise<void>;
@@ -129,6 +137,7 @@ type OperationController = {
};
const activeOperations = new Map<string, ActiveOperationState>();
const activePerformanceCaptures = new Set<WorkerPerformanceCapture>();
if (!parentPort) {
throw new Error('Database worker must be started with a parent port');
@@ -178,17 +187,22 @@ async function pauseBetweenBatches(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, batchDelayMs));
}
function createOperationController(config: {
operationId?: string;
operation: string;
playlistId?: string;
requestId: string;
cancellable?: boolean;
}): OperationController {
function createOperationController(
config: {
operationId?: string;
operation: string;
playlistId?: string;
requestId: string;
cancellable?: boolean;
},
preRegisteredState?: ActiveOperationState
): OperationController {
const { operationId, operation, playlistId, requestId } = config;
const cancellable = config.cancellable ?? true;
const activeState =
operationId && cancellable ? { cancelled: false } : null;
operationId && cancellable
? (preRegisteredState ?? { cancelled: false })
: null;
if (operationId && activeState) {
activeOperations.set(operationId, activeState);
@@ -255,7 +269,10 @@ function createOperationController(config: {
});
},
cleanup: () => {
if (operationId) {
if (
operationId &&
activeOperations.get(operationId) === activeState
) {
activeOperations.delete(operationId);
}
},
@@ -264,9 +281,10 @@ function createOperationController(config: {
async function executeTrackedOperation<TResult>(
config: Parameters<typeof createOperationController>[0],
handler: (controller: OperationController) => Promise<TResult>
handler: (controller: OperationController) => Promise<TResult>,
preRegisteredState?: ActiveOperationState
): Promise<TResult> {
const controller = createOperationController(config);
const controller = createOperationController(config, preRegisteredState);
try {
return await handler(controller);
@@ -282,7 +300,53 @@ async function executeTrackedOperation<TResult>(
}
}
async function executeRequest(message: DbWorkerRequestMessage) {
function preRegisterCancellableOperation(
message: DbWorkerRequestMessage
): PreRegisteredActiveOperation | null {
switch (message.operation) {
case 'DB_SAVE_CONTENT':
case 'DB_DELETE_PLAYLIST':
case 'DB_DELETE_XTREAM_CONTENT':
case 'DB_RESTORE_XTREAM_USER_DATA':
break;
default:
return null;
}
if (
typeof message.payload !== 'object' ||
message.payload === null ||
Array.isArray(message.payload)
) {
return null;
}
const operationId = (message.payload as Record<string, unknown>)[
'operationId'
];
if (typeof operationId !== 'string' || operationId.length === 0) {
return null;
}
const state: ActiveOperationState = { cancelled: false };
activeOperations.set(operationId, state);
return { operationId, state };
}
function releasePreRegisteredOperation(
operation: PreRegisteredActiveOperation | null
): void {
if (
operation &&
activeOperations.get(operation.operationId) === operation.state
) {
activeOperations.delete(operation.operationId);
}
}
async function executeRequest(
message: DbWorkerRequestMessage,
preRegisteredState?: ActiveOperationState
) {
const db = await getWorkerDatabase();
switch (message.operation) {
@@ -413,7 +477,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
});
return result;
}
},
preRegisteredState
);
}
@@ -586,7 +651,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
});
return result;
}
},
preRegisteredState
);
}
@@ -695,7 +761,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
});
return result;
}
},
preRegisteredState
);
}
@@ -739,7 +806,8 @@ async function executeRequest(message: DbWorkerRequestMessage) {
});
return result;
}
},
preRegisteredState
);
}
@@ -929,33 +997,49 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
return;
}
const performanceCapture = startWorkerPerformanceCapture();
await armWorkerPerformanceCapture(performanceCapture);
try {
const result = await executeRequest(message);
const performance =
await finishWorkerPerformanceCapture(performanceCapture);
const preRegisteredOperation = preRegisterCancellableOperation(message);
let performanceCapture: WorkerPerformanceCapture | null = null;
const execution = await (async () => {
try {
performanceCapture = startWorkerPerformanceCapture();
registerDatabaseWorkerPerformanceCapture(
activePerformanceCaptures,
performanceCapture
);
await armWorkerPerformanceCapture(performanceCapture);
return await executeWithWorkerPerformanceCapture(
performanceCapture,
() => executeRequest(message, preRegisteredOperation?.state)
);
} finally {
releaseDatabaseWorkerPerformanceCapture(
activePerformanceCaptures,
performanceCapture
);
releasePreRegisteredOperation(preRegisteredOperation);
}
})();
if (execution.success) {
postMessage({
type: 'response',
requestId: message.requestId,
success: true,
result,
performance,
result: execution.result,
performance: execution.performance,
});
} catch (error) {
} else {
console.error(
loggerLabel,
`Error handling ${message.operation}:`,
error
execution.error
);
const performance =
await finishWorkerPerformanceCapture(performanceCapture);
postMessage({
type: 'response',
requestId: message.requestId,
success: false,
error: serializeError(error),
performance,
error: serializeError(execution.error),
performance: execution.performance,
});
}
});
@@ -24,7 +24,7 @@ import { requestWithValidatedRedirects } from '../util/validated-axios';
import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source';
import {
armWorkerPerformanceCapture,
finishWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
@@ -86,6 +86,15 @@ function checkpoint(payload: PlaylistRefreshPayload): void {
}
}
function releaseActiveRefresh(
operationId: string,
activeRefresh: ActiveRefreshState
): void {
if (activeRefreshes.get(operationId) === activeRefresh) {
activeRefreshes.delete(operationId);
}
}
async function fetchPlaylistFromUrl(
payload: PlaylistRefreshPayload,
controller: AbortController
@@ -159,23 +168,32 @@ async function fetchPlaylistFromFile(
}
async function executeRefresh(
payload: PlaylistRefreshPayload
payload: PlaylistRefreshPayload,
activeRefresh: ActiveRefreshState
): Promise<Playlist> {
const controller = new AbortController();
activeRefreshes.set(payload.operationId, {
cancelled: false,
controller,
});
try {
const playlist = payload.url
? await fetchPlaylistFromUrl(payload, controller)
? await fetchPlaylistFromUrl(payload, activeRefresh.controller)
: await fetchPlaylistFromFile(payload);
checkpoint(payload);
return playlist;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, {
status: 'cancelled',
phase: 'parsing',
});
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
} finally {
activeRefreshes.delete(payload.operationId);
releaseActiveRefresh(payload.operationId, activeRefresh);
}
}
@@ -191,39 +209,39 @@ parentPort.on(
return;
}
const performanceCapture = startWorkerPerformanceCapture();
await armWorkerPerformanceCapture(performanceCapture);
const payload = message.payload;
const activeRefresh: ActiveRefreshState = {
cancelled: false,
controller: new AbortController(),
};
activeRefreshes.set(payload.operationId, activeRefresh);
try {
const result = await executeRefresh(message.payload);
const performance =
await finishWorkerPerformanceCapture(performanceCapture);
postMessage({
type: 'response',
success: true,
result,
performance,
});
} catch (error) {
const payload = message.payload;
if (error instanceof Error && error.name === 'AbortError') {
emitEvent(payload, { status: 'cancelled', phase: 'parsing' });
const performanceCapture = startWorkerPerformanceCapture();
await armWorkerPerformanceCapture(performanceCapture);
const execution = await executeWithWorkerPerformanceCapture(
performanceCapture,
() => executeRefresh(payload, activeRefresh)
);
if (execution.success) {
postMessage({
type: 'response',
success: true,
result: execution.result,
performance: execution.performance,
});
} else {
emitEvent(payload, {
status: 'error',
phase: payload.url ? 'fetching' : 'reading-file',
error:
error instanceof Error ? error.message : String(error),
const error = execution.error;
postMessage({
type: 'response',
success: false,
error: serializeError(error),
performance: execution.performance,
});
}
const performance =
await finishWorkerPerformanceCapture(performanceCapture);
postMessage({
type: 'response',
success: false,
error: serializeError(error),
performance,
});
} finally {
releaseActiveRefresh(payload.operationId, activeRefresh);
}
}
);
@@ -0,0 +1,313 @@
import type {
DbWorkerIncomingMessage,
DbWorkerMessage,
} from './database-worker.types';
import type {
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage,
} from './playlist-refresh.worker.types';
type WorkerMessageHandler<TMessage> = (
message: TMessage
) => Promise<void> | void;
interface ArmGate {
readonly promise: Promise<void>;
readonly release: () => void;
}
function createArmGate(): ArmGate {
let release = (): void => undefined;
const promise = new Promise<void>((resolvePromise) => {
release = resolvePromise;
});
return { promise, release };
}
function createParentPortHarness<TIncoming, TOutgoing>() {
let messageHandler: WorkerMessageHandler<TIncoming> | null = null;
const postMessage = jest.fn<void, [TOutgoing]>();
const parentPort = {
on: jest.fn(
(event: string, handler: WorkerMessageHandler<TIncoming>): void => {
if (event === 'message') {
messageHandler = handler;
}
}
),
postMessage,
};
return {
getMessageHandler: (): WorkerMessageHandler<TIncoming> => {
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<unknown>) => {
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(),
startWorkerPerformanceCapture: jest.fn(() => ({})),
}));
}
describe('worker cancellation while performance capture arms', () => {
afterEach(() => {
jest.restoreAllMocks();
jest.resetModules();
});
it('cancels playlist work before file access when cancel arrives during arming', async () => {
const armGate = createArmGate();
const profilingFinished = createArmGate();
const failureObserved = createArmGate();
const port = createParentPortHarness<
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage<unknown>
>();
const readFile = jest.fn().mockResolvedValue('#EXTM3U');
mockPerformanceCapture(armGate, {
observed: failureObserved,
profilingFinished,
});
jest.doMock('worker_threads', () => ({
parentPort: port.parentPort,
}));
jest.doMock('node:fs/promises', () => ({ readFile }));
jest.doMock('iptv-playlist-parser', () => ({
parse: jest.fn(() => ({ items: [] })),
}));
jest.doMock('@iptvnator/shared/m3u-utils', () => ({
createPlaylistObject: jest.fn(() => ({ id: 'playlist-1' })),
getFilenameFromUrl: jest.fn(() => 'playlist.m3u'),
}));
await import('./playlist-refresh.worker');
port.postMessage.mockClear();
const handleMessage = port.getMessageHandler();
const requestPromise = handleMessage({
type: 'request',
payload: {
filePath: '/fixtures/playlist.m3u',
operationId: 'refresh-1',
playlistId: 'playlist-1',
title: 'Fixture',
},
});
await handleMessage({
type: 'cancel',
operationId: 'refresh-1',
});
armGate.release();
await failureObserved.promise;
const cancelledBeforeProfilingFinished =
port.postMessage.mock.calls.some(
([message]) =>
message.type === 'event' &&
message.event.status === 'cancelled'
);
const responseBeforeProfilingFinished =
port.postMessage.mock.calls.some(
([message]) => message.type === 'response'
);
profilingFinished.release();
await requestPromise;
expect(readFile).not.toHaveBeenCalled();
expect(cancelledBeforeProfilingFinished).toBe(true);
expect(responseBeforeProfilingFinished).toBe(false);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
operationId: 'refresh-1',
status: 'cancelled',
}),
type: 'event',
})
);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ name: 'AbortError' }),
success: false,
type: 'response',
})
);
});
it('cancels cancellable database work when cancel arrives during arming', async () => {
const armGate = createArmGate();
const port = createParentPortHarness<
DbWorkerIncomingMessage,
DbWorkerMessage
>();
const saveContent = jest.fn(
async (
_db: unknown,
_playlistId: string,
_streams: unknown[],
_type: string,
control: { checkpoint: () => Promise<void> }
) => {
await control.checkpoint();
return { count: 0 };
}
);
mockPerformanceCapture(armGate);
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-1',
playlistId: 'playlist-1',
streams: [],
type: 'movie',
},
requestId: 'request-1',
});
await handleMessage({
type: 'cancel',
operationId: 'database-operation-1',
});
armGate.release();
await requestPromise;
expect(saveContent).toHaveBeenCalledTimes(1);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
operationId: 'database-operation-1',
status: 'cancelled',
}),
requestId: 'request-1',
type: 'event',
})
);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ name: 'AbortError' }),
requestId: 'request-1',
success: false,
type: 'response',
})
);
});
it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => {
const armGate = createArmGate();
const port = createParentPortHarness<
DbWorkerIncomingMessage,
DbWorkerMessage
>();
const deleteAllPlaylists = jest.fn(
async (
_db: unknown,
control: { checkpoint: () => Promise<void> }
) => {
await control.checkpoint();
return { success: true };
}
);
mockPerformanceCapture(armGate);
jest.doMock('worker_threads', () => ({
parentPort: port.parentPort,
}));
jest.doMock('./database.worker-connection', () => ({
closeWorkerDatabase: jest.fn(),
getWorkerDatabase: jest.fn().mockResolvedValue({}),
}));
jest.doMock('../database/operations/playlist.operations', () => ({
deleteAllPlaylists,
}));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await import('./database.worker');
port.postMessage.mockClear();
const handleMessage = port.getMessageHandler();
const requestPromise = handleMessage({
type: 'request',
operation: 'DB_DELETE_ALL_PLAYLISTS',
payload: {
operationId: 'database-operation-2',
},
requestId: 'request-2',
});
await handleMessage({
type: 'cancel',
operationId: 'database-operation-2',
});
armGate.release();
await requestPromise;
expect(deleteAllPlaylists).toHaveBeenCalledTimes(1);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
requestId: 'request-2',
result: { success: true },
success: true,
type: 'response',
})
);
expect(port.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({ status: 'cancelled' }),
requestId: 'request-2',
type: 'event',
})
);
});
});
@@ -0,0 +1,95 @@
import {
WORKER_PERFORMANCE_INVALID_REASON,
armWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
registerDatabaseWorkerPerformanceCapture,
releaseDatabaseWorkerPerformanceCapture,
startWorkerPerformanceCapture,
type WorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
describe('worker performance capture concurrency and real timers', () => {
const originalProfilingValue = process.env[PROFILING_ENV];
afterEach(() => {
if (originalProfilingValue === undefined) {
delete process.env[PROFILING_ENV];
} else {
process.env[PROFILING_ENV] = originalProfilingValue;
}
});
it('fails every overlapping database request capture closed without serializing execution', async () => {
const activeCaptures = new Set<WorkerPerformanceCapture>();
const firstHarness = createFakeRuntime();
const secondHarness = createFakeRuntime();
const first = startWorkerPerformanceCapture({
enabled: true,
runtime: firstHarness.runtime,
});
const second = startWorkerPerformanceCapture({
enabled: true,
runtime: secondHarness.runtime,
});
registerDatabaseWorkerPerformanceCapture(activeCaptures, first);
registerDatabaseWorkerPerformanceCapture(activeCaptures, second);
await Promise.all([
armWorkerPerformanceCapture(first),
armWorkerPerformanceCapture(second),
]);
const [firstExecution, secondExecution] = await Promise.all([
executeWithWorkerPerformanceCapture(first, async () => 'first'),
executeWithWorkerPerformanceCapture(second, async () => 'second'),
]);
releaseDatabaseWorkerPerformanceCapture(activeCaptures, first);
releaseDatabaseWorkerPerformanceCapture(activeCaptures, second);
for (const execution of [firstExecution, secondExecution]) {
expect(execution.performance).toMatchObject({
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS,
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason:
WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS,
histogramFlushedEpochMs: null,
invalidReason:
WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS,
threadCpuSystemMicros: null,
threadCpuUnavailableReason:
WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS,
threadCpuUserMicros: null,
});
}
expect(firstExecution.result).toBe('first');
expect(secondExecution.result).toBe('second');
expect(activeCaptures.size).toBe(0);
});
it('reliably observes a real 20ms event-loop block after condition-based arming', async () => {
process.env[PROFILING_ENV] = '1';
const capture = startWorkerPerformanceCapture();
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => {
const blockStartedAt = performance.now();
while (performance.now() - blockStartedAt < 20) {
// This deliberate block is the behavior under measurement.
}
}
);
expect(execution.success).toBe(true);
expect(execution.performance?.eventLoopDelay).not.toBeNull();
expect(execution.performance?.eventLoopDelay?.maxMs).toBeGreaterThan(
10
);
expect(execution.performance?.histogramFlushedEpochMs).not.toBeNull();
});
});
@@ -0,0 +1,39 @@
import {
armWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
describe('worker performance histogram lifecycle', () => {
it('accepts a nonthrowing false disable result because the histogram is already disabled', async () => {
const harness = createFakeRuntime({
histogramDisableResult: false,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'business-result'
);
expect(execution).toMatchObject({
error: null,
result: 'business-result',
success: true,
performance: {
eventLoopDelay: {
maxMs: 24,
p95Ms: 18,
p99Ms: 22,
},
eventLoopDelayUnavailableReason: null,
histogramFlushedEpochMs: 145,
},
});
});
});
@@ -0,0 +1,136 @@
import {
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
type WorkerEventLoopDelayHistogram,
type WorkerEventLoopDelayMetrics,
type WorkerPerformanceCapture,
type WorkerPerformanceMetricUnavailableReason,
} from './worker-performance-capture.model';
import {
HISTOGRAM_POLL_INTERVAL_MS,
HISTOGRAM_WAIT_CAP_MS,
} from './worker-performance-capture.runtime';
export function disableWorkerPerformanceHistogram(
capture: WorkerPerformanceCapture
): boolean {
if (!capture.eventLoopDelay || capture.histogramDisabled) {
return true;
}
try {
capture.eventLoopDelay.disable();
capture.histogramDisabled = true;
return true;
} catch {
// Performance instrumentation must never affect worker execution.
return false;
}
}
export function markEventLoopDelayUnavailable(
capture: WorkerPerformanceCapture,
reason: WorkerPerformanceMetricUnavailableReason
): void {
capture.eventLoopDelayUnavailableReason ??= reason;
disableWorkerPerformanceHistogram(capture);
}
export async function waitForHistogramCondition(
capture: WorkerPerformanceCapture,
condition: (histogram: WorkerEventLoopDelayHistogram) => boolean
): Promise<boolean> {
const histogram = capture.eventLoopDelay;
if (
!histogram ||
capture.histogramDisabled ||
capture.invalidReason !== null
) {
return false;
}
let startedAt: number;
try {
startedAt = capture.runtime.readMonotonicMs();
} catch {
return false;
}
const maximumPolls = Math.ceil(
HISTOGRAM_WAIT_CAP_MS / HISTOGRAM_POLL_INTERVAL_MS
);
let pollCount = 0;
while (true) {
if (capture.invalidReason !== null || capture.histogramDisabled) {
return false;
}
try {
if (condition(histogram)) {
return true;
}
} catch {
return false;
}
let elapsedMs: number;
try {
elapsedMs = capture.runtime.readMonotonicMs() - startedAt;
} catch {
return false;
}
if (
!Number.isFinite(elapsedMs) ||
elapsedMs >= HISTOGRAM_WAIT_CAP_MS ||
pollCount >= maximumPolls
) {
return false;
}
const delayMs = Math.min(
HISTOGRAM_POLL_INTERVAL_MS,
HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs)
);
try {
pollCount += 1;
await new Promise<void>((resolvePromise) => {
capture.runtime.scheduleTimeout(resolvePromise, delayMs);
});
} catch {
return false;
}
}
}
export function readWorkerEventLoopDelay(
capture: WorkerPerformanceCapture
): WorkerEventLoopDelayMetrics | null {
if (
capture.invalidReason !== null ||
capture.eventLoopDelayUnavailableReason !== null ||
capture.histogramFlushedEpochMs === null ||
!capture.eventLoopDelay
) {
return null;
}
try {
const metrics: WorkerEventLoopDelayMetrics = {
maxMs: capture.eventLoopDelay.max / 1e6,
p95Ms: capture.eventLoopDelay.percentile(95) / 1e6,
p99Ms: capture.eventLoopDelay.percentile(99) / 1e6,
};
if (
Object.values(metrics).every(
(value) => Number.isFinite(value) && value >= 0
)
) {
return metrics;
}
} catch {
// The fixed reason below is intentionally payload-free.
}
capture.eventLoopDelayUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID;
return null;
}
@@ -0,0 +1,235 @@
import type { EventLoopUtilization } from 'node:perf_hooks';
import {
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
type WorkerPerformanceCapture,
type WorkerPerformanceCaptureResult,
} from './worker-performance-capture.model';
import { readWorkerEventLoopDelay } from './worker-performance-capture.histogram';
import { safeReadEpochMs } from './worker-performance-capture.runtime';
function readThreadCpuBoundary(
capture: WorkerPerformanceCapture
): NodeJS.CpuUsage | null {
if (!capture.runtime.readThreadCpuUsage) {
capture.threadCpuUnavailableReason ??=
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE;
return null;
}
try {
const usage = capture.runtime.readThreadCpuUsage();
if (
!Number.isFinite(usage.system) ||
!Number.isFinite(usage.user) ||
usage.system < 0 ||
usage.user < 0
) {
capture.threadCpuUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID;
return null;
}
return usage;
} catch {
capture.threadCpuUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE;
return null;
}
}
function readEventLoopUtilizationBoundary(
capture: WorkerPerformanceCapture
): EventLoopUtilization | null {
try {
const utilization = capture.runtime.readEventLoopUtilization();
if (
!Number.isFinite(utilization.active) ||
!Number.isFinite(utilization.idle)
) {
capture.eventLoopUtilizationUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE;
return null;
}
return utilization;
} catch {
capture.eventLoopUtilizationUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE;
return null;
}
}
export function beginWorkerPerformanceCapture(
capture: WorkerPerformanceCapture | null
): void {
if (!capture) {
return;
}
capture.workStartedEpochMs = safeReadEpochMs(capture.runtime);
capture.threadCpuStart = readThreadCpuBoundary(capture);
capture.eventLoopUtilizationStart =
readEventLoopUtilizationBoundary(capture);
}
function calculateThreadCpu(
capture: WorkerPerformanceCapture
): Pick<
WorkerPerformanceCaptureResult,
'threadCpuSystemMicros' | 'threadCpuUserMicros'
> {
if (
capture.invalidReason !== null ||
capture.threadCpuUnavailableReason !== null ||
!capture.threadCpuStart ||
!capture.threadCpuEnd
) {
return {
threadCpuSystemMicros: null,
threadCpuUserMicros: null,
};
}
try {
const system =
capture.threadCpuEnd.system - capture.threadCpuStart.system;
const user = capture.threadCpuEnd.user - capture.threadCpuStart.user;
if (
!Number.isFinite(system) ||
!Number.isFinite(user) ||
system < 0 ||
user < 0
) {
capture.threadCpuUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID;
return {
threadCpuSystemMicros: null,
threadCpuUserMicros: null,
};
}
return {
threadCpuSystemMicros: system,
threadCpuUserMicros: user,
};
} catch {
capture.threadCpuUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID;
return {
threadCpuSystemMicros: null,
threadCpuUserMicros: null,
};
}
}
function calculateEventLoopUtilization(
capture: WorkerPerformanceCapture
): number | null {
if (
capture.invalidReason !== null ||
capture.eventLoopUtilizationUnavailableReason !== null ||
!capture.eventLoopUtilizationStart ||
!capture.eventLoopUtilizationEnd
) {
return null;
}
try {
const active =
capture.eventLoopUtilizationEnd.active -
capture.eventLoopUtilizationStart.active;
const idle =
capture.eventLoopUtilizationEnd.idle -
capture.eventLoopUtilizationStart.idle;
const total = active + idle;
if (
!Number.isFinite(active) ||
!Number.isFinite(idle) ||
active < 0 ||
idle < 0 ||
total < 0
) {
capture.eventLoopUtilizationUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE;
return null;
}
return total === 0 ? 0 : active / total;
} catch {
capture.eventLoopUtilizationUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE;
return null;
}
}
function createFallbackResult(
capture: WorkerPerformanceCapture
): WorkerPerformanceCaptureResult {
const invalidReason = capture.invalidReason;
return {
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
invalidReason ??
capture.eventLoopDelayUnavailableReason ??
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE,
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason:
invalidReason ??
capture.eventLoopUtilizationUnavailableReason ??
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE,
histogramFlushedEpochMs: null,
invalidReason,
requestReceivedEpochMs: capture.requestReceivedEpochMs,
threadCpuSystemMicros: null,
threadCpuUnavailableReason:
invalidReason ??
capture.threadCpuUnavailableReason ??
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE,
threadCpuUserMicros: null,
workEndedEpochMs:
capture.workEndedEpochMs ?? capture.requestReceivedEpochMs,
workStartedEpochMs:
capture.workStartedEpochMs ?? capture.requestReceivedEpochMs,
};
}
export function createWorkerPerformanceCaptureResult(
capture: WorkerPerformanceCapture
): WorkerPerformanceCaptureResult {
try {
const overlapReason = capture.invalidReason;
if (overlapReason) {
capture.eventLoopDelayUnavailableReason = overlapReason;
capture.eventLoopUtilizationUnavailableReason = overlapReason;
capture.threadCpuUnavailableReason = overlapReason;
capture.histogramFlushedEpochMs = null;
}
const threadCpu = calculateThreadCpu(capture);
return {
eventLoopDelay: readWorkerEventLoopDelay(capture),
eventLoopDelayUnavailableReason:
capture.eventLoopDelayUnavailableReason,
eventLoopUtilization: calculateEventLoopUtilization(capture),
eventLoopUtilizationUnavailableReason:
capture.eventLoopUtilizationUnavailableReason,
histogramFlushedEpochMs: capture.histogramFlushedEpochMs,
invalidReason: capture.invalidReason,
requestReceivedEpochMs: capture.requestReceivedEpochMs,
...threadCpu,
threadCpuUnavailableReason: capture.threadCpuUnavailableReason,
workEndedEpochMs:
capture.workEndedEpochMs ?? capture.requestReceivedEpochMs,
workStartedEpochMs:
capture.workStartedEpochMs ?? capture.requestReceivedEpochMs,
};
} catch {
return createFallbackResult(capture);
}
}
export function finishWorkerPerformanceMetricBoundaries(
capture: WorkerPerformanceCapture
): void {
capture.workEndedEpochMs = safeReadEpochMs(capture.runtime);
capture.threadCpuEnd = readThreadCpuBoundary(capture);
capture.eventLoopUtilizationEnd = readEventLoopUtilizationBoundary(capture);
}
@@ -0,0 +1,94 @@
import type { EventLoopUtilization } from 'node:perf_hooks';
export const WORKER_PERFORMANCE_INVALID_REASON = {
OVERLAPPING_DATABASE_WORKER_REQUESTS:
'overlapping-database-worker-requests',
} as const;
export type WorkerPerformanceInvalidReason =
(typeof WORKER_PERFORMANCE_INVALID_REASON)[keyof typeof WORKER_PERFORMANCE_INVALID_REASON];
export const WORKER_PERFORMANCE_UNAVAILABLE_REASON = {
EVENT_LOOP_DELAY_ARM_TIMEOUT: 'event-loop-delay-arm-timeout',
EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE:
'event-loop-delay-capture-unavailable',
EVENT_LOOP_DELAY_FLUSH_TIMEOUT: 'event-loop-delay-flush-timeout',
EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid',
EVENT_LOOP_UTILIZATION_UNAVAILABLE: 'event-loop-utilization-unavailable',
THREAD_CPU_USAGE_INVALID: 'thread-cpu-usage-invalid',
THREAD_CPU_USAGE_UNAVAILABLE: 'thread-cpu-usage-unavailable',
} as const;
export type WorkerPerformanceUnavailableReason =
(typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON)[keyof typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON];
export type WorkerPerformanceMetricUnavailableReason =
WorkerPerformanceInvalidReason | WorkerPerformanceUnavailableReason;
export interface WorkerEventLoopDelayMetrics {
maxMs: number;
p95Ms: number;
p99Ms: number;
}
export interface WorkerEventLoopDelayHistogram {
readonly count: number;
readonly max: number;
disable: () => boolean;
enable: () => boolean;
percentile: (percentile: number) => number;
}
export interface WorkerPerformanceCaptureResult {
eventLoopDelay: WorkerEventLoopDelayMetrics | null;
eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
eventLoopUtilization: number | null;
eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
histogramFlushedEpochMs: number | null;
invalidReason: WorkerPerformanceInvalidReason | null;
requestReceivedEpochMs: number;
threadCpuSystemMicros: number | null;
threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
threadCpuUserMicros: number | null;
workEndedEpochMs: number;
workStartedEpochMs: number;
}
export interface WorkerPerformanceCaptureRuntime {
createEventLoopDelayHistogram: () => WorkerEventLoopDelayHistogram;
readEpochMs: () => number;
readEventLoopUtilization: () => EventLoopUtilization;
readMonotonicMs: () => number;
readThreadCpuUsage: (() => NodeJS.CpuUsage) | null;
scheduleTimeout: (callback: () => void, delayMs: number) => void;
}
export interface WorkerPerformanceCaptureOptions {
enabled?: boolean;
runtime?: WorkerPerformanceCaptureRuntime;
}
export interface WorkerPerformanceExecutionResult<TResult> {
error: unknown | null;
performance: WorkerPerformanceCaptureResult | undefined;
result: TResult | undefined;
success: boolean;
}
export interface WorkerPerformanceCapture {
eventLoopDelay: WorkerEventLoopDelayHistogram | null;
eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
eventLoopUtilizationEnd: EventLoopUtilization | null;
eventLoopUtilizationStart: EventLoopUtilization | null;
eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
histogramDisabled: boolean;
histogramFlushedEpochMs: number | null;
invalidReason: WorkerPerformanceInvalidReason | null;
requestReceivedEpochMs: number;
runtime: WorkerPerformanceCaptureRuntime;
threadCpuEnd: NodeJS.CpuUsage | null;
threadCpuStart: NodeJS.CpuUsage | null;
threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
workEndedEpochMs: number | null;
workStartedEpochMs: number | null;
}
@@ -0,0 +1,45 @@
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import type {
WorkerPerformanceCaptureRuntime,
WorkerEventLoopDelayHistogram,
} from './worker-performance-capture.model';
export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
export const HISTOGRAM_POLL_INTERVAL_MS = 1;
export const HISTOGRAM_WAIT_CAP_MS = 50;
function readRuntimeThreadCpuUsage(): (() => NodeJS.CpuUsage) | null {
const candidate: unknown = Reflect.get(process, 'threadCpuUsage');
if (typeof candidate !== 'function') {
return null;
}
return () =>
(candidate as (this: NodeJS.Process) => NodeJS.CpuUsage).call(process);
}
export const DEFAULT_WORKER_PERFORMANCE_RUNTIME: WorkerPerformanceCaptureRuntime =
{
createEventLoopDelayHistogram: (): WorkerEventLoopDelayHistogram =>
monitorEventLoopDelay({
resolution: HISTOGRAM_POLL_INTERVAL_MS,
}),
readEpochMs: () => performance.timeOrigin + performance.now(),
readEventLoopUtilization: () => performance.eventLoopUtilization(),
readMonotonicMs: () => performance.now(),
readThreadCpuUsage: readRuntimeThreadCpuUsage(),
scheduleTimeout: (callback, delayMs) => {
setTimeout(callback, delayMs);
},
};
export function safeReadEpochMs(
runtime: WorkerPerformanceCaptureRuntime
): number {
try {
const epochMs = runtime.readEpochMs();
return Number.isFinite(epochMs) ? epochMs : Date.now();
} catch {
return Date.now();
}
}
@@ -1,8 +1,10 @@
import {
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
armWorkerPerformanceCapture,
finishWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
@@ -23,26 +25,355 @@ describe('worker performance capture', () => {
expect(startWorkerPerformanceCapture()).toBeNull();
});
it('reports finite worker event-loop metrics when opted in', async () => {
process.env[PROFILING_ENV] = '1';
const capture = startWorkerPerformanceCapture();
await armWorkerPerformanceCapture(capture);
const blockStartedAt = performance.now();
while (performance.now() - blockStartedAt < 20) {
// Deliberately block the loop so the histogram contract is tested.
}
const result = await finishWorkerPerformanceCapture(capture);
expect(result).toEqual({
eventLoopDelay: {
maxMs: expect.any(Number),
p95Ms: expect.any(Number),
p99Ms: expect.any(Number),
},
eventLoopUtilization: expect.any(Number),
it('captures exact work-boundary CPU, ELU, timestamps, and a flushed fresh histogram', async () => {
const harness = createFakeRuntime();
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => {
harness.lifecycle.push('execute');
await Promise.resolve();
harness.lifecycle.push('execute:settled');
return 'result';
}
);
expect(execution).toEqual({
error: null,
performance: {
eventLoopDelay: {
maxMs: 24,
p95Ms: 18,
p99Ms: 22,
},
eventLoopDelayUnavailableReason: null,
eventLoopUtilization: 0.75,
eventLoopUtilizationUnavailableReason: null,
histogramFlushedEpochMs: 145,
invalidReason: null,
requestReceivedEpochMs: 100,
threadCpuSystemMicros: 30,
threadCpuUnavailableReason: null,
threadCpuUserMicros: 80,
workEndedEpochMs: 140,
workStartedEpochMs: 110,
},
result: 'result',
success: true,
});
expect(harness.lifecycle).toEqual([
'epoch:100',
'histogram:create',
'timeout:1',
'epoch:110',
'cpu:1',
'elu:1',
'execute',
'execute:settled',
'epoch:140',
'cpu:2',
'elu:2',
'timeout:1',
'epoch:145',
]);
expect(harness.histogramLifecycle.enableCalls).toBe(1);
expect(harness.histogramLifecycle.disableCalls).toBe(1);
expect('reset' in harness.histogram).toBe(false);
});
it('finishes profiling before returning a rejected execution to worker side effects', async () => {
const harness = createFakeRuntime();
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
const failure = new Error('worker failed');
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => {
harness.lifecycle.push('execute');
await Promise.resolve();
harness.lifecycle.push('execute:rejected');
throw failure;
}
);
harness.lifecycle.push('worker:emit-error');
expect(execution).toMatchObject({
error: failure,
success: false,
});
expect(harness.lifecycle.indexOf('epoch:140')).toBe(
harness.lifecycle.indexOf('execute:rejected') + 1
);
expect(harness.lifecycle.indexOf('worker:emit-error')).toBeGreaterThan(
harness.lifecycle.indexOf('epoch:145')
);
});
it('times out arming after a monotonic 50ms without blocking work metrics', async () => {
const harness = createFakeRuntime({
armHistogram: false,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'result'
);
expect(
harness.scheduledTimeouts.reduce(
(total, timeout) => total + timeout,
0
)
).toBe(50);
expect(execution.performance).toMatchObject({
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT,
eventLoopUtilization: 0.75,
histogramFlushedEpochMs: null,
threadCpuSystemMicros: 30,
threadCpuUserMicros: 80,
});
});
it('cannot poll forever when the monotonic runtime clock stalls', async () => {
const harness = createFakeRuntime({
armHistogram: false,
stallMonotonicClock: true,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'result'
);
expect(harness.scheduledTimeouts).toHaveLength(50);
expect(execution).toMatchObject({
result: 'result',
success: true,
performance: {
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT,
},
});
});
it('times out flushing after a monotonic 50ms while preserving work CPU and ELU', async () => {
const harness = createFakeRuntime({
flushHistogram: false,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'result'
);
expect(
harness.scheduledTimeouts
.slice(1)
.reduce((total, timeout) => total + timeout, 0)
).toBe(50);
expect(execution.performance).toMatchObject({
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT,
eventLoopUtilization: 0.75,
histogramFlushedEpochMs: null,
threadCpuSystemMicros: 30,
threadCpuUserMicros: 80,
});
});
it('reports unavailable thread CPU without falling back to process CPU usage', async () => {
const harness = createFakeRuntime({
threadCpuAvailable: false,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'result'
);
expect(execution.performance).toMatchObject({
eventLoopUtilization: 0.75,
threadCpuSystemMicros: null,
threadCpuUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE,
threadCpuUserMicros: null,
});
expect(harness.lifecycle).not.toContain('cpu:1');
expect(harness.lifecycle).not.toContain('cpu:2');
});
it('preserves the business result when CPU and ELU runtime callbacks throw', async () => {
const harness = createFakeRuntime({
throwBoundaryCallbacks: true,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'business-result'
);
expect(execution).toMatchObject({
error: null,
result: 'business-result',
success: true,
performance: {
eventLoopDelay: {
maxMs: 24,
p95Ms: 18,
p99Ms: 22,
},
eventLoopUtilization: null,
eventLoopUtilizationUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE,
threadCpuSystemMicros: null,
threadCpuUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE,
threadCpuUserMicros: null,
},
});
});
it('preserves work metrics and the business result when timer scheduling throws', async () => {
const harness = createFakeRuntime({
throwScheduleTimeout: true,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'business-result'
);
expect(execution).toMatchObject({
error: null,
result: 'business-result',
success: true,
performance: {
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT,
eventLoopUtilization: 0.75,
threadCpuSystemMicros: 30,
threadCpuUserMicros: 80,
},
});
});
it.each([
{
label: 'count getter during finish',
options: {
throwHistogramCountAtRead: 3,
},
reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE,
},
{
label: 'metric getter after flush',
options: {
throwHistogramMetric: true,
},
reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID,
},
])(
'preserves the business result when the histogram $label throws',
async ({ options, reason }) => {
const harness = createFakeRuntime(options);
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'business-result'
);
expect(execution).toMatchObject({
error: null,
result: 'business-result',
success: true,
performance: {
eventLoopDelay: null,
eventLoopDelayUnavailableReason: reason,
eventLoopUtilization: 0.75,
threadCpuSystemMicros: 30,
threadCpuUserMicros: 80,
},
});
}
);
it('keeps the business result and rejects delay metrics when histogram disable throws', async () => {
const harness = createFakeRuntime({
throwHistogramDisable: true,
});
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'business-result'
);
expect(execution).toMatchObject({
error: null,
result: 'business-result',
success: true,
performance: {
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE,
eventLoopUtilization: 0.75,
histogramFlushedEpochMs: null,
threadCpuSystemMicros: 30,
threadCpuUserMicros: 80,
},
});
expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0);
expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10);
});
});
@@ -0,0 +1,158 @@
import type {
WorkerEventLoopDelayHistogram,
WorkerPerformanceCaptureRuntime,
} from './worker-performance-capture';
interface FakeRuntimeOptions {
armHistogram?: boolean;
flushHistogram?: boolean;
histogramDisableResult?: boolean;
stallMonotonicClock?: boolean;
threadCpuAvailable?: boolean;
throwBoundaryCallbacks?: boolean;
throwHistogramDisable?: boolean;
throwHistogramCountAtRead?: number;
throwHistogramMetric?: boolean;
throwScheduleTimeout?: boolean;
}
export interface FakeRuntimeHarness {
histogram: WorkerEventLoopDelayHistogram;
histogramLifecycle: {
disableCalls: number;
enableCalls: number;
};
lifecycle: string[];
runtime: WorkerPerformanceCaptureRuntime;
scheduledTimeouts: number[];
}
export function createFakeRuntime(
options: FakeRuntimeOptions = {}
): FakeRuntimeHarness {
const lifecycle: string[] = [];
const scheduledTimeouts: number[] = [];
let epochIndex = 0;
let monotonicMs = 0;
let timeoutCount = 0;
let cpuIndex = 0;
let eluIndex = 0;
let histogramCount = 0;
let histogramCountReads = 0;
const histogramLifecycle = {
disableCalls: 0,
enableCalls: 0,
};
const epochValues = [100, 110, 140, 145];
const cpuValues = [
{ system: 50, user: 100 },
{ system: 80, user: 180 },
];
const eluValues = [
{ active: 10, idle: 20, utilization: 1 / 3 },
{ active: 40, idle: 30, utilization: 4 / 7 },
];
const histogram = {
get count() {
histogramCountReads += 1;
if (histogramCountReads === options.throwHistogramCountAtRead) {
throw new Error('histogram count unavailable');
}
return histogramCount;
},
disable: () => {
histogramLifecycle.disableCalls += 1;
if (options.throwHistogramDisable === true) {
throw new Error('histogram disable unavailable');
}
return options.histogramDisableResult ?? true;
},
enable: () => {
histogramLifecycle.enableCalls += 1;
return true;
},
get max() {
if (options.throwHistogramMetric === true) {
throw new Error('histogram max unavailable');
}
return 24_000_000;
},
percentile: (percentile: number) => {
if (options.throwHistogramMetric === true) {
throw new Error('histogram percentile unavailable');
}
if (percentile === 95) {
return 18_000_000;
}
if (percentile === 99) {
return 22_000_000;
}
return 0;
},
} satisfies WorkerEventLoopDelayHistogram;
const runtime: WorkerPerformanceCaptureRuntime = {
createEventLoopDelayHistogram: () => {
lifecycle.push('histogram:create');
return histogram;
},
readEpochMs: () => {
const value = epochValues[epochIndex] ?? 145 + epochIndex;
epochIndex += 1;
lifecycle.push(`epoch:${value}`);
return value;
},
readEventLoopUtilization: () => {
if (options.throwBoundaryCallbacks === true) {
throw new Error('ELU callback unavailable');
}
const value = eluValues[eluIndex] ?? eluValues.at(-1)!;
eluIndex += 1;
lifecycle.push(`elu:${eluIndex}`);
return value;
},
readMonotonicMs: () => monotonicMs,
readThreadCpuUsage:
options.threadCpuAvailable === false
? null
: () => {
if (options.throwBoundaryCallbacks === true) {
throw new Error('thread CPU callback unavailable');
}
const value = cpuValues[cpuIndex] ?? cpuValues.at(-1)!;
cpuIndex += 1;
lifecycle.push(`cpu:${cpuIndex}`);
return value;
},
scheduleTimeout: (callback, delayMs) => {
lifecycle.push(`timeout:${delayMs}`);
scheduledTimeouts.push(delayMs);
if (options.throwScheduleTimeout === true) {
throw new Error('timer callback unavailable');
}
if (options.stallMonotonicClock !== true) {
monotonicMs += delayMs;
} else if (timeoutCount >= 55) {
throw new Error('test scheduler fail-safe');
}
timeoutCount += 1;
if (timeoutCount === 1 && options.armHistogram !== false) {
histogramCount += 1;
} else if (
timeoutCount > 1 &&
options.armHistogram !== false &&
options.flushHistogram !== false
) {
histogramCount += 1;
}
callback();
},
};
return {
histogram,
histogramLifecycle,
lifecycle,
runtime,
scheduledTimeouts,
};
}
@@ -1,47 +1,119 @@
import {
monitorEventLoopDelay,
performance,
type IntervalHistogram,
} from 'node:perf_hooks';
WORKER_PERFORMANCE_INVALID_REASON,
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
type WorkerPerformanceCapture,
type WorkerPerformanceCaptureOptions,
type WorkerPerformanceCaptureResult,
type WorkerPerformanceExecutionResult,
} from './worker-performance-capture.model';
import {
disableWorkerPerformanceHistogram,
markEventLoopDelayUnavailable,
waitForHistogramCondition,
} from './worker-performance-capture.histogram';
import {
beginWorkerPerformanceCapture,
createWorkerPerformanceCaptureResult,
finishWorkerPerformanceMetricBoundaries,
} from './worker-performance-capture.metrics';
import {
DEFAULT_WORKER_PERFORMANCE_RUNTIME,
safeReadEpochMs,
WORKER_PROFILING_ENV,
} from './worker-performance-capture.runtime';
const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
export {
WORKER_PERFORMANCE_INVALID_REASON,
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
} from './worker-performance-capture.model';
export type {
WorkerEventLoopDelayHistogram,
WorkerEventLoopDelayMetrics,
WorkerPerformanceCapture,
WorkerPerformanceCaptureOptions,
WorkerPerformanceCaptureResult,
WorkerPerformanceCaptureRuntime,
WorkerPerformanceExecutionResult,
WorkerPerformanceInvalidReason,
WorkerPerformanceUnavailableReason,
} from './worker-performance-capture.model';
export interface WorkerPerformanceCaptureResult {
eventLoopDelay: {
maxMs: number;
p95Ms: number;
p99Ms: number;
};
eventLoopUtilization: number;
}
interface WorkerPerformanceCapture {
eventLoopDelay: IntervalHistogram;
eventLoopUtilizationStart: ReturnType<
typeof performance.eventLoopUtilization
>;
}
export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null {
if (process.env[WORKER_PROFILING_ENV] !== '1') {
export function startWorkerPerformanceCapture(
options: WorkerPerformanceCaptureOptions = {}
): WorkerPerformanceCapture | null {
const enabled =
options.enabled ?? process.env[WORKER_PROFILING_ENV] === '1';
if (!enabled) {
return null;
}
const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 });
eventLoopDelay.enable();
return {
eventLoopDelay,
eventLoopUtilizationStart: performance.eventLoopUtilization(),
const runtime = options.runtime ?? DEFAULT_WORKER_PERFORMANCE_RUNTIME;
const capture: WorkerPerformanceCapture = {
eventLoopDelay: null,
eventLoopDelayUnavailableReason: null,
eventLoopUtilizationEnd: null,
eventLoopUtilizationStart: null,
eventLoopUtilizationUnavailableReason: null,
histogramDisabled: false,
histogramFlushedEpochMs: null,
invalidReason: null,
requestReceivedEpochMs: safeReadEpochMs(runtime),
runtime,
threadCpuEnd: null,
threadCpuStart: null,
threadCpuUnavailableReason:
runtime.readThreadCpuUsage === null
? WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE
: null,
workEndedEpochMs: null,
workStartedEpochMs: null,
};
try {
capture.eventLoopDelay = runtime.createEventLoopDelayHistogram();
capture.eventLoopDelay.enable();
} catch {
capture.eventLoopDelay = null;
capture.eventLoopDelayUnavailableReason =
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE;
}
return capture;
}
export async function armWorkerPerformanceCapture(
capture: WorkerPerformanceCapture | null
): Promise<void> {
if (capture) {
await new Promise<void>((resolvePromise) =>
setTimeout(resolvePromise, 2)
if (!capture) {
return;
}
try {
if (
capture.invalidReason !== null ||
capture.eventLoopDelayUnavailableReason !== null
) {
return;
}
const armed = await waitForHistogramCondition(
capture,
(histogram) => histogram.count > 0
);
if (!armed && capture.invalidReason === null) {
markEventLoopDelayUnavailable(
capture,
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT
);
}
} catch {
try {
markEventLoopDelayUnavailable(
capture,
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE
);
} catch {
// Instrumentation must never prevent business execution.
}
}
}
@@ -52,17 +124,148 @@ export async function finishWorkerPerformanceCapture(
return undefined;
}
await new Promise<void>((resolvePromise) => setTimeout(resolvePromise, 2));
capture.eventLoopDelay.disable();
const eventLoopUtilization = performance.eventLoopUtilization(
capture.eventLoopUtilizationStart
finishWorkerPerformanceMetricBoundaries(capture);
if (
capture.invalidReason === null &&
capture.eventLoopDelayUnavailableReason === null &&
capture.eventLoopDelay
) {
try {
const workEndedHistogramCount = capture.eventLoopDelay.count;
const flushed = await waitForHistogramCondition(
capture,
(histogram) => histogram.count > workEndedHistogramCount
);
if (flushed) {
const disabled = disableWorkerPerformanceHistogram(capture);
if (disabled) {
capture.histogramFlushedEpochMs = safeReadEpochMs(
capture.runtime
);
} else {
markEventLoopDelayUnavailable(
capture,
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE
);
}
} else if (
capture.invalidReason === null &&
capture.eventLoopDelayUnavailableReason === null
) {
markEventLoopDelayUnavailable(
capture,
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT
);
}
} catch {
markEventLoopDelayUnavailable(
capture,
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE
);
}
} else {
disableWorkerPerformanceHistogram(capture);
}
return createWorkerPerformanceCaptureResult(capture);
}
export async function executeWithWorkerPerformanceCapture<TResult>(
capture: WorkerPerformanceCapture | null,
execute: () => Promise<TResult>,
finish: (
capture: WorkerPerformanceCapture | null
) => Promise<
WorkerPerformanceCaptureResult | undefined
> = finishWorkerPerformanceCapture
): Promise<WorkerPerformanceExecutionResult<TResult>> {
try {
beginWorkerPerformanceCapture(capture);
} catch {
// Instrumentation setup must never prevent business execution.
}
let result: TResult;
try {
result = await execute();
} catch (error) {
const performance = await safelyFinishWorkerPerformanceCapture(
capture,
finish
);
return {
error,
performance,
result: undefined,
success: false,
};
}
const performance = await safelyFinishWorkerPerformanceCapture(
capture,
finish
);
return {
eventLoopDelay: {
maxMs: capture.eventLoopDelay.max / 1e6,
p95Ms: capture.eventLoopDelay.percentile(95) / 1e6,
p99Ms: capture.eventLoopDelay.percentile(99) / 1e6,
},
eventLoopUtilization: eventLoopUtilization.utilization,
error: null,
performance,
result,
success: true,
};
}
async function safelyFinishWorkerPerformanceCapture(
capture: WorkerPerformanceCapture | null,
finish: (
capture: WorkerPerformanceCapture | null
) => Promise<WorkerPerformanceCaptureResult | undefined>
): Promise<WorkerPerformanceCaptureResult | undefined> {
try {
return await finish(capture);
} catch {
if (!capture) {
return undefined;
}
try {
if (capture.workEndedEpochMs === null) {
finishWorkerPerformanceMetricBoundaries(capture);
}
markEventLoopDelayUnavailable(
capture,
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE
);
return createWorkerPerformanceCaptureResult(capture);
} catch {
return undefined;
}
}
}
export function registerDatabaseWorkerPerformanceCapture(
activeCaptures: Set<WorkerPerformanceCapture>,
capture: WorkerPerformanceCapture | null
): void {
if (!capture) {
return;
}
if (activeCaptures.size > 0) {
for (const activeCapture of activeCaptures) {
activeCapture.invalidReason =
WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS;
disableWorkerPerformanceHistogram(activeCapture);
}
capture.invalidReason =
WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS;
disableWorkerPerformanceHistogram(capture);
}
activeCaptures.add(capture);
}
export function releaseDatabaseWorkerPerformanceCapture(
activeCaptures: Set<WorkerPerformanceCapture>,
capture: WorkerPerformanceCapture | null
): void {
if (capture) {
activeCaptures.delete(capture);
}
}
@@ -0,0 +1,115 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
armWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
function readWorkerSource(filename: string): string {
return readFileSync(
join(process.cwd(), 'apps/electron-backend/src/app/workers', filename),
'utf8'
);
}
describe('worker performance integration order', () => {
it('profiles overlapping database requests without serializing their execution', () => {
const source = readWorkerSource('database.worker.ts');
const handler = source.slice(
source.lastIndexOf("parentPort.on('message'")
);
expect(handler).toContain('registerDatabaseWorkerPerformanceCapture');
expect(handler).toContain('executeWithWorkerPerformanceCapture');
expect(handler).toContain('releaseDatabaseWorkerPerformanceCapture');
expect(handler).toContain('finally');
expect(handler).not.toContain('finishWorkerPerformanceCapture');
expect(
handler.indexOf('executeWithWorkerPerformanceCapture')
).toBeLessThan(handler.indexOf('console.error'));
expect(
handler.indexOf('executeWithWorkerPerformanceCapture')
).toBeLessThan(handler.indexOf('postMessage'));
expect(handler).not.toContain('await previous');
expect(handler).not.toContain('requestQueue');
});
it('keeps playlist response serialization after profiling completion', () => {
const source = readWorkerSource('playlist-refresh.worker.ts');
const handler = source.slice(source.lastIndexOf('parentPort.on('));
const executionIndex = handler.indexOf(
'executeWithWorkerPerformanceCapture'
);
expect(executionIndex).toBeGreaterThanOrEqual(0);
expect(handler).not.toContain('finishWorkerPerformanceCapture');
expect(executionIndex).toBeLessThan(
handler.indexOf('serializeError', executionIndex)
);
expect(executionIndex).toBeLessThan(
handler.indexOf('postMessage', executionIndex)
);
});
it('preserves success and business errors when the finish seam throws once', async () => {
const successHarness = createFakeRuntime();
const successCapture = startWorkerPerformanceCapture({
enabled: true,
runtime: successHarness.runtime,
});
await armWorkerPerformanceCapture(successCapture);
const failingSuccessFinish = jest.fn(async () => {
throw new Error('finish failed');
});
const success = await executeWithWorkerPerformanceCapture(
successCapture,
async () => 'business-result',
failingSuccessFinish
);
expect(success).toMatchObject({
error: null,
result: 'business-result',
success: true,
performance: {
eventLoopDelay: null,
eventLoopDelayUnavailableReason:
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE,
eventLoopUtilization: 0.75,
threadCpuSystemMicros: 30,
threadCpuUserMicros: 80,
},
});
expect(failingSuccessFinish).toHaveBeenCalledTimes(1);
const errorHarness = createFakeRuntime();
const errorCapture = startWorkerPerformanceCapture({
enabled: true,
runtime: errorHarness.runtime,
});
await armWorkerPerformanceCapture(errorCapture);
const businessError = new Error('business failed');
const failingErrorFinish = jest.fn(async () => {
throw new Error('finish failed');
});
const failure = await executeWithWorkerPerformanceCapture(
errorCapture,
async () => {
throw businessError;
},
failingErrorFinish
);
expect(failure).toMatchObject({
error: businessError,
result: undefined,
success: false,
});
expect(failingErrorFinish).toHaveBeenCalledTimes(1);
});
});
+11
View File
@@ -49,6 +49,11 @@
],
"outputHashing": "all"
},
"electron-performance": {
"optimization": true,
"outputHashing": "all",
"sourceMap": true
},
"development": {
"optimization": false,
"extractLicenses": false,
@@ -63,6 +68,12 @@
"command": "pnpm nx run remote-control-web:build:development"
}
},
"build-performance": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm nx run remote-control-web:build:electron-performance"
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
-1
View File
@@ -42,7 +42,6 @@ export default {
'^shaka-player$': '<rootDir>/src/test-stubs/shaka-player.js',
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
'^uuid$': '<rootDir>/../../node_modules/uuid/wrapper.mjs',
},
transform: angularEsmPreset.transform,
transformIgnorePatterns: [],
+28 -1
View File
@@ -13,7 +13,7 @@
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"outputs": ["{workspaceRoot}/dist/apps/web"],
"options": {
"outputPath": {
"base": "dist/apps/web",
@@ -122,6 +122,27 @@
"extractLicenses": false,
"sourceMap": true
},
"electron-performance": {
"baseHref": "./",
"serviceWorker": false,
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false,
"removeSpecialComments": true
},
"fonts": true
},
"outputHashing": "all",
"sourceMap": true,
"fileReplacements": [
{
"replace": "apps/web/src/environments/environment.ts",
"with": "apps/web/src/environments/environment.prod.ts"
}
]
},
"electron-e2e": {
"baseHref": "./",
"serviceWorker": false,
@@ -138,6 +159,12 @@
"command": "pnpm nx run web:build:electron-e2e"
}
},
"build-performance": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm nx run web:build:electron-performance"
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
@@ -0,0 +1,218 @@
import { inject, Injectable, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { take } from 'rxjs';
import { SettingsService } from '../services/settings.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60;
const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250;
/**
* Owns everything the About section needs to talk about versions: the
* electron-updater status stream and the "is my build outdated" message.
*/
@Injectable()
export class SettingsAppUpdateFacade {
private readonly dataService = inject(DataService);
private readonly matDialog = inject(MatDialog);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsService = inject(SettingsService);
private readonly translate = inject(TranslateService);
/** Latest updater status, polled once and then pushed by the backend */
readonly status = signal<ElectronBridgeAppUpdateStatus | null>(null);
/** Current version of the app */
readonly version = signal('');
/** Update message to show */
readonly updateMessage = signal('');
private unsubscribeStatus: (() => void) | null = null;
/** Subscribes to status pushes and kicks off the initial status load */
init(): void {
this.bindStatusEvents();
void this.loadStatus();
}
dispose(): void {
this.unsubscribeStatus?.();
this.unsubscribeStatus = null;
}
async checkForAppUpdate(): Promise<void> {
if (!this.runtime.isElectron || !window.electron?.checkForAppUpdate) {
return;
}
this.status.set(await window.electron.checkForAppUpdate());
}
async downloadAppUpdate(): Promise<void> {
if (!this.runtime.isElectron || !window.electron?.downloadAppUpdate) {
return;
}
this.status.set(await window.electron.downloadAppUpdate());
}
async installAppUpdate(): Promise<void> {
if (!this.runtime.isElectron || !window.electron?.installAppUpdate) {
return;
}
this.status.set(await window.electron.installAppUpdate());
}
openManualAppUpdate(): void {
const manualDownloadUrl = this.status()?.manualDownloadUrl;
if (!manualDownloadUrl) {
return;
}
window.open(manualDownloadUrl, '_blank', 'noreferrer');
}
openReleaseNotes(): void {
const status = this.status();
const isUpdateRelease =
status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
status?.status ===
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded;
const initialReleaseNotesVersion = isUpdateRelease
? (status?.latestVersion ??
status?.release?.version ??
status?.currentVersion)
: status?.currentVersion;
this.matDialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
...(!isUpdateRelease ? { fallbackToLatest: true } : {}),
initialVersion: initialReleaseNotesVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
/**
* Checks whether the latest version of the application
* is used and updates the version message in the
* settings UI
*/
checkAppVersion(): void {
this.settingsService
.getAppVersion()
.pipe(take(1))
.subscribe((version) => this.showVersionInformation(version));
}
/**
* Updates the message in settings UI about the used
* version of the app
* @param currentVersion current version of the application
*/
showVersionInformation(currentVersion: string): void {
const isOutdated = this.isCurrentVersionOutdated(currentVersion);
if (isOutdated) {
this.updateMessage.set(
`${
this.translate.instant(
'SETTINGS.NEW_VERSION_AVAILABLE'
) as string
}: ${currentVersion}`
);
} else {
this.updateMessage.set(
this.translate.instant('SETTINGS.LATEST_VERSION')
);
}
}
/**
* Compares actual with latest version of the
* application
* @param latestVersion latest version
* @returns returns true if an update is available
*/
isCurrentVersionOutdated(latestVersion: string): boolean {
this.version.set(this.dataService.getAppVersion());
return this.settingsService.isVersionOutdated(
this.version(),
latestVersion
);
}
private bindStatusEvents(): void {
if (
!this.runtime.isElectron ||
!window.electron?.onAppUpdateStatusChange
) {
return;
}
this.unsubscribeStatus = window.electron.onAppUpdateStatusChange(
(status) => {
this.status.set(status);
}
);
}
/**
* The updater IPC handlers can still be registering while the settings
* page mounts, so the first load retries until the bridge answers.
*/
private async loadStatus(): Promise<void> {
if (!this.runtime.isElectron) {
return;
}
let lastError: unknown;
for (
let attempt = 1;
attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS;
attempt += 1
) {
const electron = window.electron;
if (electron?.getAppUpdateStatus) {
try {
this.status.set(await electron.getAppUpdateStatus());
return;
} catch (error) {
lastError = error;
}
}
if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) {
console.warn(
'Failed to load app update status:',
lastError ??
new Error('Desktop app update bridge is unavailable')
);
return;
}
await this.waitForRetry();
}
}
private async waitForRetry(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS);
});
}
}
@@ -0,0 +1,74 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces';
/**
* Probes the desktop backend for embedded MPV support so the playback
* section can hide options the current build cannot deliver.
*/
@Injectable()
export class SettingsEmbeddedMpvFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
readonly support = signal<EmbeddedMpvSupport | null>(null);
readonly supported = computed(
() => this.runtime.isElectron && !!this.support()?.supported
);
readonly frameCopyAvailable = computed(
() => this.runtime.isElectron && !!this.support()?.frameCopyAvailable
);
readonly frameCopyActive = computed(
() => this.support()?.engine === 'frame-copy'
);
async load(): Promise<void> {
if (!this.runtime.isElectron) {
this.support.set({
supported: false,
platform: 'web',
reason: 'Embedded MPV requires the Electron desktop build.',
});
return;
}
if (!window.electron?.getEmbeddedMpvSupport) {
this.support.set({
supported: false,
platform: window.electron.platform,
reason: 'Embedded MPV support is not available in this build.',
});
return;
}
try {
this.support.set(await window.electron.getEmbeddedMpvSupport());
} catch (error) {
this.support.set({
supported: false,
platform: window.electron.platform,
reason: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Opens the native folder picker for MPV recordings.
* @returns the selected folder, or null when the user cancelled or the
* desktop bridge cannot show the dialog
*/
async selectRecordingFolder(): Promise<string | null> {
if (
!this.runtime.isElectron ||
!window.electron?.selectEmbeddedMpvRecordingFolder
) {
return null;
}
return (
(await window.electron.selectEmbeddedMpvRecordingFolder()) ?? null
);
}
}
@@ -0,0 +1,123 @@
import { inject, Injectable, signal } from '@angular/core';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import { DialogService } from '@iptvnator/ui/components';
import { TranslateService } from '@ngx-translate/core';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsFormFacade } from './settings-form.facade';
import { SettingsSnackbarService } from './settings-snackbar.service';
/**
* EPG maintenance actions offered by the settings page: force refreshes and
* the destructive "clear all EPG data" flow.
*/
@Injectable()
export class SettingsEpgFacade {
private readonly dialogService = inject(DialogService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly epgService = inject(EpgService);
private readonly formFacade = inject(SettingsFormFacade);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly settingsStore = inject(SettingsStore);
private readonly translate = inject(TranslateService);
readonly isClearing = signal(false);
/**
* Force-fetch EPG for a single URL, bypassing the 12-hour freshness check.
* The plain fetchEpg would short-circuit on fresh data and click the
* refresh button would be a no-op — that's exactly not what the user
* intends when clicking "Refresh".
*/
refresh(url: string): void {
if (!this.epgBridge.supportsDataManagement || !url) {
return;
}
void this.epgBridge.forceFetchEpg(
url,
this.settingsStore.getTrustOptions()
);
}
/**
* Force-fetch every configured EPG URL sequentially. Empty fields are
* skipped. Each URL flows through the normal progress panel so the user
* gets visible per-URL feedback.
*/
refreshAll(): void {
if (!this.epgBridge.supportsDataManagement) return;
const options = this.settingsStore.getTrustOptions();
this.formFacade.epgUrls.forEach(
(url) => void this.epgBridge.forceFetchEpg(url, options)
);
}
/**
* Clears all EPG data from database and immediately re-fetches every
* configured URL so the user isn't left staring at an empty state.
* Tracks progress with `isClearing` so the UI can show a spinner
* and block double-clicks, and surfaces failures via a dedicated snackbar.
*/
clear(): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'),
message: this.translate.instant(
'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE'
),
onConfirm: async (): Promise<void> => {
if (
!this.epgBridge.supportsDataManagement ||
this.isClearing()
) {
return;
}
this.isClearing.set(true);
try {
const result = await this.epgBridge.clearEpgData();
if (result && result.success === false) {
throw new Error('Clear EPG returned success=false');
}
this.settingsSnackbar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEARED')
);
this.refreshAll();
} catch (error) {
console.error('Failed to clear EPG data:', error);
this.settingsSnackbar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED')
);
} finally {
this.isClearing.set(false);
}
},
});
}
/**
* Re-fetches the EPG sources that were just saved so the guide reflects
* the new configuration without a restart.
*/
fetchConfiguredEpg(): void {
if (!this.formFacade.supportsEpg) {
return;
}
const epgUrl = this.formFacade.form.value.epgUrl;
if (!epgUrl) {
return;
}
const validEpgUrls = (Array.isArray(epgUrl) ? epgUrl : [epgUrl]).filter(
(url): url is string => typeof url === 'string' && url !== ''
);
if (validEpgUrls.length > 0) {
// Fetch all EPG URLs at once
this.epgService.fetchEpg(validEpgUrls);
}
}
}
@@ -0,0 +1,197 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder } from '@angular/forms';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import {
CoverSize,
EpgViewMode,
Language,
Theme,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from '../services/settings.service';
import {
applyEpgUrlsToFormArray,
createEpgUrlControl,
createSettingsForm,
createSettingsFromFormValue,
SettingsForm,
} from './settings-form.utils';
type SettingsFormPatch = Parameters<SettingsForm['patchValue']>[0];
/**
* Owns the settings form: creation, hydration from the store, the small
* mutations the section components trigger, and persisting on submit.
*/
@Injectable()
export class SettingsFormFacade {
private readonly destroyRef = inject(DestroyRef);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly formBuilder = inject(FormBuilder);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsService = inject(SettingsService);
private readonly settingsStore = inject(SettingsStore);
private readonly translate = inject(TranslateService);
readonly supportsEpg =
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
/** Settings form object */
readonly form = createSettingsForm(this.formBuilder, this.supportsEpg);
/** Form array with epg sources — absent when EPG is unsupported */
readonly epgUrl = this.form.get('epgUrl') as FormArray;
/** Trimmed, non-empty EPG source URLs currently in the form */
get epgUrls(): string[] {
return ((this.epgUrl?.value as string[] | undefined) ?? [])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
}
/** Waits for the persisted settings to be available */
loadSettings(): Promise<void> {
return this.settingsStore.loadSettings();
}
/**
* Sets saved settings from the indexed db store
*/
hydrateFromStore(): void {
const currentSettings = this.settingsStore.getSettings();
this.form.patchValue(currentSettings);
this.syncDashboardControlsEnabledState(
currentSettings.showDashboard ?? true
);
if (this.supportsEpg && currentSettings.epgUrl) {
this.epgUrl.clear();
this.setEpgUrls(currentSettings.epgUrl);
}
}
bindDashboardControlsEnabledState(): void {
this.form
.get('showDashboard')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((showDashboard) =>
this.syncDashboardControlsEnabledState(showDashboard ?? true)
);
}
selectTheme(theme: Theme): void {
if (this.form.value.theme === theme) {
return;
}
this.patchAndMarkDirty({ theme }, 'theme');
this.settingsService.changeTheme(theme);
}
selectCoverSize(coverSize: CoverSize): void {
if (this.form.value.coverSize === coverSize) {
return;
}
this.patchAndMarkDirty({ coverSize }, 'coverSize');
void this.settingsStore.updateSettings({ coverSize });
}
selectEpgViewMode(epgViewMode: EpgViewMode): void {
if (this.form.value.epgViewMode === epgViewMode) {
return;
}
this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode');
void this.settingsStore.updateSettings({ epgViewMode });
}
setRecordingFolder(recordingFolder: string): void {
this.patchAndMarkDirty({ recordingFolder }, 'recordingFolder');
}
/**
* Sets the epg urls to the form array
* @param epgUrls urls of the EPG sources
*/
setEpgUrls(epgUrls: string[] | string): void {
applyEpgUrlsToFormArray(this.epgUrl, epgUrls);
}
/**
* Initializes new entry in form array for EPG URL
*/
addEpgSource(): void {
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
}
/**
* Removes entry from form array for EPG URL
* @param index index of the item to remove
*/
removeEpgSource(index: number): void {
this.epgUrl.removeAt(index);
this.form.markAsDirty();
}
/**
* Persists the form to the settings store and mirrors the result to the
* desktop backend.
* @param onSaved runs after the store write, before the backend is
* notified, so the UI confirmation is not delayed by IPC
*/
async save(onSaved: () => void): Promise<void> {
const settings = createSettingsFromFormValue(
this.form,
this.settingsStore.getSettings()
);
await this.settingsStore.updateSettings(settings);
onSaved();
if (!window.electron) {
return;
}
window.electron.updateSettings(settings);
if (this.runtime.supportsExternalPlayerPathSettings) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
}
}
/** Applies the saved language/theme and resets the dirty state */
applySavedSettings(): void {
this.form.markAsPristine();
this.translate.use(this.form.value.language ?? Language.ENGLISH);
this.settingsService.changeTheme(
this.form.value.theme ?? Theme.SystemTheme
);
}
private patchAndMarkDirty(
value: SettingsFormPatch,
controlName: string
): void {
this.form.patchValue(value);
this.form.get(controlName)?.markAsDirty();
this.form.markAsDirty();
}
private syncDashboardControlsEnabledState(showDashboard: boolean): void {
const dashboardRails = this.form.get('dashboardRails');
if (!dashboardRails) {
return;
}
if (showDashboard) {
dashboardRails.enable({ emitEvent: false });
} else {
dashboardRails.disable({ emitEvent: false });
}
}
}
@@ -100,6 +100,33 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [
},
];
export interface SettingsPlayerAvailability {
supportsEmbeddedMpv: boolean;
supportsManagedExternalPlayers: boolean;
}
/**
* Built-in web players are always offered; the OS-backed ones only show up
* when the current runtime can actually launch them.
*/
export function buildSettingsPlayerOptions({
supportsEmbeddedMpv,
supportsManagedExternalPlayers,
}: SettingsPlayerAvailability): SettingsPlayerOption[] {
return [
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
...(supportsEmbeddedMpv
? [
{
id: VideoPlayer.EmbeddedMpv,
labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV',
},
]
: []),
...(supportsManagedExternalPlayers ? SETTINGS_OS_PLAYER_OPTIONS : []),
];
}
export interface SettingsSectionVisibility {
supportsEpg: boolean;
supportsRemoteControl: boolean;
@@ -1,19 +1,30 @@
import { inject, Injectable, signal } from '@angular/core';
import { computed, inject, Injectable, signal } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { firstValueFrom, take } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import {
DatabaseService,
DbOperationEvent,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
SettingsDeleteAllPlaylistsDialogComponent,
SettingsDeleteAllPlaylistsDialogData,
} from './settings-delete-all-playlists-dialog.component';
import {
buildRemoveAllProgressLabel,
buildSettingsPlaylistDeleteSummary,
} from './settings-playlist-summary.utils';
import { SettingsPlaylistDeleteSummary } from './settings.models';
import { SettingsSnackbarService } from './settings-snackbar.service';
@Injectable()
export class SettingsPlaylistResetFacade {
private readonly databaseService = inject(DatabaseService);
private readonly matDialog = inject(MatDialog);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
@@ -23,7 +34,59 @@ export class SettingsPlaylistResetFacade {
readonly isRemovingAllPlaylists = signal(false);
readonly removeAllProgress = signal<DbOperationEvent | null>(null);
async removeAllConfirmed(
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly deleteSummary = computed<SettingsPlaylistDeleteSummary>(() =>
buildSettingsPlaylistDeleteSummary(this.playlists())
);
readonly canRemoveAll = computed(
() => !this.isRemovingAllPlaylists() && this.deleteSummary().total > 0
);
readonly removeAllProgressLabel = computed(() =>
buildRemoveAllProgressLabel({
isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
progress: this.removeAllProgress(),
translate: (key, params) => this.translate.instant(key, params),
})
);
/**
* Asks for confirmation in a dedicated dialog before wiping every
* playlist, so the summary of what is about to be lost is visible.
*/
confirmAndRemoveAll(waitForUiFeedbackFrame: () => Promise<void>): void {
if (!this.canRemoveAll()) {
return;
}
this.matDialog
.open<
SettingsDeleteAllPlaylistsDialogComponent,
SettingsDeleteAllPlaylistsDialogData,
boolean
>(SettingsDeleteAllPlaylistsDialogComponent, {
autoFocus: false,
data: {
summary: this.deleteSummary(),
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '460px',
})
.afterClosed()
.pipe(take(1))
.subscribe((confirmed) => {
if (confirmed) {
void this.removeAllConfirmed(waitForUiFeedbackFrame);
}
});
}
private async removeAllConfirmed(
waitForUiFeedbackFrame: () => Promise<void>
): Promise<void> {
if (this.isRemovingAllPlaylists()) {
@@ -0,0 +1,37 @@
import { inject, Injectable, signal } from '@angular/core';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
/**
* Backs the remote-control section: the reachable LAN addresses and which of
* them currently shows its pairing QR code.
*/
@Injectable()
export class SettingsRemoteControlFacade {
private readonly runtime = inject(RuntimeCapabilitiesService);
/** Local IP addresses for remote control URL display */
readonly localIpAddresses = signal<string[]>([]);
/** Currently visible QR code IP (null = none visible) */
readonly visibleQrCodeIp = signal<string | null>(null);
/**
* Fetches local IP addresses for remote control URL display
*/
async fetchLocalIpAddresses(): Promise<void> {
if (
this.runtime.supportsRemoteControl &&
window.electron?.getLocalIpAddresses
) {
const addresses = await window.electron.getLocalIpAddresses();
this.localIpAddresses.set(addresses);
}
}
/**
* Toggle QR code visibility for a given IP address
*/
toggleQrCode(ip: string): void {
this.visibleQrCodeIp.update((visible) => (visible === ip ? null : ip));
}
}
@@ -38,8 +38,8 @@
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
(selectTheme)="selectTheme($event)"
(selectCoverSize)="selectCoverSize($event)"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
/>
<app-settings-playback-section
@@ -50,8 +50,8 @@
[isDesktop]="isDesktop"
[supportsManagedExternalPlayers]="supportsManagedExternalPlayers"
[supportsExternalPlayerPathSettings]="supportsExternalPlayerPathSettings"
[frameCopyAvailable]="frameCopyAvailable()"
[frameCopyActive]="frameCopyActive()"
[frameCopyAvailable]="embeddedMpv.frameCopyAvailable()"
[frameCopyActive]="embeddedMpv.frameCopyActive()"
(selectRecordingFolder)="selectRecordingFolder()"
/>
@@ -59,15 +59,15 @@
<app-settings-epg-section
[form]="settingsForm"
[activeSection]="activeSection()"
[epgUrl]="epgUrl"
[isClearingEpgData]="isClearingEpgData()"
[epgUrl]="form.epgUrl"
[isClearingEpgData]="epg.isClearing()"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="refreshEpg($event)"
(removeEpgSource)="removeEpgSource($event)"
(addEpgSource)="addEpgSource()"
(refreshAllEpg)="refreshAllEpg()"
(clearEpgData)="clearEpgData()"
(selectEpgViewMode)="selectEpgViewMode($event)"
(refreshEpg)="epg.refresh($event)"
(removeEpgSource)="form.removeEpgSource($event)"
(addEpgSource)="form.addEpgSource()"
(refreshAllEpg)="epg.refreshAll()"
(clearEpgData)="epg.clear()"
(selectEpgViewMode)="form.selectEpgViewMode($event)"
/>
}
@@ -80,9 +80,9 @@
<app-settings-remote-control-section
[form]="settingsForm"
[activeSection]="activeSection()"
[localIpAddresses]="localIpAddresses()"
[visibleQrCodeIp]="visibleQrCodeIp()"
(toggleQrCode)="toggleQrCode($event)"
[localIpAddresses]="remoteControl.localIpAddresses()"
[visibleQrCodeIp]="remoteControl.visibleQrCodeIp()"
(toggleQrCode)="remoteControl.toggleQrCode($event)"
/>
}
@@ -94,33 +94,33 @@
<app-settings-backup-section
[activeSection]="activeSection()"
[isPwa]="isPwa"
[isRemovingAllPlaylists]="isRemovingAllPlaylists()"
[isExportingData]="isExportingData()"
[isRemovingAllPlaylists]="playlistReset.isRemovingAllPlaylists()"
[isExportingData]="backup.isExportingData()"
(importData)="importData()"
(exportData)="exportData()"
/>
<app-settings-reset-section
[activeSection]="activeSection()"
[isRemovingAllPlaylists]="isRemovingAllPlaylists()"
[canRemoveAllPlaylists]="canRemoveAllPlaylists()"
[playlistDeleteSummary]="playlistDeleteSummary()"
[removeAllProgressLabel]="removeAllProgressLabel()"
[isRemovingAllPlaylists]="playlistReset.isRemovingAllPlaylists()"
[canRemoveAllPlaylists]="playlistReset.canRemoveAll()"
[playlistDeleteSummary]="playlistReset.deleteSummary()"
[removeAllProgressLabel]="playlistReset.removeAllProgressLabel()"
(removeAll)="removeAll()"
/>
<app-settings-about-section
[activeSection]="activeSection()"
[isDesktop]="isDesktop"
[version]="version"
[version]="appUpdate.version()"
[buildCommit]="buildCommit"
[updateMessage]="updateMessage"
[appUpdateStatus]="appUpdateStatus()"
(checkForAppUpdate)="checkForAppUpdate()"
(downloadAppUpdate)="downloadAppUpdate()"
(installAppUpdate)="installAppUpdate()"
(openManualAppUpdate)="openManualAppUpdate()"
(openAppUpdateReleaseNotes)="openAppUpdateReleaseNotes()"
[updateMessage]="appUpdate.updateMessage()"
[appUpdateStatus]="appUpdate.status()"
(checkForAppUpdate)="appUpdate.checkForAppUpdate()"
(downloadAppUpdate)="appUpdate.downloadAppUpdate()"
(installAppUpdate)="appUpdate.installAppUpdate()"
(openManualAppUpdate)="appUpdate.openManualAppUpdate()"
(openAppUpdateReleaseNotes)="appUpdate.openReleaseNotes()"
/>
<footer class="action-bar" data-test-id="settings-action-bar">
@@ -46,6 +46,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { SettingsComponent } from './settings.component';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
import { signal } from '@angular/core';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
@@ -171,10 +172,28 @@ interface SettingsSectionScrollDirectiveTestApi {
}
interface SettingsComponentPrivateTestApi {
loadAppUpdateStatus(): Promise<void>;
matDialog: MatDialog;
waitForUiFeedbackFrame(): Promise<void>;
waitForAppUpdateStatusRetry(): Promise<void>;
}
interface SettingsAppUpdateFacadePrivateTestApi {
loadStatus(): Promise<void>;
waitForRetry(): Promise<void>;
}
/**
* `ngOnInit` kicks off a version check and a LAN address lookup; both are
* stubbed on the owning facades so tests stay deterministic.
*/
function stubSettingsSideEffects(settingsComponent: SettingsComponent): void {
jest.spyOn(
settingsComponent.appUpdate,
'checkAppVersion'
).mockImplementation();
jest.spyOn(
settingsComponent.remoteControl,
'fetchLocalIpAddresses'
).mockResolvedValue(undefined);
}
describe('SettingsComponent', () => {
@@ -361,10 +380,7 @@ describe('SettingsComponent', () => {
snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub;
component = fixture.componentInstance;
component.checkAppVersion = jest.fn();
component.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(component);
fixture.detectChanges();
});
@@ -384,6 +400,13 @@ describe('SettingsComponent', () => {
return settingsComponent as unknown as SettingsComponentPrivateTestApi;
}
/** Exposes the app update facade's retry internals to the tests */
function appUpdateFacade(): SettingsAppUpdateFacade &
SettingsAppUpdateFacadePrivateTestApi {
return component.appUpdate as SettingsAppUpdateFacade &
SettingsAppUpdateFacadePrivateTestApi;
}
it('should create and init component', () => {
expect(component).toBeTruthy();
});
@@ -405,7 +428,7 @@ describe('SettingsComponent', () => {
expect(window.electron.onAppUpdateStatusChange).toHaveBeenCalledTimes(
1
);
expect(component.appUpdateStatus()).toEqual(pushedStatus);
expect(component.appUpdate.status()).toEqual(pushedStatus);
});
it('retries the initial app update status load when IPC handlers are still starting', async () => {
@@ -419,15 +442,14 @@ describe('SettingsComponent', () => {
.mockReset()
.mockRejectedValueOnce(new Error('No handler registered'))
.mockResolvedValueOnce(retriedStatus);
jest.spyOn(
privateApi(component),
'waitForAppUpdateStatusRetry'
).mockResolvedValue(undefined);
jest.spyOn(appUpdateFacade(), 'waitForRetry').mockResolvedValue(
undefined
);
await privateApi(component).loadAppUpdateStatus();
await appUpdateFacade().loadStatus();
expect(window.electron.getAppUpdateStatus).toHaveBeenCalledTimes(2);
expect(component.appUpdateStatus()).toEqual(retriedStatus);
expect(component.appUpdate.status()).toEqual(retriedStatus);
});
it('waits for the desktop app update bridge method before loading status', async () => {
@@ -441,28 +463,27 @@ describe('SettingsComponent', () => {
.getAppUpdateStatus;
let retryCount = 0;
const getStatus = jest.fn().mockResolvedValue(retriedStatus);
jest.spyOn(
privateApi(component),
'waitForAppUpdateStatusRetry'
).mockImplementation(async () => {
retryCount += 1;
jest.spyOn(appUpdateFacade(), 'waitForRetry').mockImplementation(
async () => {
retryCount += 1;
if (retryCount === 1) {
window.electron.getAppUpdateStatus = getStatus;
if (retryCount === 1) {
window.electron.getAppUpdateStatus = getStatus;
}
}
});
);
await privateApi(component).loadAppUpdateStatus();
await appUpdateFacade().loadStatus();
expect(retryCount).toBe(1);
expect(getStatus).toHaveBeenCalledTimes(1);
expect(component.appUpdateStatus()).toEqual(retriedStatus);
expect(component.appUpdate.status()).toEqual(retriedStatus);
});
it('forwards app update actions to the desktop bridge', async () => {
await component.checkForAppUpdate();
await component.downloadAppUpdate();
await component.installAppUpdate();
await component.appUpdate.checkForAppUpdate();
await component.appUpdate.downloadAppUpdate();
await component.appUpdate.installAppUpdate();
expect(window.electron.checkForAppUpdate).toHaveBeenCalledTimes(1);
expect(window.electron.downloadAppUpdate).toHaveBeenCalledTimes(1);
@@ -471,13 +492,13 @@ describe('SettingsComponent', () => {
it('opens the manual release URL from unsupported update status', () => {
const openSpy = jest.spyOn(window, 'open').mockReturnValue(null);
component.appUpdateStatus.set({
component.appUpdate.status.set({
...DEFAULT_APP_UPDATE_STATUS,
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Unsupported,
supportedSelfUpdate: false,
});
component.openManualAppUpdate();
component.appUpdate.openManualAppUpdate();
expect(openSpy).toHaveBeenCalledWith(
DEFAULT_APP_UPDATE_STATUS.manualDownloadUrl,
@@ -490,13 +511,13 @@ describe('SettingsComponent', () => {
const openSpy = jest
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue(createDialogRef(false));
component.appUpdateStatus.set({
component.appUpdate.status.set({
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.23.0',
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available,
});
component.openAppUpdateReleaseNotes();
component.appUpdate.openReleaseNotes();
expect(openSpy).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
@@ -512,7 +533,7 @@ describe('SettingsComponent', () => {
const openSpy = jest
.spyOn(privateApi(component).matDialog, 'open')
.mockReturnValue(createDialogRef(false));
component.appUpdateStatus.set({
component.appUpdate.status.set({
...DEFAULT_APP_UPDATE_STATUS,
latestVersion: '0.21.0',
release: {
@@ -521,7 +542,7 @@ describe('SettingsComponent', () => {
status: ELECTRON_BRIDGE_APP_UPDATE_STATUSES.NotAvailable,
});
component.openAppUpdateReleaseNotes();
component.appUpdate.openReleaseNotes();
expect(openSpy).toHaveBeenCalledWith(
AppUpdateReleaseNotesDialogComponent,
@@ -549,10 +570,7 @@ describe('SettingsComponent', () => {
const dialogFixture = TestBed.createComponent(SettingsComponent);
const dialogComponent = dialogFixture.componentInstance;
dialogComponent.checkAppVersion = jest.fn();
dialogComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(dialogComponent);
dialogComponent.isDialog = true;
dialogFixture.detectChanges();
@@ -587,10 +605,7 @@ describe('SettingsComponent', () => {
scrollTo,
} as unknown as HTMLElement;
scrollComponent.checkAppVersion = jest.fn();
scrollComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(scrollComponent);
scrollFixture.detectChanges();
const scrollDirective = scrollFixture.debugElement
.query(By.directive(SettingsSectionScrollDirective))
@@ -656,7 +671,7 @@ describe('SettingsComponent', () => {
...settings,
});
component.setSettings();
component.form.hydrateFromStore();
//expect(settingsStore.loadSettings).toHaveBeenCalled();
expect(component.settingsForm.value).toEqual({
@@ -671,7 +686,7 @@ describe('SettingsComponent', () => {
webPlayerSharedControls: true,
});
component.setSettings();
component.form.hydrateFromStore();
expect(
component.settingsForm.get('webPlayerSharedControls')?.value
@@ -733,10 +748,7 @@ describe('SettingsComponent', () => {
TestBed.createComponent(SettingsComponent);
const partialBridgeComponent =
partialBridgeFixture.componentInstance;
partialBridgeComponent.checkAppVersion = jest.fn();
partialBridgeComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(partialBridgeComponent);
partialBridgeFixture.detectChanges();
expect(partialBridgeComponent.isDesktop).toBe(true);
@@ -787,10 +799,7 @@ describe('SettingsComponent', () => {
TestBed.createComponent(SettingsComponent);
const launchOnlyBridgeComponent =
launchOnlyBridgeFixture.componentInstance;
launchOnlyBridgeComponent.checkAppVersion = jest.fn();
launchOnlyBridgeComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(launchOnlyBridgeComponent);
launchOnlyBridgeFixture.detectChanges();
expect(
@@ -880,7 +889,7 @@ describe('SettingsComponent', () => {
currentVersion
);
const isOutdated =
component.isCurrentVersionOutdated(latestVersion);
component.appUpdate.isCurrentVersionOutdated(latestVersion);
expect(isOutdated).toBeTruthy();
});
@@ -889,11 +898,11 @@ describe('SettingsComponent', () => {
jest.spyOn(electronService, 'getAppVersion').mockReturnValue(
currentVersion
);
component.showVersionInformation(latestVersion);
component.appUpdate.showVersionInformation(latestVersion);
expect(translate.instant).toHaveBeenCalledWith(
'SETTINGS.NEW_VERSION_AVAILABLE'
);
expect(component.updateMessage).toBe(
expect(component.appUpdate.updateMessage()).toBe(
'New version available: 1.0.0'
);
});
@@ -906,7 +915,7 @@ describe('SettingsComponent', () => {
fixture.nativeElement as HTMLElement
).querySelector('.danger-zone__button') as HTMLButtonElement | null;
expect(component.canRemoveAllPlaylists()).toBe(false);
expect(component.playlistReset.canRemoveAll()).toBe(false);
expect(deleteButton?.disabled).toBe(true);
});
@@ -929,7 +938,7 @@ describe('SettingsComponent', () => {
component.removeAll();
expect(component.playlistDeleteSummary()).toEqual({
expect(component.playlistReset.deleteSummary()).toEqual({
total: 3,
m3u: 1,
xtream: 1,
@@ -1006,8 +1015,8 @@ describe('SettingsComponent', () => {
await Promise.resolve();
fixture.detectChanges();
expect(component.isRemovingAllPlaylists()).toBe(true);
expect(component.removeAllProgressLabel()).toBe('3/7');
expect(component.playlistReset.isRemovingAllPlaylists()).toBe(true);
expect(component.playlistReset.removeAllProgressLabel()).toBe('3/7');
expect(databaseService.deleteAllPlaylists).toHaveBeenCalledWith(
expect.objectContaining({
operationId: 'delete-all-op',
@@ -1018,8 +1027,8 @@ describe('SettingsComponent', () => {
resolveDelete(true);
await fixture.whenStable();
expect(component.isRemovingAllPlaylists()).toBe(false);
expect(component.removeAllProgress()).toBeNull();
expect(component.playlistReset.isRemovingAllPlaylists()).toBe(false);
expect(component.playlistReset.removeAllProgress()).toBeNull();
expect(dispatchSpy).toHaveBeenCalledWith(
PlaylistActions.removeAllPlaylists()
);
@@ -1042,10 +1051,7 @@ describe('SettingsComponent', () => {
const browserFixture = TestBed.createComponent(SettingsComponent);
const browserComponent = browserFixture.componentInstance;
browserComponent.checkAppVersion = jest.fn();
browserComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(browserComponent);
browserFixture.detectChanges();
expect(browserComponent.isDesktop).toBe(false);
@@ -1104,7 +1110,7 @@ describe('SettingsComponent', () => {
it('should force-fetch EPG for a single URL (bypassing freshness cache)', () => {
const url = 'http://epg-url-here/data.xml';
component.refreshEpg(url);
component.epg.refresh(url);
expect(epgBridge.forceFetchEpg).toHaveBeenCalledWith(url, {
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
@@ -1122,18 +1128,18 @@ describe('SettingsComponent', () => {
void onConfirm();
}
);
const refreshSpy = jest.spyOn(component, 'refreshAllEpg');
const refreshSpy = jest.spyOn(component.epg, 'refreshAll');
jest.spyOn(translate, 'instant').mockImplementation((key) => key);
component.clearEpgData();
component.epg.clear();
expect(component.isClearingEpgData()).toBe(true);
expect(component.epg.isClearing()).toBe(true);
expect(refreshSpy).not.toHaveBeenCalled();
resolveClear();
await fixture.whenStable();
expect(component.isClearingEpgData()).toBe(false);
expect(component.epg.isClearing()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEARED',
undefined,
@@ -1163,7 +1169,7 @@ describe('SettingsComponent', () => {
const exportPromise = component.exportData();
expect(component.isExportingData()).toBe(true);
expect(component.backup.isExportingData()).toBe(true);
resolveExport({
defaultFileName: 'iptvnator-playlist-backup-2026-04-21.json',
@@ -1192,7 +1198,7 @@ describe('SettingsComponent', () => {
'/tmp/backup.json',
'{}'
);
expect(component.isExportingData()).toBe(false);
expect(component.backup.isExportingData()).toBe(false);
});
it('falls back to browser backup download when desktop file-save preload is incomplete', async () => {
@@ -1225,10 +1231,7 @@ describe('SettingsComponent', () => {
partialFileSaveFixture = TestBed.createComponent(SettingsComponent);
const partialFileSaveComponent =
partialFileSaveFixture.componentInstance;
partialFileSaveComponent.checkAppVersion = jest.fn();
partialFileSaveComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(partialFileSaveComponent);
partialFileSaveFixture.detectChanges();
expect(partialFileSaveComponent.isDesktop).toBe(true);
@@ -1265,14 +1268,14 @@ describe('SettingsComponent', () => {
void onConfirm();
}
);
const refreshSpy = jest.spyOn(component, 'refreshAllEpg');
const refreshSpy = jest.spyOn(component.epg, 'refreshAll');
jest.spyOn(translate, 'instant').mockImplementation((key) => key);
jest.spyOn(console, 'error').mockImplementation();
component.clearEpgData();
component.epg.clear();
await fixture.whenStable();
expect(component.isClearingEpgData()).toBe(false);
expect(component.epg.isClearing()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(
'SETTINGS.EPG_DATA_CLEAR_FAILED',
undefined,
@@ -1492,10 +1495,7 @@ describe('SettingsComponent', () => {
const webFixture = TestBed.createComponent(SettingsComponent);
const webComponent = webFixture.componentInstance;
webComponent.checkAppVersion = jest.fn();
webComponent.fetchLocalIpAddresses = jest
.fn()
.mockResolvedValue(undefined);
stubSettingsSideEffects(webComponent);
webFixture.detectChanges();
await webFixture.whenStable();
+74 -634
View File
@@ -4,14 +4,11 @@ import {
computed,
inject,
Input,
DestroyRef,
OnDestroy,
OnInit,
signal,
ViewEncapsulation,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormArray, FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
@@ -20,79 +17,45 @@ import {
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import {
EpgRuntimeBridgeService,
EpgService,
} from '@iptvnator/epg/data-access';
import { SettingsContextService } from '@iptvnator/workspace/shell/util';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService } from '@iptvnator/ui/components';
import {
selectAllPlaylistsMeta,
selectIsEpgAvailable,
} from '@iptvnator/m3u-state';
import { take } from 'rxjs';
import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services';
import {
EmbeddedMpvSupport,
CoverSize,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
EpgViewMode,
Language,
StreamFormat,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { Language, StreamFormat } from '@iptvnator/shared/interfaces';
import { BUILD_COMMIT } from '../../environments/build-commit';
import { SettingsStore } from '../services/settings-store.service';
import { SettingsService } from './../services/settings.service';
import { SettingsAboutSectionComponent } from './settings-about-section.component';
import { SettingsAppUpdateFacade } from './settings-app-update.facade';
import { SettingsBackupSectionComponent } from './settings-backup-section.component';
import { SettingsDashboardSectionComponent } from './settings-dashboard-section.component';
import {
SettingsDeleteAllPlaylistsDialogComponent,
SettingsDeleteAllPlaylistsDialogData,
} from './settings-delete-all-playlists-dialog.component';
import { SettingsEmbeddedMpvFacade } from './settings-embedded-mpv.facade';
import { SettingsEpgFacade } from './settings-epg.facade';
import { SettingsEpgSectionComponent } from './settings-epg-section.component';
import {
applyEpgUrlsToFormArray,
createEpgUrlControl,
createSettingsForm,
createSettingsFromFormValue,
} from './settings-form.utils';
import { SettingsFormFacade } from './settings-form.facade';
import { SettingsGeneralSectionComponent } from './settings-general-section.component';
import { SettingsSection } from './settings.models';
import {
SettingsPlaylistDeleteSummary,
SettingsSection,
} from './settings.models';
import {
buildSettingsPlayerOptions,
buildSettingsSectionNavItems,
SETTINGS_COVER_SIZE_OPTIONS,
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
SETTINGS_EPG_VIEW_MODE_OPTIONS,
SETTINGS_OS_PLAYER_OPTIONS,
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
import { SettingsRemoteControlFacade } from './settings-remote-control.facade';
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
import { SettingsResetSectionComponent } from './settings-reset-section.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
import { SettingsBackupFacade } from './settings-backup.facade';
import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
import {
buildRemoveAllProgressLabel,
buildSettingsPlaylistDeleteSummary,
} from './settings-playlist-summary.utils';
import { SettingsSnackbarService } from './settings-snackbar.service';
import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component';
const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60;
const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250;
/**
* Thin coordinator for the settings page. The behaviour of each section lives
* in a dedicated facade the template binds to directly; what stays here is the
* page-level wiring: environment capabilities, section navigation, and the
* save/close flow that spans several facades.
*/
@Component({
templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'],
@@ -119,33 +82,38 @@ const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250;
SettingsTmdbSectionComponent,
],
providers: [
SettingsAppUpdateFacade,
SettingsBackupFacade,
SettingsEmbeddedMpvFacade,
SettingsEpgFacade,
SettingsFormFacade,
SettingsPlaylistResetFacade,
SettingsRemoteControlFacade,
SettingsSnackbarService,
],
})
export class SettingsComponent implements OnInit, OnDestroy {
private dialogService = inject(DialogService);
public dataService = inject(DataService);
private epgService = inject(EpgService);
private formBuilder = inject(FormBuilder);
private destroyRef = inject(DestroyRef);
private router = inject(Router);
private settingsService = inject(SettingsService);
private settingsSnackbar = inject(SettingsSnackbarService);
private store = inject(Store);
private translate = inject(TranslateService);
private matDialog = inject(MatDialog);
private readonly backupFacade = inject(SettingsBackupFacade);
private readonly playlistResetFacade = inject(SettingsPlaylistResetFacade);
readonly appUpdate = inject(SettingsAppUpdateFacade);
readonly backup = inject(SettingsBackupFacade);
readonly embeddedMpv = inject(SettingsEmbeddedMpvFacade);
readonly epg = inject(SettingsEpgFacade);
readonly form = inject(SettingsFormFacade);
readonly playlistReset = inject(SettingsPlaylistResetFacade);
readonly remoteControl = inject(SettingsRemoteControlFacade);
private readonly settingsCtx = inject(SettingsContextService);
private readonly settingsSnackbar = inject(SettingsSnackbarService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly matDialog = inject(MatDialog);
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly dialogData = inject<{ isDialog: boolean } | null>(
MAT_DIALOG_DATA,
{ optional: true }
);
@Input() isDialog = this.dialogData?.isDialog ?? false;
/** List with available languages as enum */
readonly languageEnum = Language;
@@ -154,114 +122,41 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = this.runtime.isElectron;
readonly isPwa = this.runtime.isPwa;
readonly supportsDesktopFileSave = this.runtime.supportsDesktopFileSave;
readonly supportsEpg =
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
readonly supportsEpg = this.form.supportsEpg;
readonly supportsManagedExternalPlayers =
this.runtime.supportsManagedExternalPlayers;
readonly supportsExternalPlayerPathSettings =
this.runtime.supportsExternalPlayerPathSettings;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly embeddedMpvSupport = signal<EmbeddedMpvSupport | null>(null);
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
);
readonly frameCopyAvailable = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.frameCopyAvailable
);
readonly frameCopyActive = computed(
() => this.embeddedMpvSupport()?.engine === 'frame-copy'
);
readonly isPwa = this.runtime.isPwa;
private readonly settingsCtx = inject(SettingsContextService);
readonly activeSection = this.settingsCtx.activeSection;
readonly osPlayers = computed(() => [
...(this.supportsEmbeddedMpv()
? [
{
id: VideoPlayer.EmbeddedMpv,
labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV',
},
]
: []),
...(this.supportsManagedExternalPlayers
? SETTINGS_OS_PLAYER_OPTIONS
: []),
]);
/** Settings form object */
readonly settingsForm = this.form.form;
/** Player options */
readonly players = computed(() => [
...SETTINGS_EMBEDDED_PLAYER_OPTIONS,
...this.osPlayers(),
]);
/** Current version of the app */
version = '';
readonly players = computed(() =>
buildSettingsPlayerOptions({
supportsEmbeddedMpv: this.embeddedMpv.supported(),
supportsManagedExternalPlayers: this.supportsManagedExternalPlayers,
})
);
/** Git commit the app was built from (CI builds only) */
readonly buildCommit = BUILD_COMMIT;
/** Update message to show */
updateMessage = '';
readonly appUpdateStatus = signal<ElectronBridgeAppUpdateStatus | null>(
null
);
/** EPG availability flag */
epgAvailable$ = this.store.select(selectIsEpgAvailable);
readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly themeOptions = SETTINGS_THEME_OPTIONS;
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
/** Settings form object */
settingsForm = createSettingsForm(this.formBuilder, this.supportsEpg);
/** Form array with epg sources */
epgUrl = this.settingsForm.get('epgUrl') as FormArray;
/** Local IP addresses for remote control URL display */
localIpAddresses = signal<string[]>([]);
/** Currently visible QR code IP (null = none visible) */
visibleQrCodeIp = signal<string | null>(null);
readonly isRemovingAllPlaylists =
this.playlistResetFacade.isRemovingAllPlaylists;
readonly isClearingEpgData = signal(false);
readonly isExportingData = this.backupFacade.isExportingData;
readonly removeAllProgress = this.playlistResetFacade.removeAllProgress;
private settingsStore = inject(SettingsStore);
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
supportsEpg: this.supportsEpg,
supportsRemoteControl: this.supportsRemoteControl,
});
readonly playlistDeleteSummary = computed<SettingsPlaylistDeleteSummary>(
() => buildSettingsPlaylistDeleteSummary(this.playlists())
);
readonly canRemoveAllPlaylists = computed(
() =>
!this.isRemovingAllPlaylists() &&
this.playlistDeleteSummary().total > 0
);
private unsubscribeAppUpdateStatus: (() => void) | null = null;
readonly removeAllProgressLabel = computed(() => {
return buildRemoveAllProgressLabel({
isRemovingAllPlaylists: this.isRemovingAllPlaylists(),
progress: this.removeAllProgress(),
translate: (key, params) => this.translate.instant(key, params),
});
});
get sectionNav(): SettingsSection[] {
return this.sectionNavItems.filter((section) => section.visible);
}
@@ -272,338 +167,31 @@ export class SettingsComponent implements OnInit, OnDestroy {
*/
async ngOnInit(): Promise<void> {
// Wait for settings to load before setting the form
await this.settingsStore.loadSettings();
this.setSettings();
this.bindDashboardControlsEnabledState();
void this.loadEmbeddedMpvSupport();
this.checkAppVersion();
this.bindAppUpdateStatusEvents();
void this.loadAppUpdateStatus();
void this.fetchLocalIpAddresses();
await this.form.loadSettings();
this.form.hydrateFromStore();
this.form.bindDashboardControlsEnabledState();
void this.embeddedMpv.load();
this.appUpdate.checkAppVersion();
this.appUpdate.init();
void this.remoteControl.fetchLocalIpAddresses();
if (!this.isDialog) {
this.settingsCtx.setSections(this.sectionNav);
}
}
private async loadEmbeddedMpvSupport(): Promise<void> {
if (!this.isDesktop) {
this.embeddedMpvSupport.set({
supported: false,
platform: 'web',
reason: 'Embedded MPV requires the Electron desktop build.',
});
return;
}
if (!window.electron?.getEmbeddedMpvSupport) {
this.embeddedMpvSupport.set({
supported: false,
platform: window.electron.platform,
reason: 'Embedded MPV support is not available in this build.',
});
return;
}
try {
this.embeddedMpvSupport.set(
await window.electron.getEmbeddedMpvSupport()
);
} catch (error) {
this.embeddedMpvSupport.set({
supported: false,
platform: window.electron.platform,
reason: error instanceof Error ? error.message : String(error),
});
}
}
ngOnDestroy(): void {
this.unsubscribeAppUpdateStatus?.();
this.unsubscribeAppUpdateStatus = null;
this.appUpdate.dispose();
this.settingsCtx.reset();
}
private bindAppUpdateStatusEvents(): void {
if (!this.isDesktop || !window.electron?.onAppUpdateStatusChange) {
return;
}
this.unsubscribeAppUpdateStatus =
window.electron.onAppUpdateStatusChange((status) => {
this.appUpdateStatus.set(status);
});
}
private async loadAppUpdateStatus(): Promise<void> {
if (!this.isDesktop) {
return;
}
let lastError: unknown;
for (
let attempt = 1;
attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS;
attempt += 1
) {
const electron = window.electron;
if (electron?.getAppUpdateStatus) {
try {
this.appUpdateStatus.set(
await electron.getAppUpdateStatus()
);
return;
} catch (error) {
lastError = error;
}
}
if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) {
console.warn(
'Failed to load app update status:',
lastError ??
new Error('Desktop app update bridge is unavailable')
);
return;
}
await this.waitForAppUpdateStatusRetry();
}
}
private async waitForAppUpdateStatusRetry(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS);
});
}
async checkForAppUpdate(): Promise<void> {
if (!this.isDesktop || !window.electron?.checkForAppUpdate) {
return;
}
this.appUpdateStatus.set(await window.electron.checkForAppUpdate());
}
async downloadAppUpdate(): Promise<void> {
if (!this.isDesktop || !window.electron?.downloadAppUpdate) {
return;
}
this.appUpdateStatus.set(await window.electron.downloadAppUpdate());
}
async installAppUpdate(): Promise<void> {
if (!this.isDesktop || !window.electron?.installAppUpdate) {
return;
}
this.appUpdateStatus.set(await window.electron.installAppUpdate());
}
openManualAppUpdate(): void {
const manualDownloadUrl = this.appUpdateStatus()?.manualDownloadUrl;
if (!manualDownloadUrl) {
return;
}
window.open(manualDownloadUrl, '_blank', 'noreferrer');
}
openAppUpdateReleaseNotes(): void {
const status = this.appUpdateStatus();
const isUpdateRelease =
status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available ||
status?.status ===
ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading ||
status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded;
const initialReleaseNotesVersion = isUpdateRelease
? (status?.latestVersion ??
status?.release?.version ??
status?.currentVersion)
: status?.currentVersion;
this.matDialog.open(AppUpdateReleaseNotesDialogComponent, {
autoFocus: false,
data: {
...(!isUpdateRelease ? { fallbackToLatest: true } : {}),
initialVersion: initialReleaseNotesVersion,
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '720px',
});
}
/**
* Fetches local IP addresses for remote control URL display
*/
async fetchLocalIpAddresses(): Promise<void> {
if (
this.supportsRemoteControl &&
window.electron?.getLocalIpAddresses
) {
const addresses = await window.electron.getLocalIpAddresses();
this.localIpAddresses.set(addresses);
}
}
/**
* Toggle QR code visibility for a given IP address
*/
toggleQrCode(ip: string): void {
if (this.visibleQrCodeIp() === ip) {
this.visibleQrCodeIp.set(null);
} else {
this.visibleQrCodeIp.set(ip);
}
}
/**
* Sets saved settings from the indexed db store
*/
setSettings() {
const currentSettings = this.settingsStore.getSettings();
this.settingsForm.patchValue(currentSettings);
this.syncDashboardControlsEnabledState(
currentSettings.showDashboard ?? true
);
if (this.supportsEpg && currentSettings.epgUrl) {
this.epgUrl.clear();
this.setEpgUrls(currentSettings.epgUrl);
}
}
private bindDashboardControlsEnabledState(): void {
this.settingsForm
.get('showDashboard')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((showDashboard) =>
this.syncDashboardControlsEnabledState(showDashboard ?? true)
);
}
private syncDashboardControlsEnabledState(showDashboard: boolean): void {
const dashboardRails = this.settingsForm.get('dashboardRails');
if (!dashboardRails) {
return;
}
if (showDashboard) {
dashboardRails.enable({ emitEvent: false });
} else {
dashboardRails.disable({ emitEvent: false });
}
}
selectTheme(theme: Theme): void {
if (this.settingsForm.value.theme === theme) {
return;
}
this.settingsForm.patchValue({ theme });
this.settingsForm.get('theme')?.markAsDirty();
this.settingsForm.markAsDirty();
this.settingsService.changeTheme(theme);
}
selectCoverSize(size: CoverSize): void {
if (this.settingsForm.value.coverSize === size) {
return;
}
this.settingsForm.patchValue({ coverSize: size });
this.settingsForm.get('coverSize')?.markAsDirty();
this.settingsForm.markAsDirty();
this.settingsStore.updateSettings({ coverSize: size });
}
selectEpgViewMode(mode: EpgViewMode): void {
if (this.settingsForm.value.epgViewMode === mode) {
return;
}
this.settingsForm.patchValue({ epgViewMode: mode });
this.settingsForm.get('epgViewMode')?.markAsDirty();
this.settingsForm.markAsDirty();
this.settingsStore.updateSettings({ epgViewMode: mode });
}
/** Picks a recording folder in the desktop shell and stages it in the form */
async selectRecordingFolder(): Promise<void> {
if (
!this.isDesktop ||
!window.electron?.selectEmbeddedMpvRecordingFolder
) {
return;
const folder = await this.embeddedMpv.selectRecordingFolder();
if (folder) {
this.form.setRecordingFolder(folder);
}
const folder = await window.electron.selectEmbeddedMpvRecordingFolder();
if (!folder) {
return;
}
this.settingsForm.patchValue({ recordingFolder: folder });
this.settingsForm.get('recordingFolder')?.markAsDirty();
this.settingsForm.markAsDirty();
}
/**
* Sets the epg urls to the form array
* @param epgUrls urls of the EPG sources
*/
setEpgUrls(epgUrls: string[] | string): void {
applyEpgUrlsToFormArray(this.epgUrl, epgUrls);
}
/**
* Checks whether the latest version of the application
* is used and updates the version message in the
* settings UI
*/
checkAppVersion(): void {
this.settingsService
.getAppVersion()
.pipe(take(1))
.subscribe((version) => this.showVersionInformation(version));
}
/**
* Updates the message in settings UI about the used
* version of the app
* @param currentVersion current version of the application
*/
showVersionInformation(currentVersion: string): void {
const isOutdated = this.isCurrentVersionOutdated(currentVersion);
if (isOutdated) {
this.updateMessage = `${
this.translate.instant(
'SETTINGS.NEW_VERSION_AVAILABLE'
) as string
}: ${currentVersion}`;
} else {
this.updateMessage = this.translate.instant(
'SETTINGS.LATEST_VERSION'
);
}
}
/**
* Compares actual with latest version of the
* application
* @param latestVersion latest version
* @returns returns true if an update is available
*/
isCurrentVersionOutdated(latestVersion: string): boolean {
this.version = this.dataService.getAppVersion();
return this.settingsService.isVersionOutdated(
this.version,
latestVersion
);
}
/**
@@ -611,59 +199,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
* the indexed db store
*/
onSubmit(): void {
const settings = this.createSettingsFromFormValue();
void this.form.save(() => this.applyChangedSettings());
this.settingsStore.updateSettings(settings).then(() => {
this.applyChangedSettings();
if (window.electron) {
window.electron.updateSettings(settings);
}
if (this.supportsExternalPlayerPathSettings && window.electron) {
window.electron.setMpvPlayerPath(settings.mpvPlayerPath);
window.electron.setVlcPlayerPath(settings.vlcPlayerPath);
}
});
if (this.isDialog) {
this.matDialog.closeAll();
}
}
private createSettingsFromFormValue() {
return createSettingsFromFormValue(
this.settingsForm,
this.settingsStore.getSettings()
);
}
/**
* Applies the changed settings to the app
*/
applyChangedSettings(): void {
this.settingsForm.markAsPristine();
if (this.supportsEpg) {
let epgUrls = this.settingsForm.value.epgUrl;
if (epgUrls) {
if (!Array.isArray(epgUrls)) {
epgUrls = [epgUrls];
}
const validEpgUrls = epgUrls.filter(
(url): url is string =>
typeof url === 'string' && url !== ''
);
if (validEpgUrls.length > 0) {
// Fetch all EPG URLs at once
this.epgService.fetchEpg(validEpgUrls);
}
}
}
this.translate.use(
this.settingsForm.value.language ?? Language.ENGLISH
);
this.settingsService.changeTheme(
this.settingsForm.value.theme ?? Theme.SystemTheme
);
this.form.applySavedSettings();
this.epg.fetchConfiguredEpg();
this.settingsSnackbar.open(
this.translate.instant('SETTINGS.SETTINGS_SAVED')
);
@@ -680,102 +228,24 @@ export class SettingsComponent implements OnInit, OnDestroy {
}
}
/**
* Force-fetch EPG for a single URL, bypassing the 12-hour freshness check.
* The plain fetchEpg would short-circuit on fresh data and click the
* refresh button would be a no-op — that's exactly not what the user
* intends when clicking "Refresh".
*/
refreshEpg(url: string): void {
if (!this.epgBridge.supportsDataManagement || !url) {
return;
}
void this.epgBridge.forceFetchEpg(
url,
this.settingsStore.getTrustOptions()
async exportData(): Promise<void> {
await this.backup.exportData(() => this.waitForUiFeedbackFrame());
}
importData(): void {
this.backup.importData(() => this.form.hydrateFromStore());
}
removeAll(): void {
this.playlistReset.confirmAndRemoveAll(() =>
this.waitForUiFeedbackFrame()
);
}
/**
* Force-fetch every configured EPG URL sequentially. Empty fields are
* skipped. Each URL flows through the normal progress panel so the user
* gets visible per-URL feedback.
* Lets the browser paint the pending busy state before a long running
* task blocks the main thread.
*/
refreshAllEpg(): void {
if (!this.epgBridge.supportsDataManagement) return;
const urls = (this.epgUrl.value as string[])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
const options = this.settingsStore.getTrustOptions();
urls.forEach((url) => void this.epgBridge.forceFetchEpg(url, options));
}
/**
* Initializes new entry in form array for EPG URL
*/
addEpgSource(): void {
this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl());
}
/**
* Removes entry from form array for EPG URL
* @param index index of the item to remove
*/
removeEpgSource(index: number): void {
this.epgUrl.removeAt(index);
this.settingsForm.markAsDirty();
}
/**
* Clears all EPG data from database and immediately re-fetches every
* configured URL so the user isn't left staring at an empty state.
* Tracks progress with `isClearingEpgData` so the UI can show a spinner
* and block double-clicks, and surfaces failures via a dedicated snackbar.
*/
clearEpgData(): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'),
message: this.translate.instant(
'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE'
),
onConfirm: async (): Promise<void> => {
if (
!this.epgBridge.supportsDataManagement ||
this.isClearingEpgData()
) {
return;
}
this.isClearingEpgData.set(true);
try {
const result = await this.epgBridge.clearEpgData();
if (result && result.success === false) {
throw new Error('Clear EPG returned success=false');
}
this.settingsSnackbar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEARED')
);
this.refreshAllEpg();
} catch (error) {
console.error('Failed to clear EPG data:', error);
this.settingsSnackbar.open(
this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED')
);
} finally {
this.isClearingEpgData.set(false);
}
},
});
}
async exportData() {
await this.backupFacade.exportData(() => this.waitForUiFeedbackFrame());
}
importData() {
this.backupFacade.importData(() => this.setSettings());
}
private async waitForUiFeedbackFrame(): Promise<void> {
if (typeof window.requestAnimationFrame !== 'function') {
await Promise.resolve();
@@ -786,34 +256,4 @@ export class SettingsComponent implements OnInit, OnDestroy {
window.requestAnimationFrame(() => resolve());
});
}
removeAll(): void {
if (!this.canRemoveAllPlaylists()) {
return;
}
this.matDialog
.open<
SettingsDeleteAllPlaylistsDialogComponent,
SettingsDeleteAllPlaylistsDialogData,
boolean
>(SettingsDeleteAllPlaylistsDialogComponent, {
autoFocus: false,
data: {
summary: this.playlistDeleteSummary(),
},
maxWidth: 'calc(100vw - 32px)',
restoreFocus: true,
width: '460px',
})
.afterClosed()
.pipe(take(1))
.subscribe((confirmed) => {
if (confirmed) {
void this.playlistResetFacade.removeAllConfirmed(() =>
this.waitForUiFeedbackFrame()
);
}
});
}
}
+8 -3
View File
@@ -1,7 +1,7 @@
# The Nx builds below emit platform-independent JS/static assets. During
# multi-arch publish builds, keep that heavy build stage on the native builder
# platform instead of running Node/Nx through QEMU for linux/arm64.
FROM --platform=$BUILDPLATFORM node:22-alpine AS build
FROM --platform=$BUILDPLATFORM node:24-alpine AS build
RUN apk add --no-cache python3 make g++ git
@@ -11,7 +11,12 @@ COPY .npmrc ./
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts
# Node 25 unbundled Corepack, so `corepack enable` cannot be the way pnpm gets
# installed here — it would break on the next base-image major. Install the
# exact pnpm from `packageManager` instead, which stays reproducible.
RUN PNPM_VERSION="$(node -p 'require("./package.json").packageManager.split("@")[1].split("+")[0]')" \
&& npm install --global "pnpm@${PNPM_VERSION}" \
&& pnpm install --frozen-lockfile --ignore-scripts
COPY . .
@@ -23,7 +28,7 @@ RUN node tools/build/inject-build-commit.mjs
RUN pnpm nx build web --configuration=pwa
RUN pnpm nx build web-backend
FROM node:22-alpine
FROM node:24-alpine
RUN apk add --no-cache gettext nginx
+23 -5
View File
@@ -121,11 +121,29 @@ before/after claims.
The target reserves and verifies CDP port 9222, freezes renderer long-task,
frame-gap, and heartbeat probes before forced post-GC heap collection, and
enables opt-in worker profiling. Worker event-loop delay is read from a
request-scoped `node:perf_hooks` capture; a worker terminated before it can flush
the capture reports the metric as unavailable rather than zero. Diagnostic CPU
profiles, heap snapshots, and Chromium traces are excluded from the five-run
headline distributions.
builds the Electron main process, renderer, and workers with optimized,
source-mapped performance configurations before enabling opt-in worker
profiling. Each worker response retains a raw
request-scoped record containing request/operation identity, received/work/flush
timestamps, thread CPU, event-loop utilization, event-loop delay, and fixed
unavailability or invalid reasons. Missing or malformed profiling metadata
still produces an outcome with its request identity, nullable metrics, and a
fixed capture-unavailable reason. A worker terminated before it can flush the
capture reports the metric as unavailable rather than zero.
The harness excludes workers created by pre-capture seed setup through an exact
capture-generation marker. Since the production M3U database payload
deliberately omits the refresh operation ID, the benchmark correlates only a
valid preload `start` marker to the exact database operation and playlist. A
missing or ambiguous marker leaves the raw operation ID `null` with a fixed
reason; it does not infer identity from timing alone.
Headline worker fields named `*WorkerRequest*` are distributions of individual
request metrics. In particular, request p95/p99 distributions are not presented
as an operation-wide or process-wide percentile, and database request
percentiles are never collapsed with `Math.max`. Diagnostic CPU profiles, heap
snapshots, and Chromium traces are excluded from the five-run headline
distributions.
### Reporting The Auto-Update Result
+4 -1
View File
@@ -175,7 +175,10 @@ browser sidecar data.
The Docker image has two stages:
1. Build stage installs dependencies and runs `web:pwa` plus `web-backend`.
2. Runtime stage uses `node:22-alpine` with nginx installed. nginx serves
pnpm is installed globally at the exact `packageManager` version rather than
through Corepack, which Node 25 unbundled, so the base-image major stays
free to move.
2. Runtime stage uses `node:24-alpine` with nginx installed. nginx serves
`dist/apps/web` and proxies `/api/*` to the local Express backend.
The entrypoint renders the nginx config from a `${PORT}` template, starts the
backend, waits for `/health`, and then starts nginx. If either process exits
+69 -62
View File
@@ -28,11 +28,11 @@ Current capabilities:
1. User opens remote web UI (`http://<local-ip>:<port>`).
2. Remote web app calls `/api/remote-control/*`.
3. Electron main handles API request and sends IPC to renderer:
- `CHANNEL_CHANGE` for up/down
- `REMOTE_CONTROL_COMMAND` for numeric/volume commands
- `CHANNEL_CHANGE` for up/down
- `REMOTE_CONTROL_COMMAND` for numeric/volume commands
4. Renderer-specific feature module (M3U/Xtream/Stalker) applies action.
5. Renderer pushes status snapshots back to main via:
- `REMOTE_CONTROL_STATUS_UPDATE`
- `REMOTE_CONTROL_STATUS_UPDATE`
6. Remote web app polls `/api/remote-control/status` and updates UI.
## Backend (Electron Main)
@@ -41,11 +41,16 @@ Current capabilities:
- File: `apps/electron-backend/src/app/server/http-server.ts`
- Responsibilities:
- Serves static remote app from:
- dev: `dist/apps/remote-control-web/browser`
- prod: `<appPath>/remote-control-web/browser`
- Routes `/api/remote-control/*` to registered handlers.
- Starts/stops/restarts on settings updates.
- Serves static remote app from:
- dev: `dist/apps/remote-control-web/browser`
- prod: `<appPath>/remote-control-web/browser`
- Keeps every non-API request inside that configured static root: the request
pathname is decoded once, malformed encoding and NUL bytes fail closed,
and the platform-specific resolved path must remain the root or its
descendant. Angular route fallback may serve only that root's `index.html`;
it must never bypass the containment check.
- Routes `/api/remote-control/*` to registered handlers.
- Starts/stops/restarts on settings updates.
### Remote control event module
@@ -66,8 +71,8 @@ IPC emitted to renderer:
- `CHANNEL_CHANGE` payload: `{ direction: 'up' | 'down' }`
- `REMOTE_CONTROL_COMMAND` payload:
- `{ type: 'channel-select-number', number }`
- `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }`
- `{ type: 'channel-select-number', number }`
- `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }`
Status ingestion from renderer:
@@ -78,7 +83,7 @@ Status ingestion from renderer:
- Main handler: `apps/electron-backend/src/app/events/settings.events.ts`
- On `SETTINGS_UPDATE`, reads `remoteControl` and `remoteControlPort`, persists to store, and calls:
- `httpServer.updateSettings(enabled, port)`
- `httpServer.updateSettings(enabled, port)`
## Preload Bridge
@@ -122,23 +127,23 @@ Used by M3U, Xtream, and Stalker live integrations.
Implemented behavior:
- Subscribes to:
- `onChannelChange` (up/down)
- `onRemoteControlCommand` (number + volume)
- `onChannelChange` (up/down)
- `onRemoteControlCommand` (number + volume)
- Applies channel up/down by active channel URL over `channels$`
- Applies number select through existing `switchToChannelByNumber(...)`
- Dispatches remote channel changes as explicit playback requests so MPV/VLC starts immediately even when mouse channel rows require double-click before external playback.
- Applies volume commands:
- up/down in 0.1 increments
- toggle mute with last non-zero volume restore
- persists to `localStorage`
- propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent`
- does not control external MPV/VLC sessions or the experimental Embedded MPV player
- up/down in 0.1 increments
- toggle mute with last non-zero volume restore
- persists to `localStorage`
- propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent`
- does not control external MPV/VLC sessions or the experimental Embedded MPV player
- Publishes status snapshots via `updateRemoteControlStatus(...)`:
- `portal: 'm3u'`
- `isLiveView: true`
- channel name/number
- EPG now fields
- `supportsVolume: true`, `volume`, `muted`
- `portal: 'm3u'`
- `isLiveView: true`
- channel name/number
- EPG now fields
- `supportsVolume: true`, `volume`, `muted`
- Cleans listeners/subscriptions in `ngOnDestroy`.
## Xtream integration (live view)
@@ -148,20 +153,20 @@ Implemented behavior:
Implemented behavior:
- Subscribes to:
- `onChannelChange` for up/down
- `onRemoteControlCommand` for number select
- `onChannelChange` for up/down
- `onRemoteControlCommand` for number select
- Up/down:
- Uses selected live item `selectedItem().xtream_id`
- Navigates inside `selectItemsFromSelectedCategory()`
- Calls `playLive(nextItem, true)` so remote actions explicitly start playback
- Uses selected live item `selectedItem().xtream_id`
- Navigates inside `selectItemsFromSelectedCategory()`
- Calls `playLive(nextItem, true)` so remote actions explicitly start playback
- Number select:
- Maps number to item in current category list
- Calls `playLive(channel, true)` so remote actions explicitly start playback
- Maps number to item in current category list
- Calls `playLive(channel, true)` so remote actions explicitly start playback
- Publishes status via effect:
- `portal: 'xtream'`
- `isLiveView` only when selected content type is `live` and item is selected
- channel name/number + current EPG item
- `supportsVolume: false`
- `portal: 'xtream'`
- `isLiveView` only when selected content type is `live` and item is selected
- channel name/number + current EPG item
- `supportsVolume: false`
- Cleans listeners in `ngOnDestroy`.
## Stalker integration (ITV live view)
@@ -171,20 +176,20 @@ Implemented behavior:
Implemented behavior:
- Subscribes to:
- `onChannelChange` for up/down
- `onRemoteControlCommand` for number select
- `onChannelChange` for up/down
- `onRemoteControlCommand` for number select
- Up/down:
- Uses `selectedItem().id`
- Navigates inside `itvChannels()`
- Calls `playChannel(nextItem, true)` so remote actions explicitly start playback
- Uses `selectedItem().id`
- Navigates inside `itvChannels()`
- Calls `playChannel(nextItem, true)` so remote actions explicitly start playback
- Number select:
- Maps number into `itvChannels()`
- Calls `playChannel(channel, true)` so remote actions explicitly start playback
- Maps number into `itvChannels()`
- Calls `playChannel(channel, true)` so remote actions explicitly start playback
- Publishes status via effect:
- `portal: 'stalker'`
- `isLiveView` only for selected content type `itv` with active item
- channel name/number + current EPG item
- `supportsVolume: false`
- `portal: 'stalker'`
- `isLiveView` only for selected content type `itv` with active item
- channel name/number + current EPG item
- `supportsVolume: false`
- Cleans listeners in `ngOnDestroy`.
## Remote Web App
@@ -199,11 +204,11 @@ Implemented behavior:
### Shared remote UI library
- Component:
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss`
- Service:
- `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts`
- `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts`
Implemented UI behavior:
@@ -217,23 +222,25 @@ Implemented UI behavior:
## Settings UI and discoverability
- Files:
- `apps/web/src/app/settings/settings.component.ts`
- `apps/web/src/app/settings/settings.component.html`
- `apps/web/src/app/settings/settings-remote-control-section.component.ts`
(+ `.html`) — the section rendered by `settings.component.html`
- `apps/web/src/app/settings/settings-remote-control.facade.ts` — LAN
address lookup and QR-code visibility state
- Features:
- Toggle `remoteControl`
- Configure `remoteControlPort`
- Display local URLs and QR codes for remote access
- Local IP list loaded via `getLocalIpAddresses()`
- Toggle `remoteControl`
- Configure `remoteControlPort`
- Display local URLs and QR codes for remote access
- Local IP list loaded via `getLocalIpAddresses()`
## Feature Matrix (Current)
| Capability | M3U | Xtream Live | Stalker ITV |
|---|---|---|---|
| Channel up/down | Yes | Yes | Yes |
| Number select | Yes | Yes | Yes |
| Status publish | Yes | Yes | Yes |
| Volume command handling | Yes, for built-in inline M3U players | No | No |
| `supportsVolume` in status | true | false | false |
| Capability | M3U | Xtream Live | Stalker ITV |
| -------------------------- | ------------------------------------ | ----------- | ----------- |
| Channel up/down | Yes | Yes | Yes |
| Number select | Yes | Yes | Yes |
| Status publish | Yes | Yes | Yes |
| Volume command handling | Yes, for built-in inline M3U players | No | No |
| `supportsVolume` in status | true | false | false |
## Known limitations
+29
View File
@@ -118,6 +118,35 @@ The worker contract lives in
3. `DbWorkerEventMessage`
4. `DbOperationEvent`
### Opt-in request performance capture
`IPTVNATOR_PERF_WORKER_PROFILING=1` adds development/test-only performance
metadata to each database-worker response. It is disabled by default and must
stay disabled for production launches.
Each enabled request gets a fresh event-loop-delay histogram and records:
- `requestReceivedEpochMs`, `workStartedEpochMs`, `workEndedEpochMs`, and
`histogramFlushedEpochMs`
- worker-thread CPU user/system microseconds from `process.threadCpuUsage()`
- event-loop utilization across the exact work interval
- event-loop-delay max/p95/p99 from the request's own histogram
- fixed invalid or unavailable reasons whenever a metric cannot be attributed
Histogram arming waits until the histogram has a sample; flushing waits for its
sample count to advance after work ends. Both waits use condition-based timer
polling. Each wait stops after 50 ms of observed monotonic time or its bounded
poll count; arming and flushing have separate caps, and timer scheduling may
overshoot wall-clock time. A timeout or profiling API failure never replaces
the business response: timestamps and any independently available CPU/ELU
metrics remain valid, while event-loop delay is `null` with a fixed reason.
The long-lived database worker still executes concurrent requests without a
profiling queue. If captures overlap, every overlapping response carries
`invalidReason: "overlapping-database-worker-requests"` and all attributable
CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared
worker activity to one request while preserving normal worker concurrency.
### Progress event contract
The worker now emits request-scoped events with:
+28 -6
View File
@@ -55,11 +55,32 @@ Docs-only changes (Markdown, `docs/`, `.plans/`, `.codex/`, `.claude/`) and
PRs no E2E validation runs in CI, which is intentional: they cannot affect app
behavior.
| Tier | Rule | Validation |
| --- | --- | --- |
| A | Product/runtime Angular, Electron, backend, data-access, portal, playlist, workspace, playback, EPG, and shared UI code collects source coverage. | `pnpm run coverage:ci` |
| B | Validate behavior without percentage coverage, such as `website`, `packaging`, and Playwright E2E projects. | `pnpm nx test website`, `pnpm nx test packaging`, or the closest E2E target |
| C | Excluded from the source coverage baseline, such as mock servers, test helper libraries, and untested feature shells. | Validate through dependent flows, or add focused tests when changing behavior directly |
Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits
nonzero on a `Failed to collect coverage` marker, a missing or invalid project
report, or a runtime-owning production TypeScript file absent from that report.
`coverage:merge` requires every configured Tier A report before replacing the
merged output. Strict health validation also requires the merged Istanbul map
itself to contain usable instrumentation for every runtime-owning Tier A file,
recomputes its summary, and then applies aggregate and selected critical-file
ratchets.
Runtime-owning files are discovered from the TypeScript AST. Specs,
declarations, test setup and stubs, generated and environment files, `index.ts`,
type-only files, and pure re-export shims are excluded. Ratchets live under
`reporting.coverageRatchet` in `tools/coverage/coverage-policy.json`; update
them only from a fresh full `coverage:ci` report when every value stays level
or rises, and never lower one to accept a regression. The only exception is an
explicitly reviewed production source shrink: `minimumCovered` may follow a
lower total statement count when the PR documents the removed executable
statements and fresh coverage proves that the corresponding
`minimumPercent`, every aggregate ratchet, and the remaining behavioral
coverage do not decrease.
| Tier | Rule | Validation |
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| A | Product/runtime Angular, Electron, backend, data-access, portal, playlist, workspace, playback, EPG, and shared UI code collects source coverage. | `pnpm run coverage:ci` |
| B | Validate behavior without percentage coverage, such as `website`, `packaging`, and Playwright E2E projects. | `pnpm nx test website`, `pnpm nx test packaging`, or the closest E2E target |
| C | Excluded from the source coverage baseline, such as mock servers, test helper libraries, and untested feature shells. | Validate through dependent flows, or add focused tests when changing behavior directly |
`apps/website` is an Astro marketing site. Its useful signal is a successful
static build plus targeted output checks, not a merged code coverage percentage.
@@ -69,9 +90,10 @@ Projects with a test target but no specs, such as `remote-control-web` and
For local coverage inspection:
```bash
pnpm run coverage:tools:test
pnpm run coverage:unit:ci
pnpm run coverage:merge
pnpm run coverage:health
pnpm run coverage:health -- --require-report
```
The merged report is written to `coverage/merged/` as HTML, LCOV, Cobertura,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,447 @@
# Coverage Integrity And Electron Runtime Boundaries Design
## Status
Approved in the delegated coverage-audit task on 2026-07-25.
Before recording the final ratchets, the implementation branch was rebased
onto fresh `origin/master` commit
`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077`.
## Goal
Increase confidence in security-sensitive and runtime-sensitive Electron
behavior while making the merged Tier A coverage report fail closed when Jest
cannot instrument executable production source.
This is a risk-weighted first change. It does not try to maximize a global
percentage by testing type-only modules, re-export shims, or low-risk code.
## Fresh Baseline
An uncached `pnpm run coverage:ci` on
`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077` completed successfully and reported:
| Metric | Covered / total | Percent |
| ---------- | --------------: | ------: |
| Statements | 24,982 / 36,062 | 69.27% |
| Branches | 17,465 / 29,637 | 58.92% |
| Functions | 5,785 / 8,577 | 67.44% |
| Lines | 24,323 / 34,959 | 69.57% |
The successful command nevertheless printed `Failed to collect coverage` for
`libs/m3u-state/src/lib/effects.ts`. TypeScript could not resolve
`@angular/material/snack-bar`, inferred the injected snack bar as `unknown`,
and did not see the shared `window.electron` declaration under the project's
spec compiler configuration (`TS2307` once, `TS2571` twice, and `TS2551`
once). The file was absent from both the project report and
`coverage/merged/coverage-final.json`.
All 30 Tier A projects produced a `coverage-final.json`, so 31 report files
including the merged report existed. The current merge included all 30 project
reports and 709 files while still missing an executable file. This demonstrates
that project-level report existence is necessary but not sufficient.
The audit file status on the same baseline is:
| File | Statement coverage |
| --------------------------------------------------------------------------------------- | -----------------: |
| `apps/electron-backend/src/app/workers/database.worker.ts` | 0 / 207 (0%) |
| `apps/electron-backend/src/app/events/remote-control.events.ts` | 0 / 115 (0%) |
| `apps/electron-backend/src/app/server/http-server.ts` | 0 / 73 (0%) |
| `apps/electron-backend/src/app/events/database/downloads.events.ts` | 69 / 147 (46.93%) |
| `apps/electron-backend/src/app/events/settings.events.ts` | 16 / 27 (59.25%) |
| `libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts` | 0 / 206 (0%) |
| `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` | 0 / 166 (0%) |
| `libs/m3u-state/src/lib/effects.ts` | absent |
The original audit is therefore partly stale: `downloads.events.ts` gained a
meaningful regression suite on master, and `settings.events.ts` already had
logging coverage. Both remain in this PR because their uncovered IPC
contracts are part of the approved Electron boundary, but new tests must cover
missing behavior rather than repeat existing assertions.
## Scope
### Included
- Add behavioral coverage for `HttpServer` routing, static serving, traversal
containment, MIME responses, and lifecycle.
- Add behavioral coverage for the remote-control HTTP and IPC boundary:
method checks, renderer commands, status, channel-number validation,
malformed JSON, and the 10 KiB body limit.
- Extend settings IPC coverage for argument normalization, defined-only
persistence, frame-copy boolean normalization, remote-control fallback, and
runtime server reconciliation while preserving redacted logging.
- Extend downloads IPC coverage around the managed-file authorization boundary
before revealing or opening local paths. Preserve the existing destructive
cleanup and pause/resume regression tests.
- Fix the `m3u-state` spec compiler configuration so `effects.ts` can be
instrumented and appears in its report.
- Add a fail-closed integrity gate for instrumentation errors, missing Tier A
reports, missing executable source files, merged metric regressions, and
regressions in the selected critical Electron files.
- Document the resulting coverage workflow in
`docs/architecture/validation-map.md`.
### Deferred
- `database.worker.ts`: its 950-line worker-thread dispatcher and database
operation surface deserve a dedicated worker-contract design and PR.
- `workspace-dashboard-rails.component.ts`: the existing same-name spec tests
extracted helpers but never instantiates the 660-line component. Component
coverage belongs in a focused Angular dashboard change.
- `stalker-search.component.ts`: the 510-line search surface and portal store
interactions belong in a focused Stalker search change.
- Broadly raising every current 0% Tier A file.
- Adding tests to type-only/shared interfaces or pure re-export modules to
inflate aggregate metrics.
- Unrelated production behavior changes.
## Coverage Integrity Architecture
### Shared integrity module
Create a pure Node module under `tools/coverage/` that owns:
- source-file exclusion rules;
- TypeScript AST classification of runtime-owning files;
- coverage path normalization;
- project- and merged-report completeness checks;
- instrumentation-error marker detection;
- merged and critical-file ratchet evaluation; and
- diagnostic formatting.
The runner, merge script, health script, and Node unit tests use this module so
the definition of an expected executable file does not drift between stages.
### Runtime-owning source classification
For each Tier A `sourceRoot`, recursively consider production `.ts` files.
Exclude:
- `*.spec.ts` and `*.test.ts`;
- `*.d.ts`;
- `test-setup.ts`, test stubs, and generated files;
- environment files; and
- `index.ts`, matching the existing Jest collection exclusions.
Parse each candidate with the TypeScript compiler API. A file is expected in
coverage when it has at least one top-level runtime-owning statement.
Import declarations, interfaces, type aliases, pure export declarations,
empty statements, and ambient `declare` statements do not make a file
runtime-owning by themselves. Classes, functions, variables, enums,
expressions, control flow, and other emitted statements do.
This rule intentionally checks executable ownership rather than all TypeScript
text. It excludes type-only contracts and re-export shims without maintaining
a large hand-authored manifest. A discovery prototype over the fresh baseline
classified 591 of 655 Tier A TypeScript source files as runtime-owning and
identified exactly one absent file: `libs/m3u-state/src/lib/effects.ts`.
Coverage keys and expected source paths are normalized to absolute POSIX-style
paths before comparison so diagnostics are stable across supported CI
platforms.
### Runner behavior
`run-tier-a-coverage.mjs` must continue relaying each child process's stdout and
stderr as it arrives. It also scans a bounded rolling text window, with ANSI
escapes removed, for Jest's `Failed to collect coverage` marker.
After each project command finishes, the runner must fail if:
- the Nx/Jest command exits nonzero or terminates by signal;
- the output contains an instrumentation failure marker;
- `coverage/<project-root>/coverage-final.json` is missing or invalid; or
- a runtime-owning file under that project's source root is absent from the
report.
The implementation should use asynchronous child-process streams rather than
buffering the full multi-project Jest output in memory.
### Merge behavior
`merge-coverage.mjs` must require exactly one valid report for every configured
Tier A project. It must list all missing or invalid reports and exit before
deleting or rewriting `coverage/merged`.
The current `.filter(existsSync)` behavior is removed because it silently
merges partial input.
### Health behavior
`coverage-health.mjs --require-report` independently reruns:
- per-project report existence and JSON validity;
- runtime-owning source completeness;
- merged-report completeness and usable instrumentation for every
runtime-owning Tier A file;
- merged metric ratchets; and
- selected critical-file ratchets.
It computes the aggregate summary from that validated merged map before
checking the serialized summary and ratchets. This defense in depth catches
stale, incomplete, or manually merged reports even when the runner was not the
process that generated them. Local health checks without `--require-report`
retain their current warning behavior when report artifacts are not available,
but any present invalid report remains an error.
### Ratchets
The policy stores four merged minimum percentages and per-file statement
minimums for:
- `http-server.ts`;
- `remote-control.events.ts`;
- `settings.events.ts`; and
- `downloads.events.ts`.
The implementation begins with the fresh master aggregate baseline shown
above. After the approved behavioral suites and the `effects.ts`
instrumentation fix are complete, the policy is updated to the reproducible
post-change values. Those final values become the ratchet: they may stay level
or rise in future changes, but must not be lowered merely to make CI green.
Per-file minimums are likewise recorded from the achieved behavioral suites,
not from an arbitrary aspirational percentage. The two currently uncovered
files must become nonzero; the existing `settings` and `downloads` coverage
must not regress. A reviewed production source shrink is the sole exception to
the normally monotonic absolute `minimumCovered`: a PR may lower it only when
it identifies the removed executable statements and a fresh full report proves
that the file's `minimumPercent`, all aggregate ratchets, and coverage of the
remaining behavior stay level or rise.
## Behavioral Test Design
### HTTP server
Use a temporary static directory and a real loopback Node HTTP server. Verify:
- `/` serves `index.html`;
- known static assets return their content and correct MIME type;
- an unknown client-side route falls back to `index.html`;
- a missing `index.html` returns a plain-text 404;
- registered remote-control API routes bypass static serving;
- unknown remote-control API routes return JSON 404;
- normalized traversal input cannot escape the configured static root;
- `start`, duplicate `start`, `stop`, enable/disable, and port-change restart
preserve the current lifecycle contract.
Tests should prefer public behavior over direct calls to private methods. If
an ephemeral port or temporary static root is not observable through the
current API, introduce only the smallest constructor dependency seam required
to use real Node HTTP and filesystem behavior. The production singleton keeps
identical defaults.
### Remote control events
Capture the HTTP handlers registered during bootstrap and exercise them with
real readable request streams plus response-contract assertions. Verify:
- bootstrap registers every documented endpoint and starts the server only
when stored settings enable it;
- channel up/down and volume commands accept only their documented HTTP
methods and send the expected renderer event;
- channel selection accepts finite positive values, floors fractional input,
and rejects missing, non-finite, or sub-one values;
- status updates merge partial IPC state and refresh `updatedAt`;
- status reads return the latest merged state;
- malformed JSON returns 400 without dispatching a command;
- payloads over 10 KiB return 413, destroy the request, and do not dispatch;
and
- missing BrowserWindows are contained without throwing.
The existing Electron E2E remote-control suite remains the end-to-end check for
real volume commands and renderer status.
### Settings events
Extend the existing same-name spec. Use the real external-player argument
normalizer and mocked persistent store/server boundaries to verify:
- MPV and VLC arguments are normalized before persistence;
- undefined values are not written;
- reuse flags preserve explicit false values;
- frame-copy persistence coerces to boolean;
- a partial remote-control update reads the missing half from the store;
- only explicitly supplied remote-control fields are persisted; and
- `httpServer.updateSettings` receives the resolved enabled/port pair.
Retain the existing assertions that logging redacts TMDB and player-argument
credentials.
### Downloads events
Extend the current event suite rather than replacing its destructive cleanup
coverage. Verify the local-file boundary:
- a path not present in the downloads database is never revealed or opened,
even when it exists on disk;
- a database-managed path missing on disk is never revealed or opened;
- only a database-managed path that exists reaches
`shell.showItemInFolder` or `shell.openPath`; and
- a database lookup failure returns the existing structured not-found response
and never reaches the shell.
Folder authorization internals remain covered by
`download-directory-authorization.spec.ts`; this event suite tests only the
IPC-to-authorizer and managed-path integration where it adds distinct signal.
## TDD Sequence
1. Add failing Node tests for runtime classification, ANSI instrumentation
marker detection, missing/invalid reports, partial merge input, aggregate
ratchets, and critical-file ratchets.
2. Implement the shared integrity module and wire the runner, merge, and health
scripts until those tests pass.
3. Enable the selected critical-file policy; confirm fresh targeted coverage
fails because `http-server.ts` and `remote-control.events.ts` are 0%.
4. For each Electron boundary, add one contract test at a time, observe the
intended failure, and make only the smallest testability refactor needed to
pass it.
5. Add a failing integrity assertion for the absent `effects.ts`, align the
`m3u-state` spec compiler configuration, and confirm the real file appears.
6. If a behavioral test exposes a production defect, add a direct regression
assertion first and make a separately justified minimal fix. Otherwise
preserve production behavior.
7. Record fresh aggregate and per-file post-change ratchets only after all
behavioral suites are green.
## Error Handling And Diagnostics
Integrity failures must identify:
- the project;
- the expected report path;
- each missing runtime-owning source path;
- the metric or critical file that fell below its ratchet; and
- the observed and required values.
The runner must preserve the original Nx/Jest output and child exit semantics.
Integrity diagnostics are additive and must not hide the underlying
instrumentation error.
Tests use temporary directories and deterministic synthetic coverage maps.
They must not depend on a previously generated workspace `coverage/` tree.
## Success Criteria
- Fresh `pnpm run coverage:ci` exits nonzero for synthetic or real
instrumentation collection failures.
- All 30 Tier A project reports are mandatory merge inputs.
- Every runtime-owning Tier A TypeScript source file is present in its project
report.
- `libs/m3u-state/src/lib/effects.ts` is present in coverage.
- The four selected Electron boundary files meet their recorded behavioral
statement ratchets; `http-server.ts` and `remote-control.events.ts` are no
longer 0%.
- Final merged statements, branches, functions, and lines are each at least
the fresh master baseline and become the new ratchet.
- Assertions verify observable contracts rather than merely invoking mocks for
line execution.
- No untested production behavior change or unrelated source change is
included.
- Deferred database worker, dashboard, and Stalker search coverage is listed
in the draft PR.
## Implementation Outcome
The fresh uncached implementation run merged all 30 required Tier A reports
into the 31st `coverage-final.json` and included 710 files, with no
collection-failure marker or missing runtime-owning source. The achieved
ratchets are:
| Metric | Covered / total | Percent |
| ---------- | --------------: | ------: |
| Statements | 25,204 / 36,241 | 69.54% |
| Branches | 17,573 / 29,748 | 59.07% |
| Functions | 5,824 / 8,635 | 67.44% |
| Lines | 24,543 / 35,136 | 69.85% |
The selected Electron boundary statement coverage changed as follows:
| File | Baseline | After |
| -------------------------- | ----------------: | -----------------: |
| `http-server.ts` | 0 / 73 (0%) | 83 / 92 (90.21%) |
| `remote-control.events.ts` | 0 / 115 (0%) | 112 / 116 (96.55%) |
| `settings.events.ts` | 16 / 27 (59.25%) | 26 / 27 (96.29%) |
| `downloads.events.ts` | 69 / 147 (46.93%) | 86 / 147 (58.50%) |
The integrity correction makes the denominator honest:
`libs/m3u-state/src/lib/effects.ts` is now present at 0 / 159 statements
instead of silently disappearing. The aggregate improvement therefore holds
even after surfacing those 159 previously unreported uncovered statements.
Strict health validation also rechecks the merged map itself, so a
runtime-owning file cannot disappear between valid project reports and the
merged artifact while permissive percentages still pass.
Two production seams were sufficient for isolated contract tests:
`HttpServer` accepts an optional server factory and static distribution path,
and `RemoteControlEvents` is a named export while the production singleton is
unchanged. Real loopback HTTP tests also exposed a Windows
double-leading-slash traversal escape in the prior static-path normalization.
A direct failing regression preceded the narrow fix: production now uses the
pure, platform-testable `resolveStaticFilePath` resolver to decode once, reject
malformed or NUL paths, strip leading separators, resolve against the static
root, and enforce containment. This is a tested behavior and security fix, not
a behavior-preserving refactor.
The release decision follows that observable fix:
`.changes/electron-remote-static-paths.md` records it as an Electron bug fix,
so the draft PR must not carry `no-release-note`. The canonical coverage
workflow is documented in `docs/architecture/validation-map.md`; `AGENTS.md`
and `CLAUDE.md` do not describe the changed coverage contract or HTTP path
logic and require no update.
The deferred production files remain uncovered in this change:
`database.worker.ts` at 0 / 207, `workspace-dashboard-rails.component.ts` at
0 / 206, and `stalker-search.component.ts` at 0 / 166. They retain the
separate follow-up scopes described above.
## Validation Ladder
Run fresh, uncached validation in this order:
```bash
node --test tools/coverage/*.test.mjs
pnpm nx test electron-backend --skip-nx-cache --runInBand
pnpm nx test m3u-state --skip-nx-cache --runInBand
pnpm run coverage:unit:ci -- --projects=electron-backend,m3u-state
pnpm nx lint electron-backend --skip-nx-cache
pnpm nx lint m3u-state --skip-nx-cache
pnpm run typecheck:backend
pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts
pnpm nx run electron-backend-e2e:e2e-ci--src/remote-control.e2e.ts
pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts
pnpm nx build electron-backend --configuration=production --skip-nx-cache
pnpm run coverage:ci
```
The final report records exact command results, aggregate before/after metrics,
and before/after metrics for the selected critical files. If an environment
prevents an Electron E2E or production build, report the exact blocker and run
the strongest available lower-level contract validation; do not silently omit
it.
## Documentation Impact
Update `docs/architecture/validation-map.md` to make the fail-closed coverage
contract and ratchet maintenance workflow canonical.
`AGENTS.md` and `CLAUDE.md` do not currently describe coverage commands or
integrity behavior, so no update is expected unless implementation changes a
process they do describe.
## Draft PR Handoff
The draft PR body will include:
- fresh master baseline and post-change aggregate metrics;
- selected-file before/after metrics;
- behavioral contracts added;
- any production-only testability refactor and why it preserves behavior;
- coverage integrity and ratchet changes;
- all validation commands and results;
- the documentation decision; and
- deferred 0%-coverage follow-ups.
+11 -1
View File
@@ -1,5 +1,10 @@
import { createActionGroup, emptyProps, props } from '@ngrx/store';
import { Channel, EpgProgram, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
Channel,
EpgProgram,
Playlist,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
export const PlaylistActions = createActionGroup({
source: 'Playlists',
@@ -11,6 +16,11 @@ export const PlaylistActions = createActionGroup({
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMeta }>(),
'Update Playlist': props<{
/**
* Instrumentation-only; stripped before the DB invoke.
* Never enters the DB worker payload or persisted playlist data.
*/
operationId?: string;
playlist: Playlist;
playlistId: string;
refreshEpg?: boolean;
+25 -21
View File
@@ -55,6 +55,7 @@ import {
} from './external-player-payload.util';
import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util';
import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util';
import { persistPlaylistUpdate } from './playlist-update.effect-handler';
@Injectable({ providedIn: 'any' })
export class PlaylistEffects {
@@ -305,10 +306,7 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylist),
switchMap((action) =>
this.playlistsService.updatePlaylist(action.playlistId, {
...action.playlist,
_id: action.playlistId,
}).pipe(
persistPlaylistUpdate(this.playlistsService, action).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist, {
force: action.refreshEpg === true,
@@ -353,12 +351,14 @@ export class PlaylistEffects {
if ('isTemporary' in action && action.isTemporary) {
return EMPTY;
}
return this.playlistsService.addPlaylist(action.playlist).pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
return this.playlistsService
.addPlaylist(action.playlist)
.pipe(
tap(() => {
this.fetchPlaylistScopedEpg(action.playlist);
this.navigateToPlaylist(action.playlist);
})
);
})
);
},
@@ -370,17 +370,21 @@ export class PlaylistEffects {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
switchMap((action) =>
this.playlistsService.updatePlaylistMeta(action.playlist).pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(action.playlist);
}
})
)
this.playlistsService
.updatePlaylistMeta(action.playlist)
.pipe(
tap(() => {
if (
this.hasPlaylistScopedEpgSourceChange(
action.playlist
)
) {
this.fetchPlaylistScopedEpg(
action.playlist
);
}
})
)
)
);
},
@@ -0,0 +1,30 @@
import { type Playlist } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { PlaylistActions } from './actions';
import { persistPlaylistUpdate } from './playlist-update.effect-handler';
describe('persistPlaylistUpdate', () => {
it('forwards the refresh operation ID to playlist persistence', () => {
const playlistsService = {
updatePlaylist: jest.fn(() => of(undefined)),
};
const playlist = {
_id: 'playlist-1',
playlist: { items: [] },
} as unknown as Playlist;
const action = PlaylistActions.updatePlaylist({
playlist,
playlistId: playlist._id,
refreshEpg: true,
operationId: 'playlist-refresh-operation',
});
persistPlaylistUpdate(playlistsService, action);
expect(playlistsService.updatePlaylist).toHaveBeenCalledWith(
playlist._id,
playlist,
'playlist-refresh-operation'
);
});
});
@@ -0,0 +1,29 @@
import type { Playlist } from '@iptvnator/shared/interfaces';
interface PlaylistUpdateAction {
operationId?: string;
playlist: Playlist;
playlistId: string;
}
interface PlaylistUpdater<TResult> {
updatePlaylist: (
playlistId: string,
playlist: Playlist,
operationId?: string
) => TResult;
}
export function persistPlaylistUpdate<TResult>(
playlistsService: PlaylistUpdater<TResult>,
action: PlaylistUpdateAction
): TResult {
return playlistsService.updatePlaylist(
action.playlistId,
{
...action.playlist,
_id: action.playlistId,
},
action.operationId
);
}
+3 -3
View File
@@ -2,12 +2,12 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"module": "preserve",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -17,8 +17,7 @@ import {
StalkerSessionService,
normalizeStalkerPortalIdentity,
} from '@iptvnator/portal/stalker/data-access';
import { Playlist } from '@iptvnator/shared/interfaces';
import { v4 as uuid } from 'uuid';
import { createRandomId, Playlist } from '@iptvnator/shared/interfaces';
@Component({
imports: [
@@ -55,7 +54,7 @@ export class StalkerPortalImportComponent {
readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
readonly form = new FormGroup({
_id: new FormControl(uuid()),
_id: new FormControl(createRandomId()),
title: new FormControl('', [Validators.required]),
macAddress: new FormControl('', [Validators.required]),
serialNumber: new FormControl(''),
@@ -82,7 +81,7 @@ export class StalkerPortalImportComponent {
clearForm(): void {
this.form.reset({
_id: uuid(),
_id: createRandomId(),
title: '',
macAddress: '',
serialNumber: '',
@@ -16,11 +16,11 @@ import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PortalStatus, PortalStatusService } from '@iptvnator/services';
import {
createRandomId,
extractXtreamCredentialsFromUrl,
normalizeXtreamServerUrl,
Playlist,
} from '@iptvnator/shared/interfaces';
import { v4 as uuid } from 'uuid';
function xtreamServerUrlValidator(
control: AbstractControl
@@ -91,7 +91,7 @@ export class XtreamCodeImportComponent {
URL_REGEX = /^\s*https?:\/\/[^ "]+\s*$/;
form = new FormGroup({
_id: new FormControl(uuid()),
_id: new FormControl(createRandomId()),
title: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required]),
username: new FormControl('', [Validators.required]),
@@ -149,7 +149,7 @@ export class XtreamCodeImportComponent {
clearForm(): void {
this.form.reset({
_id: uuid(),
_id: createRandomId(),
title: '',
password: '',
username: '',
@@ -630,6 +630,7 @@ describe('PlaylistRefreshActionService', () => {
playlist: refreshedPlaylist,
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
@@ -205,12 +205,11 @@ export class PlaylistRefreshActionService {
}
try {
const operationId =
this.databaseService.createOperationId('playlist-refresh');
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist({
operationId:
this.databaseService.createOperationId(
'playlist-refresh'
),
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
@@ -228,6 +227,7 @@ export class PlaylistRefreshActionService {
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
@@ -549,5 +549,15 @@ describe('RecentPlaylistsComponent busy state', () => {
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
await Promise.resolve();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylist({
playlist: expect.objectContaining({
_id: item._id,
}),
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
});
@@ -463,6 +463,7 @@ export class RecentPlaylistsComponent {
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
@@ -1,6 +1,5 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { DatabaseService } from './database-electron.service';
@@ -23,6 +22,7 @@ import {
XtreamBackupContentType,
XtreamPlaylistBackupEntry,
XtreamPendingRestoreState,
createRandomId,
} from '@iptvnator/shared/interfaces';
export interface PlaylistBackupExportPayload {
@@ -585,10 +585,10 @@ export class PlaylistBackupService {
return entry.exportedId;
}
let nextId = uuidv4();
let nextId = createRandomId();
while (existingIds.has(nextId)) {
nextId = uuidv4();
nextId = createRandomId();
}
return nextId;
@@ -1336,6 +1336,37 @@ describe('PlaylistsService', () => {
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledTimes(2);
});
it('forwards refresh instrumentation only to its SQLite read and write', async () => {
const { electron } = createStatefulElectronStore(
createBasePlaylist('playlist-instrumented-refresh')
);
testWindow.electron = electron;
const service = createService();
const operationId = 'playlist-refresh-operation';
await firstValueFrom(
service.updatePlaylist(
'playlist-instrumented-refresh',
{
_id: 'playlist-instrumented-refresh',
playlist: { items: [] },
} as Playlist,
operationId
)
);
expect(electron.dbGetAppPlaylist).toHaveBeenCalledWith(
'playlist-instrumented-refresh',
operationId
);
expect(electron.dbUpsertAppPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'playlist-instrumented-refresh',
}),
operationId
);
});
it('keeps both favorites when two rapid favorite adds overlap (SQLite)', async () => {
const { store, electron } = createStatefulElectronStore(
createBasePlaylist('portal-race-two-favorites')
+36 -13
View File
@@ -52,7 +52,10 @@ type PlaylistRawItem = {
type PlaylistStorageElectronApi = {
dbDeleteAllPlaylists: () => Promise<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylist: (
playlistId: string,
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels?: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
@@ -60,7 +63,10 @@ type PlaylistStorageElectronApi = {
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<unknown>;
dbUpsertAppPlaylist: (
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
@@ -362,14 +368,18 @@ export class PlaylistsService {
}
}
private upsertSqlitePlaylist(playlist: Playlist) {
private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
return playlist;
}
await electron.dbUpsertAppPlaylist(playlist);
if (operationId === undefined) {
await electron.dbUpsertAppPlaylist(playlist);
} else {
await electron.dbUpsertAppPlaylist(playlist, operationId);
}
return playlist;
});
}
@@ -411,9 +421,14 @@ export class PlaylistsService {
});
}
private persistPlaylistMutation(nextPlaylist: Playlist) {
private persistPlaylistMutation(
nextPlaylist: Playlist,
operationId?: string
) {
if (this.isElectronStorageAvailable) {
return firstValueFrom(this.upsertSqlitePlaylist(nextPlaylist));
return firstValueFrom(
this.upsertSqlitePlaylist(nextPlaylist, operationId)
);
}
return firstValueFrom(
@@ -554,10 +569,14 @@ export class PlaylistsService {
};
}
updatePlaylist(playlistId: string, updatedPlaylist: Playlist) {
updatePlaylist(
playlistId: string,
updatedPlaylist: Playlist,
operationId?: string
) {
return this.serializePlaylistWrite(playlistId, async () => {
const currentPlaylist = await firstValueFrom(
this.getPlaylistById(playlistId)
this.getPlaylistById(playlistId, operationId)
);
const mergedPlaylist = this.mergeRefreshedPlaylist(
currentPlaylist,
@@ -565,17 +584,21 @@ export class PlaylistsService {
playlistId
);
return this.persistPlaylistMutation(mergedPlaylist);
return this.persistPlaylistMutation(mergedPlaylist, operationId);
});
}
getPlaylistById(id: string) {
getPlaylistById(id: string, operationId?: string) {
if (this.isElectronStorageAvailable) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlist = electron
? await electron.dbGetAppPlaylist(id)
: null;
let playlist: Playlist | null = null;
if (electron) {
playlist =
operationId === undefined
? await electron.dbGetAppPlaylist(id)
: await electron.dbGetAppPlaylist(id, operationId);
}
return playlist
? this.createSqliteFallbackPlaylist(playlist as Playlist)
: (undefined as unknown as Playlist);
+2
View File
@@ -23,6 +23,7 @@ export * from './lib/playback-position.interface';
export * from './lib/playlist-auto-update.interface';
export * from './lib/playlist-backup.interface';
export * from './lib/playlist-meta.type';
export * from './lib/preload-performance-marker.interface';
export * from './lib/playlist-recently-viewed.interface';
export * from './lib/playlist-recently-viewed.utils';
export * from './lib/playlist-refresh.interface';
@@ -30,6 +31,7 @@ export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
export * from './lib/portal-playback.interface';
export * from './lib/random-id.util';
export * from './lib/security-policy-error.utils';
export * from './lib/settings.interface';
export * from './lib/stalker-portal-actions.enum';
@@ -684,13 +684,27 @@ export interface ElectronBridgeApi {
dbGetPlaylist: (
playlistId: string
) => Promise<ElectronBridgePlaylistRow | null>;
dbUpsertAppPlaylist: (playlist: Playlist) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylist: (
playlist: Playlist,
/**
* Instrumentation-only; stripped before the DB invoke.
* Never enters the DB worker payload or persisted playlist data.
*/
operationId?: string
) => Promise<ElectronBridgeResult>;
dbUpsertAppPlaylists: (
playlists: Playlist[]
) => Promise<ElectronBridgeCountResult>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppPlaylistMetas: () => Promise<Playlist[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<Playlist | null>;
dbGetAppPlaylist: (
playlistId: string,
/**
* Instrumentation-only; stripped before the DB invoke.
* Never enters the DB worker payload or persisted playlist data.
*/
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
@@ -0,0 +1,52 @@
export const PRELOAD_PERFORMANCE_MARKER_CHANNEL =
'IPTVNATOR_PERF_CAPTURE_MARKER';
export const PRELOAD_PERFORMANCE_METHOD = {
DB_GET_APP_PLAYLIST: 'dbGetAppPlaylist',
DB_UPSERT_APP_PLAYLIST: 'dbUpsertAppPlaylist',
REFRESH_PLAYLIST: 'refreshPlaylist',
} as const;
export type PreloadPerformanceMethod =
(typeof PRELOAD_PERFORMANCE_METHOD)[keyof typeof PRELOAD_PERFORMANCE_METHOD];
export const PRELOAD_PERFORMANCE_PHASE = {
ERROR: 'error',
START: 'start',
SUCCESS: 'success',
} as const;
export type PreloadPerformancePhase =
(typeof PRELOAD_PERFORMANCE_PHASE)[keyof typeof PRELOAD_PERFORMANCE_PHASE];
export const PRELOAD_PERFORMANCE_CORRELATION_STATE = {
COMPLETE: 'complete',
CORRELATED: 'correlated',
INVALID: 'invalid',
UNCORRELATED: 'uncorrelated',
} as const;
export type PreloadPerformanceCorrelationStateName =
(typeof PRELOAD_PERFORMANCE_CORRELATION_STATE)[keyof typeof PRELOAD_PERFORMANCE_CORRELATION_STATE];
export const PRELOAD_PERFORMANCE_INVALID_REASON = {
CONCURRENT_REFRESH: 'concurrent-refresh',
IPC_ERROR: 'ipc-error',
MALFORMED_IDENTIFIER: 'malformed-identifier',
OUT_OF_ORDER: 'out-of-order',
REFRESH_CANCELLED: 'refresh-cancelled',
} as const;
export type PreloadPerformanceInvalidReason =
(typeof PRELOAD_PERFORMANCE_INVALID_REASON)[keyof typeof PRELOAD_PERFORMANCE_INVALID_REASON];
export interface PreloadPerformanceMarker {
method: PreloadPerformanceMethod;
phase: PreloadPerformancePhase;
sourceEpochMs: number;
ipcCallId: number;
playlistId: string | null;
operationId: string | null;
correlationState: PreloadPerformanceCorrelationStateName;
invalidReason: PreloadPerformanceInvalidReason | null;
}
@@ -0,0 +1,84 @@
import { createRandomId } from './random-id.util';
const UUID_V4 =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
describe('createRandomId', () => {
const nativeRandomUUID = crypto.randomUUID;
afterEach(() => {
Object.defineProperty(crypto, 'randomUUID', {
configurable: true,
value: nativeRandomUUID,
});
});
it('returns a v4 uuid', () => {
expect(createRandomId()).toMatch(UUID_V4);
});
it('returns unique ids', () => {
const ids = new Set(
Array.from({ length: 500 }, () => createRandomId())
);
expect(ids.size).toBe(500);
});
it('uses the native randomUUID when the context exposes it', () => {
const randomUUID = jest
.fn()
.mockReturnValue('11111111-1111-4111-8111-111111111111');
Object.defineProperty(crypto, 'randomUUID', {
configurable: true,
value: randomUUID,
});
expect(createRandomId()).toBe('11111111-1111-4111-8111-111111111111');
expect(randomUUID).toHaveBeenCalledTimes(1);
});
describe('without randomUUID (insecure context)', () => {
beforeEach(() => {
Object.defineProperty(crypto, 'randomUUID', {
configurable: true,
value: undefined,
});
});
it('still returns a v4 uuid from getRandomValues', () => {
expect(createRandomId()).toMatch(UUID_V4);
});
it('returns unique ids', () => {
const ids = new Set(
Array.from({ length: 500 }, () => createRandomId())
);
expect(ids.size).toBe(500);
});
it('sets the version and variant bits on all-zero randomness', () => {
jest.spyOn(crypto, 'getRandomValues').mockImplementation(
(buffer) => buffer
);
expect(createRandomId()).toBe(
'00000000-0000-4000-8000-000000000000'
);
});
it('keeps the remaining bits from getRandomValues', () => {
jest.spyOn(crypto, 'getRandomValues').mockImplementation(
(buffer) => {
(buffer as Uint8Array).fill(0xff);
return buffer;
}
);
expect(createRandomId()).toBe(
'ffffffff-ffff-4fff-bfff-ffffffffffff'
);
});
});
});
@@ -0,0 +1,43 @@
/**
* Random RFC 4122 v4 identifier used for playlist `_id` values, shared by the
* renderer, the Electron backend and the web backend. Pure function — no
* Angular/Node dependencies.
*
* `crypto.randomUUID()` is only exposed in secure contexts, and the
* self-hosted PWA is regularly served over plain http on a LAN address. So the
* fallback builds the same v4 shape from `crypto.getRandomValues()`, which
* insecure contexts do expose. This mirrors what `uuid`'s `v4()` did before it
* was dropped as a dependency.
*/
const HEX_BY_BYTE = Array.from({ length: 256 }, (_unused, byte) =>
byte.toString(16).padStart(2, '0')
);
function randomUuidFromBytes(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// Version 4 and the RFC 4122 variant bits.
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => HEX_BY_BYTE[byte]);
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join(''),
].join('-');
}
/** Returns a lowercase RFC 4122 v4 UUID. */
export function createRandomId(): string {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return randomUuidFromBytes();
}
+1 -2
View File
@@ -7,7 +7,6 @@
"types": "./src/index.d.ts",
"dependencies": {
"@iptvnator/shared/interfaces": "0.0.1",
"tslib": "^2.3.0",
"uuid": "^9.0.0"
"tslib": "^2.3.0"
}
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more