mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark * feat(xtream-mock-server): add deterministic 100k fixture * style(xtream-mock-server): apply repository formatting * fix(xtream-mock-server): harden performance fixture data * feat(xtream-mock-server): add performance control plane * docs(performance): correct Xtream capture plan * fix(xtream-mock-server): harden performance controls * fix(xtream-mock-server): harden control lifecycle * feat(performance): add Xtream preload markers * feat(performance): trace Xtream main phases * feat(performance): mark Xtream store publications * feat(performance): trace Xtream database phases * feat(performance): trace Xtream delete cancellation * feat(performance): capture Xtream phase attribution * feat(performance): mark Sources Xtream refresh * test(performance): define Xtream benchmark evidence contracts * test(performance): add Xtream benchmark runner * test(performance): surface failure evidence writes * test(performance): align database read clock * test(performance): preserve capture failure contracts
This commit is contained in:
1 parent
5e725a06f3
commit
a2fafcfc08
256 files changed
+43760
-883
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,
|
||||
@@ -78,10 +83,17 @@ const dbSaveContentProgressListeners = new Set<
|
||||
|
||||
const shouldTraceRendererApi = isRendererApiTraceEnabled();
|
||||
const shouldCapturePerformance = isPerformanceCaptureEnabled();
|
||||
const shouldCaptureXtreamPerformance =
|
||||
isXtreamPreloadPerformanceCaptureEnabled();
|
||||
const preloadPerformanceCapture = createPreloadPerformanceCapture(
|
||||
shouldCapturePerformance,
|
||||
(channel, marker) => ipcRenderer.send(channel, marker)
|
||||
);
|
||||
const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance
|
||||
? createXtreamPreloadPerformanceCapture(true, (channel, marker) =>
|
||||
ipcRenderer.send(channel, marker)
|
||||
)
|
||||
: null;
|
||||
|
||||
function emitRendererTrace(payload: {
|
||||
method: string;
|
||||
@@ -105,12 +117,18 @@ function wrapElectronApi<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];
|
||||
}
|
||||
@@ -134,6 +152,9 @@ function wrapElectronApi<T extends object>(api: T): T {
|
||||
name,
|
||||
args
|
||||
);
|
||||
const xtreamPerformanceCall =
|
||||
xtreamPreloadPerformanceCapture?.start(name, args) ??
|
||||
null;
|
||||
|
||||
try {
|
||||
const result = original(...args);
|
||||
@@ -162,6 +183,9 @@ function wrapElectronApi<T extends object>(api: T): T {
|
||||
performanceCall,
|
||||
resolvedValue
|
||||
);
|
||||
xtreamPreloadPerformanceCapture?.success(
|
||||
xtreamPerformanceCall
|
||||
);
|
||||
return resolvedValue;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -179,6 +203,9 @@ function wrapElectronApi<T extends object>(api: T): T {
|
||||
preloadPerformanceCapture.error(
|
||||
performanceCall
|
||||
);
|
||||
xtreamPreloadPerformanceCapture?.error(
|
||||
xtreamPerformanceCall
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
@@ -198,6 +225,9 @@ function wrapElectronApi<T extends object>(api: T): T {
|
||||
performanceCall,
|
||||
result
|
||||
);
|
||||
xtreamPreloadPerformanceCapture?.success(
|
||||
xtreamPerformanceCall
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -213,6 +243,9 @@ function wrapElectronApi<T extends object>(api: T): T {
|
||||
});
|
||||
}
|
||||
preloadPerformanceCapture.error(performanceCall);
|
||||
xtreamPreloadPerformanceCapture?.error(
|
||||
xtreamPerformanceCall
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
@@ -738,11 +771,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
kind: 'all' | 'vod' | 'series',
|
||||
limit?: number,
|
||||
playlistType?:
|
||||
| 'xtream'
|
||||
| 'stalker'
|
||||
| 'm3u-file'
|
||||
| 'm3u-text'
|
||||
| 'm3u-url'
|
||||
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'
|
||||
) =>
|
||||
ipcRenderer.invoke(
|
||||
'DB_GET_GLOBAL_RECENTLY_ADDED',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user