mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -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
@@ -23,9 +23,26 @@ import {
|
||||
PlaylistMeta,
|
||||
SECURITY_ERROR_PREFIX,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PlaylistRefreshActionService } from './playlist-refresh-action.service';
|
||||
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -233,6 +250,7 @@ describe('PlaylistRefreshActionService', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPerformanceHook(null);
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -382,6 +400,11 @@ describe('PlaylistRefreshActionService', () => {
|
||||
it('stores Xtream restore data before updating playlist meta and navigating', async () => {
|
||||
const item = createPlaylistMeta();
|
||||
const executionOrder: string[] = [];
|
||||
const performanceEvents: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => {
|
||||
performanceEvents.push(event);
|
||||
executionOrder.push(event.boundary);
|
||||
});
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
@@ -456,7 +479,34 @@ describe('PlaylistRefreshActionService', () => {
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
expect(executionOrder).toEqual([
|
||||
'setItem',
|
||||
'start',
|
||||
'dispatch',
|
||||
'end',
|
||||
'navigate',
|
||||
]);
|
||||
expect(
|
||||
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 1,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 1,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
PLAYLIST_UPDATE,
|
||||
PlaylistMeta,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
|
||||
export interface XtreamRefreshPreparationState {
|
||||
@@ -159,10 +163,15 @@ export class PlaylistRefreshActionService {
|
||||
playbackPositions,
|
||||
});
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
|
||||
await this.router.navigate([
|
||||
|
||||
+117
-1
@@ -27,8 +27,25 @@ import {
|
||||
SortService,
|
||||
} from '@iptvnator/services';
|
||||
import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
|
||||
type RendererPerformancePhaseEvent,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import { RecentPlaylistsComponent } from './recent-playlists.component';
|
||||
|
||||
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
|
||||
|
||||
function setPerformanceHook(
|
||||
hook: ((event: RendererPerformancePhaseEvent) => void) | null
|
||||
): void {
|
||||
const target = globalThis as unknown as Record<symbol, unknown>;
|
||||
if (hook === null) {
|
||||
delete target[performanceHookSymbol];
|
||||
} else {
|
||||
target[performanceHookSymbol] = hook;
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
@@ -233,11 +250,14 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setPerformanceHook(null);
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('tracks delete progress and clears the busy row after completion', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
|
||||
const deletion = createDeferred<boolean>();
|
||||
|
||||
@@ -290,9 +310,32 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
expect(
|
||||
events.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
items: metadata?.items,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
items: 1,
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
items: 1,
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('delegates playlist deletion and updates local UI state after success', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
const item = createPlaylistMeta({
|
||||
_id: 'pwa-playlist-1',
|
||||
serverUrl: undefined,
|
||||
@@ -316,6 +359,43 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
undefined,
|
||||
{ duration: 2000 }
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('records dispatch failure and still clears delete-row busy state', async () => {
|
||||
const events: RendererPerformancePhaseEvent[] = [];
|
||||
const item = createPlaylistMeta({ _id: 'dispatch-failure' });
|
||||
const dispatchError = new Error('dispatch failed');
|
||||
setPerformanceHook((event) => events.push(event));
|
||||
(store.dispatch as jest.Mock).mockImplementation(() => {
|
||||
throw dispatchError;
|
||||
});
|
||||
|
||||
await expect(component.removePlaylist(item)).rejects.toBe(
|
||||
dispatchError
|
||||
);
|
||||
|
||||
expect(component.isDeletePending(item._id)).toBe(false);
|
||||
expect(component.getBusyProgress(item._id)).toBeNull();
|
||||
expect(snackBar.open).not.toHaveBeenCalled();
|
||||
expect(
|
||||
events.map(({ boundary, outcome, phase }) => ({
|
||||
boundary,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
outcome: 'error',
|
||||
phase: 'store.xtream-delete-row',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
|
||||
@@ -388,6 +468,11 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
const executionOrder: string[] = [];
|
||||
const performanceEvents: RendererPerformancePhaseEvent[] = [];
|
||||
setPerformanceHook((event) => {
|
||||
performanceEvents.push(event);
|
||||
executionOrder.push(event.boundary);
|
||||
});
|
||||
const dateNowSpy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValue(1712145600000);
|
||||
@@ -476,7 +561,38 @@ describe('RecentPlaylistsComponent busy state', () => {
|
||||
'xtreams',
|
||||
item._id,
|
||||
]);
|
||||
expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']);
|
||||
expect(executionOrder).toEqual([
|
||||
'setItem',
|
||||
'start',
|
||||
'dispatch',
|
||||
'end',
|
||||
'navigate',
|
||||
]);
|
||||
expect(performanceEvents).toHaveLength(2);
|
||||
expect(performanceEvents[0]?.phaseId).toBe(
|
||||
performanceEvents[1]?.phaseId
|
||||
);
|
||||
expect(
|
||||
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
|
||||
boundary,
|
||||
metadata,
|
||||
outcome,
|
||||
phase,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
boundary: 'start',
|
||||
metadata: { items: 1 },
|
||||
outcome: undefined,
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
{
|
||||
boundary: 'end',
|
||||
metadata: { items: 1 },
|
||||
outcome: 'success',
|
||||
phase: 'store.xtream-refresh-meta',
|
||||
},
|
||||
]);
|
||||
|
||||
setItemSpy.mockRestore();
|
||||
dateNowSpy.mockRestore();
|
||||
|
||||
@@ -46,6 +46,10 @@ import {
|
||||
PlaylistMeta,
|
||||
PlaylistRefreshEvent,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
measureRendererPerformancePhase,
|
||||
RENDERER_PERFORMANCE_PHASE,
|
||||
} from '@iptvnator/shared/logging';
|
||||
|
||||
import { EmptyStateComponent } from './empty-state/empty-state.component';
|
||||
import type { PlaylistType } from '../add-playlist-menu/playlist-type';
|
||||
@@ -271,9 +275,24 @@ export class RecentPlaylistsComponent {
|
||||
}
|
||||
);
|
||||
if (deleted) {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({ playlistId: item._id })
|
||||
);
|
||||
if (item.serverUrl) {
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({
|
||||
playlistId: item._id,
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(
|
||||
PlaylistActions.removePlaylist({
|
||||
playlistId: item._id,
|
||||
})
|
||||
);
|
||||
}
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
|
||||
@@ -389,10 +408,15 @@ export class RecentPlaylistsComponent {
|
||||
});
|
||||
|
||||
// Update the timestamp in NgRx / IndexedDB
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
measureRendererPerformancePhase(
|
||||
RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META,
|
||||
() =>
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
playlist: { ...item, updateDate },
|
||||
})
|
||||
),
|
||||
() => ({ items: 1 })
|
||||
);
|
||||
|
||||
// Navigate to the playlist to trigger re-import
|
||||
|
||||
Reference in new issue
Block a user