diff --git a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts new file mode 100644 index 000000000..51234846f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts @@ -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((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((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 }); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts index 090eecabb..a68e70fdf 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -1,189 +1,19 @@ -type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; +import { + createDownloadRow, + getHandler, + mockBroadcastDownloadUpdate, + mockDownloadRow, + mockRemoveDownloadFromRuntime, + mockRemovePartialDownloadFile, + mockTerminalRows, + setupDownloadsEventsHarness, +} from './downloads.test-helpers'; -const mockRegisteredHandlers = new Map(); -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('node:fs'), - existsSync: mockExistsSync, - })); - jest.doMock('drizzle-orm', () => { - const actual = - jest.requireActual('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, - 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((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((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 }); - }); }); diff --git a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts new file mode 100644 index 000000000..21f2dd4ab --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts @@ -0,0 +1,192 @@ +type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; + +/** + * 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(); +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 { + 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('node:fs'), + existsSync: mockExistsSync, + })); + jest.doMock('drizzle-orm', () => { + const actual = + jest.requireActual('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, + 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 }; +} diff --git a/apps/electron-backend/src/app/events/remote-control-http.spec.ts b/apps/electron-backend/src/app/events/remote-control-http.spec.ts new file mode 100644 index 000000000..fc8c7617b --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control-http.spec.ts @@ -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(); + } + ); +}); diff --git a/apps/electron-backend/src/app/events/remote-control.events.spec.ts b/apps/electron-backend/src/app/events/remote-control.events.spec.ts index 67713fe93..4c3f8efa5 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.spec.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.spec.ts @@ -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; -} - -interface RequestInvocation { - response: ResponseSnapshot; - request: PassThrough; - requestClosed: Promise; - 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(); -const mockIpcHandlers = new Map(); -const mockIpcListeners = new Map(); -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((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 { - const request = new PassThrough(); - const requestErrors: Error[] = []; - request.on('error', (error: Error) => { - requestErrors.push(error); - }); - const requestClosed = new Promise((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(); - } - ); }); diff --git a/apps/electron-backend/src/app/events/remote-control.test-helpers.ts b/apps/electron-backend/src/app/events/remote-control.test-helpers.ts new file mode 100644 index 000000000..e594659c6 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.test-helpers.ts @@ -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; +} + +export interface RequestInvocation { + response: ResponseSnapshot; + request: PassThrough; + requestClosed: Promise; + 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(); +export const mockIpcHandlers = new Map(); +export const mockIpcListeners = new Map(); +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((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 { + const request = new PassThrough(); + const requestErrors: Error[] = []; + request.on('error', (error: Error) => { + requestErrors.push(error); + }); + const requestClosed = new Promise((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; +} diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts index 88f3d8d38..d4207d498 100644 --- a/apps/electron-backend/src/app/server/http-server.spec.ts +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -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; @@ -119,77 +119,6 @@ async function getAvailablePort(): Promise { 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; diff --git a/apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts b/apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts new file mode 100644 index 000000000..3cda4154c --- /dev/null +++ b/apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts @@ -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'); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts new file mode 100644 index 000000000..c56a2fb36 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts @@ -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, + }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index d6438dec3..a0e0db15c 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -146,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, - }, - }); - }); });