Merge master, dedupe the kept copy, and retire superseded switches

Master had moved ten commits ahead. Two conflicts, both resolved without
losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into
`events/stream-probe.ts` stays extracted (master added performance capture
nearby but never touched that handler), and the mock-server scenario table
takes the union of master's `performance` row and this branch's `multisrc`
pair.

Two findings fixed alongside it.

Pinning the copy the route is already on makes discovery return that very
row, so prepending the current source listed one stream twice — a phantom
copy in the grouping and a chip that counted it.

And handing the "playing" badge back to the route row left an in-flight
switch valid, so a slow resolution could arrive afterwards and replace the
playback the user had just started with Play, Resume or Restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 08:44:49 +02:00
commit 9f0a8b4f19
385 files changed
+58306 -2264

No files matched your search

@@ -3,6 +3,7 @@ import type {
Playlist,
PlaylistRefreshPayload,
PreloadPerformanceMarker,
XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
export const PRELOAD_ENVIRONMENT = {
@@ -31,6 +32,7 @@ export interface PreloadPerformanceHarness {
getApi: () => ExposedElectronApi;
getMarkers: () => PreloadPerformanceMarker[];
getSentPayloads: <T>(channel: string) => T[];
getXtreamMarkers: () => XtreamPreloadPerformanceMarker[];
ipcRenderer: MockIpcRenderer;
load: (environment?: PreloadEnvironment) => Promise<void>;
}
@@ -130,6 +132,10 @@ export function createPreloadPerformanceHarness(): PreloadPerformanceHarness {
'IPTVNATOR_PERF_CAPTURE_MARKER'
),
getSentPayloads,
getXtreamMarkers: () =>
getSentPayloads<XtreamPreloadPerformanceMarker>(
'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER'
),
ipcRenderer,
load: async (environment = {}) => {
jest.resetModules();
@@ -15,6 +15,11 @@ import {
createPreloadPerformanceCapture,
toPreloadPerformanceTargetMethod,
} from './preload-performance-capture';
import {
createXtreamPreloadPerformanceCapture,
isXtreamPreloadPerformanceCaptureEnabled,
toXtreamPreloadPerformanceTargetMethod,
} from './xtream-preload-performance-capture';
import type {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
@@ -80,10 +85,17 @@ const dbSaveContentProgressListeners = new Set<
const shouldTraceRendererApi = isRendererApiTraceEnabled();
const shouldCapturePerformance = isPerformanceCaptureEnabled();
const shouldCaptureXtreamPerformance =
isXtreamPreloadPerformanceCaptureEnabled();
const preloadPerformanceCapture = createPreloadPerformanceCapture(
shouldCapturePerformance,
(channel, marker) => ipcRenderer.send(channel, marker)
);
const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance
? createXtreamPreloadPerformanceCapture(true, (channel, marker) =>
ipcRenderer.send(channel, marker)
)
: null;
function emitRendererTrace(payload: {
method: string;
@@ -107,12 +119,18 @@ function wrapElectronApi<T extends object>(api: T): T {
return Object.fromEntries(
Object.entries(api).map(([name, value]) => {
const isPreloadPerformanceTarget =
toPreloadPerformanceTargetMethod(name) !== null;
const isXtreamPreloadPerformanceTarget =
shouldCaptureXtreamPerformance &&
toXtreamPreloadPerformanceTargetMethod(name) !== null;
if (
typeof value !== 'function' ||
name.startsWith('on') ||
name.startsWith('remove') ||
(!shouldTraceRendererApi &&
!toPreloadPerformanceTargetMethod(name))
!isPreloadPerformanceTarget &&
!isXtreamPreloadPerformanceTarget)
) {
return [name, value];
}
@@ -136,6 +154,9 @@ function wrapElectronApi<T extends object>(api: T): T {
name,
args
);
const xtreamPerformanceCall =
xtreamPreloadPerformanceCapture?.start(name, args) ??
null;
try {
const result = original(...args);
@@ -164,6 +185,9 @@ function wrapElectronApi<T extends object>(api: T): T {
performanceCall,
resolvedValue
);
xtreamPreloadPerformanceCapture?.success(
xtreamPerformanceCall
);
return resolvedValue;
})
.catch((error: unknown) => {
@@ -181,6 +205,9 @@ function wrapElectronApi<T extends object>(api: T): T {
preloadPerformanceCapture.error(
performanceCall
);
xtreamPreloadPerformanceCapture?.error(
xtreamPerformanceCall
);
throw error;
});
}
@@ -200,6 +227,9 @@ function wrapElectronApi<T extends object>(api: T): T {
performanceCall,
result
);
xtreamPreloadPerformanceCapture?.success(
xtreamPerformanceCall
);
return result;
} catch (error) {
@@ -215,6 +245,9 @@ function wrapElectronApi<T extends object>(api: T): T {
});
}
preloadPerformanceCapture.error(performanceCall);
xtreamPreloadPerformanceCapture?.error(
xtreamPerformanceCall
);
throw error;
}
},
@@ -745,11 +778,7 @@ const electronApi: ElectronBridgeApi = {
kind: 'all' | 'vod' | 'series',
limit?: number,
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url'
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'
) =>
ipcRenderer.invoke(
'DB_GET_GLOBAL_RECENTLY_ADDED',
@@ -0,0 +1,187 @@
import {
PRELOAD_PERFORMANCE_MARKER_CHANNEL,
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
type XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import {
createPreloadPerformanceHarness,
createRefreshPayload,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
import {
XTREAM_PRELOAD_REQUEST_PAYLOAD,
XTREAM_PRELOAD_TARGET_CASES,
} from './main.preload.xtream-performance.spec-data';
const MARKER_KEYS = [
'action',
'boundary',
'categoryType',
'contentType',
'ipcCallId',
'itemCount',
'method',
'operationId',
'outcome',
'playlistId',
'schemaVersion',
'sessionId',
'sourceEpochMs',
] as const;
describe('main preload Xtream performance marker contract', () => {
let harness: PreloadPerformanceHarness;
beforeEach(() => {
harness = createPreloadPerformanceHarness();
});
afterEach(() => {
harness.cleanup();
jest.restoreAllMocks();
});
it.each(XTREAM_PRELOAD_TARGET_CASES)(
'marks $method without changing IPC arguments or result identity',
async ({ expectedInvoke, expectedMetadata, invoke, method }) => {
const result = new Proxy({}, { get: () => undefined });
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
await expect(
Promise.resolve(invoke(harness.getApi()))
).resolves.toBe(result);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith(
...expectedInvoke
);
const markers = harness.getXtreamMarkers();
expect(markers).toHaveLength(2);
expect(markers).toEqual([
expect.objectContaining({
...expectedMetadata,
boundary: 'start',
ipcCallId: 1,
method,
outcome: null,
schemaVersion: 1,
}),
expect.objectContaining({
...expectedMetadata,
boundary: 'end',
ipcCallId: 1,
method,
outcome: 'success',
schemaVersion: 1,
}),
]);
expectFixedMarkerShape(markers);
}
);
it('preserves exact rejection and synchronous throw identities', async () => {
const rejection = new Error('provider-error-secret');
const synchronousError = new Error('provider-sync-secret');
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection);
await expect(
harness.getApi().dbGetContent('playlist-1', 'live')
).rejects.toBe(rejection);
expect(
harness.getXtreamMarkers().map(({ outcome }) => outcome)
).toEqual([null, 'error']);
harness.ipcRenderer.invoke.mockImplementationOnce(() => {
throw synchronousError;
});
expect(() => harness.getApi().dbCancelOperation('operation-1')).toThrow(
synchronousError
);
expect(
harness.getXtreamMarkers().map(({ outcome }) => outcome)
).toEqual([null, 'error', null, 'error']);
});
it('preserves a synchronous target return identity', async () => {
const result = { synchronous: true };
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockReturnValueOnce(result);
const actual = harness
.getApi()
.dbCancelOperation('operation-1') as unknown;
expect(actual).toBe(result);
expect(
harness.getXtreamMarkers().map(({ outcome }) => outcome)
).toEqual([null, 'success']);
});
it('keeps M3U and Xtream protocols on their own channels', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
await harness.getApi().refreshPlaylist(createRefreshPayload());
await harness.getApi().xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD);
expect(harness.getMarkers()).toHaveLength(2);
expect(harness.getXtreamMarkers()).toHaveLength(2);
expect(
harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL)
).toEqual(harness.getMarkers());
expect(
harness.getSentPayloads(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL)
).toEqual(harness.getXtreamMarkers());
expect(
harness
.getMarkers()
.every(({ method }) => method === 'refreshPlaylist')
).toBe(true);
expect(
harness
.getXtreamMarkers()
.every(({ method }) => method === 'xtreamRequest')
).toBe(true);
});
});
function expectFixedMarkerShape(
markers: XtreamPreloadPerformanceMarker[]
): void {
for (const [index, marker] of markers.entries()) {
expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort());
expect(Number.isFinite(marker.sourceEpochMs)).toBe(true);
if (index > 0) {
expect(marker.sourceEpochMs).toBeGreaterThanOrEqual(
markers[index - 1].sourceEpochMs
);
}
}
const serialized = JSON.stringify(markers);
for (const forbidden of [
'provider-password-secret',
'provider-user-secret',
'provider-url-secret',
'request-secret',
'secret-title',
'secret-stream-title',
'secret-favorite-title',
'secret-recent-title',
'provider-search-secret',
'global-search-secret',
'secret-source',
'provider-error-secret',
]) {
expect(serialized).not.toContain(forbidden);
}
}
@@ -0,0 +1,180 @@
import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace';
import {
createPreloadPerformanceHarness,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
describe('main preload Xtream performance marker gates', () => {
let harness: PreloadPerformanceHarness;
beforeEach(() => {
harness = createPreloadPerformanceHarness();
});
afterEach(() => {
harness.cleanup();
jest.restoreAllMocks();
});
it.each([
undefined,
'',
'0',
'false',
'true',
' TRUE ',
'yes',
'On',
' 1 ',
])(
'requires the exact performance flag value 1, not %p',
async (flagValue) => {
await harness.load(
flagValue === undefined
? {}
: {
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue,
}
);
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(harness.getXtreamMarkers()).toEqual([]);
}
);
it('enables the Xtream marker only for the exact value 1', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(harness.getXtreamMarkers()).toHaveLength(2);
});
it('does no Xtream clock or metadata work while its exact gate is disabled', async () => {
const clock = jest
.spyOn(globalThis.performance, 'now')
.mockImplementation(() => {
throw new Error('clock must stay idle');
});
const payload = new Proxy(
{},
{
get: () => {
throw new Error('metadata must stay opaque');
},
}
);
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: 'true',
});
await expect(
harness.getApi().xtreamRequest(payload as never)
).resolves.toEqual({ success: true });
expect(clock).not.toHaveBeenCalled();
expect(harness.getXtreamMarkers()).toEqual([]);
expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe(
'XTREAM_REQUEST'
);
expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(payload);
});
it('does not inspect or mark non-target methods in performance-only mode', async () => {
const settings = new Proxy(
{},
{
get: () => {
throw new Error('non-target data must stay opaque');
},
}
);
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
await harness.getApi().updateSettings(settings);
expect(harness.getXtreamMarkers()).toEqual([]);
expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe(
'SETTINGS_UPDATE'
);
expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(settings);
});
it('keeps trace-only Xtream calls on the existing debug channel', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(
harness
.getSentPayloads<Record<string, unknown>>(
DEBUG_TRACE_EVENT_CHANNEL
)
.map(({ method, phase }) => ({ method, phase }))
).toEqual([
{ method: 'dbGetContent', phase: 'start' },
{ method: 'dbGetContent', phase: 'success' },
]);
expect(harness.getXtreamMarkers()).toEqual([]);
});
it('keeps both debug tracing and Xtream markers when both exact gates are enabled', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(harness.getSentPayloads(DEBUG_TRACE_EVENT_CHANNEL)).toHaveLength(
2
);
expect(harness.getXtreamMarkers()).toHaveLength(2);
});
it('keeps the bridge fail-neutral when marker delivery fails', async () => {
const result = { success: true };
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
harness.ipcRenderer.send.mockImplementation((channel: string) => {
if (channel === 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER') {
throw new Error('marker sink failure');
}
});
await expect(
harness.getApi().dbGetContent('playlist-1', 'live')
).resolves.toBe(result);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1);
});
it('keeps the bridge fail-neutral when the high-resolution clock fails', async () => {
const result = { success: true };
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
jest.spyOn(globalThis.performance, 'now').mockImplementation(() => {
throw new Error('clock failure');
});
await expect(
harness.getApi().dbGetContent('playlist-1', 'live')
).resolves.toBe(result);
expect(
harness
.getXtreamMarkers()
.every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs))
).toBe(true);
});
});
@@ -0,0 +1,230 @@
import {
XtreamCodeActions,
type XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import type { ExposedElectronApi } from './main.preload.performance.test-helpers';
type ExpectedMetadata = Pick<
XtreamPreloadPerformanceMarker,
| 'action'
| 'categoryType'
| 'contentType'
| 'itemCount'
| 'operationId'
| 'playlistId'
| 'sessionId'
>;
const EMPTY_METADATA: ExpectedMetadata = {
action: null,
categoryType: null,
contentType: null,
itemCount: null,
operationId: null,
playlistId: null,
sessionId: null,
};
export const XTREAM_PRELOAD_REQUEST_PAYLOAD = {
params: {
action: XtreamCodeActions.GetLiveStreams,
password: 'provider-password-secret',
username: 'provider-user-secret',
},
requestId: 'request-secret',
sessionId: 'session-1',
suppressErrorLog: true,
url: 'http://provider-url-secret',
};
const categoryItems = [
{
category_id: '1',
category_name: 'secret-title',
parent_id: 0,
},
];
const streamItems = [{ name: 'secret-stream-title', stream_id: 1 }];
const favorites = [{ title: 'secret-favorite-title' }];
const recent = [{ title: 'secret-recent-title' }, {}];
const globalSources = [{ playlistId: 'secret-source' }];
const globalOptions = { limit: 25 };
interface TargetCase {
expectedInvoke: readonly unknown[];
expectedMetadata: ExpectedMetadata;
invoke: (api: ExposedElectronApi) => unknown;
method: string;
}
export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [
{
expectedInvoke: ['XTREAM_REQUEST', XTREAM_PRELOAD_REQUEST_PAYLOAD],
expectedMetadata: {
...EMPTY_METADATA,
action: XtreamCodeActions.GetLiveStreams,
sessionId: 'session-1',
},
invoke: (api) => api.xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD),
method: 'xtreamRequest',
},
{
expectedInvoke: ['XTREAM_CANCEL_SESSION', 'session-2'],
expectedMetadata: { ...EMPTY_METADATA, sessionId: 'session-2' },
invoke: (api) => api.xtreamCancelSession('session-2'),
method: 'xtreamCancelSession',
},
{
expectedInvoke: ['DB_GET_CATEGORIES', 'playlist-1', 'movies'],
expectedMetadata: {
...EMPTY_METADATA,
categoryType: 'movies',
playlistId: 'playlist-1',
},
invoke: (api) => api.dbGetCategories('playlist-1', 'movies'),
method: 'dbGetCategories',
},
{
expectedInvoke: [
'DB_SAVE_CATEGORIES',
'playlist-2',
categoryItems,
'live',
[91],
],
expectedMetadata: {
...EMPTY_METADATA,
categoryType: 'live',
itemCount: 1,
playlistId: 'playlist-2',
},
invoke: (api) =>
api.dbSaveCategories('playlist-2', categoryItems, 'live', [91]),
method: 'dbSaveCategories',
},
{
expectedInvoke: ['DB_GET_CONTENT', 'playlist-3', 'series'],
expectedMetadata: {
...EMPTY_METADATA,
contentType: 'series',
playlistId: 'playlist-3',
},
invoke: (api) => api.dbGetContent('playlist-3', 'series'),
method: 'dbGetContent',
},
{
expectedInvoke: [
'DB_SAVE_CONTENT',
'playlist-4',
streamItems,
'movie',
'operation-4',
],
expectedMetadata: {
...EMPTY_METADATA,
contentType: 'movie',
itemCount: 1,
operationId: 'operation-4',
playlistId: 'playlist-4',
},
invoke: (api) =>
api.dbSaveContent(
'playlist-4',
streamItems as never,
'movie',
'operation-4'
),
method: 'dbSaveContent',
},
{
expectedInvoke: [
'DB_DELETE_XTREAM_CONTENT',
'playlist-5',
'operation-5',
],
expectedMetadata: {
...EMPTY_METADATA,
operationId: 'operation-5',
playlistId: 'playlist-5',
},
invoke: (api) => api.dbDeleteXtreamContent('playlist-5', 'operation-5'),
method: 'dbDeleteXtreamContent',
},
{
expectedInvoke: ['DB_DELETE_PLAYLIST', 'playlist-6', 'operation-6'],
expectedMetadata: {
...EMPTY_METADATA,
operationId: 'operation-6',
playlistId: 'playlist-6',
},
invoke: (api) => api.dbDeletePlaylist('playlist-6', 'operation-6'),
method: 'dbDeletePlaylist',
},
{
expectedInvoke: [
'DB_RESTORE_XTREAM_USER_DATA',
'playlist-7',
favorites,
recent,
'operation-7',
],
expectedMetadata: {
...EMPTY_METADATA,
itemCount: 3,
operationId: 'operation-7',
playlistId: 'playlist-7',
},
invoke: (api) =>
api.dbRestoreXtreamUserData(
'playlist-7',
favorites as never,
recent as never,
'operation-7'
),
method: 'dbRestoreXtreamUserData',
},
{
expectedInvoke: [
'DB_SEARCH_CONTENT',
'playlist-8',
'provider-search-secret',
['live'],
true,
],
expectedMetadata: { ...EMPTY_METADATA, playlistId: 'playlist-8' },
invoke: (api) =>
api.dbSearchContent(
'playlist-8',
'provider-search-secret',
['live'],
true
),
method: 'dbSearchContent',
},
{
expectedInvoke: [
'DB_GLOBAL_SEARCH',
'global-search-secret',
['live'],
true,
globalSources,
globalOptions,
],
expectedMetadata: EMPTY_METADATA,
invoke: (api) =>
api.dbGlobalSearch(
'global-search-secret',
['live'],
true,
globalSources as never,
globalOptions
),
method: 'dbGlobalSearch',
},
{
expectedInvoke: ['DB_CANCEL_OPERATION', 'operation-9'],
expectedMetadata: { ...EMPTY_METADATA, operationId: 'operation-9' },
invoke: (api) => api.dbCancelOperation('operation-9'),
method: 'dbCancelOperation',
},
];
@@ -0,0 +1,127 @@
import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces';
import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture';
describe('Xtream preload performance capture runtime', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('does zero target metadata or clock work when disabled', () => {
const clock = jest.fn(() => {
throw new Error('clock must not run');
});
const sendMarker = jest.fn();
const args = new Proxy([{}], {
get: () => {
throw new Error('metadata must not run');
},
});
const capture = createXtreamPreloadPerformanceCapture(
false,
sendMarker,
clock
);
expect(capture.start('xtreamRequest', args)).toBeNull();
expect(capture.start('updateSettings', args)).toBeNull();
capture.success(null);
capture.error(null);
expect(clock).not.toHaveBeenCalled();
expect(sendMarker).not.toHaveBeenCalled();
});
it('keeps calls independent with positive ids and nondecreasing finite timestamps', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const clockValues = [10.5, 9.5, Number.NaN, 11.25];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => clockValues.shift() as number
);
const first = capture.start('dbGetContent', ['playlist-1', 'live']);
const second = capture.start('dbGetContent', ['playlist-2', 'movie']);
capture.success(second);
capture.error(first);
expect(
markers.map(({ boundary, ipcCallId, outcome }) => ({
boundary,
ipcCallId,
outcome,
}))
).toEqual([
{ boundary: 'start', ipcCallId: 1, outcome: null },
{ boundary: 'start', ipcCallId: 2, outcome: null },
{ boundary: 'end', ipcCallId: 2, outcome: 'success' },
{ boundary: 'end', ipcCallId: 1, outcome: 'error' },
]);
expect(
markers.every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs))
).toBe(true);
expect(markers.map(({ sourceEpochMs }) => sourceEpochMs)).toEqual([
10.5, 10.5, 10.5, 11.25,
]);
});
it.each([
['non-finite', () => Number.NaN],
[
'throwing',
() => {
throw new Error('clock failure');
},
],
])(
'uses a positive finite epoch fallback for a first %s clock sample',
(_description, clock) => {
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
clock
);
capture.start('dbGetContent', ['playlist-1', 'live']);
expect(markers[0].sourceEpochMs).toBe(1_700_000_000_000);
expect(markers[0].sourceEpochMs).toBeGreaterThan(0);
expect(Number.isFinite(markers[0].sourceEpochMs)).toBe(true);
}
);
it('swallows sink and clock failures without inspecting result or error values', () => {
const result = new Proxy(
{},
{
get: () => {
throw new Error('result must stay opaque');
},
}
);
const error = new Proxy(
{},
{
get: () => {
throw new Error('error must stay opaque');
},
}
);
const capture = createXtreamPreloadPerformanceCapture(
true,
() => {
throw new Error('sink failure');
},
() => {
throw new Error('clock failure');
}
);
const call = capture.start('dbGetContent', ['playlist-1', 'live']);
expect(() => capture.success(call, result)).not.toThrow();
expect(() => capture.error(call, error)).not.toThrow();
});
});
@@ -0,0 +1,230 @@
import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces';
import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture';
describe('Xtream preload performance capture safety', () => {
it.each([
['', null],
[' ', null],
['-leading-dash', null],
['contains space', null],
['contains/slash', null],
['x'.repeat(129), null],
[' playlist.ok:1 ', null],
['playlist.ok:1', 'playlist.ok:1'],
['A', 'A'],
])('normalizes identifier %p to %p', (identifier, expected) => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
capture.start('dbDeletePlaylist', [identifier, identifier]);
expect(markers[0]).toMatchObject({
operationId: expected,
playlistId: expected,
});
});
it.each([
['xtreamRequest', [{ params: { action: 'provider-action-secret' } }]],
['dbGetCategories', ['playlist-1', 'vod']],
['dbGetCategories', ['playlist-1', 'LIVE']],
['dbGetContent', ['playlist-1', 'movies']],
['dbGetContent', ['playlist-1', 'vod']],
] as const)(
'turns invalid action or type metadata into null for %s',
(method, args) => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
capture.start(method, args);
expect(markers[0]).toMatchObject({
action: null,
categoryType: null,
contentType: null,
});
expect(JSON.stringify(markers)).not.toContain(
'provider-action-secret'
);
}
);
it('never enumerates provider payloads, reads array entries, or records forbidden strings', () => {
const forbidden = [
'provider-url-secret',
'provider-user-secret',
'provider-password-secret',
'provider-title-secret',
'provider-search-secret',
'provider-result-secret',
'provider-error-secret',
];
const params = Object.defineProperties(
{ action: 'get_series' },
{
password: {
enumerable: true,
get: () => {
throw new Error(forbidden[2]);
},
},
username: {
enumerable: true,
get: () => {
throw new Error(forbidden[1]);
},
},
}
);
const payload = {
params,
sessionId: 'session-1',
title: forbidden[3],
url: forbidden[0],
};
const entryRead = jest.fn(() => {
throw new Error('array entries must stay opaque');
});
const streams = new Array<unknown>(1);
Object.defineProperty(streams, 0, { get: entryRead });
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
const request = capture.start('xtreamRequest', [payload]);
capture.success(request);
const save = capture.start('dbSaveContent', [
'playlist-1',
streams,
'live',
'operation-1',
]);
capture.error(save);
capture.start('dbSearchContent', [
'playlist-1',
forbidden[4],
['live'],
]);
capture.start('dbGlobalSearch', [
forbidden[4],
['live'],
false,
[{ title: forbidden[3] }],
]);
expect(entryRead).not.toHaveBeenCalled();
const serialized = JSON.stringify(markers);
for (const value of forbidden) {
expect(serialized).not.toContain(value);
}
});
it('swallows hostile argument, payload, params, and array length traps', () => {
const throwingProxy = new Proxy(
{},
{
get: () => {
throw new Error('hostile getter');
},
}
);
const hostileArray = new Proxy([], {
get: () => {
throw new Error('hostile array length');
},
});
const hostileArgs = new Proxy([throwingProxy], {
get: () => {
throw new Error('hostile args');
},
});
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
expect(() => capture.start('xtreamRequest', hostileArgs)).not.toThrow();
expect(() =>
capture.start('dbSaveCategories', [
'playlist-1',
hostileArray,
'live',
])
).not.toThrow();
expect(markers).toHaveLength(2);
expect(markers[0]).toMatchObject({
action: null,
sessionId: null,
});
expect(markers[1].itemCount).toBeNull();
});
it('swallows revoked array proxies without changing the call', () => {
const { proxy, revoke } = Proxy.revocable([], {});
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
revoke();
expect(() =>
capture.start('dbSaveContent', [
'playlist-1',
proxy,
'live',
'operation-1',
])
).not.toThrow();
expect(markers[0].itemCount).toBeNull();
});
it('accepts only bounded array lengths and safely sums restore counts', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
capture.start('dbSaveCategories', [
'playlist-1',
new Array(1_000_001),
'live',
]);
capture.start('dbRestoreXtreamUserData', [
'playlist-1',
new Array(600_000),
new Array(400_000),
'operation-1',
]);
capture.start('dbRestoreXtreamUserData', [
'playlist-1',
new Array(600_000),
new Array(400_001),
'operation-1',
]);
expect(markers.map(({ itemCount }) => itemCount)).toEqual([
null,
1_000_000,
null,
]);
});
});
@@ -0,0 +1,219 @@
import {
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
type XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import { XtreamCodeActions } from '@iptvnator/shared/interfaces';
import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture';
const MARKER_KEYS = [
'action',
'boundary',
'categoryType',
'contentType',
'ipcCallId',
'itemCount',
'method',
'operationId',
'outcome',
'playlistId',
'schemaVersion',
'sessionId',
'sourceEpochMs',
] as const;
const EMPTY_METADATA = {
action: null,
categoryType: null,
contentType: null,
itemCount: null,
operationId: null,
playlistId: null,
sessionId: null,
} as const;
describe('Xtream preload performance capture contract', () => {
it.each([
{
args: [
{
params: { action: XtreamCodeActions.GetLiveStreams },
sessionId: 'session-1',
},
],
expected: {
...EMPTY_METADATA,
action: XtreamCodeActions.GetLiveStreams,
sessionId: 'session-1',
},
method: 'xtreamRequest',
},
{
args: ['session-2'],
expected: { ...EMPTY_METADATA, sessionId: 'session-2' },
method: 'xtreamCancelSession',
},
{
args: ['playlist-1', 'movies'],
expected: {
...EMPTY_METADATA,
categoryType: 'movies',
playlistId: 'playlist-1',
},
method: 'dbGetCategories',
},
{
args: ['playlist-2', [{}, {}, {}], 'live', [99]],
expected: {
...EMPTY_METADATA,
categoryType: 'live',
itemCount: 3,
playlistId: 'playlist-2',
},
method: 'dbSaveCategories',
},
{
args: ['playlist-3', 'series'],
expected: {
...EMPTY_METADATA,
contentType: 'series',
playlistId: 'playlist-3',
},
method: 'dbGetContent',
},
{
args: ['playlist-4', [{}, {}], 'movie', 'operation-4'],
expected: {
...EMPTY_METADATA,
contentType: 'movie',
itemCount: 2,
operationId: 'operation-4',
playlistId: 'playlist-4',
},
method: 'dbSaveContent',
},
{
args: ['playlist-5', 'operation-5'],
expected: {
...EMPTY_METADATA,
operationId: 'operation-5',
playlistId: 'playlist-5',
},
method: 'dbDeleteXtreamContent',
},
{
args: ['playlist-6', 'operation-6'],
expected: {
...EMPTY_METADATA,
operationId: 'operation-6',
playlistId: 'playlist-6',
},
method: 'dbDeletePlaylist',
},
{
args: ['playlist-7', [{}, {}], [{}], 'operation-7'],
expected: {
...EMPTY_METADATA,
itemCount: 3,
operationId: 'operation-7',
playlistId: 'playlist-7',
},
method: 'dbRestoreXtreamUserData',
},
{
args: ['playlist-8', 'provider search', ['live'], true],
expected: {
...EMPTY_METADATA,
playlistId: 'playlist-8',
},
method: 'dbSearchContent',
},
{
args: ['provider search', ['live'], true, [{ id: 'source-1' }]],
expected: EMPTY_METADATA,
method: 'dbGlobalSearch',
},
{
args: ['operation-9'],
expected: { ...EMPTY_METADATA, operationId: 'operation-9' },
method: 'dbCancelOperation',
},
] as const)(
'extracts only safe positional metadata for $method',
({ args, expected, method }) => {
const sendMarker = jest.fn();
const capture = createXtreamPreloadPerformanceCapture(
true,
sendMarker,
() => 1_000.25
);
const call = capture.start(method, args);
expect(call).not.toBeNull();
expect(sendMarker).toHaveBeenCalledWith(
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
{
...expected,
boundary: 'start',
ipcCallId: 1,
method,
outcome: null,
schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
sourceEpochMs: 1_000.25,
}
);
}
);
it('emits fixed-key start and end markers with matching call metadata', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 2_000.5
);
const successCall = capture.start('dbGetContent', [
'playlist-1',
'live',
]);
capture.success(successCall);
const errorCall = capture.start('dbCancelOperation', ['operation-2']);
capture.error(errorCall);
expect(
markers.map(({ boundary, ipcCallId, outcome }) => ({
boundary,
ipcCallId,
outcome,
}))
).toEqual([
{ boundary: 'start', ipcCallId: 1, outcome: null },
{ boundary: 'end', ipcCallId: 1, outcome: 'success' },
{ boundary: 'start', ipcCallId: 2, outcome: null },
{ boundary: 'end', ipcCallId: 2, outcome: 'error' },
]);
for (const marker of markers) {
expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort());
}
});
it('allows every current Xtream action including the legacy alias', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 3_000
);
const actions = Object.values(XtreamCodeActions);
for (const action of actions) {
capture.start('xtreamRequest', [{ params: { action } }]);
}
expect(markers.map(({ action }) => action)).toEqual(actions);
expect(actions).toContain('get_simple_data_table');
expect(actions).toContain('get_simple_date_table');
});
});
@@ -0,0 +1,191 @@
import {
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
XTREAM_PRELOAD_PERFORMANCE_METHOD,
XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
type XtreamPreloadPerformanceBoundary,
type XtreamPreloadPerformanceMarker,
type XtreamPreloadPerformanceMethod,
type XtreamPreloadPerformanceOutcome,
} from '@iptvnator/shared/interfaces';
import {
extractXtreamPreloadPerformanceMetadata,
type XtreamPreloadMarkerMetadata,
} from './xtream-preload-performance-metadata';
export interface XtreamPreloadPerformanceCallContext extends XtreamPreloadMarkerMetadata {
ipcCallId: number;
method: XtreamPreloadPerformanceMethod;
}
export interface XtreamPreloadPerformanceCapture {
error: (
call: XtreamPreloadPerformanceCallContext | null,
ignoredError?: unknown
) => void;
start: (
methodName: string,
args: readonly unknown[]
) => XtreamPreloadPerformanceCallContext | null;
success: (
call: XtreamPreloadPerformanceCallContext | null,
ignoredResult?: unknown
) => void;
}
type MarkerSender = (
channel: typeof XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
marker: XtreamPreloadPerformanceMarker
) => void;
type EpochReader = () => number;
export function toXtreamPreloadPerformanceTargetMethod(
methodName: string
): XtreamPreloadPerformanceMethod | null {
switch (methodName) {
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION:
return methodName;
default:
return null;
}
}
export function isXtreamPreloadPerformanceCaptureEnabled(): boolean {
return process.env['IPTVNATOR_PERF_CAPTURE'] === '1';
}
function readHighResolutionEpochMs(): number {
try {
const timeOrigin = globalThis.performance?.timeOrigin;
const monotonicNow = globalThis.performance?.now?.();
const highResolutionEpoch =
typeof timeOrigin === 'number' && typeof monotonicNow === 'number'
? timeOrigin + monotonicNow
: Number.NaN;
if (Number.isFinite(highResolutionEpoch)) {
return highResolutionEpoch;
}
} catch {
// Fall through to the wall clock.
}
try {
return Date.now();
} catch {
return Number.NaN;
}
}
function readPositiveWallClockEpochMs(): number {
try {
const wallClockEpochMs = Date.now();
return Number.isFinite(wallClockEpochMs) && wallClockEpochMs > 0
? wallClockEpochMs
: 1;
} catch {
return 1;
}
}
function readMonotonicEpochMs(
readEpochMs: EpochReader,
lastSourceEpochMs: number
): number {
let clockValue = Number.NaN;
try {
clockValue = readEpochMs();
} catch {
// Use the last valid sample or the wall clock below.
}
const fallbackEpochMs =
lastSourceEpochMs > 0
? lastSourceEpochMs
: readPositiveWallClockEpochMs();
const sourceEpochMs =
Number.isFinite(clockValue) && clockValue > 0
? clockValue
: fallbackEpochMs;
return Math.max(sourceEpochMs, lastSourceEpochMs);
}
export function createXtreamPreloadPerformanceCapture(
enabled: boolean,
sendMarker: MarkerSender,
readEpochMs: EpochReader = readHighResolutionEpochMs
): XtreamPreloadPerformanceCapture {
let lastSourceEpochMs = 0;
let nextIpcCallId = 1;
function emit(
call: XtreamPreloadPerformanceCallContext,
boundary: XtreamPreloadPerformanceBoundary,
outcome: XtreamPreloadPerformanceOutcome
): void {
const sourceEpochMs = readMonotonicEpochMs(
readEpochMs,
lastSourceEpochMs
);
lastSourceEpochMs = sourceEpochMs;
try {
sendMarker(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, {
schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
sourceEpochMs,
ipcCallId: call.ipcCallId,
method: call.method,
boundary,
outcome,
playlistId: call.playlistId,
operationId: call.operationId,
sessionId: call.sessionId,
action: call.action,
categoryType: call.categoryType,
contentType: call.contentType,
itemCount: call.itemCount,
});
} catch {
// Performance instrumentation must never affect the bridge call.
}
}
return {
error: (call) => {
if (call) {
emit(call, 'end', 'error');
}
},
start: (methodName, args) => {
if (!enabled) {
return null;
}
const method = toXtreamPreloadPerformanceTargetMethod(methodName);
if (!method || nextIpcCallId > Number.MAX_SAFE_INTEGER) {
return null;
}
const call = {
ipcCallId: nextIpcCallId,
method,
...extractXtreamPreloadPerformanceMetadata(method, args),
};
nextIpcCallId += 1;
emit(call, 'start', null);
return call;
},
success: (call) => {
if (call) {
emit(call, 'end', 'success');
}
},
};
}
@@ -0,0 +1,190 @@
import {
XTREAM_PRELOAD_PERFORMANCE_METHOD,
XtreamCodeActions,
type XtreamPreloadPerformanceCategoryType,
type XtreamPreloadPerformanceContentType,
type XtreamPreloadPerformanceMarker,
type XtreamPreloadPerformanceMethod,
} from '@iptvnator/shared/interfaces';
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
const MAX_IDENTIFIER_LENGTH = 128;
const MAX_ITEM_COUNT = 1_000_000;
const ALLOWED_ACTIONS = new Set<string>(Object.values(XtreamCodeActions));
const ALLOWED_CATEGORY_TYPES = new Set(['live', 'movies', 'series']);
const ALLOWED_CONTENT_TYPES = new Set(['live', 'movie', 'series']);
export type XtreamPreloadMarkerMetadata = Pick<
XtreamPreloadPerformanceMarker,
| 'action'
| 'categoryType'
| 'contentType'
| 'itemCount'
| 'operationId'
| 'playlistId'
| 'sessionId'
>;
function readValue(value: unknown, property: PropertyKey): unknown {
if (
(typeof value !== 'object' || value === null) &&
typeof value !== 'function'
) {
return undefined;
}
try {
return Reflect.get(value, property);
} catch {
return undefined;
}
}
function readArgument(args: readonly unknown[], index: number): unknown {
return readValue(args, index);
}
function normalizeIdentifier(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
return value.length >= 1 &&
value.length <= MAX_IDENTIFIER_LENGTH &&
IDENTIFIER_PATTERN.test(value)
? value
: null;
}
function normalizeAction(value: unknown): XtreamCodeActions | null {
return typeof value === 'string' && ALLOWED_ACTIONS.has(value)
? (value as XtreamCodeActions)
: null;
}
function normalizeCategoryType(
value: unknown
): XtreamPreloadPerformanceCategoryType | null {
return typeof value === 'string' && ALLOWED_CATEGORY_TYPES.has(value)
? (value as XtreamPreloadPerformanceCategoryType)
: null;
}
function normalizeContentType(
value: unknown
): XtreamPreloadPerformanceContentType | null {
return typeof value === 'string' && ALLOWED_CONTENT_TYPES.has(value)
? (value as XtreamPreloadPerformanceContentType)
: null;
}
function readArrayCount(value: unknown): number | null {
let isArray = false;
try {
isArray = Array.isArray(value);
} catch {
return null;
}
if (!isArray) {
return null;
}
const length = readValue(value, 'length');
return typeof length === 'number' &&
Number.isSafeInteger(length) &&
length >= 0 &&
length <= MAX_ITEM_COUNT
? length
: null;
}
function sumArrayCounts(first: unknown, second: unknown): number | null {
const firstCount = readArrayCount(first);
const secondCount = readArrayCount(second);
if (firstCount === null || secondCount === null) {
return null;
}
const total = firstCount + secondCount;
return Number.isSafeInteger(total) && total <= MAX_ITEM_COUNT
? total
: null;
}
function emptyMetadata(): XtreamPreloadMarkerMetadata {
return {
action: null,
categoryType: null,
contentType: null,
itemCount: null,
operationId: null,
playlistId: null,
sessionId: null,
};
}
export function extractXtreamPreloadPerformanceMetadata(
method: XtreamPreloadPerformanceMethod,
args: readonly unknown[]
): XtreamPreloadMarkerMetadata {
const metadata = emptyMetadata();
const first = readArgument(args, 0);
switch (method) {
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST: {
const params = readValue(first, 'params');
metadata.sessionId = normalizeIdentifier(
readValue(first, 'sessionId')
);
metadata.action = normalizeAction(readValue(params, 'action'));
return metadata;
}
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION:
metadata.sessionId = normalizeIdentifier(first);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES:
metadata.playlistId = normalizeIdentifier(first);
metadata.categoryType = normalizeCategoryType(
readArgument(args, 1)
);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES:
metadata.playlistId = normalizeIdentifier(first);
metadata.itemCount = readArrayCount(readArgument(args, 1));
metadata.categoryType = normalizeCategoryType(
readArgument(args, 2)
);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT:
metadata.playlistId = normalizeIdentifier(first);
metadata.contentType = normalizeContentType(readArgument(args, 1));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT:
metadata.playlistId = normalizeIdentifier(first);
metadata.itemCount = readArrayCount(readArgument(args, 1));
metadata.contentType = normalizeContentType(readArgument(args, 2));
metadata.operationId = normalizeIdentifier(readArgument(args, 3));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST:
metadata.playlistId = normalizeIdentifier(first);
metadata.operationId = normalizeIdentifier(readArgument(args, 1));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA:
metadata.playlistId = normalizeIdentifier(first);
metadata.itemCount = sumArrayCounts(
readArgument(args, 1),
readArgument(args, 2)
);
metadata.operationId = normalizeIdentifier(readArgument(args, 3));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT:
metadata.playlistId = normalizeIdentifier(first);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION:
metadata.operationId = normalizeIdentifier(first);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH:
return metadata;
}
}
+53 -4
View File
@@ -50,6 +50,8 @@ import {
} from './app';
import App from './app';
import { app as electronApp, BrowserWindow, screen } from 'electron';
import * as path from 'path';
import { pathToFileURL } from 'url';
import { store } from './services/store.service';
type MockMainWindow = {
@@ -57,6 +59,7 @@ type MockMainWindow = {
getNormalBounds: jest.Mock<object, []>;
// Read once by attachWindowStateEvents to seed the tracked window
// state; only reached off macOS, where the custom controls exist.
isDestroyed: jest.Mock<boolean, []>;
isFullScreen: jest.Mock<boolean, []>;
isMaximized: jest.Mock<boolean, []>;
loadFile: jest.Mock<Promise<void>, [string]>;
@@ -82,6 +85,7 @@ function createMockMainWindow(): MockMainWindow {
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
setMenu: jest.fn<void, [unknown]>(),
show: jest.fn<void, []>(),
webContents: {
@@ -95,6 +99,7 @@ function createMockMainWindow(): MockMainWindow {
type AppInternals = {
loadedMainWindow: MockMainWindow | null;
mainWindow: MockMainWindow | null;
mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>;
mainWindowLoadPromise: Promise<void> | null;
onReady: () => void;
rendererLoadingEnabled: boolean;
@@ -118,6 +123,7 @@ describe('Electron app security helpers', () => {
appInternals.mainWindow = null;
appInternals.mainWindowLoadPromise = null;
appInternals.rendererLoadingEnabled = false;
appInternals.mainWindowListeners.length = 0;
(electronApp as unknown as { isPackaged: boolean }).isPackaged = false;
(screen.getPrimaryDisplay as jest.Mock).mockReturnValue({
workAreaSize: { height: 720, width: 1280 },
@@ -214,18 +220,23 @@ describe('Electron app security helpers', () => {
});
it('allows only the packaged renderer file in packaged navigation', () => {
// Resolve to host-native absolute paths: POSIX-style file URLs such
// as file:///tmp/... are not valid win32 file URLs (no drive letter).
const packagedIndexPath = path.resolve('/tmp/iptvnator/index.html');
const otherIndexPath = path.resolve('/tmp/other/index.html');
expect(
isTrustedRendererNavigationUrl(
'file:///tmp/iptvnator/index.html',
pathToFileURL(packagedIndexPath).href,
false,
'/tmp/iptvnator/index.html'
packagedIndexPath
)
).toBe(true);
expect(
isTrustedRendererNavigationUrl(
'file:///tmp/other/index.html',
pathToFileURL(otherIndexPath).href,
false,
'/tmp/iptvnator/index.html'
packagedIndexPath
)
).toBe(false);
expect(
@@ -315,6 +326,44 @@ describe('Electron app security helpers', () => {
expect(mainWindow.loadFile).not.toHaveBeenCalled();
});
it('runs a main-window listener immediately when a window already exists', () => {
const mainWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
(electronApp.isReady as jest.Mock).mockReturnValue(true);
App.main(electronApp, BrowserWindow);
const listener = jest.fn();
App.onMainWindowCreated(
listener as unknown as (mainWindow: Electron.BrowserWindow) => void
);
expect(listener).toHaveBeenCalledWith(mainWindow);
});
// macOS keeps the process alive without windows, so anything caching the
// window (download-broadcast) must be handed the rebuilt one.
it('re-runs main-window listeners when the window is rebuilt', () => {
const firstWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(firstWindow);
(electronApp.isReady as jest.Mock).mockReturnValue(true);
App.main(electronApp, BrowserWindow);
const listener = jest.fn();
App.onMainWindowCreated(
listener as unknown as (mainWindow: Electron.BrowserWindow) => void
);
listener.mockClear();
// Simulate the macOS 'closed' handler clearing the reference.
getAppInternals().mainWindow = null;
const secondWindow = createMockMainWindow();
(BrowserWindow as unknown as jest.Mock).mockReturnValue(secondWindow);
App.ensureMainWindow();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith(secondWindow);
});
it('clears only service worker registrations and cache storage', async () => {
await clearElectronServiceWorkerStorage();
+50 -3
View File
@@ -216,6 +216,9 @@ export default class App {
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
static mainWindow: Electron.BrowserWindow | null = null;
private static readonly mainWindowListeners: Array<
(mainWindow: Electron.BrowserWindow) => void
> = [];
static application: Electron.App;
static BrowserWindow;
private static loadedMainWindow: Electron.BrowserWindow | null = null;
@@ -268,9 +271,46 @@ export default class App {
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
/**
* Registers a listener that needs the current main window, and re-runs it
* whenever a new one is created.
*
* The main window is not created once per process: on macOS the window can
* be closed and rebuilt (dock `activate`, or a second launch handed over by
* the single-instance guard) while the process lives on. Anything that
* caches the window — `download-broadcast`'s module-level reference, for
* one — would otherwise keep pointing at a destroyed window and silently
* stop delivering to the renderer. Fires immediately when a window already
* exists, so callers registering after startup do not miss the first one.
*/
static onMainWindowCreated(
listener: (mainWindow: Electron.BrowserWindow) => void
): void {
App.mainWindowListeners.push(listener);
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
listener(App.mainWindow);
}
}
private static notifyMainWindowCreated(
mainWindow: Electron.BrowserWindow
): void {
for (const listener of App.mainWindowListeners) {
listener(mainWindow);
}
}
/**
* Brings the app back to a windowed state, re-creating the main window if
* it is gone.
*
* On macOS closing the last window deliberately keeps the process alive
* (`onWindowAllClosed`), so this is the recovery path for both the dock
* `activate` event and a second launch that the single-instance guard
* hands over to this process.
*/
static ensureMainWindow() {
if (App.mainWindow === null) {
App.onReady();
}
@@ -279,6 +319,12 @@ export default class App {
}
}
private static onActivate() {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
App.ensureMainWindow();
}
private static handleRendererNavigation(
event: Electron.Event,
url: string
@@ -383,6 +429,7 @@ export default class App {
App.mainWindow.setMenu(null);
attachWindowTrace(App.mainWindow);
App.attachWindowStateEvents(App.mainWindow);
App.notifyMainWindowCreated(App.mainWindow);
if (!savedWindowBounds) {
App.mainWindow.center();
}
@@ -0,0 +1,110 @@
import type { AppDatabase } from '../database.types';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import { getCategories, saveCategories } from './category.operations';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe('category operation performance phases', () => {
it('keeps the categories read phase open until the query resolves', async () => {
const rows = [{ id: 1, name: 'News' }];
const pending = createDeferred<typeof rows>();
const orderBy = jest.fn(() => pending.promise);
const where = jest.fn(() => ({ orderBy }));
const from = jest.fn(() => ({ where }));
const db = {
select: jest.fn(() => ({ from })),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
const result = getCategories(
db,
'playlist-1',
'live',
recording.capture
);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
},
]);
pending.resolve(rows);
await expect(result).resolves.toBe(rows);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
},
]);
});
it('captures normalization and the existing category write separately', async () => {
const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]);
const select = jest.fn(() => ({
from: jest.fn(() => ({ where: existingWhere })),
}));
const onConflictDoNothing = jest.fn().mockResolvedValue(undefined);
const values = jest.fn(() => ({ onConflictDoNothing }));
const insert = jest.fn(() => ({ values }));
const db = { insert, select } as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await expect(
saveCategories(
db,
'playlist-1',
[
{ category_name: 'News', category_id: '101' },
{ category_name: 'Broken', category_id: 'not-a-number' },
{ category_name: 'Sports', category_id: 102 },
],
'live',
[102],
recording.capture
)
).resolves.toEqual({ success: true });
expect(values).toHaveBeenCalledWith([
expect.objectContaining({ name: 'News', xtreamId: 101 }),
expect.objectContaining({
hidden: true,
name: 'Sports',
xtreamId: 102,
}),
]);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES,
},
{
boundary: 'end',
metadata: { itemCount: 2 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 2 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS,
},
]);
});
});
@@ -1,12 +1,16 @@
import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
type XtreamCategoryInput = {
category_name: string;
category_id: string | number;
};
type XtreamCategoryValue = typeof schema.categories.$inferInsert;
// Category rows cross the DB-worker IPC boundary in the snake_case wire
// shape declared by XCategoryFromDb/XtreamCategoryFromDb. A bare select()
// would leak Drizzle's camelCase property names (xtreamId, playlistId)
@@ -29,6 +33,49 @@ function normalizeXtreamCategoryId(
return Number.isNaN(xtreamId) ? null : xtreamId;
}
function normalizeXtreamCategories(
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): XtreamCategoryValue[] {
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
return categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
}
async function insertXtreamCategories(
db: AppDatabase,
values: XtreamCategoryValue[]
): Promise<void> {
await db
.insert(schema.categories)
.values(values)
.onConflictDoNothing({
target: [
schema.categories.playlistId,
schema.categories.type,
schema.categories.xtreamId,
],
});
}
export async function hasCategories(
db: AppDatabase,
playlistId: string,
@@ -50,13 +97,14 @@ export async function hasCategories(
export async function getCategories(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series'
type: 'live' | 'movies' | 'series',
capturePhase?: DatabaseOperationPerformancePhaseCapture
) {
// Xtream categories are inserted once in provider order and existing
// xtream IDs are preserved, so row id order represents server order.
// If partial category re-inserts are added later, persist a provider
// sort index instead of relying on the insertion id.
return db
const query = db
.select(categoryWireShape)
.from(schema.categories)
.where(
@@ -67,6 +115,14 @@ export async function getCategories(
)
)
.orderBy(schema.categories.id);
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
async () => query,
(rows) => ({ itemCount: rows.length })
)
: query;
}
export async function saveCategories(
@@ -74,7 +130,8 @@ export async function saveCategories(
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
if (!categories || categories.length === 0) {
return { success: true };
@@ -94,39 +151,38 @@ export async function saveCategories(
return { success: true };
}
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const values = categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
const values = capturePhase
? capturePhase.captureSync(
XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES,
() =>
normalizeXtreamCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
),
(result) => ({ itemCount: result.length })
)
: normalizeXtreamCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
);
if (values.length === 0) {
return { success: true };
}
await db
.insert(schema.categories)
.values(values)
.onConflictDoNothing({
target: [
schema.categories.playlistId,
schema.categories.type,
schema.categories.xtreamId,
],
});
if (capturePhase) {
await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS,
() => insertXtreamCategories(db, values),
() => ({ itemCount: values.length })
);
} else {
await insertXtreamCategories(db, values);
}
return { success: true };
}
@@ -0,0 +1,269 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
clearXtreamImportCache,
getContent,
saveContent,
} from './content.operations';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
function createContentDb(streamCount: number, timeline: string[]) {
const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]);
const categoriesWhere = jest
.fn()
.mockResolvedValue([{ id: 42, xtreamId: 201 }]);
const select = jest
.fn()
.mockReturnValueOnce({
from: jest.fn(() => ({
innerJoin: jest.fn(() => ({ where: existingWhere })),
})),
})
.mockReturnValueOnce({
from: jest.fn(() => ({ where: categoriesWhere })),
});
const run = jest.fn();
const values = jest.fn((_chunk: unknown[]) => ({
onConflictDoNothing: jest.fn(() => ({ run })),
}));
const insert = jest.fn(() => ({ values }));
const transactionResults: unknown[] = [];
const transaction = jest.fn((callback: (tx: unknown) => unknown) => {
timeline.push('transaction');
const result = callback({ insert });
transactionResults.push(result);
return result;
});
const streams = Array.from({ length: streamCount }, (_, index) => ({
category_id: '201',
name: `Channel ${index}`,
stream_id: String(index + 1),
}));
return {
db: { select, transaction } as unknown as AppDatabase,
streams,
transaction,
transactionResults,
values,
};
}
describe('content import performance phases', () => {
it('keeps the content read phase open until the ordered query resolves', async () => {
const rows = [{ id: 1, title: 'One' }];
let resolveRows!: (value: typeof rows) => void;
const pending = new Promise<typeof rows>((resolve) => {
resolveRows = resolve;
});
const orderBy = jest.fn(() => pending);
const where = jest.fn(() => ({ orderBy }));
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
const db = {
select: jest.fn(() => ({ from })),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
const result = getContent(db, 'playlist-1', 'live', recording.capture);
expect(recording.events).toHaveLength(1);
resolveRows(rows);
await expect(result).resolves.toBe(rows);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
},
]);
});
it('uses one aggregate write pair around every existing batch commit and progress callback', async () => {
const timeline: string[] = [];
const recording = createRecordingOperationPhaseCapture((event) =>
timeline.push(`${event.phase}:${event.boundary}`)
);
const harness = createContentDb(205, timeline);
const checkpoint = jest.fn(() => {
timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { current?: number }) => {
timeline.push(`progress:${progress.current}`);
});
await expect(
saveContent(
harness.db,
'playlist-1',
harness.streams,
'live',
{ checkpoint, onProgress },
recording.capture
)
).resolves.toEqual({ success: true, count: 205 });
expect(harness.transaction).toHaveBeenCalledTimes(3);
expect(harness.transactionResults).toEqual([
undefined,
undefined,
undefined,
]);
expect(
harness.values.mock.calls.map(([chunk]) => chunk.length)
).toEqual([100, 100, 5]);
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
100, 200, 205,
]);
const writeStart = timeline.indexOf(
'sqlite.content.write-transactions:start'
);
const writeEnd = timeline.indexOf(
'sqlite.content.write-transactions:end'
);
expect(writeStart).toBeGreaterThanOrEqual(0);
expect(writeEnd).toBeGreaterThan(timeline.lastIndexOf('progress:205'));
expect(
timeline
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => entry === 'transaction')
.every(({ index }) => index > writeStart && index < writeEnd)
).toBe(true);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT,
},
{
boundary: 'end',
metadata: { itemCount: 205 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 205 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
},
]);
});
it('closes the aggregate write phase on cancellation without changing the error', async () => {
const timeline: string[] = [];
const recording = createRecordingOperationPhaseCapture();
const harness = createContentDb(101, timeline);
const cancellation = new Error('cancelled');
cancellation.name = 'AbortError';
let checkpointCount = 0;
await expect(
saveContent(
harness.db,
'playlist-1',
harness.streams,
'live',
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) throw cancellation;
},
},
recording.capture
)
).rejects.toBe(cancellation);
expect(harness.transaction).toHaveBeenCalledTimes(1);
expect(recording.events.at(-1)).toEqual({
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
});
});
it('captures cache deletion as one pair across content and category chunks', async () => {
const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id }));
const contentRows = Array.from({ length: 205 }, (_, id) => ({ id }));
const select = jest
.fn()
.mockReturnValueOnce({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(categoryRows),
})),
})
.mockReturnValueOnce({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(contentRows),
})),
});
const run = jest.fn();
const deleteRows = jest.fn(() => ({
where: jest.fn(() => ({ run })),
}));
const transaction = jest.fn((callback: (tx: unknown) => unknown) =>
callback({ delete: deleteRows })
);
const db = { select, transaction } as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await clearXtreamImportCache(
db,
'playlist-1',
'live',
recording.capture
);
expect(transaction).toHaveBeenCalledTimes(5);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 355 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
]);
});
it('emits one zero-count cache-clear pair when no categories exist', async () => {
const where = jest.fn().mockResolvedValue([]);
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const transaction = jest.fn();
const db = { select, transaction } as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await expect(
clearXtreamImportCache(db, 'playlist-1', 'live', recording.capture)
).resolves.toEqual({ success: true });
expect(transaction).not.toHaveBeenCalled();
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 0 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
]);
});
});
@@ -0,0 +1,124 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { searchContent } from './content.operations';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
function createSearchDb(query: Promise<unknown[]>) {
const limit = jest.fn(() => query);
const where = jest.fn(() => ({ limit }));
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
const select = jest.fn(() => ({ from }));
return {
db: { select } as unknown as AppDatabase,
select,
};
}
describe('in-playlist search performance phases', () => {
it('closes query before measuring synchronous filtering and ranking', async () => {
const candidates = [
{
id: 1,
title: 'News One',
xtream_id: 101,
type: 'live',
},
{
id: 2,
title: 'Movies',
xtream_id: 102,
type: 'live',
},
];
let resolveQuery!: (value: typeof candidates) => void;
const query = new Promise<typeof candidates>((resolve) => {
resolveQuery = resolve;
});
const { db } = createSearchDb(query);
const recording = createRecordingOperationPhaseCapture();
const result = searchContent(
db,
'playlist-1',
'news',
['live'],
false,
recording.capture
);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
]);
resolveQuery(candidates);
await expect(result).resolves.toEqual([candidates[0]]);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
{
boundary: 'end',
metadata: { itemCount: 2 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK,
},
]);
});
it('closes a rejected query without starting rank and preserves identity', async () => {
const queryError = new Error('search query failed');
const { db } = createSearchDb(Promise.reject(queryError));
const recording = createRecordingOperationPhaseCapture();
await expect(
searchContent(
db,
'playlist-1',
'news',
['live'],
false,
recording.capture
)
).rejects.toBe(queryError);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
{
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
]);
});
it('keeps invalid input as zero-work with no query or phases', async () => {
const { db, select } = createSearchDb(Promise.resolve([]));
const recording = createRecordingOperationPhaseCapture();
await expect(
searchContent(
db,
'playlist-1',
'',
['live'],
false,
recording.capture
)
).resolves.toEqual([]);
expect(select).not.toHaveBeenCalled();
expect(recording.events).toEqual([]);
});
});
@@ -9,6 +9,7 @@ import {
GlobalSearchResult,
GlobalSearchResultSource,
M3uGlobalSearchResult,
XTREAM_DATABASE_PERFORMANCE_PHASE,
getXtreamRecentlyAddedMaxEpochSeconds,
toXtreamRecentlyAddedEpochSeconds,
XtreamGlobalSearchResult,
@@ -31,12 +32,15 @@ import {
shouldUseContentTitleFts,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
import type { OperationControl } from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
deleteXtreamCacheRows,
normalizeXtreamContentValues,
rankSearchCandidates,
writeXtreamContentValues,
type XtreamContentValue,
} from './xtream-content-operation-steps';
export { scoreSearchTextMatch } from './content-search.util';
@@ -673,7 +677,8 @@ export async function hasContent(
export async function getContent(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
type: 'live' | 'movie' | 'series',
capturePhase?: DatabaseOperationPerformancePhaseCapture
) {
const baseQuery = db
.select(selectContentFields())
@@ -689,17 +694,22 @@ export async function getContent(
)
);
return type === 'live'
? baseQuery.orderBy(asc(schema.content.id))
: baseQuery.orderBy(desc(schema.content.added));
const query =
type === 'live'
? baseQuery.orderBy(asc(schema.content.id))
: baseQuery.orderBy(desc(schema.content.added));
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
async () => query,
(rows) => ({ itemCount: rows.length })
)
: query;
}
export type RecentlyAddedPlaylistType =
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
export async function getGlobalRecentlyAdded(
db: AppDatabase,
@@ -796,20 +806,6 @@ function getGlobalRecentlyAddedByType(
.limit(limit);
}
type XtreamContentValue = {
categoryId: number;
title: string;
rating: string;
added: string;
posterUrl: string;
epgChannelId?: string | null;
tvArchive?: number | null;
tvArchiveDuration?: number | null;
directSource?: string | null;
xtreamId: number;
type: 'live' | 'movie' | 'series';
};
type XtreamContentSource = Record<string, unknown> & {
category_id?: string | number;
rating?: string | number;
@@ -898,7 +894,8 @@ export async function saveContent(
playlistId: string,
streams: Array<Record<string, unknown>>,
type: 'live' | 'movie' | 'series',
control?: OperationControl
control?: OperationControl,
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean; count: number }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
@@ -922,7 +919,7 @@ export async function saveContent(
return { success: true, count: existingContent[0].count };
}
const categories = await db
const categoriesQuery = db
.select({
id: schema.categories.id,
xtreamId: schema.categories.xtreamId,
@@ -935,49 +932,46 @@ export async function saveContent(
)
);
const categoryMap = new Map(
categories.map((category) => [category.xtreamId, category.id])
);
const categories = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ,
async () => categoriesQuery,
(rows) => ({ itemCount: rows.length })
)
: await categoriesQuery;
const values = capturePhase
? capturePhase.captureSync(
XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT,
() =>
normalizeXtreamContentValues(
streams,
type,
categories,
toXtreamContentValue
),
(result) => ({ itemCount: result.length })
)
: normalizeXtreamContentValues(
streams,
type,
categories,
toXtreamContentValue
);
const values = streams
.map((stream) => toXtreamContentValue(stream, type, categoryMap))
.filter((value): value is XtreamContentValue => value !== null);
const total = values.length;
const chunkSize = 100;
let totalInserted = 0;
for (let index = 0; index < values.length; index += chunkSize) {
await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db.transaction((tx) => {
tx.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
})
.run();
});
totalInserted += chunk.length;
await reportOperationProgress(control, {
phase: 'saving-content',
current: totalInserted,
total,
increment: chunk.length,
});
}
return { success: true, count: totalInserted };
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
() => writeXtreamContentValues(db, values, control),
(result) => ({ itemCount: result.count })
)
: writeXtreamContentValues(db, values, control);
}
export async function clearXtreamImportCache(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
type: 'live' | 'movie' | 'series',
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
@@ -994,7 +988,13 @@ export async function clearXtreamImportCache(
const categoryIds = categoryRows.map((category) => category.id);
if (categoryIds.length === 0) {
return { success: true };
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
async () => ({ success: true }),
() => ({ itemCount: 0 })
)
: { success: true };
}
const contentRows = await db
@@ -1002,26 +1002,13 @@ export async function clearXtreamImportCache(
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
for (const chunk of chunkValues(
contentRows.map((row) => row.id),
100
)) {
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db.transaction((tx) => {
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
}
return { success: true };
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
() => deleteXtreamCacheRows(db, contentRows, categoryIds),
() => ({ itemCount: contentRows.length + categoryIds.length })
)
: deleteXtreamCacheRows(db, contentRows, categoryIds);
}
export async function getContentByXtreamId(
@@ -1057,7 +1044,8 @@ export async function searchContent(
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden = false
excludeHidden = false,
capturePhase?: DatabaseOperationPerformancePhaseCapture
) {
if (!types || types.length === 0 || !normalizeSearchMatchText(searchTerm)) {
return [];
@@ -1076,7 +1064,7 @@ export async function searchContent(
conditions.push(eq(schema.categories.hidden, false));
}
const candidates = await db
const query = db
.select(selectContentFields())
.from(schema.content)
.innerJoin(
@@ -1086,12 +1074,21 @@ export async function searchContent(
.where(and(...conditions))
.limit(200);
return candidates
.filter(
(item) =>
scoreSearchTextMatch(item.title ?? '', searchTerm) !== null
)
.slice(0, 50);
const candidates = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
async () => query,
(rows) => ({ itemCount: rows.length })
)
: await query;
return capturePhase
? capturePhase.captureSync(
XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK,
() => rankSearchCandidates(candidates, searchTerm),
(result) => ({ itemCount: result.length })
)
: rankSearchCandidates(candidates, searchTerm);
}
export async function globalSearch(
@@ -0,0 +1,68 @@
import type {
XtreamDatabasePerformancePhase,
PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
export interface RecordedOperationPhase {
readonly boundary: 'end' | 'start';
readonly metadata?: PerformancePhaseMetadata;
readonly phase: XtreamDatabasePerformancePhase;
}
export function createRecordingOperationPhaseCapture(
onBoundary?: (event: RecordedOperationPhase) => void
): {
readonly capture: DatabaseOperationPerformancePhaseCapture;
readonly events: RecordedOperationPhase[];
} {
const events: RecordedOperationPhase[] = [];
const record = (event: RecordedOperationPhase): void => {
events.push(event);
onBoundary?.(event);
};
return {
capture: {
async captureAsync<TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult> {
record({ boundary: 'start', phase });
try {
const result = await execute();
record({
boundary: 'end',
metadata: metadata?.(result),
phase,
});
return result;
} catch (error) {
record({ boundary: 'end', phase });
throw error;
}
},
captureSync<TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
): TResult {
record({ boundary: 'start', phase });
try {
const result = execute();
record({
boundary: 'end',
metadata: metadata?.(result),
phase,
});
return result;
} catch (error) {
record({ boundary: 'end', phase });
throw error;
}
},
},
events,
};
}
@@ -0,0 +1,17 @@
import type {
PerformancePhaseMetadata,
XtreamDatabasePerformancePhase,
} from '@iptvnator/shared/interfaces';
export interface DatabaseOperationPerformancePhaseCapture {
captureAsync: <TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => Promise<TResult>;
captureSync: <TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => TResult;
}
@@ -0,0 +1,165 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
import { deletePlaylist } from './playlist.operations';
function createSelectStep(
rows: readonly unknown[],
label: string,
timeline: string[]
) {
const where = jest.fn(() => {
timeline.push(label);
return Promise.resolve(rows);
});
const from = jest.fn(() => ({ where }));
return { from };
}
function createDeleteHarness() {
const timeline: string[] = [];
const selections = [
['select:favorites', [{ id: 1 }, { id: 2 }]],
['select:recently-viewed', [{ id: 3 }]],
['select:playback-positions', []],
['select:downloads', [{ id: 4 }]],
['select:categories', [{ id: 10 }, { id: 11 }]],
[
'select:content',
Array.from({ length: 205 }, (_, id) => ({ id: id + 100 })),
],
] as const;
const select = jest.fn();
for (const [label, rows] of selections) {
select.mockReturnValueOnce(createSelectStep(rows, label, timeline));
}
const transactionRun = jest.fn();
const transactionDelete = jest.fn(() => ({
where: jest.fn(() => ({ run: transactionRun })),
}));
const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
timeline.push('transaction');
return execute({ delete: transactionDelete });
});
const playlistWhere = jest.fn(() => {
timeline.push('delete:playlist');
return Promise.resolve(undefined);
});
const deletePlaylistRow = jest.fn(() => ({ where: playlistWhere }));
return {
db: {
delete: deletePlaylistRow,
select,
transaction,
} as unknown as AppDatabase,
timeline,
transaction,
};
}
describe('playlist delete performance phases', () => {
it('counts collected dependent IDs and includes the final playlist row in writes', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture((event) => {
harness.timeline.push(`${event.phase}:${event.boundary}`);
});
const checkpoint = jest.fn(() => {
harness.timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { phase: string }) => {
harness.timeline.push(`progress:${progress.phase}`);
});
await expect(
deletePlaylist(
harness.db,
'playlist-1',
{ checkpoint, onProgress },
recording.capture
)
).resolves.toEqual({ success: true });
expect(harness.transaction).toHaveBeenCalledTimes(7);
// Seven committed chunks plus the final playlist row each keep both
// the pre-write and post-progress cooperative checkpoints.
expect(checkpoint).toHaveBeenCalledTimes(16);
expect(harness.timeline.slice(1, 7)).toEqual([
'select:favorites',
'select:recently-viewed',
'select:playback-positions',
'select:downloads',
'select:categories',
'select:content',
]);
const writeStart = harness.timeline.indexOf(
'sqlite.playlist-delete.write-transactions:start'
);
const writeEnd = harness.timeline.indexOf(
'sqlite.playlist-delete.write-transactions:end'
);
expect(harness.timeline.indexOf('delete:playlist')).toBeGreaterThan(
writeStart
);
expect(writeEnd).toBeGreaterThan(
harness.timeline.indexOf('progress:deleting-playlist')
);
expect(
harness.timeline
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => entry === 'transaction')
.every(({ index }) => index > writeStart && index < writeEnd)
).toBe(true);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS,
},
{
boundary: 'end',
metadata: { itemCount: 211 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 212 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
},
]);
});
it('closes the write phase on cancellation before deleting the playlist row', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture();
const cancellation = new Error('synthetic cancellation');
cancellation.name = 'AbortError';
let checkpointCount = 0;
await expect(
deletePlaylist(
harness.db,
'playlist-1',
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) {
throw cancellation;
}
},
},
recording.capture
)
).rejects.toBe(cancellation);
expect(harness.transaction).toHaveBeenCalledTimes(1);
expect(harness.timeline).not.toContain('delete:playlist');
expect(recording.events.at(-1)).toEqual({
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
});
});
});
@@ -0,0 +1,123 @@
import type {
AppPlaylistGetPerformancePhase,
PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import {
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { getAppPlaylist } from './playlist.operations';
type TestGetPhaseCapture = {
captureAsync: <TResult>(
phase: AppPlaylistGetPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => Promise<TResult>;
captureSync: <TResult>(
phase: AppPlaylistGetPerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => TResult;
};
function createDatabaseMock(rows: unknown[]) {
const limit = jest.fn().mockResolvedValue(rows);
const where = jest.fn(() => ({ limit }));
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
return {
db: { select } as unknown as AppDatabase,
from,
limit,
select,
where,
};
}
const playlistRow = {
id: 'synthetic-playlist',
name: 'Synthetic playlist',
type: 'm3u-url',
payload: JSON.stringify({
_id: 'synthetic-playlist',
playlist: {
items: [{ id: 'channel-1' }, { id: 'channel-2' }],
},
title: 'Synthetic playlist',
}),
favorites: null,
recentlyViewed: null,
} as never;
describe('app playlist GET performance boundaries', () => {
it('keeps the default read and parse path unchanged', async () => {
const { db, limit, select } = createDatabaseMock([playlistRow]);
await expect(
getAppPlaylist(db, 'synthetic-playlist')
).resolves.toEqual(
expect.objectContaining({
_id: 'synthetic-playlist',
playlist: {
items: [{ id: 'channel-1' }, { id: 'channel-2' }],
},
})
);
expect(select).toHaveBeenCalledTimes(1);
expect(limit).toHaveBeenCalledWith(1);
});
it('separates the awaited SQLite read from playlist deserialization', async () => {
const { db, limit } = createDatabaseMock([playlistRow]);
const phases: Array<{
metadata: PerformancePhaseMetadata | undefined;
phase: AppPlaylistGetPerformancePhase;
}> = [];
const capture: TestGetPhaseCapture = {
async captureAsync(phase, execute, metadata) {
const result = await execute();
phases.push({
metadata: metadata?.(result),
phase,
});
return result;
},
captureSync(phase, execute, metadata) {
const result = execute();
phases.push({
metadata: metadata?.(result),
phase,
});
return result;
},
};
const getWithCapture = getAppPlaylist as unknown as (
database: AppDatabase,
playlistId: string,
phaseCapture: TestGetPhaseCapture
) => Promise<Record<string, unknown> | null>;
await expect(
getWithCapture(db, 'synthetic-playlist', capture)
).resolves.toEqual(
expect.objectContaining({
_id: 'synthetic-playlist',
})
);
expect(phases).toEqual([
{
metadata: { itemCount: 1 },
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
},
{
metadata: { itemCount: 2 },
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
},
]);
expect(limit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,101 @@
import {
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
type AppPlaylistUpsertPerformancePhase,
type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
type AppPlaylistUpsertPhaseCapture,
upsertAppPlaylist,
} from './playlist.operations';
function createDatabaseMock(runImplementation: () => unknown = () => ({})) {
const run = jest.fn(runImplementation);
const then = jest.fn(
(
onFulfilled: (value: unknown) => unknown,
onRejected: (error: unknown) => unknown
) => Promise.resolve().then(run).then(onFulfilled, onRejected)
);
const onConflictDoUpdate = jest.fn(() => ({ run, then }));
const values = jest.fn(() => ({ onConflictDoUpdate }));
const insert = jest.fn(() => ({ values }));
return {
db: { insert } as unknown as AppDatabase,
insert,
onConflictDoUpdate,
run,
values,
};
}
describe('app playlist upsert performance boundaries', () => {
const playlist = {
_id: 'synthetic-playlist',
playlist: {
items: [{ id: 'channel-1' }, { id: 'channel-2' }],
},
title: 'Synthetic playlist',
type: 'm3u-url',
url: 'http://127.0.0.1:43210/synthetic-performance.m3u',
};
afterEach(() => {
jest.restoreAllMocks();
});
it('keeps the default path free of phase hooks and executes one autocommit run', async () => {
const { db, run } = createDatabaseMock();
await expect(upsertAppPlaylist(db, playlist)).resolves.toEqual({
success: true,
});
expect(run).toHaveBeenCalledTimes(1);
});
it('separates row/JSON serialization from SQLite without rescanning the payload for metadata', async () => {
const { db, run } = createDatabaseMock();
const byteLength = jest.spyOn(Buffer, 'byteLength');
const phases: Array<{
metadata: PerformancePhaseMetadata | undefined;
phase: AppPlaylistUpsertPerformancePhase;
}> = [];
const capture: AppPlaylistUpsertPhaseCapture = {
async captureAsync(phase, execute, metadata) {
const result = await execute();
phases.push({
metadata: metadata?.(result),
phase,
});
return result;
},
captureSync(phase, execute, metadata) {
const result = execute();
phases.push({
metadata: metadata?.(result),
phase,
});
return result;
},
};
await expect(upsertAppPlaylist(db, playlist, capture)).resolves.toEqual(
{ success: true }
);
expect(phases).toEqual([
{
metadata: { itemCount: 2 },
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
},
{
metadata: { itemCount: 1 },
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
},
]);
expect(byteLength).not.toHaveBeenCalled();
expect(run).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,14 @@
import { eq, inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
import {
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
XTREAM_DATABASE_PERFORMANCE_PHASE,
type AppPlaylistGetPerformancePhase,
type AppPlaylistUpsertPerformancePhase,
type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
@@ -8,6 +16,7 @@ import {
type OperationControl,
reportOperationProgress,
} from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
const PLAYLIST_TYPES = {
XTREAM: 'xtream',
@@ -19,6 +28,32 @@ const PLAYLIST_TYPES = {
const DEFAULT_BATCH_SIZE = 100;
export interface AppPlaylistUpsertPhaseCapture {
captureAsync: <TResult>(
phase: AppPlaylistUpsertPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => Promise<TResult>;
captureSync: <TResult>(
phase: AppPlaylistUpsertPerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => TResult;
}
export interface AppPlaylistGetPhaseCapture {
captureAsync: <TResult>(
phase: AppPlaylistGetPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => Promise<TResult>;
captureSync: <TResult>(
phase: AppPlaylistGetPerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => TResult;
}
type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
const PLAYLIST_TYPE_VALUES = new Set<PlaylistType>(
Object.values(PLAYLIST_TYPES)
@@ -196,6 +231,21 @@ function buildPlaylistRow(
};
}
function getPlaylistItemCount(
playlist: Record<string, unknown>
): number | undefined {
try {
const value = playlist.playlist;
if (typeof value !== 'object' || value === null) {
return undefined;
}
const items = Reflect.get(value, 'items');
return Array.isArray(items) ? items.length : undefined;
} catch {
return undefined;
}
}
export function parseAppPlaylist(
row: schema.Playlist
): Record<string, unknown> {
@@ -300,17 +350,39 @@ export async function createPlaylist(
export async function upsertAppPlaylist(
db: AppDatabase,
playlist: Record<string, unknown>
playlist: Record<string, unknown>,
capturePhase?: AppPlaylistUpsertPhaseCapture
): Promise<{ success: boolean }> {
const row = buildPlaylistRow(playlist);
const row = capturePhase
? capturePhase.captureSync(
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
() => buildPlaylistRow(playlist),
() => ({
itemCount: getPlaylistItemCount(playlist),
})
)
: buildPlaylistRow(playlist);
if (!row) {
throw new Error('Playlist ID is required for upsert');
}
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
const write = async () => {
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
};
if (capturePhase) {
await capturePhase.captureAsync(
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
write,
() => ({
itemCount: 1,
})
);
} else {
await write();
}
return { success: true };
}
@@ -392,14 +464,35 @@ export async function getAppPlaylistMetas(db: AppDatabase) {
);
}
export async function getAppPlaylist(db: AppDatabase, playlistId: string) {
const rows = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
export async function getAppPlaylist(
db: AppDatabase,
playlistId: string,
capturePhase?: AppPlaylistGetPhaseCapture
) {
const read = () =>
db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
const rows = capturePhase
? await capturePhase.captureAsync(
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
read,
(value) => ({ itemCount: value.length })
)
: await read();
const deserialize = () => (rows[0] ? parseAppPlaylist(rows[0]) : null);
return rows[0] ? parseAppPlaylist(rows[0]) : null;
return capturePhase
? capturePhase.captureSync(
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
deserialize,
(value) => ({
itemCount: value ? (getPlaylistItemCount(value) ?? 0) : 0,
})
)
: deserialize();
}
export async function getAppPlaylistFavoriteChannels(
@@ -495,11 +588,19 @@ export async function updatePlaylist(
return { success: true };
}
export async function deletePlaylist(
interface PlaylistDeletionCollection {
readonly categoryIds: number[];
readonly contentRows: Array<{ id: number }>;
readonly downloadRows: Array<{ id: number }>;
readonly favoriteRows: Array<{ id: number }>;
readonly playbackPositionRows: Array<{ id: number }>;
readonly recentlyViewedRows: Array<{ id: number }>;
}
async function collectPlaylistDeletionRows(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{ success: boolean }> {
playlistId: string
): Promise<PlaylistDeletionCollection> {
const [
favoriteRows,
recentlyViewedRows,
@@ -537,40 +638,69 @@ export async function deletePlaylist(
.where(inArray(schema.content.categoryId, categoryIds))
: [];
return {
categoryIds,
contentRows,
downloadRows,
favoriteRows,
playbackPositionRows,
recentlyViewedRows,
};
}
function countPlaylistDeletionRows(
collection: PlaylistDeletionCollection
): number {
return (
collection.favoriteRows.length +
collection.recentlyViewedRows.length +
collection.playbackPositionRows.length +
collection.downloadRows.length +
collection.contentRows.length +
collection.categoryIds.length
);
}
async function deleteCollectedPlaylistRows(
db: AppDatabase,
playlistId: string,
collection: PlaylistDeletionCollection,
control?: OperationControl
): Promise<number> {
for (const [phase, ids, column, table] of [
[
'deleting-favorites',
favoriteRows.map((row) => row.id),
collection.favoriteRows.map((row) => row.id),
schema.favorites.id,
schema.favorites,
],
[
'deleting-recently-viewed',
recentlyViewedRows.map((row) => row.id),
collection.recentlyViewedRows.map((row) => row.id),
schema.recentlyViewed.id,
schema.recentlyViewed,
],
[
'deleting-playback-positions',
playbackPositionRows.map((row) => row.id),
collection.playbackPositionRows.map((row) => row.id),
schema.playbackPositions.id,
schema.playbackPositions,
],
[
'deleting-downloads',
downloadRows.map((row) => row.id),
collection.downloadRows.map((row) => row.id),
schema.downloads.id,
schema.downloads,
],
[
'deleting-content',
contentRows.map((row) => row.id),
collection.contentRows.map((row) => row.id),
schema.content.id,
schema.content,
],
[
'deleting-categories',
categoryIds,
collection.categoryIds,
schema.categories.id,
schema.categories,
],
@@ -604,6 +734,41 @@ export async function deletePlaylist(
increment: 1,
});
return countPlaylistDeletionRows(collection) + 1;
}
export async function deletePlaylist(
db: AppDatabase,
playlistId: string,
control?: OperationControl,
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
const collection = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS,
() => collectPlaylistDeletionRows(db, playlistId),
(result) => ({
itemCount: countPlaylistDeletionRows(result),
})
)
: await collectPlaylistDeletionRows(db, playlistId);
if (capturePhase) {
await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
() =>
deleteCollectedPlaylistRows(
db,
playlistId,
collection,
control
),
(itemCount) => ({ itemCount })
);
} else {
await deleteCollectedPlaylistRows(db, playlistId, collection, control);
}
return { success: true };
}
@@ -0,0 +1,117 @@
import { inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { scoreSearchTextMatch } from './content-search.util';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
export type XtreamContentValue = {
categoryId: number;
title: string;
rating: string;
added: string;
posterUrl: string;
epgChannelId?: string | null;
tvArchive?: number | null;
tvArchiveDuration?: number | null;
directSource?: string | null;
xtreamId: number;
type: 'live' | 'movie' | 'series';
};
export function normalizeXtreamContentValues(
streams: Array<Record<string, unknown>>,
type: 'live' | 'movie' | 'series',
categories: Array<{ id: number; xtreamId: number }>,
normalizeValue: (
stream: Record<string, unknown>,
type: 'live' | 'movie' | 'series',
categoryMap: Map<number, number>
) => XtreamContentValue | null
): XtreamContentValue[] {
const categoryMap = new Map(
categories.map((category) => [category.xtreamId, category.id])
);
return streams
.map((stream) => normalizeValue(stream, type, categoryMap))
.filter((value): value is XtreamContentValue => value !== null);
}
export async function writeXtreamContentValues(
db: AppDatabase,
values: XtreamContentValue[],
control?: OperationControl
): Promise<{ success: boolean; count: number }> {
const total = values.length;
const chunkSize = 100;
let totalInserted = 0;
for (let index = 0; index < values.length; index += chunkSize) {
await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db.transaction((tx) => {
tx.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
})
.run();
});
totalInserted += chunk.length;
await reportOperationProgress(control, {
phase: 'saving-content',
current: totalInserted,
total,
increment: chunk.length,
});
}
return { success: true, count: totalInserted };
}
export async function deleteXtreamCacheRows(
db: AppDatabase,
contentRows: Array<{ id: number }>,
categoryIds: number[]
): Promise<{ success: boolean }> {
for (const chunk of chunkValues(
contentRows.map((row) => row.id),
100
)) {
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db.transaction((tx) => {
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
}
return { success: true };
}
export function rankSearchCandidates<
TCandidate extends { title: string | null },
>(candidates: TCandidate[], searchTerm: string): TCandidate[] {
return candidates
.filter(
(item) =>
scoreSearchTextMatch(item.title ?? '', searchTerm) !== null
)
.slice(0, 50);
}
@@ -0,0 +1,217 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
import { deleteXtreamContent } from './xtream.operations';
function createSelectStep(
rows: readonly unknown[],
label: string,
timeline: string[]
) {
const where = jest.fn(() => {
timeline.push(label);
return Promise.resolve(rows);
});
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin, where }));
return { from };
}
function createDeleteHarness() {
const timeline: string[] = [];
const categories = [
{ hidden: true, id: 11, type: 'live', xtreamId: 101 },
{ hidden: false, id: 12, type: 'movies', xtreamId: 102 },
];
const favorites = [
{
addedAt: '2026-01-01T00:00:00.000Z',
contentType: 'live',
position: 3,
xtreamId: 201,
},
];
const recentlyViewed = [
{
contentType: 'movie',
viewedAt: '2026-01-02T00:00:00.000Z',
xtreamId: 202,
},
];
const content = Array.from({ length: 205 }, (_, id) => ({ id: id + 1 }));
const select = jest
.fn()
.mockReturnValueOnce(
createSelectStep(categories, 'select:categories', timeline)
)
.mockReturnValueOnce(
createSelectStep(favorites, 'select:favorites', timeline)
)
.mockReturnValueOnce(
createSelectStep(recentlyViewed, 'select:recently-viewed', timeline)
)
.mockReturnValueOnce(
createSelectStep(content, 'select:content', timeline)
);
const run = jest.fn();
const deleteRows = jest.fn(() => ({
where: jest.fn(() => ({ run })),
}));
const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
timeline.push('transaction');
return execute({ delete: deleteRows });
});
return {
content,
db: { select, transaction } as unknown as AppDatabase,
timeline,
transaction,
};
}
describe('Xtream delete performance phases', () => {
it('times all user-data collection but counts only content/category deletion candidates', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture((event) => {
harness.timeline.push(`${event.phase}:${event.boundary}`);
});
const checkpoint = jest.fn(() => {
harness.timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { current?: number }) => {
harness.timeline.push(`progress:${progress.current}`);
});
await expect(
deleteXtreamContent(
harness.db,
'playlist-1',
{ checkpoint, onProgress },
recording.capture
)
).resolves.toEqual({
favorites: [
{
addedAt: '2026-01-01T00:00:00.000Z',
contentType: 'live',
position: 3,
xtreamId: 201,
},
],
hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }],
recentlyViewed: [
{
contentType: 'movie',
viewedAt: '2026-01-02T00:00:00.000Z',
xtreamId: 202,
},
],
success: true,
});
expect(harness.transaction).toHaveBeenCalledTimes(4);
// Existing progress reporting performs a second cooperative
// checkpoint after each committed batch.
expect(checkpoint).toHaveBeenCalledTimes(8);
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
100, 200, 205, 2,
]);
expect(harness.timeline.slice(1, 5)).toEqual([
'select:categories',
'select:favorites',
'select:recently-viewed',
'select:content',
]);
const writeStart = harness.timeline.indexOf(
'sqlite.xtream-delete.write-transactions:start'
);
const writeEnd = harness.timeline.indexOf(
'sqlite.xtream-delete.write-transactions:end'
);
expect(writeEnd).toBeGreaterThan(
harness.timeline.lastIndexOf('progress:2')
);
expect(
harness.timeline
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => entry === 'transaction')
.every(({ index }) => index > writeStart && index < writeEnd)
).toBe(true);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
{
boundary: 'end',
metadata: { itemCount: 207 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 207 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
},
]);
});
it('closes the write phase on cancellation and preserves error identity', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture();
const cancellation = new Error('synthetic cancellation');
cancellation.name = 'AbortError';
let checkpointCount = 0;
await expect(
deleteXtreamContent(
harness.db,
'playlist-1',
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) {
throw cancellation;
}
},
},
recording.capture
)
).rejects.toBe(cancellation);
expect(harness.transaction).toHaveBeenCalledTimes(1);
expect(recording.events.at(-1)).toEqual({
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
});
});
it('closes collection on query failure and preserves error identity', async () => {
const failure = new Error('synthetic select failure');
const where = jest.fn().mockRejectedValue(failure);
const from = jest.fn(() => ({ where }));
const db = {
select: jest.fn(() => ({ from })),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await expect(
deleteXtreamContent(db, 'playlist-1', undefined, recording.capture)
).rejects.toBe(failure);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
{
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
]);
});
});
@@ -1,9 +1,10 @@
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type {
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
import {
XTREAM_DATABASE_PERFORMANCE_PHASE,
type XtreamBackupFavoriteItem,
type XtreamBackupHiddenCategory,
type XtreamBackupRecentlyViewedItem,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
@@ -12,6 +13,7 @@ import {
type OperationControl,
reportOperationProgress,
} from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
const DEFAULT_BATCH_SIZE = 100;
@@ -28,16 +30,18 @@ function toContentIdentityKey(
return `${contentType}:${xtreamId}`;
}
export async function deleteXtreamContent(
interface XtreamDeletionCollection {
readonly categoryIds: number[];
readonly contentRows: Array<{ id: number }>;
readonly favorites: XtreamBackupFavoriteItem[];
readonly hiddenCategories: XtreamBackupHiddenCategory[];
readonly recentlyViewed: XtreamBackupRecentlyViewedItem[];
}
async function collectXtreamDeletionRows(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
playlistId: string
): Promise<XtreamDeletionCollection> {
const categories = await db
.select({
id: schema.categories.id,
@@ -58,6 +62,7 @@ export async function deleteXtreamContent(
let favorites: XtreamBackupFavoriteItem[] = [];
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
let contentRows: Array<{ id: number }> = [];
if (categoryIds.length > 0) {
const favoritedContent = await db
@@ -110,43 +115,58 @@ export async function deleteXtreamContent(
viewedAt: item.viewedAt || new Date().toISOString(),
}));
const contentRows = await db
contentRows = await db
.select({ id: schema.content.id })
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
}
let deletedContent = 0;
const totalContent = contentRows.length;
return {
categoryIds,
contentRows,
favorites,
hiddenCategories,
recentlyViewed,
};
}
for (const chunk of chunkValues(
contentRows.map((content) => content.id),
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx
.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
current: deletedContent,
total: totalContent,
increment: chunk.length,
});
}
async function deleteCollectedXtreamRows(
db: AppDatabase,
collection: XtreamDeletionCollection,
control?: OperationControl
): Promise<number> {
let deletedContent = 0;
const totalContent = collection.contentRows.length;
for (const chunk of chunkValues(
collection.contentRows.map((content) => content.id),
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
current: deletedContent,
total: totalContent,
increment: chunk.length,
});
}
let deletedCategories = 0;
const totalCategories = categoryIds.length;
const totalCategories = collection.categoryIds.length;
for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) {
for (const chunk of chunkValues(
collection.categoryIds,
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx
.delete(schema.categories)
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
@@ -159,11 +179,46 @@ export async function deleteXtreamContent(
});
}
return totalContent + totalCategories;
}
export async function deleteXtreamContent(
db: AppDatabase,
playlistId: string,
control?: OperationControl,
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
const collection = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
() => collectXtreamDeletionRows(db, playlistId),
(result) => ({
itemCount:
result.contentRows.length + result.categoryIds.length,
})
)
: await collectXtreamDeletionRows(db, playlistId);
if (capturePhase) {
await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
() => deleteCollectedXtreamRows(db, collection, control),
(itemCount) => ({ itemCount })
);
} else {
await deleteCollectedXtreamRows(db, collection, control);
}
return {
success: true,
favorites,
recentlyViewed,
hiddenCategories,
favorites: collection.favorites,
recentlyViewed: collection.recentlyViewed,
hiddenCategories: collection.hiddenCategories,
};
}
@@ -0,0 +1,196 @@
import {
expectManagedPathLookup,
getHandler,
MANAGED_PATH_STATE,
mockExistsSync,
mockManagedPath,
mockOpenPath,
mockPauseDownload,
mockResumeDownloadRequest,
mockShowItemInFolder,
setupDownloadsEventsHarness,
} from './downloads.test-helpers';
describe('downloads events: pause, resume, and reveal', () => {
beforeEach(async () => {
await setupDownloadsEventsHarness();
});
it('maps a successful runtime pause to a success response', async () => {
mockPauseDownload.mockResolvedValue(true);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_PAUSE')(null, 42)
).resolves.toEqual({ success: true });
} finally {
consoleLog.mockRestore();
}
expect(mockPauseDownload).toHaveBeenCalledWith(42);
});
it('maps an unknown pause target to an error response', async () => {
mockPauseDownload.mockResolvedValue(false);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_PAUSE')(null, 42)
).resolves.toEqual({
error: 'Download not found in queue',
success: false,
});
} finally {
consoleLog.mockRestore();
}
});
it('forwards resume requests with the download folder and returns the result', async () => {
mockResumeDownloadRequest.mockResolvedValue({
error: 'Can only resume paused downloads',
success: false,
});
await expect(
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
).resolves.toEqual({
error: 'Can only resume paused downloads',
success: false,
});
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
42,
'/downloads',
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 });
});
});
@@ -1,189 +1,19 @@
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
import {
createDownloadRow,
getHandler,
mockBroadcastDownloadUpdate,
mockDownloadRow,
mockRemoveDownloadFromRuntime,
mockRemovePartialDownloadFile,
mockTerminalRows,
setupDownloadsEventsHarness,
} from './downloads.test-helpers';
const mockRegisteredHandlers = new Map<string, IpcHandler>();
const mockGetDatabase = jest.fn();
const mockRemoveDownloadFromRuntime = jest.fn();
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);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
function createDownloadRow(status: string) {
return {
filePath: '/downloads/resume.mp4',
status,
};
}
describe('downloads events', () => {
describe('downloads events: partial-file cleanup', () => {
beforeEach(async () => {
jest.resetModules();
mockRegisteredHandlers.clear();
mockGetDatabase.mockReset();
mockRemoveDownloadFromRuntime.mockReset();
mockBroadcastDownloadUpdate.mockReset();
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) =>
name === 'userData' ? '/user-data' : '/downloads'
),
},
dialog: {
showOpenDialog: jest.fn(),
},
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
mockRegisteredHandlers.set(channel, handler);
}),
},
shell: {
openPath: mockOpenPath,
showItemInFolder: mockShowItemInFolder,
},
}));
jest.doMock('../../database/connection', () => ({
getDatabase: mockGetDatabase,
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile: mockRemovePartialDownloadFile,
}));
jest.doMock('./download-runtime', () => ({
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
cancelDownload: jest.fn(),
pauseDownload: mockPauseDownload,
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
setMainWindow: jest.fn(),
}));
jest.doMock('./download-requests', () => ({
resumeDownloadRequest: mockResumeDownloadRequest,
retryDownloadRequest: jest.fn(),
startDownloadRequest: jest.fn(),
}));
jest.doMock('./download-recovery', () => ({
resetStaleDownloads: jest.fn(),
}));
await import('./downloads.events');
const schema = await import('../../database/schema');
downloadsFilePathColumn = schema.downloads.filePath;
await setupDownloadsEventsHarness();
});
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 = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([row]),
})),
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere };
}
function mockTerminalRows(
rows: Array<{ filePath: string | null; status: string }>
) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const selectWhere = jest
.fn()
.mockResolvedValue(
rows.map((row, index) => ({ id: index + 1, ...row }))
);
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: selectWhere,
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere, selectWhere };
}
it('removes queued resumed partial files before deleting the row', async () => {
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
@@ -349,182 +179,4 @@ describe('downloads events', () => {
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2);
expect(deleteWhere).toHaveBeenCalledTimes(1);
});
it('maps a successful runtime pause to a success response', async () => {
mockPauseDownload.mockResolvedValue(true);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_PAUSE')(null, 42)
).resolves.toEqual({ success: true });
} finally {
consoleLog.mockRestore();
}
expect(mockPauseDownload).toHaveBeenCalledWith(42);
});
it('maps an unknown pause target to an error response', async () => {
mockPauseDownload.mockResolvedValue(false);
const consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
try {
await expect(
getHandler('DOWNLOADS_PAUSE')(null, 42)
).resolves.toEqual({
error: 'Download not found in queue',
success: false,
});
} finally {
consoleLog.mockRestore();
}
});
it('forwards resume requests with the download folder and returns the result', async () => {
mockResumeDownloadRequest.mockResolvedValue({
error: 'Can only resume paused downloads',
success: false,
});
await expect(
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
).resolves.toEqual({
error: 'Can only resume paused downloads',
success: false,
});
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
42,
'/downloads',
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,192 @@
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
/**
* Shared harness for the downloads-events specs.
*
* The module registry is reset and re-mocked per test via `jest.doMock`, which
* is deliberately not hoisted — so the whole setup can live here and each spec
* just calls `setupDownloadsEventsHarness()` from its own `beforeEach`.
*/
export const mockRegisteredHandlers = new Map<string, IpcHandler>();
export const mockGetDatabase = jest.fn();
export const mockRemoveDownloadFromRuntime = jest.fn();
export const mockBroadcastDownloadUpdate = jest.fn();
export const mockRemovePartialDownloadFile = jest.fn();
export const mockPauseDownload = jest.fn();
export const mockResumeDownloadRequest = jest.fn();
export const mockExistsSync = jest.fn();
export const mockOpenPath = jest.fn();
export const mockShowItemInFolder = jest.fn();
export const mockEq = jest.fn();
let downloadsFilePathColumn: unknown;
export const MANAGED_PATH_STATE = {
ERROR: 'error',
MANAGED: 'managed',
UNMANAGED: 'unmanaged',
} as const;
export type ManagedPathState =
(typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE];
export function getHandler(channel: string): IpcHandler {
const handler = mockRegisteredHandlers.get(channel);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
export function createDownloadRow(status: string) {
return {
filePath: '/downloads/resume.mp4',
status,
};
}
export async function setupDownloadsEventsHarness(): Promise<void> {
jest.resetModules();
mockRegisteredHandlers.clear();
mockGetDatabase.mockReset();
mockRemoveDownloadFromRuntime.mockReset();
mockBroadcastDownloadUpdate.mockReset();
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) =>
name === 'userData' ? '/user-data' : '/downloads'
),
},
dialog: {
showOpenDialog: jest.fn(),
},
ipcMain: {
handle: jest.fn((channel: string, handler: IpcHandler) => {
mockRegisteredHandlers.set(channel, handler);
}),
},
shell: {
openPath: mockOpenPath,
showItemInFolder: mockShowItemInFolder,
},
}));
jest.doMock('../../database/connection', () => ({
getDatabase: mockGetDatabase,
}));
jest.doMock('./download-file-path', () => ({
removePartialDownloadFile: mockRemovePartialDownloadFile,
}));
jest.doMock('./download-runtime', () => ({
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
cancelDownload: jest.fn(),
pauseDownload: mockPauseDownload,
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
setMainWindow: jest.fn(),
}));
jest.doMock('./download-requests', () => ({
resumeDownloadRequest: mockResumeDownloadRequest,
retryDownloadRequest: jest.fn(),
startDownloadRequest: jest.fn(),
}));
jest.doMock('./download-recovery', () => ({
resetStaleDownloads: jest.fn(),
}));
await import('./downloads.events');
const schema = await import('../../database/schema');
downloadsFilePathColumn = schema.downloads.filePath;
}
export 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 };
}
export 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);
}
export function mockDownloadRow(row: { filePath: string | null; status: string }) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: jest.fn(() => ({
limit: jest.fn().mockResolvedValue([row]),
})),
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere };
}
export function mockTerminalRows(
rows: Array<{ filePath: string | null; status: string }>
) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const selectWhere = jest
.fn()
.mockResolvedValue(
rows.map((row, index) => ({ id: index + 1, ...row }))
);
const db = {
delete: jest.fn(() => ({ where: deleteWhere })),
select: jest.fn(() => ({
from: jest.fn(() => ({
where: selectWhere,
})),
})),
};
mockGetDatabase.mockResolvedValue(db);
return { db, deleteWhere, selectWhere };
}
@@ -0,0 +1,174 @@
import type EpgEventsType from './epg.events';
const getDatabase = jest.fn();
const getEpgMapping = jest.fn();
const getEpgMappingsBatch = jest.fn();
const setEpgMapping = jest.fn();
const deleteEpgMapping = jest.fn();
const searchEpgChannels = jest.fn();
const ipcHandlers = new Map<
string,
(event: unknown, args: unknown) => Promise<unknown>
>();
jest.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/mock/app.asar',
},
BrowserWindow: {
getAllWindows: () => [],
},
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
jest.mock('../database/operations/epg-mapping.operations', () => ({
getEpgMapping: (...args: unknown[]) => getEpgMapping(...args),
getEpgMappingsBatch: (...args: unknown[]) => getEpgMappingsBatch(...args),
setEpgMapping: (...args: unknown[]) => setEpgMapping(...args),
deleteEpgMapping: (...args: unknown[]) => deleteEpgMapping(...args),
searchEpgChannels: (...args: unknown[]) => searchEpgChannels(...args),
}));
/**
* The EPG mapping IPC handlers must fail soft: a mapping lookup is a side
* concern of an EPG request and must never take that request down. The
* handlers wrap the DB call in try/catch, but a bare `return promise` inside
* an async function is *adopted*, not awaited — so the catch block only ever
* saw a synchronous `getDatabase()` failure while a rejection from the query
* itself escaped to the IPC caller. These tests pin both failure modes.
*/
describe('EPG mapping IPC handlers', () => {
let EpgEvents: typeof EpgEventsType;
beforeEach(async () => {
jest.resetModules();
ipcHandlers.clear();
[
getDatabase,
getEpgMapping,
getEpgMappingsBatch,
setEpgMapping,
deleteEpgMapping,
searchEpgChannels,
].forEach((mock) => mock.mockReset());
getDatabase.mockResolvedValue({});
({ default: EpgEvents } = await import('./epg.events'));
const { ipcMain } = jest.requireMock('electron');
(ipcMain.handle as jest.Mock).mockReset();
(ipcMain.handle as jest.Mock).mockImplementation(
(
channel: string,
handler: (event: unknown, args: unknown) => Promise<unknown>
) => {
ipcHandlers.set(channel, handler);
}
);
EpgEvents.bootstrapEpgEvents();
});
function invoke(channel: string, args: unknown): Promise<unknown> {
const handler = ipcHandlers.get(channel);
if (!handler) {
throw new Error(`No IPC handler registered for "${channel}"`);
}
return handler({}, args);
}
describe.each([
{
channel: 'EPG_MAPPING_GET',
args: { channelKey: 'bbc.one.uk' },
operation: () => getEpgMapping,
resolved: {
id: 1,
channelKey: 'bbc.one.uk',
epgChannelId: 'BBC.ONE.UK',
playlistId: null,
},
fallback: null,
},
{
channel: 'EPG_MAPPING_SET',
args: { channelKey: 'bbc.one.uk', epgChannelId: 'BBC.ONE.UK' },
operation: () => setEpgMapping,
resolved: { success: true },
fallback: { success: false },
},
{
channel: 'EPG_MAPPING_DELETE',
args: { channelKey: 'bbc.one.uk' },
operation: () => deleteEpgMapping,
resolved: { success: true },
fallback: { success: false },
},
{
channel: 'EPG_CHANNEL_SEARCH',
args: { searchTerm: 'bbc' },
operation: () => searchEpgChannels,
resolved: [
{ id: 'BBC.ONE.UK', displayName: 'BBC One', iconUrl: null },
],
fallback: [],
},
{
channel: 'EPG_MAPPING_GET_BATCH',
args: { channelKeys: ['bbc.one.uk'] },
operation: () => getEpgMappingsBatch,
resolved: new Map([['bbc.one.uk', 'BBC.ONE.UK']]),
fallback: {},
},
])('$channel', ({ channel, args, operation, resolved, fallback }) => {
it('returns the fallback when the underlying query rejects', async () => {
operation().mockRejectedValue(new Error('database is locked'));
await expect(invoke(channel, args)).resolves.toEqual(fallback);
});
it('returns the fallback when opening the database rejects', async () => {
getDatabase.mockRejectedValue(new Error('no such file'));
await expect(invoke(channel, args)).resolves.toEqual(fallback);
expect(operation()).not.toHaveBeenCalled();
});
it('passes the resolved value through on success', async () => {
operation().mockResolvedValue(resolved);
const result = await invoke(channel, args);
expect(result).toEqual(
// The batch handler is the one that reshapes its result.
resolved instanceof Map
? Object.fromEntries(resolved)
: resolved
);
});
});
it('short-circuits a blank search term without touching the database', async () => {
await expect(
invoke('EPG_CHANNEL_SEARCH', { searchTerm: ' ' })
).resolves.toEqual([]);
expect(getDatabase).not.toHaveBeenCalled();
expect(searchEpgChannels).not.toHaveBeenCalled();
});
it('short-circuits an empty batch without touching the database', async () => {
await expect(
invoke('EPG_MAPPING_GET_BATCH', { channelKeys: [] })
).resolves.toEqual({});
expect(getDatabase).not.toHaveBeenCalled();
expect(getEpgMappingsBatch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,246 @@
const getDatabase = jest.fn();
const getEpgMapping = jest.fn();
const getEpgMappingsBatch = jest.fn();
const setEpgMapping = jest.fn();
const deleteEpgMapping = jest.fn();
const searchEpgChannels = jest.fn();
jest.mock('../database/connection', () => ({
getDatabase: (...args: unknown[]) => getDatabase(...args),
}));
jest.mock('../database/operations/epg-mapping.operations', () => ({
getEpgMapping: (...args: unknown[]) => getEpgMapping(...args),
getEpgMappingsBatch: (...args: unknown[]) => getEpgMappingsBatch(...args),
setEpgMapping: (...args: unknown[]) => setEpgMapping(...args),
deleteEpgMapping: (...args: unknown[]) => deleteEpgMapping(...args),
searchEpgChannels: (...args: unknown[]) => searchEpgChannels(...args),
}));
import {
handleDeleteEpgMapping,
handleGetEpgMapping,
handleGetEpgMappingsBatch,
handleSearchEpgChannels,
handleSetEpgMapping,
queryByResolvedChannelIds,
resolveChannelIds,
} from './epg-mapping.service';
const DB_HANDLE = { db: true };
const DB_ERROR = new Error('getDatabase failed');
const OP_ERROR = new Error('sqlite operation failed');
describe('epg-mapping.service', () => {
beforeEach(() => {
jest.resetAllMocks();
getDatabase.mockResolvedValue(DB_HANDLE);
});
/**
* The fail-soft contract: a mapping lookup must never take down an EPG IPC
* request. Each entry is checked against BOTH failure modes — a rejecting
* `getDatabase()` and a rejecting database operation.
*/
const failSoftCases: Array<{
name: string;
operation: jest.Mock;
invoke: () => Promise<unknown>;
fallback: unknown;
}> = [
{
name: 'resolveChannelIds',
operation: getEpgMappingsBatch,
invoke: () => resolveChannelIds(['channel-a']),
fallback: new Map(),
},
{
name: 'handleGetEpgMapping',
operation: getEpgMapping,
invoke: () => handleGetEpgMapping('channel-a'),
fallback: null,
},
{
name: 'handleGetEpgMappingsBatch',
operation: getEpgMappingsBatch,
invoke: () => handleGetEpgMappingsBatch(['channel-a']),
fallback: {},
},
{
name: 'handleSetEpgMapping',
operation: setEpgMapping,
invoke: () => handleSetEpgMapping('channel-a', 'epg-a'),
fallback: { success: false },
},
{
name: 'handleDeleteEpgMapping',
operation: deleteEpgMapping,
invoke: () => handleDeleteEpgMapping('channel-a'),
fallback: { success: false },
},
{
name: 'handleSearchEpgChannels',
operation: searchEpgChannels,
invoke: () => handleSearchEpgChannels('bbc'),
fallback: [],
},
];
for (const { name, operation, invoke, fallback } of failSoftCases) {
describe(`${name} fail-soft`, () => {
it('returns the fallback when getDatabase() rejects', async () => {
getDatabase.mockRejectedValue(DB_ERROR);
await expect(invoke()).resolves.toEqual(fallback);
expect(operation).not.toHaveBeenCalled();
});
it('returns the fallback when the database operation rejects', async () => {
operation.mockRejectedValue(OP_ERROR);
await expect(invoke()).resolves.toEqual(fallback);
expect(operation).toHaveBeenCalled();
});
});
}
describe('resolveChannelIds', () => {
it('returns the mapping batch keyed by original channel id', async () => {
const mappings = new Map([['channel-a', 'epg-a']]);
getEpgMappingsBatch.mockResolvedValue(mappings);
await expect(resolveChannelIds(['channel-a'])).resolves.toEqual(
mappings
);
expect(getEpgMappingsBatch).toHaveBeenCalledWith(DB_HANDLE, [
'channel-a',
]);
});
});
describe('queryByResolvedChannelIds', () => {
it('queries mapped ids and remaps results onto the requested ids', async () => {
getEpgMappingsBatch.mockResolvedValue(
new Map([['channel-a', 'epg-a']])
);
const query = jest
.fn()
.mockResolvedValue({ 'epg-a': 'A', 'channel-b': 'B' });
const result = await queryByResolvedChannelIds(
['channel-a', 'channel-b'],
query
);
expect(query).toHaveBeenCalledWith(['epg-a', 'channel-b']);
expect(result).toEqual({ 'channel-a': 'A', 'channel-b': 'B' });
});
it('fills missing query results with null', async () => {
getEpgMappingsBatch.mockResolvedValue(new Map());
const query = jest.fn().mockResolvedValue({});
await expect(
queryByResolvedChannelIds(['channel-a'], query)
).resolves.toEqual({ 'channel-a': null });
});
it('falls back to unmapped ids when the mapping lookup fails', async () => {
getEpgMappingsBatch.mockRejectedValue(OP_ERROR);
const query = jest.fn().mockResolvedValue({ 'channel-a': 'A' });
const result = await queryByResolvedChannelIds(
['channel-a'],
query
);
expect(query).toHaveBeenCalledWith(['channel-a']);
expect(result).toEqual({ 'channel-a': 'A' });
});
});
describe('handleGetEpgMappingsBatch', () => {
it('converts the mapping batch into a plain record', async () => {
getEpgMappingsBatch.mockResolvedValue(
new Map([['channel-a', 'epg-a']])
);
await expect(
handleGetEpgMappingsBatch(['channel-a'])
).resolves.toEqual({ 'channel-a': 'epg-a' });
});
it.each([
['an empty array', [] as string[]],
['a non-array value', undefined as unknown as string[]],
])(
'short-circuits for %s without touching the database',
async (_label, channelKeys) => {
await expect(
handleGetEpgMappingsBatch(channelKeys)
).resolves.toEqual({});
expect(getDatabase).not.toHaveBeenCalled();
}
);
});
describe('handleSearchEpgChannels', () => {
it('forwards the search term and limit to the database', async () => {
const matches = [
{ id: 'epg-a', displayName: 'BBC One', iconUrl: null },
];
searchEpgChannels.mockResolvedValue(matches);
await expect(handleSearchEpgChannels('bbc', 25)).resolves.toEqual(
matches
);
expect(searchEpgChannels).toHaveBeenCalledWith(
DB_HANDLE,
'bbc',
25
);
});
it.each([
['an empty term', ''],
['a whitespace-only term', ' '],
['an undefined term', undefined as unknown as string],
])(
'short-circuits for %s without touching the database',
async (_label, searchTerm) => {
await expect(
handleSearchEpgChannels(searchTerm)
).resolves.toEqual([]);
expect(getDatabase).not.toHaveBeenCalled();
}
);
});
describe('mapping writes', () => {
it('passes the optional playlist id through to setEpgMapping', async () => {
setEpgMapping.mockResolvedValue({ success: true });
await expect(
handleSetEpgMapping('channel-a', 'epg-a', 'playlist-1')
).resolves.toEqual({ success: true });
expect(setEpgMapping).toHaveBeenCalledWith(
DB_HANDLE,
'channel-a',
'epg-a',
'playlist-1'
);
});
it('deletes a mapping by channel key', async () => {
deleteEpgMapping.mockResolvedValue({ success: true });
await expect(handleDeleteEpgMapping('channel-a')).resolves.toEqual({
success: true,
});
expect(deleteEpgMapping).toHaveBeenCalledWith(
DB_HANDLE,
'channel-a'
);
});
});
});
@@ -67,7 +67,7 @@ export async function handleGetEpgMapping(
): Promise<EpgMappingRecord | null> {
try {
const db = await getDatabase();
return getEpgMapping(db, channelKey);
return await getEpgMapping(db, channelKey);
} catch {
return null;
}
@@ -96,7 +96,7 @@ export async function handleSetEpgMapping(
): Promise<{ success: boolean }> {
try {
const db = await getDatabase();
return setEpgMapping(db, channelKey, epgChannelId, playlistId);
return await setEpgMapping(db, channelKey, epgChannelId, playlistId);
} catch {
return { success: false };
}
@@ -107,7 +107,7 @@ export async function handleDeleteEpgMapping(
): Promise<{ success: boolean }> {
try {
const db = await getDatabase();
return deleteEpgMapping(db, channelKey);
return await deleteEpgMapping(db, channelKey);
} catch {
return { success: false };
}
@@ -123,7 +123,7 @@ export async function handleSearchEpgChannels(
try {
const db = await getDatabase();
return searchEpgChannels(db, searchTerm, limit);
return await searchEpgChannels(db, searchTerm, limit);
} catch {
return [];
}
@@ -422,7 +422,6 @@ export class EpgWorkerService {
}
let settled = false;
let timeoutId: ReturnType<typeof setTimeout>;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
@@ -430,7 +429,7 @@ export class EpgWorkerService {
fn();
};
timeoutId = setTimeout(() => {
const timeoutId = setTimeout(() => {
const errorMessage = `${options.timeoutLabel} timed out after ${
this.fetchTimeoutMs / 1000
}s`;
@@ -80,7 +80,9 @@ function resolveMacOSAppBundlePlayerPath(
return playerPath;
}
return path.join(
// .app bundle paths are always POSIX, even when a darwin platform is
// simulated on a win32 host in tests.
return path.posix.join(
appBundlePath,
'Contents',
'MacOS',
@@ -320,10 +322,12 @@ function getHomebrewCaskVlcPaths(readDirectory: ReadDirectory): string[] {
const caskroomPath = '/opt/homebrew/Caskroom/vlc';
try {
// Caskroom paths are always POSIX, even when a darwin platform is
// simulated on a win32 host in tests.
return readDirectory(caskroomPath)
.filter((entry) => entry.trim().length > 0)
.map((entry) =>
path.join(
path.posix.join(
caskroomPath,
entry,
'VLC.app',
@@ -47,6 +47,24 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(registry.getActiveSessionId()).toBeNull();
});
it('marks the session closed even when the runtime close fails', async () => {
const close = jest.fn().mockRejectedValue(new Error('close failed'));
const session = registry.beginSession({
player: 'vlc',
title: 'Example',
streamUrl: 'https://example.com/video.m3u8',
});
registry.attachCloser(session.id, close);
const closed = await registry.closeSession(session.id);
expect(close).toHaveBeenCalled();
expect(closed?.status).toBe('closed');
expect(closed?.canClose).toBe(false);
expect(registry.getActiveSessionId()).toBeNull();
});
it('marks runtime failures as errors without clearing the active id', () => {
const session = registry.beginSession({
player: 'mpv',
@@ -130,8 +130,11 @@ export class ExternalPlayerSessionRegistry {
try {
await runtime.close?.();
} finally {
return this.markClosed(id);
} catch {
// Close failures must not keep the session in a live state; the
// registry still reports it as closed below.
}
return this.markClosed(id);
}
}
@@ -0,0 +1,215 @@
import {
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL,
PERFORMANCE_PHASE_BOUNDARY,
type M3uImportPerformancePhase,
type PerformancePhaseEvent,
type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import { randomUUID } from 'node:crypto';
import { channel, type Channel } from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
export interface M3uImportPerformanceCaptureRuntime {
readonly createRequestId: () => string;
readonly readEpochMs: () => number;
readonly readMonotonicMs: () => number;
}
export interface M3uImportPerformanceCapture {
measure<TResult>(
phase: M3uImportPerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
): TResult;
measureAsync<TResult>(
phase: M3uImportPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult>;
}
interface CaptureOptions {
readonly emit?: (
event: PerformancePhaseEvent<M3uImportPerformancePhase>
) => void;
readonly enabled: boolean;
readonly requestId?: string;
readonly runtime?: M3uImportPerformanceCaptureRuntime;
}
interface PhaseStart {
readonly monotonicMs: number;
readonly phase: M3uImportPerformancePhase;
}
const DEFAULT_RUNTIME: M3uImportPerformanceCaptureRuntime = {
createRequestId: () => randomUUID(),
readEpochMs: () => performance.timeOrigin + performance.now(),
readMonotonicMs: () => performance.now(),
};
let performanceChannel: Channel | null = null;
let fallbackRequestSequence = 0;
export function publishM3uImportPerformancePhaseEvent(
event: PerformancePhaseEvent<M3uImportPerformancePhase>
): void {
try {
performanceChannel ??= channel(
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL
);
performanceChannel.publish(event);
} catch {
// Development-only instrumentation must never affect playlist import.
}
}
export function createM3uImportPerformanceCapture(
options: CaptureOptions
): M3uImportPerformanceCapture | null {
if (!options.enabled) {
return null;
}
const runtime = options.runtime ?? DEFAULT_RUNTIME;
const requestId =
normalizeRequestId(options.requestId) ?? createRequestId(runtime);
const emit = options.emit ?? publishM3uImportPerformancePhaseEvent;
const begin = (phase: M3uImportPerformancePhase): PhaseStart => {
const monotonicMs = safeReadMonotonicMs(runtime);
safeEmit(emit, {
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
durationMs: null,
epochMs: safeReadEpochMs(runtime),
metadata: undefined,
phase,
requestId,
});
return { monotonicMs, phase };
};
const end = (
start: PhaseStart,
createMetadata?: () => PerformancePhaseMetadata
): void => {
const endedMonotonicMs = safeReadMonotonicMs(runtime);
const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs);
const epochMs = safeReadEpochMs(runtime);
safeEmit(emit, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: Number.isFinite(durationMs) ? durationMs : 0,
epochMs,
metadata: sanitizeMetadata(safelyCreateMetadata(createMetadata)),
phase: start.phase,
requestId,
});
};
return {
measure: (phase, execute, metadata) => {
const start = begin(phase);
try {
const result = execute();
end(start, metadata ? () => metadata(result) : undefined);
return result;
} catch (error) {
end(start);
throw error;
}
},
measureAsync: async (phase, execute, metadata) => {
const start = begin(phase);
try {
const result = await execute();
end(start, metadata ? () => metadata(result) : undefined);
return result;
} catch (error) {
end(start);
throw error;
}
},
};
}
function createRequestId(runtime: M3uImportPerformanceCaptureRuntime): string {
try {
const requestId = normalizeRequestId(runtime.createRequestId());
if (requestId) {
return requestId;
}
} catch {
// Fall through to a process-local, credential-free identifier.
}
fallbackRequestSequence += 1;
return `m3u-import-${Date.now()}-${fallbackRequestSequence}`;
}
function normalizeRequestId(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function safeReadEpochMs(runtime: M3uImportPerformanceCaptureRuntime): number {
try {
const value = runtime.readEpochMs();
return Number.isFinite(value) ? value : Date.now();
} catch {
return Date.now();
}
}
function safeReadMonotonicMs(
runtime: M3uImportPerformanceCaptureRuntime
): number {
try {
const value = runtime.readMonotonicMs();
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
function safelyCreateMetadata(
createMetadata: (() => PerformancePhaseMetadata) | undefined
): PerformancePhaseMetadata | undefined {
if (!createMetadata) {
return undefined;
}
try {
return createMetadata();
} catch {
return undefined;
}
}
function sanitizeMetadata(
metadata: PerformancePhaseMetadata | undefined
): PerformancePhaseMetadata | undefined {
if (!metadata) {
return undefined;
}
const sanitized: PerformancePhaseMetadata = {};
if (isCount(metadata.byteCount)) {
(sanitized as { byteCount?: number }).byteCount = metadata.byteCount;
}
if (isCount(metadata.itemCount)) {
(sanitized as { itemCount?: number }).itemCount = metadata.itemCount;
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function isCount(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function safeEmit(
emit: (event: PerformancePhaseEvent<M3uImportPerformancePhase>) => void,
event: PerformancePhaseEvent<M3uImportPerformancePhase>
): void {
try {
emit(event);
} catch {
// Instrumentation delivery is deliberately fail-neutral.
}
}
@@ -0,0 +1,312 @@
import {
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
M3U_IMPORT_PERFORMANCE_PHASE,
type PerformancePhaseEvent,
} from '@iptvnator/shared/interfaces';
import {
createM3uImportPerformanceCapture,
type M3uImportPerformanceCaptureRuntime,
} from './playlist-import-performance';
import { fetchPlaylistFromUrl } from './playlist-source';
const mockCreatePlaylistObject = jest.fn();
const mockGetFilenameFromUrl = jest.fn();
const mockParse = jest.fn();
const mockRequestWithValidatedRedirects = jest.fn();
jest.mock('@iptvnator/shared/m3u-utils', () => ({
createPlaylistObject: (...args: unknown[]) =>
mockCreatePlaylistObject(...args),
getFilenameFromUrl: (...args: unknown[]) => mockGetFilenameFromUrl(...args),
}));
jest.mock('iptv-playlist-parser', () => ({
parse: (...args: unknown[]) => mockParse(...args),
}));
jest.mock('../util/secure-https', () => ({
createPlaylistAgentFactory: jest.fn(() => jest.fn()),
}));
jest.mock('../util/validated-axios', () => ({
requestWithValidatedRedirects: (...args: unknown[]) =>
mockRequestWithValidatedRedirects(...args),
}));
type ImportPhaseEvent = PerformancePhaseEvent<
(typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE]
>;
function createRuntime(): M3uImportPerformanceCaptureRuntime {
const epochValues = [100, 110, 120, 130, 140, 150];
const monotonicValues = [10, 14, 20, 23, 30, 36];
return {
createRequestId: jest.fn(() => 'm3u-request-1'),
readEpochMs: jest.fn(() => epochValues.shift() ?? 150),
readMonotonicMs: jest.fn(() => monotonicValues.shift() ?? 36),
};
}
describe('URL playlist import performance phases', () => {
afterEach(() => {
jest.restoreAllMocks();
});
beforeEach(() => {
mockCreatePlaylistObject.mockReset();
mockGetFilenameFromUrl.mockReset();
mockParse.mockReset();
mockRequestWithValidatedRedirects.mockReset();
});
it('does not allocate phase state or read clocks while capture is disabled', async () => {
const runtime = {
createRequestId: jest.fn(() => {
throw new Error('request id must stay untouched');
}),
readEpochMs: jest.fn(() => {
throw new Error('epoch clock must stay untouched');
}),
readMonotonicMs: jest.fn(() => {
throw new Error('monotonic clock must stay untouched');
}),
};
const emit = jest.fn();
const playlist = { playlist: { items: [] }, title: 'Synthetic' };
mockRequestWithValidatedRedirects.mockResolvedValue({
data: '#EXTM3U',
});
mockParse.mockReturnValue({ items: [] });
mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u');
mockCreatePlaylistObject.mockReturnValue(playlist);
const capture = createM3uImportPerformanceCapture({
emit,
enabled: false,
runtime,
});
const result = await fetchPlaylistFromUrl(
'http://127.0.0.1:43210/synthetic-performance.m3u',
undefined,
{ performanceCapture: capture }
);
expect(capture).toBeNull();
expect(result).toBe(playlist);
expect(emit).not.toHaveBeenCalled();
expect(runtime.createRequestId).not.toHaveBeenCalled();
expect(runtime.readEpochMs).not.toHaveBeenCalled();
expect(runtime.readMonotonicMs).not.toHaveBeenCalled();
});
it('emits ordered epoch-aligned pairs with durations and only byte/item metadata', async () => {
const events: ImportPhaseEvent[] = [];
const body = '#EXTM3U\n#EXTINF:-1,One\nhttps://stream.test/1';
const bodyBytes = Buffer.byteLength(body, 'utf8');
const parsed = { items: [{ name: 'One' }, { name: 'Two' }] };
const playlist = {
playlist: { items: parsed.items },
title: 'Synthetic',
};
mockRequestWithValidatedRedirects.mockResolvedValue({
data: body,
headers: {
'content-length': String(bodyBytes),
},
});
mockParse.mockReturnValue(parsed);
mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u');
mockCreatePlaylistObject.mockReturnValue(playlist);
const capture = createM3uImportPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId: 'm3u-request-1',
runtime: createRuntime(),
});
const byteLength = jest.spyOn(Buffer, 'byteLength');
const result = await fetchPlaylistFromUrl(
'http://127.0.0.1:43210/synthetic-performance.m3u',
undefined,
{ performanceCapture: capture }
);
expect(result).toBe(playlist);
expect(byteLength).not.toHaveBeenCalled();
expect(
events.map(
({
boundary,
durationMs,
epochMs,
metadata,
phase,
requestId,
}) => ({
boundary,
durationMs,
epochMs,
metadata,
phase,
requestId,
})
)
).toEqual([
{
boundary: 'start',
durationMs: null,
epochMs: 100,
metadata: undefined,
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
requestId: 'm3u-request-1',
},
{
boundary: 'end',
durationMs: 4,
epochMs: 110,
metadata: { byteCount: bodyBytes },
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
requestId: 'm3u-request-1',
},
{
boundary: 'start',
durationMs: null,
epochMs: 120,
metadata: undefined,
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
requestId: 'm3u-request-1',
},
{
boundary: 'end',
durationMs: 3,
epochMs: 130,
metadata: { itemCount: 2 },
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
requestId: 'm3u-request-1',
},
{
boundary: 'start',
durationMs: null,
epochMs: 140,
metadata: undefined,
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
requestId: 'm3u-request-1',
},
{
boundary: 'end',
durationMs: 6,
epochMs: 150,
metadata: { itemCount: 2 },
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
requestId: 'm3u-request-1',
},
]);
const serialized = JSON.stringify(events);
expect(serialized).not.toContain('127.0.0.1');
expect(serialized).not.toContain('stream.test');
expect(serialized).not.toContain('Synthetic');
expect(serialized).not.toContain('username');
expect(serialized).not.toContain('password');
expect(serialized).not.toContain(
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE
);
});
it('preserves exact results and errors when event or metadata hooks fail', async () => {
const resultIdentity = { result: 'same-object' };
const capture = createM3uImportPerformanceCapture({
emit: () => {
throw new Error('event sink failed');
},
enabled: true,
requestId: 'm3u-request-2',
runtime: createRuntime(),
});
expect(
capture?.measure(
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
() => resultIdentity,
() => {
throw new Error('metadata failed');
}
)
).toBe(resultIdentity);
const businessError = new Error('download failed');
const events: ImportPhaseEvent[] = [];
mockRequestWithValidatedRedirects.mockRejectedValue(businessError);
const errorCapture = createM3uImportPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId: 'm3u-request-3',
runtime: createRuntime(),
});
await expect(
fetchPlaylistFromUrl(
'http://127.0.0.1:43210/failure.m3u',
undefined,
{
performanceCapture: errorCapture,
}
)
).rejects.toBe(businessError);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
},
{
boundary: 'end',
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
},
]);
});
it('samples the end boundary before creating phase metadata', () => {
const events: ImportPhaseEvent[] = [];
let epochMs = 100;
let monotonicMs = 10;
const resultIdentity = { itemCount: 2 };
const capture = createM3uImportPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId: 'm3u-request-boundary',
runtime: {
createRequestId: jest.fn(() => 'unused-request-id'),
readEpochMs: jest.fn(() => epochMs),
readMonotonicMs: jest.fn(() => monotonicMs),
},
});
const result = capture?.measure(
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
() => {
epochMs = 110;
monotonicMs = 20;
return resultIdentity;
},
({ itemCount }) => {
epochMs = 1_110;
monotonicMs = 1_020;
return { itemCount };
}
);
expect(result).toBe(resultIdentity);
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({
boundary: 'end',
durationMs: 10,
epochMs: 110,
metadata: { itemCount: 2 },
});
});
});
@@ -13,8 +13,11 @@ import {
isInvalidTlsCertificateError,
} from '../util/security-errors';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import type { M3uImportPerformanceCapture } from './playlist-import-performance';
import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
export interface PlaylistFetchOptions {
performanceCapture?: M3uImportPerformanceCapture | null;
trustedInsecureTlsHosts?: readonly string[];
}
@@ -26,14 +29,67 @@ export interface PlaylistFetchOptions {
*/
export const PLAYLIST_FETCH_TIMEOUT_MS = 30000;
function countPlaylistItems(value: unknown): number | undefined {
try {
if (typeof value !== 'object' || value === null) {
return undefined;
}
const items = Reflect.get(value, 'items');
return Array.isArray(items) ? items.length : undefined;
} catch {
return undefined;
}
}
function countNormalizedPlaylistItems(value: unknown): number | undefined {
try {
if (typeof value !== 'object' || value === null) {
return undefined;
}
return countPlaylistItems(Reflect.get(value, 'playlist'));
} catch {
return undefined;
}
}
function readResponseContentLength(response: unknown): number | undefined {
try {
if (typeof response !== 'object' || response === null) {
return undefined;
}
const headers = Reflect.get(response, 'headers');
if (typeof headers !== 'object' || headers === null) {
return undefined;
}
const directValue = Reflect.get(headers, 'content-length');
const getHeader = Reflect.get(headers, 'get');
const value =
directValue ??
(typeof getHeader === 'function'
? Reflect.apply(getHeader, headers, ['content-length'])
: undefined);
const numericValue =
typeof value === 'number'
? value
: typeof value === 'string' && value.trim().length > 0
? Number(value)
: Number.NaN;
return Number.isSafeInteger(numericValue) && numericValue >= 0
? numericValue
: undefined;
} catch {
return undefined;
}
}
export async function fetchPlaylistFromUrl(
url: string,
title?: string,
options: PlaylistFetchOptions = {}
): Promise<Playlist> {
let result;
try {
result = await requestWithValidatedRedirects<string>(
const performanceCapture = options.performanceCapture;
const request = () =>
requestWithValidatedRedirects<string>(
url,
{
agentFactory: createPlaylistAgentFactory({
@@ -44,6 +100,17 @@ export async function fetchPlaylistFromUrl(
},
{ allowPrivateNetworks: true }
);
let result;
try {
result = performanceCapture
? await performanceCapture.measureAsync(
M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
request,
(response) => ({
byteCount: readResponseContentLength(response),
})
)
: await request();
} catch (error) {
if (isInvalidTlsCertificateError(error)) {
throw createInvalidTlsCertificateError(
@@ -53,19 +120,38 @@ export async function fetchPlaylistFromUrl(
throw error;
}
const parsedPlaylist = parse(result.data);
const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
const parsedPlaylist = performanceCapture
? performanceCapture.measure(
M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
() => parse(result.data),
(parsed) => ({ itemCount: countPlaylistItems(parsed) })
)
: parse(result.data);
const normalize = (): Playlist => {
const extractedName =
url && url.length > 1 ? getFilenameFromUrl(url) : '';
const playlistName =
!extractedName || extractedName === 'Untitled playlist'
? 'Imported from URL'
: extractedName;
return createPlaylistObject(
title ?? playlistName,
parsedPlaylist,
url,
'URL'
);
return createPlaylistObject(
title ?? playlistName,
parsedPlaylist,
url,
'URL'
);
};
return performanceCapture
? performanceCapture.measure(
M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
normalize,
(playlist) => ({
itemCount: countNormalizedPlaylistItems(playlist),
})
)
: normalize();
}
export async function fetchPlaylistFromFile(
@@ -6,10 +6,13 @@ import type {
} from '@iptvnator/shared/interfaces';
import {
AUTO_UPDATE_PLAYLISTS,
M3U_IMPORT_PERFORMANCE_PHASE,
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL,
PLAYLIST_CANCEL_REFRESH,
PLAYLIST_REFRESH,
PLAYLIST_REFRESH_EVENT,
} from '@iptvnator/shared/interfaces';
import { channel } from 'node:diagnostics_channel';
import { resolve } from 'node:path';
type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise<unknown>;
@@ -41,6 +44,8 @@ const mockCreatePlaylistObject = jest.fn();
const mockGetFilenameFromUrl = jest.fn();
const mockResolveWorkerRuntimeBootstrap = jest.fn();
const mockWorkerInstances: MockWorker[] = [];
const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE';
const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV];
jest.mock('electron', () => ({
app: {
@@ -169,6 +174,11 @@ describe('playlist IPC events', () => {
});
afterEach(() => {
if (originalPerformanceCaptureValue === undefined) {
delete process.env[PERF_CAPTURE_ENV];
} else {
process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue;
}
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore();
@@ -210,6 +220,101 @@ describe('playlist IPC events', () => {
expect(result).toEqual(playlist);
});
it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => {
process.env[PERF_CAPTURE_ENV] = '1';
const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news';
const parsedPlaylist = { items: [{ name: 'News' }] };
const playlist = createPlaylist({
title: 'synthetic-performance.m3u',
url: 'http://127.0.0.1:43210/synthetic-performance.m3u',
});
const events: unknown[] = [];
const performanceChannel = channel(
M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) => events.push(event);
mockAxiosGet.mockResolvedValue({
data: body,
headers: {
'content-length': String(Buffer.byteLength(body, 'utf8')),
},
});
mockParse.mockReturnValue(parsedPlaylist);
mockGetFilenameFromUrl.mockReturnValue('synthetic-performance.m3u');
mockCreatePlaylistObject.mockReturnValue(playlist);
performanceChannel.subscribe(subscriber);
try {
await expect(
getHandler('fetch-playlist-by-url')(
createIpcEvent(),
'http://127.0.0.1:43210/synthetic-performance.m3u'
)
).resolves.toBe(playlist);
} finally {
performanceChannel.unsubscribe(subscriber);
}
expect(
events.map((event) => {
const marker = event as {
boundary: string;
metadata?: { byteCount?: number; itemCount?: number };
phase: string;
requestId: string;
};
return {
boundary: marker.boundary,
metadata: marker.metadata,
phase: marker.phase,
requestIdType: typeof marker.requestId,
};
})
).toEqual([
{
boundary: 'start',
metadata: undefined,
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
requestIdType: 'string',
},
{
boundary: 'end',
metadata: { byteCount: Buffer.byteLength(body, 'utf8') },
phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE,
requestIdType: 'string',
},
{
boundary: 'start',
metadata: undefined,
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
requestIdType: 'string',
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U,
requestIdType: 'string',
},
{
boundary: 'start',
metadata: undefined,
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
requestIdType: 'string',
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE,
requestIdType: 'string',
},
]);
expect(
new Set(events.map((event) => (event as any).requestId)).size
).toBe(1);
expect(JSON.stringify(events)).not.toContain('stream.test');
});
it('returns null when the open playlist dialog is cancelled', async () => {
mockShowOpenDialog.mockResolvedValue({
canceled: true,
@@ -32,6 +32,8 @@ import {
fetchPlaylistFromUrl,
} from './playlist-source';
import { PlaylistWriteAuthorizer } from './playlist-write-authorization';
import { createM3uImportPerformanceCapture } from './playlist-import-performance';
import { isPerformanceCaptureEnabled } from '../services/debug-trace';
export default class PlaylistEvents {
static bootstrapPlaylistEvents(): Electron.IpcMain {
@@ -95,7 +97,11 @@ ipcMain.handle(
options?: ElectronBridgeTrustOptions
) => {
try {
const performanceCapture = createM3uImportPerformanceCapture({
enabled: isPerformanceCaptureEnabled(),
});
return await fetchPlaylistFromUrl(url, title, {
performanceCapture,
trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts,
});
} catch (error) {
@@ -0,0 +1,264 @@
import {
bootstrapRemoteControl as bootstrapRemoteControlWith,
createBodyAtByteLength,
getIpcListener,
invokeHttpHandler,
JSON_HEADERS,
METHOD_NOT_ALLOWED_RESPONSE,
mockFirstRendererSend,
mockGetAllWindows,
mockIpcHandle,
mockIpcOn,
mockRegisterRemoteControlHandler,
mockSecondRendererSend,
mockStartHttpServer,
mockStoreGet,
REMOTE_CONTROL_PATHS,
resetRemoteControlMocks,
SUCCESS_RESPONSE,
type RemoteControlSettings,
} from './remote-control.test-helpers';
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';
const bootstrapRemoteControl = (settings?: RemoteControlSettings) =>
bootstrapRemoteControlWith(RemoteControlEvents, settings);
describe('RemoteControlEvents HTTP endpoints', () => {
let consoleLog: jest.SpyInstance;
let consoleWarn: jest.SpyInstance;
beforeEach(() => {
resetRemoteControlMocks();
consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
consoleWarn = jest
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
});
afterEach(() => {
jest.useRealTimers();
consoleLog.mockRestore();
consoleWarn.mockRestore();
});
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();
}
);
});
@@ -1,81 +1,23 @@
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);
});
import {
bootstrapRemoteControl as bootstrapRemoteControlWith,
getIpcHandler,
invokeHttpHandler,
METHOD_NOT_ALLOWED_RESPONSE,
mockFirstRendererSend,
mockGetAllWindows,
mockHttpHandlers,
mockIpcHandle,
mockIpcHandlers,
mockIpcOn,
mockRegisterRemoteControlHandler,
mockSecondRendererSend,
mockStartHttpServer,
mockStoreGet,
REMOTE_CONTROL_PATHS,
resetRemoteControlMocks,
SUCCESS_RESPONSE,
type RemoteControlSettings,
} from './remote-control.test-helpers';
jest.mock('electron', () => ({
BrowserWindow: {
@@ -102,162 +44,15 @@ jest.mock('../services/store.service', () => ({
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;
});
const bootstrapRemoteControl = (settings?: RemoteControlSettings) =>
bootstrapRemoteControlWith(RemoteControlEvents, settings);
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', () => {
describe('RemoteControlEvents bootstrap and dispatch', () => {
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 } },
]);
resetRemoteControlMocks();
consoleLog = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
@@ -390,199 +185,4 @@ describe('RemoteControlEvents', () => {
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();
}
);
});
@@ -0,0 +1,249 @@
import type * as http from 'node:http';
import { PassThrough } from 'node:stream';
/**
* Shared harness for the remote-control specs.
*
* Every exported mock is `mock`-prefixed so the spec files can reference them
* from their hoisted `jest.mock()` factories.
*/
export type HttpHandler = (
request: http.IncomingMessage,
response: http.ServerResponse
) => void;
export type IpcCallback = (...args: unknown[]) => unknown;
export interface RemoteControlSettings {
enabled: boolean;
port: number;
}
export interface ResponseSnapshot {
statusCode: number;
headers: http.OutgoingHttpHeaders;
body: string;
}
export interface ResponseRecorder {
response: http.ServerResponse;
completed: Promise<ResponseSnapshot>;
}
export interface RequestInvocation {
response: ResponseSnapshot;
request: PassThrough;
requestClosed: Promise<void>;
requestErrors: Error[];
}
export interface InvokeOptions {
method: string;
body?: string | Buffer;
}
export 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;
export const JSON_HEADERS = { 'Content-Type': 'application/json' } as const;
export const SUCCESS_RESPONSE = {
statusCode: 200,
headers: JSON_HEADERS,
body: JSON.stringify({ success: true }),
} as const;
export const METHOD_NOT_ALLOWED_RESPONSE = {
statusCode: 405,
headers: JSON_HEADERS,
body: JSON.stringify({ error: 'Method not allowed' }),
} as const;
export const mockHttpHandlers = new Map<string, HttpHandler>();
export const mockIpcHandlers = new Map<string, IpcCallback>();
export const mockIpcListeners = new Map<string, IpcCallback>();
export const mockRegisterRemoteControlHandler = jest.fn(
(path: string, handler: HttpHandler) => {
mockHttpHandlers.set(path, handler);
}
);
export const mockStartHttpServer = jest.fn();
export const mockStoreGet = jest.fn();
export const mockFirstRendererSend = jest.fn();
export const mockSecondRendererSend = jest.fn();
export const mockGetAllWindows = jest.fn();
export const mockIpcHandle = jest.fn(
(channel: string, handler: IpcCallback) => {
mockIpcHandlers.set(channel, handler);
}
);
export const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => {
mockIpcListeners.set(channel, listener);
});
/** Restore every mock to the state each spec's `beforeEach` expects. */
export function resetRemoteControlMocks(): void {
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 } },
]);
}
export function createResponseRecorder(): ResponseRecorder {
let statusCode = 0;
let headers: http.OutgoingHttpHeaders = {};
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;
});
const 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 };
}
export function getHttpHandler(path: string): HttpHandler {
const handler = mockHttpHandlers.get(path);
if (!handler) {
throw new Error(`Expected HTTP handler for ${path}`);
}
return handler;
}
export function getIpcHandler(channel: string): IpcCallback {
const handler = mockIpcHandlers.get(channel);
if (!handler) {
throw new Error(`Expected IPC handler for ${channel}`);
}
return handler;
}
export function getIpcListener(channel: string): IpcCallback {
const listener = mockIpcListeners.get(channel);
if (!listener) {
throw new Error(`Expected IPC listener for ${channel}`);
}
return listener;
}
/**
* Construct and bootstrap the events class under test. The constructor is
* injected so this module never imports the subject — the spec files own that
* import order relative to their `jest.mock()` calls.
*/
export function bootstrapRemoteControl<
T extends { bootstrapRemoteControlEvents(): unknown },
>(
RemoteControlEvents: new () => T,
settings: RemoteControlSettings = { enabled: false, port: 8765 }
): T {
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;
}
export 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,
};
}
export 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;
}
@@ -0,0 +1,263 @@
import type {
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
import {
XTREAM_MAIN_PERFORMANCE_PHASE,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import {
createXtreamMainPerformanceCapture,
createXtreamMeasuredTransformResponse,
type XtreamMainPerformanceCapture,
type XtreamMainPerformanceCaptureRuntime,
} from './xtream-performance';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
function requireCapture(
runtime: XtreamMainPerformanceCaptureRuntime,
events: MainPhaseEvent[],
requestId?: string
): XtreamMainPerformanceCapture {
const capture = createXtreamMainPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId,
runtime,
});
if (!capture) {
throw new Error('Expected performance capture');
}
return capture;
}
describe('Xtream main performance runtime hardening', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('uses a clock-free safe identifier and epoch when every injected clock throws', () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => {
throw new Error('identifier failed');
}),
readEpochMs: jest.fn(() => {
throw new Error('epoch failed');
}),
readMonotonicMs: jest.fn(() => {
throw new Error('monotonic failed');
}),
};
const wallClock = jest.spyOn(Date, 'now').mockImplementation(() => {
throw new Error('wall clock must stay untouched');
});
let capture: XtreamMainPerformanceCapture | undefined;
expect(() => {
capture = requireCapture(runtime, events, ' unsafe request id ');
}).not.toThrow();
expect(() =>
capture?.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => undefined
)
).not.toThrow();
expect(wallClock).not.toHaveBeenCalled();
expect(events).toHaveLength(2);
expect(
events.every(
({ epochMs, requestId }) =>
Number.isFinite(epochMs) &&
epochMs > 0 &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
)
).toBe(true);
});
it('keeps emitted epochs positive, finite, and nondecreasing across invalid and backward samples', () => {
const events: MainPhaseEvent[] = [];
const epochSamples = [Number.NaN, 100, 90, Number.POSITIVE_INFINITY];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'unused'),
readEpochMs: jest.fn(() => epochSamples.shift() ?? 80),
readMonotonicMs: jest.fn(() => 10),
};
const capture = requireCapture(runtime, events, 'xtream-mabc1001-1');
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() => undefined
);
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => undefined
);
const epochs = events.map(({ epochMs }) => epochMs);
expect(epochs.every((epochMs) => Number.isFinite(epochMs))).toBe(true);
expect(epochs.every((epochMs) => epochMs > 0)).toBe(true);
expect(
epochs.every(
(epochMs, index) => index === 0 || epochMs >= epochs[index - 1]
)
).toBe(true);
});
it('matches Axios by visiting a sparse transform entry and closing the failed JSON phase', () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'unused'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const capture = requireCapture(runtime, events, 'xtream-mabc1002-2');
const sparseTransforms = new Array<AxiosResponseTransformer>(1);
const measured = createXtreamMeasuredTransformResponse(
capture,
sparseTransforms
);
expect(() =>
measured.call(
{} as InternalAxiosRequestConfig,
'raw',
{} as AxiosResponseHeaders,
200
)
).toThrow(TypeError);
expect(events.map(({ boundary }) => boundary)).toEqual([
'start',
'end',
]);
});
it('is fail-neutral and never publishes unsafe request identifiers or business data', () => {
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'xtream-main-safe-fallback'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const capture = createXtreamMainPerformanceCapture({
emit: () => {
throw new Error('diagnostics sink failed');
},
enabled: true,
requestId: 'provider-password-secret',
runtime,
});
const businessError = new Error('provider-password-secret');
expect(() =>
capture?.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
() => {
throw businessError;
}
)
).toThrow(businessError);
expect(runtime.createRequestId).toHaveBeenCalledTimes(1);
const events: MainPhaseEvent[] = [];
const safeCapture = requireCapture(
runtime,
events,
'provider-password-secret'
);
safeCapture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => ({ secret: 'response-body-secret' })
);
expect(
events.every(
({ requestId }) => requestId === 'xtream-main-safe-fallback'
)
).toBe(true);
const serialized = JSON.stringify(events);
expect(serialized).not.toContain('provider-password-secret');
expect(serialized).not.toContain('response-body-secret');
expect(
events.every(
(event) =>
event.metadata === undefined &&
Object.keys(event).every((key) =>
[
'boundary',
'durationMs',
'epochMs',
'metadata',
'phase',
'requestId',
].includes(key)
)
)
).toBe(true);
});
it('correlates only the renderer-generated Xtream request ID shape', () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'xtream-main-unused'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const capture = requireCapture(runtime, events, 'xtream-mabc1234-17');
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => undefined
);
expect(events.map(({ requestId }) => requestId)).toEqual([
'xtream-mabc1234-17',
'xtream-mabc1234-17',
]);
expect(runtime.createRequestId).not.toHaveBeenCalled();
});
it('keeps overlapping request phases isolated by capture-local request ID', async () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'unused'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const first = requireCapture(runtime, events, 'xtream-mabc1003-3');
const second = requireCapture(runtime, events, 'xtream-mabc1004-4');
let resolveFirst!: () => void;
let resolveSecond!: () => void;
const firstWork = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const secondWork = new Promise<void>((resolve) => {
resolveSecond = resolve;
});
const firstResult = first.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() => firstWork
);
const secondResult = second.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() => secondWork
);
resolveSecond();
await secondResult;
resolveFirst();
await firstResult;
for (const requestId of ['xtream-mabc1003-3', 'xtream-mabc1004-4']) {
const requestEvents = events.filter(
(event) => event.requestId === requestId
);
expect(requestEvents.map(({ boundary }) => boundary)).toEqual([
'start',
'end',
]);
}
});
});
@@ -0,0 +1,235 @@
import type {
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
import {
XTREAM_MAIN_PERFORMANCE_PHASE,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import {
createXtreamMainPerformanceCapture,
createXtreamMeasuredTransformResponse,
type XtreamMainPerformanceCapture,
type XtreamMainPerformanceCaptureRuntime,
} from './xtream-performance';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
function createRuntime(
requestId = 'xtream-main-generated'
): XtreamMainPerformanceCaptureRuntime {
let epochMs = 100;
let monotonicMs = 10;
return {
createRequestId: jest.fn(() => requestId),
readEpochMs: jest.fn(() => {
epochMs += 10;
return epochMs;
}),
readMonotonicMs: jest.fn(() => {
monotonicMs += 2;
return monotonicMs;
}),
};
}
function createCapture(
events: MainPhaseEvent[],
requestId = 'xtream-mabc1000-1',
runtime = createRuntime()
): XtreamMainPerformanceCapture {
const capture = createXtreamMainPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId,
runtime,
});
if (!capture) {
throw new Error('Expected performance capture');
}
return capture;
}
describe('Xtream main-process performance capture', () => {
it('does no identifier, clock, or sink work while disabled', () => {
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => {
throw new Error('identifier must stay untouched');
}),
readEpochMs: jest.fn(() => {
throw new Error('epoch clock must stay untouched');
}),
readMonotonicMs: jest.fn(() => {
throw new Error('monotonic clock must stay untouched');
}),
};
const emit = jest.fn(() => {
throw new Error('sink must stay untouched');
});
expect(
createXtreamMainPerformanceCapture({
emit,
enabled: false,
requestId: 'xtream-mabc1000-1',
runtime,
})
).toBeNull();
expect(runtime.createRequestId).not.toHaveBeenCalled();
expect(runtime.readEpochMs).not.toHaveBeenCalled();
expect(runtime.readMonotonicMs).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it('preserves nested async results and exact errors while closing every phase', async () => {
const events: MainPhaseEvent[] = [];
const capture = createCapture(events);
const resultIdentity = { payload: 'same-object' };
const result = await capture.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
async () =>
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
() => resultIdentity
)
);
expect(result).toBe(resultIdentity);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
]);
const businessError = new Error('same-error');
expect(() =>
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
() => {
throw businessError;
}
)
).toThrow(businessError);
expect(events.slice(-2).map(({ boundary }) => boundary)).toEqual([
'start',
'end',
]);
});
it('replays the exact Axios transform chain, context, headers, and status', () => {
const events: MainPhaseEvent[] = [];
const capture = createCapture(events);
const context = {
responseType: undefined,
} as InternalAxiosRequestConfig;
const headers = {
normalize: jest.fn(function () {
return this;
}),
} as unknown as AxiosResponseHeaders;
const firstResult = { step: 1 };
const finalResult = { step: 2 };
const observations: unknown[] = [];
const transforms: AxiosResponseTransformer[] = [
function (data, receivedHeaders, status) {
observations.push({
context: this,
data,
headers: receivedHeaders,
status,
});
return firstResult;
},
function (data, receivedHeaders, status) {
observations.push({
context: this,
data,
headers: receivedHeaders,
status,
});
return finalResult;
},
];
const measured = createXtreamMeasuredTransformResponse(
capture,
transforms
);
const result = measured.call(context, 'raw-json', headers, 200);
expect(result).toBe(finalResult);
expect(observations).toEqual([
{
context,
data: 'raw-json',
headers,
status: 200,
},
{
context,
data: firstResult,
headers,
status: 200,
},
]);
expect(headers.normalize).toHaveBeenCalledTimes(1);
expect(
events.filter(
({ phase }) =>
phase === XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM
)
).toHaveLength(2);
});
it('runs redirect transforms unchanged but emits one aggregate pair for the final response', () => {
const events: MainPhaseEvent[] = [];
const capture = createCapture(events);
const transform = jest.fn(function (data: unknown) {
return data;
}) as AxiosResponseTransformer;
const measured = createXtreamMeasuredTransformResponse(
capture,
transform
);
const context = {} as InternalAxiosRequestConfig;
const headers = {} as AxiosResponseHeaders;
expect(measured.call(context, 'redirect-body', headers, 302)).toBe(
'redirect-body'
);
expect(measured.call(context, 'final-body', headers, 200)).toBe(
'final-body'
);
expect(transform).toHaveBeenCalledTimes(2);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
]);
});
});
@@ -0,0 +1,262 @@
import type {
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
import {
PERFORMANCE_PHASE_BOUNDARY,
XTREAM_MAIN_PERFORMANCE_PHASE,
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import { randomUUID } from 'node:crypto';
import { channel, type Channel } from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
export interface XtreamMainPerformanceCaptureRuntime {
readonly createRequestId: () => string;
readonly readEpochMs: () => number;
readonly readMonotonicMs: () => number;
}
export interface XtreamMainPerformanceCapture {
measure<TResult>(
phase: XtreamMainPerformancePhase,
execute: () => TResult
): TResult;
measureAsync<TResult>(
phase: XtreamMainPerformancePhase,
execute: () => Promise<TResult>
): Promise<TResult>;
}
interface CaptureOptions {
readonly emit?: (event: MainPhaseEvent) => void;
readonly enabled: boolean;
readonly requestId?: string;
readonly runtime?: XtreamMainPerformanceCaptureRuntime;
}
interface PhaseStart {
readonly monotonicMs: number;
readonly phase: XtreamMainPerformancePhase;
}
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
type TransformResponse =
AxiosResponseTransformer | AxiosResponseTransformer[] | undefined;
const DEFAULT_RUNTIME: XtreamMainPerformanceCaptureRuntime = {
createRequestId: () => `xtream-main-${randomUUID()}`,
readEpochMs: () => performance.timeOrigin + performance.now(),
readMonotonicMs: () => performance.now(),
};
const ENVIRONMENT_CAPTURE_ENABLED =
process.env['IPTVNATOR_PERF_CAPTURE'] === '1';
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const INTERNAL_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RENDERER_REQUEST_ID = /^xtream-[0-9a-z]+-[1-9][0-9]*$/;
const MAX_RENDERER_REQUEST_ID_LENGTH = 64;
let fallbackRequestSequence = 0;
let performanceChannel: Channel | null = null;
export function createXtreamMainPerformanceCaptureForRequest(
requestId?: string
): XtreamMainPerformanceCapture | null {
return ENVIRONMENT_CAPTURE_ENABLED
? createXtreamMainPerformanceCapture({
enabled: true,
requestId,
})
: null;
}
export function createXtreamMainPerformanceCapture(
options: CaptureOptions
): XtreamMainPerformanceCapture | null {
if (!options.enabled) {
return null;
}
const runtime = options.runtime ?? DEFAULT_RUNTIME;
const requestId =
normalizeRendererRequestId(options.requestId) ??
createRequestId(runtime);
const emit = options.emit ?? publishXtreamMainPerformancePhaseEvent;
let lastEpochMs = 0;
const readEpochMs = (): number => {
lastEpochMs = safeReadEpochMs(runtime, lastEpochMs);
return lastEpochMs;
};
const begin = (phase: XtreamMainPerformancePhase): PhaseStart => {
const monotonicMs = safeReadMonotonicMs(runtime);
safeEmit(emit, {
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
durationMs: null,
epochMs: readEpochMs(),
metadata: undefined,
phase,
requestId,
});
return { monotonicMs, phase };
};
const end = (start: PhaseStart): void => {
const endedMonotonicMs = safeReadMonotonicMs(runtime);
const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs);
safeEmit(emit, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: Number.isFinite(durationMs) ? durationMs : 0,
epochMs: readEpochMs(),
metadata: undefined,
phase: start.phase,
requestId,
});
};
return {
measure: (phase, execute) => {
const start = begin(phase);
try {
return execute();
} finally {
end(start);
}
},
measureAsync: async (phase, execute) => {
const start = begin(phase);
try {
return await execute();
} finally {
end(start);
}
},
};
}
export function createXtreamMeasuredTransformResponse(
capture: XtreamMainPerformanceCapture,
configuredTransforms: TransformResponse
): AxiosResponseTransformer {
const transforms = normalizeTransforms(configuredTransforms);
return function measuredTransformResponse(data, headers, status) {
const execute = () =>
runTransformChain(transforms, this, data, headers, status);
return status !== undefined && REDIRECT_STATUSES.has(status)
? execute()
: capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
execute
);
};
}
function runTransformChain(
transforms: readonly AxiosResponseTransformer[],
context: InternalAxiosRequestConfig,
initialData: unknown,
headers: AxiosResponseHeaders,
status: number | undefined
): unknown {
let data = initialData;
for (let index = 0; index < transforms.length; index += 1) {
const transform = transforms[index];
const normalizedHeaders =
index === 0 ? headers : normalizeHeaders(headers);
data = transform.call(context, data, normalizedHeaders, status);
}
return data;
}
function normalizeTransforms(
transforms: TransformResponse
): readonly AxiosResponseTransformer[] {
if (!transforms) {
return [];
}
return Array.isArray(transforms) ? transforms.slice() : [transforms];
}
function normalizeHeaders(headers: AxiosResponseHeaders): AxiosResponseHeaders {
const normalize = (
headers as AxiosResponseHeaders & {
normalize?: () => AxiosResponseHeaders;
}
).normalize;
return typeof normalize === 'function' ? normalize.call(headers) : headers;
}
function publishXtreamMainPerformancePhaseEvent(event: MainPhaseEvent): void {
try {
performanceChannel ??= channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
performanceChannel.publish(event);
} catch {
// Development-only instrumentation must never affect Xtream requests.
}
}
function createRequestId(runtime: XtreamMainPerformanceCaptureRuntime): string {
try {
const requestId = normalizeInternalRequestId(runtime.createRequestId());
if (requestId) {
return requestId;
}
} catch {
// Fall through to a process-local, credential-free identifier.
}
fallbackRequestSequence += 1;
return `xtream-main-fallback-${fallbackRequestSequence}`;
}
function normalizeRendererRequestId(value: unknown): string | null {
return typeof value === 'string' &&
value.length <= MAX_RENDERER_REQUEST_ID_LENGTH &&
RENDERER_REQUEST_ID.test(value)
? value
: null;
}
function normalizeInternalRequestId(value: unknown): string | null {
return typeof value === 'string' && INTERNAL_REQUEST_ID.test(value)
? value
: null;
}
function safeReadEpochMs(
runtime: XtreamMainPerformanceCaptureRuntime,
previousEpochMs: number
): number {
try {
const value = runtime.readEpochMs();
return Number.isFinite(value) && value > 0
? Math.max(previousEpochMs, value)
: Math.max(previousEpochMs, 1);
} catch {
return Math.max(previousEpochMs, 1);
}
}
function safeReadMonotonicMs(
runtime: XtreamMainPerformanceCaptureRuntime
): number {
try {
const value = runtime.readMonotonicMs();
return Number.isFinite(value) && value >= 0 ? value : 0;
} catch {
return 0;
}
}
function safeEmit(
emit: (event: MainPhaseEvent) => void,
event: MainPhaseEvent
) {
try {
emit(event);
} catch {
// Instrumentation delivery is deliberately fail-neutral.
}
}
@@ -0,0 +1,26 @@
interface ActiveXtreamRequest {
readonly controller: AbortController;
readonly sessionId?: string;
}
interface XtreamCancellationResult {
readonly cancelled: number;
readonly success: boolean;
}
export function cancelXtreamSessionRequests(
activeRequests: Iterable<ActiveXtreamRequest>,
sessionId: string
): XtreamCancellationResult {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
let cancelled = 0;
for (const activeRequest of activeRequests) {
if (activeRequest.sessionId === sessionId) {
activeRequest.controller.abort();
cancelled += 1;
}
}
return { success: cancelled > 0, cancelled };
}
@@ -0,0 +1,204 @@
import type { AxiosRequestConfig } from 'axios';
import {
XTREAM_MAIN_PERFORMANCE_PHASE,
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import { channel } from 'node:diagnostics_channel';
import {
applyConfiguredTransforms,
axiosMock,
getHandler,
loadXtreamEvents,
mockParseTransform,
} from './xtream.events.performance.test-helpers';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
function expectFailedResponsePhases(events: MainPhaseEvent[]): void {
expect(events.map(({ boundary, phase }) => ({ boundary, phase }))).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
]);
expect(
events.some(
({ phase }) =>
phase === XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY
)
).toBe(false);
}
describe('XtreamEvents failed-response performance phases', () => {
const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE'];
afterEach(() => {
if (originalCaptureValue === undefined) {
delete process.env['IPTVNATOR_PERF_CAPTURE'];
} else {
process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue;
}
});
it('closes JSON and network phases without marking an Axios error response ready', async () => {
await loadXtreamEvents('1');
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
let axiosError: Error & {
response?: {
data: unknown;
headers: object;
status: number;
statusText: string;
};
};
axiosMock.mockImplementation((config: AxiosRequestConfig) => {
axiosError = Object.assign(new Error('provider unavailable'), {
response: {
data: applyConfiguredTransforms(
config,
'{"message":"provider unavailable"}',
503
),
headers: {},
status: 503,
statusText: 'Unavailable',
},
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === axiosError
);
return Promise.reject(axiosError);
});
await expect(
getHandler('XTREAM_REQUEST')(
{},
{
params: {
action: 'get_live_categories',
password: 'provider-password-secret',
username: 'provider-user-secret',
},
requestId: 'xtream-mabc1007-7',
sessionId: 'xtream-session-error',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)
).rejects.toEqual({
message: 'provider unavailable',
status: 503,
type: 'ERROR',
});
expectFailedResponsePhases(events);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
it('closes a fulfilled 4xx response without marking an IPC result ready', async () => {
await loadXtreamEvents('1');
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
axiosMock.mockImplementation((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: applyConfiguredTransforms(
config,
'{"message":"not found"}',
404
),
headers: {},
status: 404,
statusText: 'Not Found',
})
);
await expect(
getHandler('XTREAM_REQUEST')(
{},
{
params: { action: 'get_live_categories' },
requestId: 'xtream-mabc1008-8',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)
).rejects.toEqual({
message: 'HTTP Error: Not Found',
status: 404,
});
expectFailedResponsePhases(events);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
it('preserves an exact default-transform error while closing JSON and network once', async () => {
await loadXtreamEvents('1');
const transformError = new Error('default transform failed');
mockParseTransform.mockImplementationOnce(() => {
throw transformError;
});
axiosMock.mockImplementation((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: applyConfiguredTransforms(config, '{"items":[]}', 200),
headers: {},
status: 200,
statusText: 'OK',
})
);
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
await expect(
getHandler('XTREAM_REQUEST')(
{},
{
params: { action: 'get_live_categories' },
requestId: 'xtream-mabc1009-9',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)
).rejects.toBe(transformError);
expectFailedResponsePhases(events);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
});
@@ -0,0 +1,254 @@
import type { AxiosRequestConfig } from 'axios';
import {
XTREAM_CANCEL_SESSION,
XTREAM_MAIN_PERFORMANCE_PHASE,
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import { channel } from 'node:diagnostics_channel';
import {
applyConfiguredTransforms,
axiosMock,
createDeferred,
getDefaultsReadCount,
getHandler,
loadXtreamEvents,
mockFinalizeTransform,
mockParseTransform,
} from './xtream.events.performance.test-helpers';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
describe('XtreamEvents performance phases', () => {
const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE'];
afterEach(() => {
if (originalCaptureValue === undefined) {
delete process.env['IPTVNATOR_PERF_CAPTURE'];
} else {
process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue;
}
jest.restoreAllMocks();
});
it.each([undefined, 'true', 'yes', 'on', ' 1 ', '0'])(
'keeps the Axios path untouched for non-exact capture value %p',
async (captureValue) => {
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
await loadXtreamEvents(captureValue);
axiosMock.mockImplementation((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: { same: 'identity' },
headers: {},
status: 200,
statusText: 'OK',
})
);
await getHandler('XTREAM_REQUEST')(
{},
{
params: { action: 'get_live_categories' },
suppressErrorLog: true,
url: 'http://localhost:3211',
}
);
const config = axiosMock.mock.calls[0][0];
expect(
Object.prototype.hasOwnProperty.call(
config,
'transformResponse'
)
).toBe(false);
expect(getDefaultsReadCount()).toBe(0);
expect(events).toEqual([]);
} finally {
phaseChannel.unsubscribe(subscriber);
}
}
);
it('pairs one final JSON phase across redirects and marks the ready response', async () => {
await loadXtreamEvents('1');
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
let finalPayload: unknown;
try {
axiosMock
.mockImplementationOnce((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: applyConfiguredTransforms(
config,
'{"redirect":true}',
302
),
headers: { location: '/final' },
status: 302,
statusText: 'Found',
})
)
.mockImplementationOnce((config: AxiosRequestConfig) => {
finalPayload = applyConfiguredTransforms(
config,
'{"items":[1,2,3]}',
200
);
return Promise.resolve({
config,
data: finalPayload,
headers: {},
status: 200,
statusText: 'OK',
});
});
const result = (await getHandler('XTREAM_REQUEST')(
{},
{
params: {
action: 'get_live_categories',
password: 'provider-password-secret',
username: 'provider-user-secret',
},
requestId: 'xtream-mabc1005-5',
sessionId: 'xtream-session-1',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)) as { action: string; payload: unknown };
expect(result.payload).toBe(finalPayload);
expect(result.action).toBe('get_live_categories');
expect(getDefaultsReadCount()).toBe(1);
expect(mockParseTransform).toHaveBeenCalledTimes(2);
expect(mockFinalizeTransform).toHaveBeenCalledTimes(2);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
},
]);
const serialized = JSON.stringify(events);
expect(serialized).not.toContain('localhost');
expect(serialized).not.toContain('provider-user-secret');
expect(serialized).not.toContain('provider-password-secret');
expect(serialized).not.toContain('items');
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
it('closes network and cancel dispatch exactly once without a response-ready marker on abort', async () => {
await loadXtreamEvents('1');
const enteredAxios = createDeferred<void>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
let signal: AbortSignal | undefined;
axiosMock.mockImplementation((config: AxiosRequestConfig) => {
signal = config.signal as AbortSignal;
enteredAxios.resolve();
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(cancelError), {
once: true,
});
});
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === cancelError
);
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
const request = getHandler('XTREAM_REQUEST')(
{},
{
params: {
action: 'get_live_streams',
password: 'secret',
username: 'user',
},
requestId: 'xtream-mabc1006-6',
sessionId: 'xtream-session-abort',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
) as Promise<unknown>;
await enteredAxios.promise;
await expect(
getHandler(XTREAM_CANCEL_SESSION)({}, 'xtream-session-abort')
).resolves.toEqual({ cancelled: 1, success: true });
await expect(request).rejects.toMatchObject({
name: 'AbortError',
status: 499,
});
expect(signal?.aborted).toBe(true);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
]);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
});
@@ -0,0 +1,118 @@
import type {
AxiosRequestConfig,
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
export const mockParseTransform = jest.fn(function (data: unknown) {
return typeof data === 'string' ? JSON.parse(data) : data;
});
export const mockFinalizeTransform = jest.fn(function (data: unknown) {
return data;
});
const mockDefaultTransforms: AxiosResponseTransformer[] = [
mockParseTransform,
mockFinalizeTransform,
];
let mockDefaultsReadCount = 0;
export const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
Object.defineProperty(axiosMock, 'defaults', {
configurable: true,
get: () => {
mockDefaultsReadCount += 1;
return { transformResponse: mockDefaultTransforms };
},
});
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(name: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(name, handler);
}
),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
jest.mock('./portal-debug.events', () => ({
emitPortalDebugEvent: jest.fn(),
}));
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
export function applyConfiguredTransforms(
config: AxiosRequestConfig,
data: unknown,
status: number
): unknown {
const configured = config.transformResponse;
if (!configured) {
return data;
}
const headers = {
normalize: jest.fn(function () {
return this;
}),
} as unknown as AxiosResponseHeaders & {
normalize: () => AxiosResponseHeaders;
};
const transforms = Array.isArray(configured) ? configured : [configured];
let transformed = data;
for (const transform of transforms) {
transformed = transform.call(
config as InternalAxiosRequestConfig,
transformed,
headers.normalize(),
status
);
}
headers.normalize();
return transformed;
}
export async function loadXtreamEvents(
captureValue: string | undefined
): Promise<void> {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
mockParseTransform.mockClear();
mockFinalizeTransform.mockClear();
mockDefaultsReadCount = 0;
if (captureValue === undefined) {
delete process.env['IPTVNATOR_PERF_CAPTURE'];
} else {
process.env['IPTVNATOR_PERF_CAPTURE'] = captureValue;
}
await import('./xtream.events');
}
export function getHandler(name: string): (...args: unknown[]) => unknown {
const handler = registeredHandlers.get(name);
if (!handler) {
throw new Error(`Missing IPC handler: ${name}`);
}
return handler;
}
export function getDefaultsReadCount(): number {
return mockDefaultsReadCount;
}
@@ -4,6 +4,8 @@ const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE';
const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV];
function createDeferred<T>() {
let resolve!: (value: T) => void;
@@ -40,6 +42,7 @@ describe('XtreamEvents session cancellation', () => {
beforeEach(async () => {
jest.resetModules();
delete process.env[PERF_CAPTURE_ENV];
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
@@ -52,6 +55,14 @@ describe('XtreamEvents session cancellation', () => {
consoleErrorSpy.mockRestore();
});
afterAll(() => {
if (originalPerformanceCaptureValue === undefined) {
delete process.env[PERF_CAPTURE_ENV];
} else {
process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue;
}
});
it('normalizes full Xtream API URLs before appending player_api.php', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
expect(requestHandler).toBeDefined();
@@ -8,11 +8,17 @@ import { ipcMain } from 'electron';
import {
PortalDebugEvent,
XTREAM_CANCEL_SESSION,
XTREAM_MAIN_PERFORMANCE_PHASE,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import {
createXtreamMainPerformanceCaptureForRequest,
createXtreamMeasuredTransformResponse,
} from './xtream-performance';
import { cancelXtreamSessionRequests } from './xtream-session-cancellation';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
@@ -100,6 +106,9 @@ ipcMain.handle(
}
) => {
const startedAt = Date.now();
const performanceCapture = createXtreamMainPerformanceCaptureForRequest(
payload.requestId
);
let activeRequestKey: string | null = null;
let requestUrlForLog = payload.url;
try {
@@ -131,13 +140,29 @@ ipcMain.handle(
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
signal: controller.signal,
};
if (performanceCapture) {
config.transformResponse =
createXtreamMeasuredTransformResponse(
performanceCapture,
axios.defaults.transformResponse
);
}
const response = await requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
);
const response = performanceCapture
? await performanceCapture.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() =>
requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
)
)
: await requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
);
// Check if response is successful
if (response.status >= 400) {
throw {
@@ -168,10 +193,16 @@ ipcMain.handle(
}
// Xtream API returns JSON data
return {
const result = {
payload: response.data,
action: params.action,
};
return performanceCapture
? performanceCapture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => result
)
: result;
} catch (error) {
const requestId = payload.requestId;
if (requestId) {
@@ -268,24 +299,20 @@ ipcMain.handle(
_event,
sessionId: string
): Promise<{ success: boolean; cancelled: number }> => {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
let cancelled = 0;
for (const activeRequest of activeXtreamRequests.values()) {
if (activeRequest.sessionId !== sessionId) {
continue;
}
activeRequest.controller.abort();
cancelled += 1;
}
return {
success: cancelled > 0,
cancelled,
};
const capture = createXtreamMainPerformanceCaptureForRequest();
return capture
? capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
() =>
cancelXtreamSessionRequests(
activeXtreamRequests.values(),
sessionId
)
)
: cancelXtreamSessionRequests(
activeXtreamRequests.values(),
sessionId
);
}
);
type ActiveXtreamRequest = {
@@ -9,9 +9,9 @@ import {
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 { join } from 'node:path';
import { HttpServer, resolveStaticFilePath } from './http-server';
import { HttpServer } from './http-server';
interface CapturedServer {
listening: Promise<void>;
@@ -119,77 +119,6 @@ async function getAvailablePort(): Promise<number> {
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;
@@ -0,0 +1,74 @@
import { posix, win32 } from 'node:path';
import { resolveStaticFilePath } from './http-server';
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');
});
});
@@ -148,6 +148,40 @@ describe('DatabaseWorkerClient', () => {
});
});
it('ignores profiling-only cancel receipts without settling or exposing the pending request', async () => {
const client = createClient();
const onEvent = jest.fn();
const requestPromise = client.request(
'DB_DELETE_XTREAM_CONTENT',
{ operationId: 'operation-1', playlistId: 'xtream-1' },
{ onEvent }
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'ready' });
await flushPromises();
const request = worker.postMessage.mock.calls[0][0];
worker.emit('message', {
type: 'performance-cancel-received',
operationId: 'operation-1',
requestId: request.requestId,
epochMs: 123,
});
expect(onEvent).not.toHaveBeenCalled();
const resultIdentity = { success: true };
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: true,
result: resultIdentity,
});
await expect(requestPromise).resolves.toBe(resultIdentity);
});
it('turns serialized worker errors into rejected Error instances', async () => {
const client = createClient();
const requestPromise = client.request('DB_DELETE_PLAYLIST', {
@@ -194,7 +194,13 @@ export class DatabaseWorkerClient {
requestId: message.requestId,
});
}
this.pendingRequests.get(message.requestId)?.onEvent?.(message.event);
this.pendingRequests
.get(message.requestId)
?.onEvent?.(message.event);
return;
}
if (message.type === 'performance-cancel-received') {
return;
}
@@ -1,53 +1,40 @@
import { readFileSync } from 'fs';
import path from 'path';
// Checkouts with core.autocrlf=true materialize these sources with CRLF
// line endings; normalize so the multi-line assertions below match the
// LF-based expectations on every host.
function readSource(relativePath: string): string {
return readFileSync(path.resolve(__dirname, relativePath), 'utf8').replace(
/\r\n/g,
'\n'
);
}
describe('Embedded MPV native source recording invariants', () => {
const nativeSource = readFileSync(
path.resolve(__dirname, '../../../native/src/embedded_mpv.mm'),
'utf8'
const nativeSource = readSource('../../../native/src/embedded_mpv.mm');
const widCommonSource = readSource(
'../../../native/src/embedded_mpv_wid_common.h'
);
const widCommonSource = readFileSync(
path.resolve(
__dirname,
'../../../native/src/embedded_mpv_wid_common.h'
),
'utf8'
const win32Source = readSource(
'../../../native/src/embedded_mpv_win32.cc'
);
const win32Source = readFileSync(
path.resolve(__dirname, '../../../native/src/embedded_mpv_win32.cc'),
'utf8'
const linuxSource = readSource(
'../../../native/src/embedded_mpv_linux.cc'
);
const linuxSource = readFileSync(
path.resolve(__dirname, '../../../native/src/embedded_mpv_linux.cc'),
'utf8'
);
const buildScriptSource = readFileSync(
path.resolve(__dirname, '../../../build-embedded-mpv.js'),
'utf8'
);
const buildAndMakeWorkflowSource = readFileSync(
path.resolve(
__dirname,
'../../../../../.github/workflows/build-and-make.yaml'
),
'utf8'
const buildScriptSource = readSource('../../../build-embedded-mpv.js');
const buildAndMakeWorkflowSource = readSource(
'../../../../../.github/workflows/build-and-make.yaml'
);
const electronBuilderConfig = JSON.parse(
readFileSync(
path.resolve(__dirname, '../../../../../electron-builder.json'),
'utf8'
)
readSource('../../../../../electron-builder.json')
) as {
snap?: {
plugs?: unknown;
};
};
const stageRuntimeSource = readFileSync(
path.resolve(
__dirname,
'../../../../../tools/embedded-mpv/stage-runtime.mjs'
),
'utf8'
const stageRuntimeSource = readSource(
'../../../../../tools/embedded-mpv/stage-runtime.mjs'
);
const frameHelperRenderSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
@@ -1112,4 +1112,21 @@ describe('EmbeddedMpvNativeService power blocker', () => {
})
);
});
it('still broadcasts a closed session when native dispose throws', () => {
startSession('s1', snapshot('playing'));
addon.disposeSession.mockImplementationOnce(() => {
throw new Error('native dispose failed');
});
const disposed = service.disposeSession('s1');
expect(disposed?.status).toBe('closed');
expect(mainWindowSendMock).toHaveBeenLastCalledWith(
'EMBEDDED_MPV_SESSION_UPDATE',
expect.objectContaining({ id: 's1', status: 'closed' })
);
// The session is removed from the registry despite the addon failure.
expect(service.disposeSession('s1')).toBeNull();
});
});
@@ -663,32 +663,35 @@ export class EmbeddedMpvNativeService {
lastRecording = undefined;
}
addon.disposeSession(sessionId);
} finally {
this.sessions.delete(sessionId);
this.pollFailuresLogged.delete(sessionId);
const payload: EmbeddedMpvSession = {
id: session.id,
title: session.title,
streamUrl: session.streamUrl,
status: 'closed',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: this.createClosedRecordingState(lastRecording),
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
};
this.sendSessionUpdate(payload);
this.stopPollingIfIdle();
this.updatePowerBlocker();
return payload;
} catch {
// Native dispose failures must not block registry cleanup or the
// closed-session broadcast below.
}
this.sessions.delete(sessionId);
this.pollFailuresLogged.delete(sessionId);
const payload: EmbeddedMpvSession = {
id: session.id,
title: session.title,
streamUrl: session.streamUrl,
status: 'closed',
positionSeconds: 0,
durationSeconds: null,
volume: 1,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: this.createClosedRecordingState(lastRecording),
startedAt: session.startedAt,
updatedAt: new Date().toISOString(),
};
this.sendSessionUpdate(payload);
this.stopPollingIfIdle();
this.updatePowerBlocker();
return payload;
}
shutdown(): void {
@@ -979,6 +982,9 @@ export class EmbeddedMpvNativeService {
private sanitizeRecordingFileName(title: string): string {
const normalized = title
// Control characters are invalid in file names on Windows; the
// range is matched intentionally.
// eslint-disable-next-line no-control-regex
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim();
@@ -0,0 +1,163 @@
import {
ALLOW_MULTIPLE_INSTANCES_ENV,
acquireSingleInstanceLock,
allowsMultipleInstances,
focusExistingWindow,
type SingleInstanceApp,
type SingleInstanceWindow,
} from './single-instance';
function createApp(hasLock: boolean): jest.Mocked<SingleInstanceApp> {
return {
quit: jest.fn(),
on: jest.fn(),
requestSingleInstanceLock: jest.fn().mockReturnValue(hasLock),
};
}
function createWindow(
overrides: Partial<SingleInstanceWindow> = {}
): jest.Mocked<SingleInstanceWindow> {
return {
focus: jest.fn(),
isDestroyed: jest.fn().mockReturnValue(false),
isMinimized: jest.fn().mockReturnValue(false),
isVisible: jest.fn().mockReturnValue(true),
restore: jest.fn(),
show: jest.fn(),
...overrides,
} as jest.Mocked<SingleInstanceWindow>;
}
describe('allowsMultipleInstances', () => {
it.each(['1', 'true', 'YES', ' on '])(
'treats %p as an opt-in',
(value) => {
expect(
allowsMultipleInstances({
[ALLOW_MULTIPLE_INSTANCES_ENV]: value,
})
).toBe(true);
}
);
it.each([undefined, '', '0', 'false', 'nope'])(
'treats %p as opted out',
(value) => {
expect(
allowsMultipleInstances(
value === undefined
? {}
: { [ALLOW_MULTIPLE_INSTANCES_ENV]: value }
)
).toBe(false);
}
);
});
describe('acquireSingleInstanceLock', () => {
it('continues startup and registers the focus handler when the lock is free', () => {
const app = createApp(true);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {})
).toBe(true);
expect(app.quit).not.toHaveBeenCalled();
expect(app.on).toHaveBeenCalledWith(
'second-instance',
expect.any(Function)
);
});
it('quits and stops startup when another instance owns the profile', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {})
).toBe(false);
expect(app.quit).toHaveBeenCalledTimes(1);
expect(app.on).not.toHaveBeenCalled();
});
it('focuses the existing window when a second instance is launched', () => {
const app = createApp(true);
const window = createWindow({
isMinimized: jest.fn().mockReturnValue(true),
isVisible: jest.fn().mockReturnValue(false),
});
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(window.restore).toHaveBeenCalledTimes(1);
expect(window.show).toHaveBeenCalledTimes(1);
expect(window.focus).toHaveBeenCalledTimes(1);
expect(createMainWindow).not.toHaveBeenCalled();
});
// macOS keeps the process alive after the last window closes, so a second
// launch must rebuild a window instead of quitting into nothing.
it.each([
['no window exists', null],
['the window was destroyed', 'destroyed'],
])('re-creates the main window when %s', (_label, windowState) => {
const app = createApp(true);
const window =
windowState === 'destroyed'
? createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
})
: null;
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(createMainWindow).toHaveBeenCalledTimes(1);
if (window) {
expect(window.focus).not.toHaveBeenCalled();
}
});
it('skips the lock entirely when multiple instances are allowed', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {
[ALLOW_MULTIPLE_INSTANCES_ENV]: '1',
})
).toBe(true);
expect(app.requestSingleInstanceLock).not.toHaveBeenCalled();
expect(app.quit).not.toHaveBeenCalled();
});
});
describe('focusExistingWindow', () => {
it('does nothing without a window', () => {
expect(() => focusExistingWindow(null)).not.toThrow();
});
it('does not touch a destroyed window', () => {
const window = createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
});
focusExistingWindow(window);
expect(window.focus).not.toHaveBeenCalled();
expect(window.restore).not.toHaveBeenCalled();
});
it('only focuses an already visible window', () => {
const window = createWindow();
focusExistingWindow(window);
expect(window.restore).not.toHaveBeenCalled();
expect(window.show).not.toHaveBeenCalled();
expect(window.focus).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,114 @@
/**
* Single-instance guard.
*
* Every Electron instance that shares a `userData` directory also shares the
* Chromium storage profile behind it. Only the first process gets the
* IndexedDB LevelDB lock, so a second concurrent instance silently loses
* renderer-side persistence: the settings store falls back to defaults on read
* and its writes never reach disk (issues #102, #1156). Holding a single
* instance lock is the fix — a second launch focuses the window that already
* owns the profile instead of opening a broken twin.
*
* The lock is scoped to `userData`, so it must be requested *after*
* `app.setPath('userData', ...)`; otherwise E2E runs with a dedicated data
* directory would contend on the default profile's lock instead of their own.
*/
/** Minimal Electron `App` surface used by the guard, kept narrow for tests. */
export interface SingleInstanceApp {
quit(): void;
on(event: 'second-instance', listener: () => void): unknown;
requestSingleInstanceLock(): boolean;
}
/** Minimal `BrowserWindow` surface used when re-focusing the existing window. */
export interface SingleInstanceWindow {
focus(): void;
isDestroyed(): boolean;
isMinimized(): boolean;
isVisible(): boolean;
restore(): void;
show(): void;
}
export const ALLOW_MULTIPLE_INSTANCES_ENV = 'IPTVNATOR_ALLOW_MULTIPLE_INSTANCES';
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
/**
* Escape hatch for local debugging (e.g. attaching a second CDP-enabled
* instance to the same database). Off by default so packaged users always get
* the guard.
*/
export function allowsMultipleInstances(
env: NodeJS.ProcessEnv = process.env
): boolean {
return TRUTHY_VALUES.has(
(env[ALLOW_MULTIPLE_INSTANCES_ENV] ?? '').trim().toLowerCase()
);
}
/** Brings an existing main window back to the foreground. */
export function focusExistingWindow(
window: SingleInstanceWindow | null | undefined
): void {
if (!window || window.isDestroyed()) {
return;
}
if (window.isMinimized()) {
window.restore();
}
if (!window.isVisible()) {
window.show();
}
window.focus();
}
/** True when there is no live window left to bring forward. */
function needsNewWindow(
window: SingleInstanceWindow | null | undefined
): boolean {
return !window || window.isDestroyed();
}
/**
* Acquires the single instance lock.
*
* @param createMainWindow re-creates the main window when the lock owner has
* none left. On macOS closing the last window keeps the process alive, so
* without this a second launch would quit silently and leave the user with no
* window at all.
* @returns `true` when this process owns the profile and startup may continue,
* `false` when another instance already owns it and this one is quitting.
*/
export function acquireSingleInstanceLock(
app: SingleInstanceApp,
getMainWindow: () => SingleInstanceWindow | null | undefined,
createMainWindow: () => void,
env: NodeJS.ProcessEnv = process.env
): boolean {
if (allowsMultipleInstances(env)) {
return true;
}
if (!app.requestSingleInstanceLock()) {
app.quit();
return false;
}
app.on('second-instance', () => {
const mainWindow = getMainWindow();
if (needsNewWindow(mainWindow)) {
createMainWindow();
return;
}
focusExistingWindow(mainWindow);
});
return true;
}
@@ -0,0 +1,120 @@
import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt';
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const originalProfilingValue = process.env[PROFILING_ENV];
describe('database worker performance cancellation receipt', () => {
beforeEach(() => {
delete process.env[PROFILING_ENV];
});
afterAll(() => {
if (originalProfilingValue === undefined) {
delete process.env[PROFILING_ENV];
} else {
process.env[PROFILING_ENV] = originalProfilingValue;
}
});
it('does no clock or transport work without the exact profiling opt-in', () => {
const publish = jest.fn();
const readEpochMs = jest.fn(() => 123);
process.env[PROFILING_ENV] = 'true';
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{ readEpochMs }
);
expect(readEpochMs).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
});
it('publishes only safe correlation IDs and a finite epoch', () => {
const publish = jest.fn();
process.env[PROFILING_ENV] = '1';
publishDatabaseWorkerCancelReceipt(
'playlist-delete:123_abc',
'08da28c4-cc14-4a79-95ec-dff6bba10db0',
publish,
{ readEpochMs: () => 1_234.5 }
);
expect(publish).toHaveBeenCalledWith({
type: 'performance-cancel-received',
operationId: 'playlist-delete:123_abc',
requestId: '08da28c4-cc14-4a79-95ec-dff6bba10db0',
epochMs: 1_234.5,
});
expect(
Object.keys(publish.mock.calls[0][0] as Record<string, unknown>)
).toEqual(['type', 'operationId', 'requestId', 'epochMs']);
});
it.each([
[' leading-space', 'request-1'],
['trailing-space ', 'request-1'],
['provider/password', 'request-1'],
['operation-1', 'request with spaces'],
['operation-1', 'request/with/slashes'],
[`a${'b'.repeat(128)}`, 'request-1'],
])(
'rejects unsafe operation/request correlation without transport work',
(operationId, requestId) => {
const publish = jest.fn();
const readEpochMs = jest.fn(() => 123);
process.env[PROFILING_ENV] = '1';
publishDatabaseWorkerCancelReceipt(
operationId,
requestId,
publish,
{ readEpochMs }
);
expect(readEpochMs).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
}
);
it('fails neutrally when the clock or diagnostic transport fails', () => {
process.env[PROFILING_ENV] = '1';
const publish = jest.fn(() => {
throw new Error('transport unavailable');
});
expect(() =>
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{ readEpochMs: () => 123 }
)
).not.toThrow();
expect(() =>
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{
readEpochMs: () => {
throw new Error('clock unavailable');
},
}
)
).not.toThrow();
expect(() =>
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{ readEpochMs: () => Number.NaN }
)
).not.toThrow();
expect(publish).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,63 @@
import { performance } from 'node:perf_hooks';
import type { DbWorkerPerformanceCancelReceivedMessage } from './database-worker.types';
import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime';
const MAX_IDENTIFIER_LENGTH = 128;
const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
interface CancelReceiptRuntime {
readonly readEpochMs: () => number;
}
type CancelReceiptPublisher = (
message: DbWorkerPerformanceCancelReceivedMessage
) => void;
const DEFAULT_RUNTIME: CancelReceiptRuntime = {
readEpochMs: () => performance.timeOrigin + performance.now(),
};
export function publishDatabaseWorkerCancelReceipt(
operationId: unknown,
requestId: unknown,
publish: CancelReceiptPublisher,
runtime: CancelReceiptRuntime = DEFAULT_RUNTIME
): void {
if (process.env[WORKER_PROFILING_ENV] !== '1') {
return;
}
if (!isSafeIdentifier(operationId) || !isSafeIdentifier(requestId)) {
return;
}
let epochMs: number;
try {
epochMs = runtime.readEpochMs();
} catch {
return;
}
if (!Number.isFinite(epochMs) || epochMs <= 0) {
return;
}
try {
publish({
type: 'performance-cancel-received',
operationId,
requestId,
epochMs,
});
} catch {
// Development-only instrumentation must not affect cancellation.
}
}
function isSafeIdentifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_IDENTIFIER_LENGTH &&
value === value.trim() &&
SAFE_IDENTIFIER_PATTERN.test(value)
);
}
@@ -0,0 +1,180 @@
import { MessageChannel, type MessagePort } from 'node:worker_threads';
import type {
DbWorkerIncomingMessage,
DbWorkerMessage,
DbWorkerResponseMessage,
} from './database-worker.types';
const BATCH_DELAY_ENV = 'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS';
const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const originalBatchDelay = process.env[BATCH_DELAY_ENV];
const originalWorkerProfiling = process.env[WORKER_PROFILING_ENV];
function restoreEnvironment(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}
function postWorkerMessage(
port: MessagePort,
message: DbWorkerIncomingMessage
): void {
port.postMessage(message);
}
describe('database worker zero-delay cancellation', () => {
let workerPort: MessagePort | null = null;
let clientPort: MessagePort | null = null;
afterEach(() => {
workerPort?.close();
clientPort?.close();
workerPort = null;
clientPort = null;
restoreEnvironment(BATCH_DELAY_ENV, originalBatchDelay);
restoreEnvironment(WORKER_PROFILING_ENV, originalWorkerProfiling);
jest.restoreAllMocks();
jest.resetModules();
});
it('processes cancel posted from progress before the default-delay operation completes', async () => {
delete process.env[BATCH_DELAY_ENV];
delete process.env[WORKER_PROFILING_ENV];
const channel = new MessageChannel();
workerPort = channel.port1;
clientPort = channel.port2;
const messages: DbWorkerMessage[] = [];
let cancelPosted = false;
let settleResponse!: (response: DbWorkerResponseMessage) => void;
const responsePromise = new Promise<DbWorkerResponseMessage>(
(resolve) => {
settleResponse = resolve;
}
);
clientPort.on('message', (message: DbWorkerMessage) => {
messages.push(message);
if (
!cancelPosted &&
message.type === 'event' &&
message.event.status === 'progress'
) {
cancelPosted = true;
postWorkerMessage(clientPort!, {
type: 'cancel',
operationId: 'operation-zero-delay',
});
}
if (message.type === 'response') {
settleResponse(message);
}
});
jest.doMock('worker_threads', () => ({
...jest.requireActual('worker_threads'),
parentPort: workerPort,
}));
jest.doMock('./database.worker-connection', () => ({
closeWorkerDatabase: jest.fn(),
getWorkerDatabase: jest.fn().mockResolvedValue({}),
}));
jest.doMock('../database/operations/content.operations', () => ({
saveContent: jest.fn(
async (
_db: unknown,
_playlistId: string,
_streams: unknown[],
_type: string,
control: {
checkpoint: () => Promise<void>;
onProgress: (progress: {
phase: string;
current: number;
total: number;
}) => Promise<void>;
}
) => {
for (let current = 1; current <= 3; current += 1) {
await control.checkpoint();
await control.onProgress({
phase: 'saving-content',
current,
total: 3,
});
}
return { count: 3, success: true };
}
),
}));
jest.doMock('./worker-performance-capture', () => ({
armWorkerPerformanceCapture: jest.fn(),
executeWithWorkerPerformanceCapture: jest.fn(
async (_capture: unknown, execute: () => Promise<unknown>) => {
try {
return {
error: null,
performance: undefined,
result: await execute(),
success: true,
};
} catch (error) {
return {
error,
performance: undefined,
result: undefined,
success: false,
};
}
}
),
registerDatabaseWorkerPerformanceCapture: jest.fn(),
releaseDatabaseWorkerPerformanceCapture: jest.fn(),
stampWorkerPerformanceResponsePostedEpoch: jest.fn(
(_capture: unknown, performance: unknown) => performance
),
startWorkerPerformanceCapture: jest.fn(() => null),
}));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await import('./database.worker');
postWorkerMessage(clientPort, {
type: 'request',
operation: 'DB_SAVE_CONTENT',
payload: {
operationId: 'operation-zero-delay',
playlistId: 'playlist-1',
streams: [{ stream_id: 1 }, { stream_id: 2 }],
type: 'live',
},
requestId: 'request-zero-delay',
});
const response = await responsePromise;
expect(cancelPosted).toBe(true);
expect(messages).toContainEqual(
expect.objectContaining({
event: expect.objectContaining({
operationId: 'operation-zero-delay',
status: 'cancelled',
}),
requestId: 'request-zero-delay',
type: 'event',
})
);
expect(response).toEqual(
expect.objectContaining({
error: expect.objectContaining({ name: 'AbortError' }),
requestId: 'request-zero-delay',
success: false,
type: 'response',
})
);
});
});
@@ -92,11 +92,7 @@ export const DB_OPERATION_PHASES = {
} as const;
export type DbOperationStatus =
| 'started'
| 'progress'
| 'completed'
| 'cancelled'
| 'error';
'started' | 'progress' | 'completed' | 'cancelled' | 'error';
export interface DbOperationEvent {
operationId?: string;
@@ -138,6 +134,13 @@ export interface DbWorkerEventMessage {
event: DbOperationEvent;
}
export interface DbWorkerPerformanceCancelReceivedMessage {
type: 'performance-cancel-received';
operationId: string;
requestId: string;
epochMs: number;
}
export interface DbWorkerResponseMessage<TResult = unknown> {
type: 'response';
requestId: string;
@@ -148,10 +151,10 @@ export interface DbWorkerResponseMessage<TResult = unknown> {
}
export type DbWorkerIncomingMessage =
| DbWorkerRequestMessage
| DbWorkerCancelMessage;
DbWorkerRequestMessage | DbWorkerCancelMessage;
export type DbWorkerMessage =
| DbWorkerReadyMessage
| DbWorkerEventMessage
| DbWorkerPerformanceCancelReceivedMessage
| DbWorkerResponseMessage;
@@ -15,7 +15,6 @@ import {
trace,
} from '../services/debug-trace';
let Database: typeof BetterSqlite3;
let drizzleFactory:
| (typeof import('drizzle-orm/better-sqlite3'))['drizzle']
| undefined;
@@ -37,7 +36,6 @@ function loadBetterSqlite3(): typeof BetterSqlite3 {
loggerLabel: '[DB Worker]',
searchPaths: nativeModuleSearchPaths,
fallbackRequire: () =>
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('better-sqlite3') as typeof BetterSqlite3,
});
}
@@ -49,7 +47,6 @@ function getDrizzleFactory(): (typeof import('drizzle-orm/better-sqlite3'))['dri
// Require drizzle only after native lookup paths have been registered.
// Its better-sqlite3 driver resolves the native package at module load time.
// eslint-disable-next-line @typescript-eslint/no-require-imports
drizzleFactory = require('drizzle-orm/better-sqlite3').drizzle as (
typeof import('drizzle-orm/better-sqlite3')
)['drizzle'];
@@ -57,7 +54,7 @@ function getDrizzleFactory(): (typeof import('drizzle-orm/better-sqlite3'))['dri
return drizzleFactory;
}
Database = loadBetterSqlite3();
const Database = loadBetterSqlite3();
let db: AppDatabase | null = null;
let sqlite: BetterSqlite3.Database | null = null;
@@ -65,6 +65,8 @@ import {
getAppState,
getPlaylist,
setAppState,
type AppPlaylistGetPhaseCapture,
type AppPlaylistUpsertPhaseCapture,
updatePlaylist,
upsertAppPlaylist,
upsertAppPlaylists,
@@ -103,13 +105,20 @@ import {
executeWithWorkerPerformanceCapture,
registerDatabaseWorkerPerformanceCapture,
releaseDatabaseWorkerPerformanceCapture,
stampWorkerPerformanceResponsePostedEpoch,
startWorkerPerformanceCapture,
type WorkerPerformanceCapture,
} from './worker-performance-capture';
import {
captureWorkerPerformancePhase,
captureWorkerPerformancePhaseAsync,
createWorkerPerformancePhaseAdapter,
} from './worker-performance-phase';
import {
handleDatabaseWorkerPostGcHeapRequest,
isDatabaseWorkerPostGcHeapRequest,
} from './database-worker-post-gc-heap';
import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt';
const loggerLabel = '[DB Worker]';
const batchDelayMs = Number.parseInt(
@@ -119,6 +128,7 @@ const batchDelayMs = Number.parseInt(
type ActiveOperationState = {
cancelled: boolean;
performanceRequestId?: string;
};
type PreRegisteredActiveOperation = {
@@ -195,6 +205,7 @@ function postEvent(requestId: string, event: DbOperationEvent): void {
async function pauseBetweenBatches(): Promise<void> {
if (batchDelayMs <= 0) {
await new Promise<void>((resolve) => setImmediate(resolve));
return;
}
@@ -359,7 +370,8 @@ function releasePreRegisteredOperation(
async function executeRequest(
message: DbWorkerRequestMessage,
preRegisteredState?: ActiveOperationState
preRegisteredState?: ActiveOperationState,
performanceCapture: WorkerPerformanceCapture | null = null
) {
const db = await getWorkerDatabase();
@@ -377,7 +389,14 @@ async function executeRequest(
playlistId: string;
type: 'live' | 'movies' | 'series';
};
return getCategories(db, payload.playlistId, payload.type);
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return getCategories(
db,
payload.playlistId,
payload.type,
capturePhase
);
}
case 'DB_SAVE_CATEGORIES': {
@@ -390,12 +409,15 @@ async function executeRequest(
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return saveCategories(
db,
payload.playlistId,
payload.categories,
payload.type,
payload.hiddenCategoryXtreamIds
payload.hiddenCategoryXtreamIds,
capturePhase
);
}
@@ -432,7 +454,14 @@ async function executeRequest(
playlistId: string;
type: 'live' | 'movie' | 'series';
};
return getContent(db, payload.playlistId, payload.type);
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return getContent(
db,
payload.playlistId,
payload.type,
capturePhase
);
}
case 'DB_GET_GLOBAL_RECENTLY_ADDED': {
@@ -440,11 +469,7 @@ async function executeRequest(
kind?: 'all' | 'vod' | 'series';
limit?: number;
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
};
return getGlobalRecentlyAdded(
db,
@@ -461,6 +486,8 @@ async function executeRequest(
type: 'live' | 'movie' | 'series';
operationId?: string;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return executeTrackedOperation(
{
@@ -481,7 +508,8 @@ async function executeRequest(
payload.playlistId,
payload.streams,
payload.type,
controller.control
controller.control,
capturePhase
);
controller.emitCompleted({
@@ -501,8 +529,15 @@ async function executeRequest(
playlistId: string;
type: 'live' | 'movie' | 'series';
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return clearXtreamImportCache(db, payload.playlistId, payload.type);
return clearXtreamImportCache(
db,
payload.playlistId,
payload.type,
capturePhase
);
}
case 'DB_GET_CONTENT_BY_XTREAM_ID': {
@@ -538,12 +573,15 @@ async function executeRequest(
types: string[];
excludeHidden?: boolean;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return searchContent(
db,
payload.playlistId,
payload.searchTerm,
payload.types,
payload.excludeHidden
payload.excludeHidden,
capturePhase
);
}
@@ -585,9 +623,29 @@ async function executeRequest(
}
case 'DB_UPSERT_APP_PLAYLIST': {
const capturePhase: AppPlaylistUpsertPhaseCapture | undefined =
performanceCapture
? {
captureAsync: (phase, execute, metadata) =>
captureWorkerPerformancePhaseAsync(
performanceCapture,
phase,
execute,
metadata
),
captureSync: (phase, execute, metadata) =>
captureWorkerPerformancePhase(
performanceCapture,
phase,
execute,
metadata
),
}
: undefined;
return upsertAppPlaylist(
db,
message.payload as Record<string, unknown>
message.payload as Record<string, unknown>,
capturePhase
);
}
@@ -606,7 +664,26 @@ async function executeRequest(
case 'DB_GET_APP_PLAYLIST': {
const payload = message.payload as { playlistId: string };
return getAppPlaylist(db, payload.playlistId);
const capturePhase: AppPlaylistGetPhaseCapture | undefined =
performanceCapture
? {
captureAsync: (phase, execute, metadata) =>
captureWorkerPerformancePhaseAsync(
performanceCapture,
phase,
execute,
metadata
),
captureSync: (phase, execute, metadata) =>
captureWorkerPerformancePhase(
performanceCapture,
phase,
execute,
metadata
),
}
: undefined;
return getAppPlaylist(db, payload.playlistId, capturePhase);
}
case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS': {
@@ -638,6 +715,8 @@ async function executeRequest(
playlistId: string;
operationId?: string;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return executeTrackedOperation(
{
@@ -655,7 +734,8 @@ async function executeRequest(
const result = await deletePlaylist(
db,
payload.playlistId,
controller.control
controller.control,
capturePhase
);
controller.emitCompleted({
@@ -772,6 +852,8 @@ async function executeRequest(
playlistId: string;
operationId?: string;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return executeTrackedOperation(
{
@@ -789,7 +871,8 @@ async function executeRequest(
const result = await deleteXtreamContent(
db,
payload.playlistId,
controller.control
controller.control,
capturePhase
);
controller.emitCompleted({
@@ -1037,6 +1120,13 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
const activeOperation = activeOperations.get(message.operationId);
if (activeOperation) {
activeOperation.cancelled = true;
if (activeOperation.performanceRequestId !== undefined) {
publishDatabaseWorkerCancelReceipt(
message.operationId,
activeOperation.performanceRequestId,
postMessage
);
}
}
return;
}
@@ -1045,7 +1135,13 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
let performanceCapture: WorkerPerformanceCapture | null = null;
const execution = await (async () => {
try {
performanceCapture = startWorkerPerformanceCapture();
performanceCapture = startWorkerPerformanceCapture({
requestId: message.requestId,
});
if (performanceCapture && preRegisteredOperation) {
preRegisteredOperation.state.performanceRequestId =
message.requestId;
}
registerDatabaseWorkerPerformanceCapture(
activePerformanceCaptures,
performanceCapture
@@ -1053,7 +1149,12 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
await armWorkerPerformanceCapture(performanceCapture);
return await executeWithWorkerPerformanceCapture(
performanceCapture,
() => executeRequest(message, preRegisteredOperation?.state)
() =>
executeRequest(
message,
preRegisteredOperation?.state,
performanceCapture
)
);
} finally {
releaseDatabaseWorkerPerformanceCapture(
@@ -1070,7 +1171,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
requestId: message.requestId,
success: true,
result: execution.result,
performance: execution.performance,
performance: stampWorkerPerformanceResponsePostedEpoch(
performanceCapture,
execution.performance
),
});
} else {
console.error(
@@ -1083,7 +1187,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
requestId: message.requestId,
success: false,
error: serializeError(execution.error),
performance: execution.performance,
performance: stampWorkerPerformanceResponsePostedEpoch(
performanceCapture,
execution.performance
),
});
}
});
@@ -25,6 +25,7 @@ import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source';
import {
armWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
stampWorkerPerformanceResponsePostedEpoch,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
@@ -229,7 +230,10 @@ parentPort.on(
type: 'response',
success: true,
result: execution.result,
performance: execution.performance,
performance: stampWorkerPerformanceResponsePostedEpoch(
performanceCapture,
execution.performance
),
});
} else {
const error = execution.error;
@@ -237,7 +241,10 @@ parentPort.on(
type: 'response',
success: false,
error: serializeError(error),
performance: execution.performance,
performance: stampWorkerPerformanceResponsePostedEpoch(
performanceCapture,
execution.performance
),
});
}
} finally {
@@ -6,88 +6,17 @@ 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(() => ({})),
}));
}
import {
createArmGate,
createParentPortHarness,
mockPerformanceCapture,
restoreWorkerProfilingEnvironment,
WORKER_PROFILING_ENV,
} from './worker-performance-cancellation.test-helpers';
describe('worker cancellation while performance capture arms', () => {
afterEach(() => {
restoreWorkerProfilingEnvironment();
jest.restoreAllMocks();
jest.resetModules();
});
@@ -172,7 +101,8 @@ describe('worker cancellation while performance capture arms', () => {
);
});
it('cancels cancellable database work when cancel arrives during arming', async () => {
it('acknowledges receipt separately before cancellable database work terminates', async () => {
process.env[WORKER_PROFILING_ENV] = '1';
const armGate = createArmGate();
const port = createParentPortHarness<
DbWorkerIncomingMessage,
@@ -222,6 +152,24 @@ describe('worker cancellation while performance capture arms', () => {
type: 'cancel',
operationId: 'database-operation-1',
});
const receipt = port.postMessage.mock.calls.find(
([message]) => message.type === 'performance-cancel-received'
)?.[0];
expect(receipt).toEqual({
type: 'performance-cancel-received',
operationId: 'database-operation-1',
requestId: 'request-1',
epochMs: expect.any(Number),
});
expect(
port.postMessage.mock.calls.some(
([message]) =>
message.type === 'event' &&
message.event.status === 'cancelled'
)
).toBe(false);
armGate.release();
await requestPromise;
@@ -246,6 +194,79 @@ describe('worker cancellation while performance capture arms', () => {
);
});
it('does not acknowledge receipt when request capture is unavailable', async () => {
process.env[WORKER_PROFILING_ENV] = '1';
const armGate = createArmGate();
const port = createParentPortHarness<
DbWorkerIncomingMessage,
DbWorkerMessage
>();
const saveContent = jest.fn(
async (
_db: unknown,
_playlistId: string,
_streams: unknown[],
_type: string,
control: { checkpoint: () => Promise<void> }
) => {
await control.checkpoint();
return { count: 0 };
}
);
mockPerformanceCapture(armGate, undefined, null);
jest.doMock('worker_threads', () => ({
parentPort: port.parentPort,
}));
jest.doMock('./database.worker-connection', () => ({
closeWorkerDatabase: jest.fn(),
getWorkerDatabase: jest.fn().mockResolvedValue({}),
}));
jest.doMock('../database/operations/content.operations', () => ({
saveContent,
}));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await import('./database.worker');
port.postMessage.mockClear();
const handleMessage = port.getMessageHandler();
const requestPromise = handleMessage({
type: 'request',
operation: 'DB_SAVE_CONTENT',
payload: {
operationId: 'database-operation-no-capture',
playlistId: 'playlist-1',
streams: [],
type: 'movie',
},
requestId: 'request-no-capture',
});
await handleMessage({
type: 'cancel',
operationId: 'database-operation-no-capture',
});
expect(
port.postMessage.mock.calls.some(
([message]) => message.type === 'performance-cancel-received'
)
).toBe(false);
armGate.release();
await requestPromise;
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
operationId: 'database-operation-no-capture',
status: 'cancelled',
}),
requestId: 'request-no-capture',
type: 'event',
})
);
});
it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => {
const armGate = createArmGate();
const port = createParentPortHarness<
@@ -314,6 +335,7 @@ describe('worker cancellation while performance capture arms', () => {
describe('database worker performance control messages', () => {
afterEach(() => {
restoreWorkerProfilingEnvironment();
jest.restoreAllMocks();
jest.resetModules();
});
@@ -0,0 +1,94 @@
type WorkerMessageHandler<TMessage> = (
message: TMessage
) => Promise<void> | void;
export interface ArmGate {
readonly promise: Promise<void>;
readonly release: () => void;
}
export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const originalWorkerProfilingValue = process.env[WORKER_PROFILING_ENV];
export function restoreWorkerProfilingEnvironment(): void {
if (originalWorkerProfilingValue === undefined) {
delete process.env[WORKER_PROFILING_ENV];
} else {
process.env[WORKER_PROFILING_ENV] = originalWorkerProfilingValue;
}
}
export function createArmGate(): ArmGate {
let release = (): void => undefined;
const promise = new Promise<void>((resolvePromise) => {
release = resolvePromise;
});
return { promise, release };
}
export 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,
};
}
export function mockPerformanceCapture(
armGate: ArmGate,
failureGates?: {
readonly observed: ArmGate;
readonly profilingFinished: ArmGate;
},
capture: unknown = {}
): void {
jest.doMock('./worker-performance-capture', () => ({
armWorkerPerformanceCapture: jest.fn(() => armGate.promise),
executeWithWorkerPerformanceCapture: jest.fn(
async (_capture: unknown, execute: () => Promise<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(),
stampWorkerPerformanceResponsePostedEpoch: jest.fn(
(_capture: unknown, performance: unknown) => performance
),
startWorkerPerformanceCapture: jest.fn(() => capture),
}));
}
@@ -6,11 +6,43 @@ import {
releaseDatabaseWorkerPerformanceCapture,
startWorkerPerformanceCapture,
type WorkerPerformanceCapture,
type WorkerPerformanceCaptureRuntime,
} from './worker-performance-capture';
import { DEFAULT_WORKER_PERFORMANCE_RUNTIME } from './worker-performance-capture.runtime';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const BLOCK_DURATION_MS = 20;
const REAL_TIMER_TEST_TIMEOUT_MS = 30_000;
/**
* `monitorEventLoopDelay()` records nothing on its first internal timer tick —
* that tick only seeds the previous timestamp, so the first delay sample lands
* on the second tick. Condition-based arming therefore needs two event-loop
* turns, and it budgets for them with a 50ms deadline read from
* `readMonotonicMs()`. A machine running the full Jest suite in parallel can
* stretch a single turn past 20ms, so that deadline expires against the
* scheduler rather than against any defect in the capture code.
*
* Slowing only that deadline clock leaves the wait bounded by its other limit,
* the 50-poll ceiling, which is ~25x the two turns arming actually needs.
* Everything else stays production code: the real `monitorEventLoopDelay()`
* histogram, real `setTimeout()` polling, and real epoch/CPU/ELU boundaries.
* The scaled clock reaches nothing but the wait budgets — its only other
* consumer records phase events, and this spec records none.
*/
const WAIT_DEADLINE_CLOCK_SCALE = 50;
function createRuntimeWithScaledWaitDeadline(): WorkerPerformanceCaptureRuntime {
return {
...DEFAULT_WORKER_PERFORMANCE_RUNTIME,
readMonotonicMs: () =>
DEFAULT_WORKER_PERFORMANCE_RUNTIME.readMonotonicMs() /
WAIT_DEADLINE_CLOCK_SCALE,
};
}
describe('worker performance capture concurrency and real timers', () => {
const originalProfilingValue = process.env[PROFILING_ENV];
@@ -70,26 +102,44 @@ describe('worker performance capture concurrency and real timers', () => {
expect(activeCaptures.size).toBe(0);
});
it('reliably observes a real 20ms event-loop block after condition-based arming', async () => {
process.env[PROFILING_ENV] = '1';
it(
'measures a real 20ms event-loop block through 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.
// No `enabled` override: the env variable is the opt-in under test.
const capture = startWorkerPerformanceCapture({
runtime: createRuntimeWithScaledWaitDeadline(),
});
expect(capture).not.toBeNull();
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => {
const blockStartedAt = performance.now();
while (
performance.now() - blockStartedAt <
BLOCK_DURATION_MS
) {
// 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();
});
expect(execution.success).toBe(true);
expect(execution.performance?.invalidReason).toBeNull();
expect(
execution.performance?.eventLoopDelayUnavailableReason
).toBeNull();
expect(
execution.performance?.histogramFlushedEpochMs
).not.toBeNull();
expect(
execution.performance?.eventLoopDelay?.maxMs
).toBeGreaterThan(10);
},
REAL_TIMER_TEST_TIMEOUT_MS
);
});
@@ -177,6 +177,7 @@ function createFallbackResult(
WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE,
histogramFlushedEpochMs: null,
invalidReason,
...copyPhaseEvents(capture),
requestReceivedEpochMs: capture.requestReceivedEpochMs,
threadCpuSystemMicros: null,
threadCpuUnavailableReason:
@@ -213,6 +214,7 @@ export function createWorkerPerformanceCaptureResult(
capture.eventLoopUtilizationUnavailableReason,
histogramFlushedEpochMs: capture.histogramFlushedEpochMs,
invalidReason: capture.invalidReason,
...copyPhaseEvents(capture),
requestReceivedEpochMs: capture.requestReceivedEpochMs,
...threadCpu,
threadCpuUnavailableReason: capture.threadCpuUnavailableReason,
@@ -226,6 +228,18 @@ export function createWorkerPerformanceCaptureResult(
}
}
function copyPhaseEvents(
capture: WorkerPerformanceCapture
): Pick<WorkerPerformanceCaptureResult, 'phaseEvents'> {
try {
return capture.phaseEvents.length > 0
? { phaseEvents: Object.freeze([...capture.phaseEvents]) }
: {};
} catch {
return {};
}
}
export function finishWorkerPerformanceMetricBoundaries(
capture: WorkerPerformanceCapture
): void {
@@ -1,4 +1,8 @@
import type { EventLoopUtilization } from 'node:perf_hooks';
import type {
DatabaseWorkerPerformancePhase,
PerformancePhaseEvent,
} from '@iptvnator/shared/interfaces';
export const WORKER_PERFORMANCE_INVALID_REASON = {
OVERLAPPING_DATABASE_WORKER_REQUESTS:
@@ -46,7 +50,9 @@ export interface WorkerPerformanceCaptureResult {
eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
histogramFlushedEpochMs: number | null;
invalidReason: WorkerPerformanceInvalidReason | null;
phaseEvents?: readonly PerformancePhaseEvent<DatabaseWorkerPerformancePhase>[];
requestReceivedEpochMs: number;
responsePostedEpochMs?: number;
threadCpuSystemMicros: number | null;
threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
threadCpuUserMicros: number | null;
@@ -65,6 +71,7 @@ export interface WorkerPerformanceCaptureRuntime {
export interface WorkerPerformanceCaptureOptions {
enabled?: boolean;
requestId?: string;
runtime?: WorkerPerformanceCaptureRuntime;
}
@@ -84,7 +91,9 @@ export interface WorkerPerformanceCapture {
histogramDisabled: boolean;
histogramFlushedEpochMs: number | null;
invalidReason: WorkerPerformanceInvalidReason | null;
phaseEvents: PerformancePhaseEvent<DatabaseWorkerPerformancePhase>[];
requestReceivedEpochMs: number;
requestId: string | null;
runtime: WorkerPerformanceCaptureRuntime;
threadCpuEnd: NodeJS.CpuUsage | null;
threadCpuStart: NodeJS.CpuUsage | null;
@@ -0,0 +1,285 @@
import {
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
armWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
/**
* Capture must never change the business result. These cases drive the
* monotonic-clock timeouts and every runtime callback that can throw, and
* assert the awaited value survives each one.
*/
describe('worker performance capture resilience', () => {
const originalProfilingValue = process.env[PROFILING_ENV];
afterEach(() => {
if (originalProfilingValue === undefined) {
delete process.env[PROFILING_ENV];
} else {
process.env[PROFILING_ENV] = originalProfilingValue;
}
});
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,
},
});
});
});
@@ -2,6 +2,7 @@ import {
WORKER_PERFORMANCE_UNAVAILABLE_REASON,
armWorkerPerformanceCapture,
executeWithWorkerPerformanceCapture,
stampWorkerPerformanceResponsePostedEpoch,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
@@ -86,6 +87,34 @@ describe('worker performance capture', () => {
expect('reset' in harness.histogram).toBe(false);
});
it('stamps the worker-send boundary only after request profiling has finished', async () => {
const harness = createFakeRuntime();
const capture = startWorkerPerformanceCapture({
enabled: true,
runtime: harness.runtime,
});
await armWorkerPerformanceCapture(capture);
const execution = await executeWithWorkerPerformanceCapture(
capture,
async () => 'result'
);
const stamped = stampWorkerPerformanceResponsePostedEpoch(
capture,
execution.performance
);
expect(stamped).toMatchObject({
histogramFlushedEpochMs: 145,
responsePostedEpochMs: 149,
workEndedEpochMs: 140,
});
expect(harness.lifecycle.at(-1)).toBe('epoch:149');
expect(
stampWorkerPerformanceResponsePostedEpoch(null, undefined)
).toBeUndefined();
});
it('finishes profiling before returning a rejected execution to worker side effects', async () => {
const harness = createFakeRuntime();
const capture = startWorkerPerformanceCapture({
@@ -117,263 +146,4 @@ describe('worker performance capture', () => {
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,
},
});
});
});
@@ -57,7 +57,13 @@ export function startWorkerPerformanceCapture(
histogramDisabled: false,
histogramFlushedEpochMs: null,
invalidReason: null,
phaseEvents: [],
requestReceivedEpochMs: safeReadEpochMs(runtime),
requestId:
typeof options.requestId === 'string' &&
options.requestId.length > 0
? options.requestId
: null,
runtime,
threadCpuEnd: null,
threadCpuStart: null,
@@ -269,3 +275,21 @@ export function releaseDatabaseWorkerPerformanceCapture(
activeCaptures.delete(capture);
}
}
export function stampWorkerPerformanceResponsePostedEpoch(
capture: WorkerPerformanceCapture | null,
performance: WorkerPerformanceCaptureResult | undefined
): WorkerPerformanceCaptureResult | undefined {
if (!capture || !performance) {
return performance;
}
try {
return {
...performance,
responsePostedEpochMs: safeReadEpochMs(capture.runtime),
};
} catch {
return performance;
}
}
@@ -30,9 +30,18 @@ describe('worker performance integration order', () => {
expect(
handler.indexOf('executeWithWorkerPerformanceCapture')
).toBeLessThan(handler.indexOf('console.error'));
const executionIndex = handler.indexOf(
'executeWithWorkerPerformanceCapture'
);
expect(executionIndex).toBeLessThan(
handler.indexOf('postMessage', executionIndex)
);
expect(
handler.indexOf('executeWithWorkerPerformanceCapture')
).toBeLessThan(handler.indexOf('postMessage'));
handler.match(/stampWorkerPerformanceResponsePostedEpoch/g)
).toHaveLength(2);
expect(handler).toMatch(
/postMessage\(\{[\s\S]*?performance:\s*stampWorkerPerformanceResponsePostedEpoch\([\s\S]*?execution\.performance[\s\S]*?\),[\s\S]*?\}\)/
);
expect(handler).not.toContain('await previous');
expect(handler).not.toContain('requestQueue');
});
@@ -52,6 +61,34 @@ describe('worker performance integration order', () => {
expect(executionIndex).toBeLessThan(
handler.indexOf('postMessage', executionIndex)
);
expect(
handler.match(/stampWorkerPerformanceResponsePostedEpoch/g)
).toHaveLength(2);
});
it('passes the request capture into the single-playlist upsert boundary', () => {
const source = readWorkerSource('database.worker.ts');
const upsertCase = source.slice(
source.indexOf("case 'DB_UPSERT_APP_PLAYLIST':"),
source.indexOf("case 'DB_UPSERT_APP_PLAYLISTS':")
);
expect(upsertCase).toContain('captureWorkerPerformancePhase');
expect(upsertCase).toContain('performanceCapture');
expect(upsertCase).toContain('upsertAppPlaylist');
});
it('passes async read and sync deserialize capture into the single-playlist GET boundary', () => {
const source = readWorkerSource('database.worker.ts');
const getCase = source.slice(
source.indexOf("case 'DB_GET_APP_PLAYLIST':"),
source.indexOf("case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS':")
);
expect(getCase).toContain('captureWorkerPerformancePhaseAsync');
expect(getCase).toContain('captureWorkerPerformancePhase');
expect(getCase).toContain('performanceCapture');
expect(getCase).toContain('getAppPlaylist');
});
it('preserves success and business errors when the finish seam throws once', async () => {
@@ -0,0 +1,76 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import {
finishWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
import { createWorkerPerformancePhaseAdapter } from './worker-performance-phase';
describe('database operation worker performance adapter', () => {
it('returns no adapter when profiling is disabled', () => {
expect(createWorkerPerformancePhaseAdapter(null)).toBeUndefined();
});
it('delegates Xtream phases into the request capture with count-only metadata', async () => {
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'xtream-db-request',
runtime: createFakeRuntime().runtime,
});
const adapter = createWorkerPerformancePhaseAdapter(capture);
const rows = [{ id: 1 }, { id: 2 }];
await expect(
adapter?.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
() => Promise.resolve(rows),
(result) => ({ itemCount: result.length })
)
).resolves.toBe(rows);
const result = await finishWorkerPerformanceCapture(capture);
expect(result?.phaseEvents).toEqual([
expect.objectContaining({
boundary: 'start',
metadata: undefined,
phase: 'sqlite.categories.read',
requestId: 'xtream-db-request',
}),
expect.objectContaining({
boundary: 'end',
metadata: { itemCount: 2 },
phase: 'sqlite.categories.read',
requestId: 'xtream-db-request',
}),
]);
});
it('closes rejected phases without metadata and preserves error identity', async () => {
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'xtream-db-error',
runtime: createFakeRuntime().runtime,
});
const adapter = createWorkerPerformancePhaseAdapter(capture);
const businessError = new Error('read failed');
await expect(
adapter?.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
() => Promise.reject(businessError),
() => ({ itemCount: 99 })
)
).rejects.toBe(businessError);
const result = await finishWorkerPerformanceCapture(capture);
expect(
result?.phaseEvents?.map(({ boundary, metadata }) => ({
boundary,
metadata,
}))
).toEqual([
{ boundary: 'start', metadata: undefined },
{ boundary: 'end', metadata: undefined },
]);
});
});
@@ -0,0 +1,245 @@
import {
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import {
finishWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import {
captureWorkerPerformancePhase,
captureWorkerPerformancePhaseAsync,
} from './worker-performance-phase';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
describe('worker request performance phases', () => {
it('executes directly without phase allocation or metadata work when disabled', () => {
const resultIdentity = { success: true };
const execute = jest.fn(() => resultIdentity);
const metadata = jest.fn(() => {
throw new Error('metadata must remain untouched');
});
const result = captureWorkerPerformancePhase(
null,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
execute,
metadata
);
expect(result).toBe(resultIdentity);
expect(execute).toHaveBeenCalledTimes(1);
expect(metadata).not.toHaveBeenCalled();
});
it('adds ordered request-scoped pairs to the existing performance envelope', async () => {
const harness = createFakeRuntime();
const monotonicValues = [10, 17, 20, 31];
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'db-request-1',
runtime: {
...harness.runtime,
readMonotonicMs: () => monotonicValues.shift() ?? 31,
},
});
const rowIdentity = { payload: '{"playlist":"synthetic"}' };
expect(
captureWorkerPerformancePhase(
capture,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
() => rowIdentity,
() =>
({
byteCount: 24,
itemCount: 100_000,
secret: 'must-not-be-copied',
}) as PerformancePhaseMetadata
)
).toBe(rowIdentity);
captureWorkerPerformancePhase(
capture,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
() => undefined,
() => ({ byteCount: 24, itemCount: 1 })
);
const result = await finishWorkerPerformanceCapture(capture);
expect(result?.phaseEvents).toEqual([
{
boundary: 'start',
durationMs: null,
epochMs: 110,
metadata: undefined,
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
requestId: 'db-request-1',
},
{
boundary: 'end',
durationMs: 7,
epochMs: 140,
metadata: { byteCount: 24, itemCount: 100_000 },
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST,
requestId: 'db-request-1',
},
{
boundary: 'start',
durationMs: null,
epochMs: 145,
metadata: undefined,
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
requestId: 'db-request-1',
},
{
boundary: 'end',
durationMs: 11,
epochMs: 149,
metadata: { byteCount: 24, itemCount: 1 },
phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
requestId: 'db-request-1',
},
]);
expect(JSON.stringify(result?.phaseEvents)).not.toContain('secret');
});
it('preserves exact error identity and still closes the phase pair', async () => {
const harness = createFakeRuntime();
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'db-request-error',
runtime: harness.runtime,
});
const businessError = new Error('sqlite write failed');
expect(() =>
captureWorkerPerformancePhase(
capture,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE,
() => {
throw businessError;
},
() => {
throw new Error('metadata failed');
}
)
).toThrow(businessError);
const result = await finishWorkerPerformanceCapture(capture);
expect(
result?.phaseEvents?.map(({ boundary, metadata }) => ({
boundary,
metadata,
}))
).toEqual([
{ boundary: 'start', metadata: undefined },
{ boundary: 'end', metadata: undefined },
]);
});
it('returns the exact business promise without metadata work when async capture is disabled', async () => {
const resultIdentity = { rows: 1 };
const businessPromise = Promise.resolve(resultIdentity);
const execute = jest.fn(() => businessPromise);
const metadata = jest.fn(() => {
throw new Error('metadata must remain untouched');
});
const result = captureWorkerPerformancePhaseAsync(
null,
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
execute,
metadata
);
expect(result).toBe(businessPromise);
await expect(result).resolves.toBe(resultIdentity);
expect(execute).toHaveBeenCalledTimes(1);
expect(metadata).not.toHaveBeenCalled();
});
it('closes an async phase only after the awaited operation settles', async () => {
const harness = createFakeRuntime();
const monotonicValues = [10, 27];
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'db-get-request',
runtime: {
...harness.runtime,
readMonotonicMs: () => monotonicValues.shift() ?? 27,
},
});
const rows = [{ id: 'synthetic-playlist' }];
let resolveRows!: (value: typeof rows) => void;
const pendingRows = new Promise<typeof rows>((resolve) => {
resolveRows = resolve;
});
const result = captureWorkerPerformancePhaseAsync(
capture,
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
() => pendingRows,
(value) => ({ itemCount: value.length })
);
expect(capture?.phaseEvents).toHaveLength(1);
resolveRows(rows);
await expect(result).resolves.toBe(rows);
expect(capture?.phaseEvents).toHaveLength(2);
const performance = await finishWorkerPerformanceCapture(capture);
expect(performance?.phaseEvents).toEqual([
{
boundary: 'start',
durationMs: null,
epochMs: 110,
metadata: undefined,
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
requestId: 'db-get-request',
},
{
boundary: 'end',
durationMs: 17,
epochMs: 140,
metadata: { itemCount: 1 },
phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
requestId: 'db-get-request',
},
]);
});
it('preserves async rejection identity and closes the phase without metadata', async () => {
const harness = createFakeRuntime();
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'db-get-error',
runtime: harness.runtime,
});
const businessError = new Error('sqlite read failed');
await expect(
captureWorkerPerformancePhaseAsync(
capture,
APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ,
() => Promise.reject(businessError),
() => {
throw new Error('metadata must not run for errors');
}
)
).rejects.toBe(businessError);
const result = await finishWorkerPerformanceCapture(capture);
expect(
result?.phaseEvents?.map(({ boundary, metadata }) => ({
boundary,
metadata,
}))
).toEqual([
{ boundary: 'start', metadata: undefined },
{ boundary: 'end', metadata: undefined },
]);
});
});
@@ -0,0 +1,191 @@
import {
PERFORMANCE_PHASE_BOUNDARY,
type DatabaseWorkerPerformancePhase,
type PerformancePhaseEvent,
type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import type { DatabaseOperationPerformancePhaseCapture } from '../database/operations/performance-phase-capture';
import type { WorkerPerformanceCapture } from './worker-performance-capture.model';
import { safeReadEpochMs } from './worker-performance-capture.runtime';
export function captureWorkerPerformancePhase<TResult>(
capture: WorkerPerformanceCapture | null,
phase: DatabaseWorkerPerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
): TResult {
if (!capture || !capture.requestId) {
return execute();
}
const startMonotonicMs = safeReadMonotonicMs(capture);
appendPhaseEvent(capture, {
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
durationMs: null,
epochMs: safeReadEpochMs(capture.runtime),
metadata: undefined,
phase,
requestId: capture.requestId,
});
try {
const result = execute();
const endMonotonicMs = safeReadMonotonicMs(capture);
appendPhaseEvent(capture, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
epochMs: safeReadEpochMs(capture.runtime),
metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)),
phase,
requestId: capture.requestId,
});
return result;
} catch (error) {
const endMonotonicMs = safeReadMonotonicMs(capture);
appendPhaseEvent(capture, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
epochMs: safeReadEpochMs(capture.runtime),
metadata: undefined,
phase,
requestId: capture.requestId,
});
throw error;
}
}
export function captureWorkerPerformancePhaseAsync<TResult>(
capture: WorkerPerformanceCapture | null,
phase: DatabaseWorkerPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult> {
const requestId = capture?.requestId;
if (!capture || !requestId) {
return execute();
}
return captureAsync(capture, requestId, phase, execute, metadata);
}
async function captureAsync<TResult>(
capture: WorkerPerformanceCapture,
requestId: string,
phase: DatabaseWorkerPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult> {
const startMonotonicMs = safeReadMonotonicMs(capture);
appendPhaseEvent(capture, {
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
durationMs: null,
epochMs: safeReadEpochMs(capture.runtime),
metadata: undefined,
phase,
requestId,
});
try {
const result = await execute();
const endMonotonicMs = safeReadMonotonicMs(capture);
appendPhaseEvent(capture, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
epochMs: safeReadEpochMs(capture.runtime),
metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)),
phase,
requestId,
});
return result;
} catch (error) {
const endMonotonicMs = safeReadMonotonicMs(capture);
appendPhaseEvent(capture, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
epochMs: safeReadEpochMs(capture.runtime),
metadata: undefined,
phase,
requestId,
});
throw error;
}
}
function safeReadMonotonicMs(capture: WorkerPerformanceCapture): number {
try {
const value = capture.runtime.readMonotonicMs();
return Number.isFinite(value) ? value : 0;
} catch {
return 0;
}
}
function safeDuration(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function safelyCreateMetadata<TResult>(
createMetadata: ((result: TResult) => PerformancePhaseMetadata) | undefined,
result: TResult
): PerformancePhaseMetadata | undefined {
if (!createMetadata) {
return undefined;
}
try {
return createMetadata(result);
} catch {
return undefined;
}
}
function sanitizeMetadata(
metadata: PerformancePhaseMetadata | undefined
): PerformancePhaseMetadata | undefined {
if (!metadata) {
return undefined;
}
const sanitized: { byteCount?: number; itemCount?: number } = {};
if (isCount(metadata.byteCount)) {
sanitized.byteCount = metadata.byteCount;
}
if (isCount(metadata.itemCount)) {
sanitized.itemCount = metadata.itemCount;
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function isCount(value: unknown): value is number {
return Number.isSafeInteger(value) && Number(value) >= 0;
}
function appendPhaseEvent(
capture: WorkerPerformanceCapture,
event: PerformancePhaseEvent<DatabaseWorkerPerformancePhase>
): void {
try {
capture.phaseEvents.push(event);
} catch {
// Phase instrumentation is deliberately fail-neutral.
}
}
export function createWorkerPerformancePhaseAdapter(
capture: WorkerPerformanceCapture | null
): DatabaseOperationPerformancePhaseCapture | undefined {
if (!capture?.requestId) {
return undefined;
}
return {
captureAsync: (phase, execute, metadata) =>
captureWorkerPerformancePhaseAsync(
capture,
phase,
execute,
metadata
),
captureSync: (phase, execute, metadata) =>
captureWorkerPerformancePhase(capture, phase, execute, metadata),
};
}
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
function readCase(source: string, operation: string, nextOperation: string) {
return source.slice(
source.indexOf(`case '${operation}':`),
source.indexOf(`case '${nextOperation}':`)
);
}
describe('Xtream database performance phase wiring', () => {
const source = readFileSync(
join(
process.cwd(),
'apps/electron-backend/src/app/workers/database.worker.ts'
),
'utf8'
);
const executeRequestSource = source.slice(
source.indexOf('async function executeRequest')
);
it.each([
['DB_GET_CATEGORIES', 'DB_SAVE_CATEGORIES', 'getCategories'],
['DB_SAVE_CATEGORIES', 'DB_GET_ALL_CATEGORIES', 'saveCategories'],
['DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', 'getContent'],
['DB_SAVE_CONTENT', 'DB_CLEAR_XTREAM_IMPORT_CACHE', 'saveContent'],
[
'DB_CLEAR_XTREAM_IMPORT_CACHE',
'DB_GET_CONTENT_BY_XTREAM_ID',
'clearXtreamImportCache',
],
['DB_SEARCH_CONTENT', 'DB_GLOBAL_SEARCH', 'searchContent'],
['DB_DELETE_PLAYLIST', 'DB_GET_APP_STATE', 'deletePlaylist'],
[
'DB_DELETE_XTREAM_CONTENT',
'DB_RESTORE_XTREAM_USER_DATA',
'deleteXtreamContent',
],
])('passes the optional adapter through %s', (operation, next, call) => {
const branch = readCase(executeRequestSource, operation, next);
expect(branch).toContain('createWorkerPerformancePhaseAdapter');
expect(branch).toContain('performanceCapture');
expect(branch).toContain(call);
});
it('leaves mixed global search uninstrumented', () => {
const branch = readCase(
executeRequestSource,
'DB_GLOBAL_SEARCH',
'DB_CREATE_PLAYLIST'
);
expect(branch).not.toContain('createWorkerPerformancePhaseAdapter');
expect(branch).not.toContain('performanceCapture');
});
});
@@ -6,67 +6,101 @@ import {
resolveWorkerRuntimeBootstrap,
} from './worker-runtime-paths';
// The implementation joins candidate paths with the host path module, so the
// expected values are built with path.join too — hardcoded POSIX strings
// would fail on win32 checkouts.
function packagedWorkerPath(root: string, workerFilename: string): string {
return path.join(
root,
'dist',
'apps',
'electron-backend',
'workers',
workerFilename
);
}
function unpackedNodeModulesPaths(root: string): string[] {
return [
path.join(root, 'app.asar.unpacked', 'node_modules'),
path.join(
root,
'app.asar.unpacked',
'electron-backend',
'node_modules'
),
path.join(
root,
'app.asar.unpacked',
'dist',
'apps',
'electron-backend',
'node_modules'
),
];
}
describe('worker-runtime-paths', () => {
it('resolves the development worker path when the worker file exists', () => {
const developmentWorkerDir =
'/workspace/dist/apps/electron-backend/workers';
const workerPath = path.join(
developmentWorkerDir,
'database.worker.js'
);
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: false,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/workspace/dist/apps/electron-backend/workers',
fileExists: (filePath) =>
filePath ===
'/workspace/dist/apps/electron-backend/workers/database.worker.js',
developmentWorkerDir,
fileExists: (filePath) => filePath === workerPath,
});
expect(bootstrap).toEqual({
workerPath:
'/workspace/dist/apps/electron-backend/workers/database.worker.js',
workerPathCandidates: [
'/workspace/dist/apps/electron-backend/workers/database.worker.js',
],
workerPath,
workerPathCandidates: [workerPath],
});
});
it('prefers process.resourcesPath for packaged worker resolution', () => {
const resourcesPath = '/Applications/IPTVnator.app/Contents/Resources';
const resourcesWorkerPath = packagedWorkerPath(
resourcesPath,
'epg-parser.worker.js'
);
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'epg-parser.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/Applications/IPTVnator.app/Contents/Resources',
resourcesPath,
appPath:
'/Applications/IPTVnator.app/Contents/Resources/app.asar',
fileExists: (filePath) =>
filePath ===
'/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js',
fileExists: (filePath) => filePath === resourcesWorkerPath,
});
expect(bootstrap.workerPath).toBe(
'/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js'
expect(bootstrap.workerPath).toBe(resourcesWorkerPath);
expect(bootstrap.nativeModuleSearchPaths).toEqual(
unpackedNodeModulesPaths(resourcesPath)
);
expect(bootstrap.nativeModuleSearchPaths).toEqual([
'/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/node_modules',
'/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/electron-backend/node_modules',
'/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/dist/apps/electron-backend/node_modules',
]);
});
it('falls back to appPath dirname when the resourcesPath candidate is missing', () => {
const appWorkerPath = packagedWorkerPath(
'/opt/IPTVnator/resources',
'database.worker.js'
);
const bootstrap = resolveWorkerRuntimeBootstrap({
isPackaged: true,
workerFilename: 'database.worker.js',
developmentWorkerDir: '/unused',
resourcesPath: '/tmp/runtime-resources',
appPath: '/opt/IPTVnator/resources/app.asar',
fileExists: (filePath) =>
filePath ===
'/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js',
fileExists: (filePath) => filePath === appWorkerPath,
});
expect(bootstrap.workerPath).toBe(
'/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js'
);
expect(bootstrap.workerPath).toBe(appWorkerPath);
expect(bootstrap.workerPathCandidates).toEqual([
'/tmp/runtime-resources/dist/apps/electron-backend/workers/database.worker.js',
'/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js',
packagedWorkerPath('/tmp/runtime-resources', 'database.worker.js'),
appWorkerPath,
]);
});
@@ -84,8 +118,11 @@ describe('worker-runtime-paths', () => {
[
'Unable to resolve worker "database.worker.js".',
'Tried:',
'- /resources/dist/apps/electron-backend/workers/database.worker.js',
'- /opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js',
`- ${packagedWorkerPath('/resources', 'database.worker.js')}`,
`- ${packagedWorkerPath(
'/opt/IPTVnator/resources',
'database.worker.js'
)}`,
].join('\n')
);
});
@@ -96,11 +133,7 @@ describe('worker-runtime-paths', () => {
resourcesPath: '/resources',
appPath: '/resources/app.asar',
})
).toEqual([
'/resources/app.asar.unpacked/node_modules',
'/resources/app.asar.unpacked/electron-backend/node_modules',
'/resources/app.asar.unpacked/dist/apps/electron-backend/node_modules',
]);
).toEqual(unpackedNodeModulesPaths('/resources'));
});
it('loads native modules from the first working candidate path', () => {
@@ -62,7 +62,6 @@ export function registerNativeModuleSearchPaths(
}
): string[] {
const env = options?.env ?? process.env;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const moduleApi =
options?.moduleApi ??
(require('module') as NodeModuleApi);
+19 -4
View File
@@ -38,6 +38,7 @@ import {
} from './app/services/embedded-mpv-frame-copy-platform.util';
import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util';
import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic';
import { acquireSingleInstanceLock } from './app/services/single-instance';
import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service';
app.setName('iptvnator');
@@ -150,10 +151,10 @@ export default class Main {
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();
// Set main window for downloads and reset stale downloads
if (App.mainWindow) {
setDownloadsMainWindow(App.mainWindow);
}
// Keep the downloads broadcaster bound to the live window. macOS can
// rebuild the window while the process runs, and a stale reference
// silently swallows every DOWNLOADS_UPDATE_EVENT.
App.onMainWindowCreated(setDownloadsMainWindow);
// Load the renderer only after IPC handlers are registered. On slower
// Linux CI hosts the renderer can otherwise invoke Electron bridge IPC
@@ -196,6 +197,20 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
// handle setup events as quickly as possible
Main.initialize();
// A second instance sharing this userData directory cannot take the
// Chromium storage lock, so its renderer silently loses every settings
// write. Focus the window that already owns the profile instead — or
// re-create it, since on macOS the process outlives its last window.
if (
!acquireSingleInstanceLock(
app,
() => App.mainWindow,
() => App.ensureMainWindow()
)
) {
return;
}
// bootstrap app
Main.bootstrapApp();