mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
Merge remote-tracking branch 'origin/master' into agent/shared-marketing-poster-catalog
This commit is contained in:
commit
8a902d2e25
114 files changed
+14115
-1418
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Prepare Docker metadata
|
||||
id: docker-meta
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'/);
|
||||
});
|
||||
+172
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+3
-4
@@ -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: '',
|
||||
|
||||
+3
-3
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user