mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge master, dedupe the kept copy, and retire superseded switches
Master had moved ten commits ahead. Two conflicts, both resolved without losing either side: the `XTREAM_PROBE_URL` handler this PR extracted into `events/stream-probe.ts` stays extracted (master added performance capture nearby but never touched that handler), and the mock-server scenario table takes the union of master's `performance` row and this branch's `multisrc` pair. Two findings fixed alongside it. Pinning the copy the route is already on makes discovery return that very row, so prepending the current source listed one stream twice — a phantom copy in the grouping and a chip that counted it. And handing the "playing" badge back to the route row left an in-flight switch valid, so a slow resolution could arrive afterwards and replace the playback the user had just started with Play, Resume or Restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
9f0a8b4f19
385 files changed
+58306
-2264
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+110
@@ -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(
|
||||
|
||||
+68
@@ -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;
|
||||
}
|
||||
+165
@@ -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);
|
||||
});
|
||||
});
|
||||
+101
@@ -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)
|
||||
);
|
||||
}
|
||||
+180
@@ -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),
|
||||
}));
|
||||
}
|
||||
+69
-19
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user