mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
refactor(electron-backend): split the last four files over the max-lines cap
Follow-up to #1278, which split four of the files the electron-backend lint target had been silently skipping. Four were left over; this splits them, so no file in the project sits above the 400-line cap outside the baseline. None are added to tools/eslint/max-lines-baseline.mjs — the baseline only shrinks. Shared setup moves to *.test-helpers.ts, the suffix tsconfig.app.json already excludes, so the production build never sees jest globals. - remote-control.events.spec.ts 588 -> 188, plus remote-control-http.spec.ts. The mock registry moves to remote-control.test-helpers.ts; each spec keeps its own jest.mock() factories, which resolve the mock-prefixed exports. - downloads.events.spec.ts 530 -> 182, plus downloads-actions.spec.ts. The jest.doMock setup is not hoisted, so the whole harness moves to downloads.test-helpers.ts behind setupDownloadsEventsHarness(). - http-server.spec.ts 448 -> 377, plus resolve-static-file-path.spec.ts. The resolveStaticFilePath cases were already self-contained. - worker-performance-capture.spec.ts 408 -> 149, plus worker-performance-capture.resilience.spec.ts, matching the .concurrency and .histogram siblings. Test bodies are unchanged; the helpers keep the same identifiers in scope so the splits are a move, not a rewrite. Verified with the glob quoted locally (that quoting is #1176's, not part of this change): 245 files, 0 max-lines errors, and the only remaining lint errors are the five #1176 fixes. Both tsconfig.app.json and tsconfig.spec.json typecheck clean. Note: worker-performance-capture.concurrency.spec.ts is flaky on master independently of this change — it asserts a real 20ms event-loop block and failed 4/4 clean-master runs here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
24f0dee6f0
commit
e17bb39537
10 files changed
+1298
-1116
No files matched your search
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
expectManagedPathLookup,
|
||||
getHandler,
|
||||
MANAGED_PATH_STATE,
|
||||
mockExistsSync,
|
||||
mockManagedPath,
|
||||
mockOpenPath,
|
||||
mockPauseDownload,
|
||||
mockResumeDownloadRequest,
|
||||
mockShowItemInFolder,
|
||||
setupDownloadsEventsHarness,
|
||||
} from './downloads.test-helpers';
|
||||
|
||||
describe('downloads events: pause, resume, and reveal', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDownloadsEventsHarness();
|
||||
});
|
||||
|
||||
it('maps a successful runtime pause to a success response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(true);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockPauseDownload).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('maps an unknown pause target to an error response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(false);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Download not found in queue',
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards resume requests with the download folder and returns the result', async () => {
|
||||
mockResumeDownloadRequest.mockResolvedValue({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
|
||||
).resolves.toEqual({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
channel: 'DOWNLOADS_REVEAL_FILE',
|
||||
filePath: '/downloads/reveal-boundary.mp4',
|
||||
operation: 'reveal',
|
||||
},
|
||||
{
|
||||
channel: 'DOWNLOADS_PLAY_FILE',
|
||||
filePath: '/downloads/play-boundary.mp4',
|
||||
operation: 'play',
|
||||
},
|
||||
])('$operation managed-path boundary', ({ channel, filePath }) => {
|
||||
it('rejects an unmanaged database path before accessing the filesystem', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).not.toHaveBeenCalled();
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a managed database path that is missing from disk', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when the managed-path database query rejects', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler(channel)(null, filePath)
|
||||
).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Error verifying managed download path:',
|
||||
expect.objectContaining({
|
||||
message: 'database unavailable',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).not.toHaveBeenCalled();
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals a managed file that exists on disk', async () => {
|
||||
const filePath = '/downloads/reveal-success.mp4';
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockShowItemInFolder).toHaveBeenCalledTimes(1);
|
||||
expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the native shell before reporting a managed file as played', async () => {
|
||||
const filePath = '/downloads/play-success.mp4';
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
let resolveOpenPath!: (value: string) => void;
|
||||
const openPathResult = new Promise<string>((resolve) => {
|
||||
resolveOpenPath = resolve;
|
||||
});
|
||||
mockOpenPath.mockReturnValue(openPathResult);
|
||||
|
||||
let responseSettled = false;
|
||||
const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then(
|
||||
(result) => {
|
||||
responseSettled = true;
|
||||
return result;
|
||||
}
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenPath).toHaveBeenCalledWith(filePath);
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
expect(responseSettled).toBe(false);
|
||||
|
||||
resolveOpenPath('');
|
||||
await expect(response).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -1,189 +1,19 @@
|
||||
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
import {
|
||||
createDownloadRow,
|
||||
getHandler,
|
||||
mockBroadcastDownloadUpdate,
|
||||
mockDownloadRow,
|
||||
mockRemoveDownloadFromRuntime,
|
||||
mockRemovePartialDownloadFile,
|
||||
mockTerminalRows,
|
||||
setupDownloadsEventsHarness,
|
||||
} from './downloads.test-helpers';
|
||||
|
||||
const mockRegisteredHandlers = new Map<string, IpcHandler>();
|
||||
const mockGetDatabase = jest.fn();
|
||||
const mockRemoveDownloadFromRuntime = jest.fn();
|
||||
const mockBroadcastDownloadUpdate = jest.fn();
|
||||
const mockRemovePartialDownloadFile = jest.fn();
|
||||
const mockPauseDownload = jest.fn();
|
||||
const mockResumeDownloadRequest = jest.fn();
|
||||
const mockExistsSync = jest.fn();
|
||||
const mockOpenPath = jest.fn();
|
||||
const mockShowItemInFolder = jest.fn();
|
||||
const mockEq = jest.fn();
|
||||
let downloadsFilePathColumn: unknown;
|
||||
|
||||
const MANAGED_PATH_STATE = {
|
||||
ERROR: 'error',
|
||||
MANAGED: 'managed',
|
||||
UNMANAGED: 'unmanaged',
|
||||
} as const;
|
||||
|
||||
type ManagedPathState =
|
||||
(typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE];
|
||||
|
||||
function getHandler(channel: string): IpcHandler {
|
||||
const handler = mockRegisteredHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected IPC handler for ${channel}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
function createDownloadRow(status: string) {
|
||||
return {
|
||||
filePath: '/downloads/resume.mp4',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
describe('downloads events', () => {
|
||||
describe('downloads events: partial-file cleanup', () => {
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
mockRegisteredHandlers.clear();
|
||||
mockGetDatabase.mockReset();
|
||||
mockRemoveDownloadFromRuntime.mockReset();
|
||||
mockBroadcastDownloadUpdate.mockReset();
|
||||
mockRemovePartialDownloadFile.mockReset();
|
||||
mockPauseDownload.mockReset();
|
||||
mockResumeDownloadRequest.mockReset();
|
||||
mockExistsSync.mockReset();
|
||||
mockOpenPath.mockReset().mockResolvedValue('');
|
||||
mockShowItemInFolder.mockReset();
|
||||
mockEq.mockReset();
|
||||
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual<typeof import('node:fs')>('node:fs'),
|
||||
existsSync: mockExistsSync,
|
||||
}));
|
||||
jest.doMock('drizzle-orm', () => {
|
||||
const actual =
|
||||
jest.requireActual<typeof import('drizzle-orm')>('drizzle-orm');
|
||||
mockEq.mockImplementation(actual.eq);
|
||||
return {
|
||||
...actual,
|
||||
eq: mockEq,
|
||||
};
|
||||
});
|
||||
jest.doMock('electron', () => ({
|
||||
app: {
|
||||
getPath: jest.fn((name: string) =>
|
||||
name === 'userData' ? '/user-data' : '/downloads'
|
||||
),
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: jest.fn(),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: IpcHandler) => {
|
||||
mockRegisteredHandlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
openPath: mockOpenPath,
|
||||
showItemInFolder: mockShowItemInFolder,
|
||||
},
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: mockGetDatabase,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile: mockRemovePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
|
||||
cancelDownload: jest.fn(),
|
||||
pauseDownload: mockPauseDownload,
|
||||
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
|
||||
setMainWindow: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-requests', () => ({
|
||||
resumeDownloadRequest: mockResumeDownloadRequest,
|
||||
retryDownloadRequest: jest.fn(),
|
||||
startDownloadRequest: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-recovery', () => ({
|
||||
resetStaleDownloads: jest.fn(),
|
||||
}));
|
||||
|
||||
await import('./downloads.events');
|
||||
const schema = await import('../../database/schema');
|
||||
downloadsFilePathColumn = schema.downloads.filePath;
|
||||
await setupDownloadsEventsHarness();
|
||||
});
|
||||
|
||||
function mockManagedPath(state: ManagedPathState) {
|
||||
const limit = jest.fn(() => {
|
||||
if (state === MANAGED_PATH_STATE.ERROR) {
|
||||
return Promise.reject(new Error('database unavailable'));
|
||||
}
|
||||
return Promise.resolve(
|
||||
state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : []
|
||||
);
|
||||
});
|
||||
const where = jest.fn((_predicate: unknown) => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const db = { select };
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { from, limit, select, where };
|
||||
}
|
||||
|
||||
function expectManagedPathLookup(
|
||||
lookup: ReturnType<typeof mockManagedPath>,
|
||||
filePath: string
|
||||
) {
|
||||
expect(mockGetDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.select).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.from).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.where).toHaveBeenCalledTimes(1);
|
||||
expect(mockEq).toHaveBeenCalledTimes(1);
|
||||
expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true);
|
||||
expect(mockEq.mock.calls[0][1]).toBe(filePath);
|
||||
expect(
|
||||
lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value
|
||||
).toBe(true);
|
||||
expect(lookup.limit).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.limit).toHaveBeenCalledWith(1);
|
||||
}
|
||||
|
||||
function mockDownloadRow(row: { filePath: string | null; status: string }) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([row]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
function mockTerminalRows(
|
||||
rows: Array<{ filePath: string | null; status: string }>
|
||||
) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const selectWhere = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
rows.map((row, index) => ({ id: index + 1, ...row }))
|
||||
);
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: selectWhere,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere, selectWhere };
|
||||
}
|
||||
|
||||
it('removes queued resumed partial files before deleting the row', async () => {
|
||||
const { deleteWhere } = mockDownloadRow(createDownloadRow('queued'));
|
||||
|
||||
@@ -349,182 +179,4 @@ describe('downloads events', () => {
|
||||
expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps a successful runtime pause to a success response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(true);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({ success: true });
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
|
||||
expect(mockPauseDownload).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('maps an unknown pause target to an error response', async () => {
|
||||
mockPauseDownload.mockResolvedValue(false);
|
||||
const consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_PAUSE')(null, 42)
|
||||
).resolves.toEqual({
|
||||
error: 'Download not found in queue',
|
||||
success: false,
|
||||
});
|
||||
} finally {
|
||||
consoleLog.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards resume requests with the download folder and returns the result', async () => {
|
||||
mockResumeDownloadRequest.mockResolvedValue({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads')
|
||||
).resolves.toEqual({
|
||||
error: 'Can only resume paused downloads',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expect(mockResumeDownloadRequest).toHaveBeenCalledWith(
|
||||
42,
|
||||
'/downloads',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
channel: 'DOWNLOADS_REVEAL_FILE',
|
||||
filePath: '/downloads/reveal-boundary.mp4',
|
||||
operation: 'reveal',
|
||||
},
|
||||
{
|
||||
channel: 'DOWNLOADS_PLAY_FILE',
|
||||
filePath: '/downloads/play-boundary.mp4',
|
||||
operation: 'play',
|
||||
},
|
||||
])('$operation managed-path boundary', ({ channel, filePath }) => {
|
||||
it('rejects an unmanaged database path before accessing the filesystem', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).not.toHaveBeenCalled();
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a managed database path that is missing from disk', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
await expect(getHandler(channel)(null, filePath)).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when the managed-path database query rejects', async () => {
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
const consoleError = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
getHandler(channel)(null, filePath)
|
||||
).resolves.toEqual({
|
||||
error: 'File not found',
|
||||
success: false,
|
||||
});
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'Error verifying managed download path:',
|
||||
expect.objectContaining({
|
||||
message: 'database unavailable',
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
consoleError.mockRestore();
|
||||
}
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).not.toHaveBeenCalled();
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals a managed file that exists on disk', async () => {
|
||||
const filePath = '/downloads/reveal-success.mp4';
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
|
||||
await expect(
|
||||
getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath)
|
||||
).resolves.toEqual({ success: true });
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockShowItemInFolder).toHaveBeenCalledTimes(1);
|
||||
expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits for the native shell before reporting a managed file as played', async () => {
|
||||
const filePath = '/downloads/play-success.mp4';
|
||||
const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED);
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
let resolveOpenPath!: (value: string) => void;
|
||||
const openPathResult = new Promise<string>((resolve) => {
|
||||
resolveOpenPath = resolve;
|
||||
});
|
||||
mockOpenPath.mockReturnValue(openPathResult);
|
||||
|
||||
let responseSettled = false;
|
||||
const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then(
|
||||
(result) => {
|
||||
responseSettled = true;
|
||||
return result;
|
||||
}
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expectManagedPathLookup(lookup, filePath);
|
||||
expect(mockExistsSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
|
||||
expect(mockOpenPath).toHaveBeenCalledTimes(1);
|
||||
expect(mockOpenPath).toHaveBeenCalledWith(filePath);
|
||||
expect(mockShowItemInFolder).not.toHaveBeenCalled();
|
||||
expect(responseSettled).toBe(false);
|
||||
|
||||
resolveOpenPath('');
|
||||
await expect(response).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Shared harness for the downloads-events specs.
|
||||
*
|
||||
* The module registry is reset and re-mocked per test via `jest.doMock`, which
|
||||
* is deliberately not hoisted — so the whole setup can live here and each spec
|
||||
* just calls `setupDownloadsEventsHarness()` from its own `beforeEach`.
|
||||
*/
|
||||
|
||||
export const mockRegisteredHandlers = new Map<string, IpcHandler>();
|
||||
export const mockGetDatabase = jest.fn();
|
||||
export const mockRemoveDownloadFromRuntime = jest.fn();
|
||||
export const mockBroadcastDownloadUpdate = jest.fn();
|
||||
export const mockRemovePartialDownloadFile = jest.fn();
|
||||
export const mockPauseDownload = jest.fn();
|
||||
export const mockResumeDownloadRequest = jest.fn();
|
||||
export const mockExistsSync = jest.fn();
|
||||
export const mockOpenPath = jest.fn();
|
||||
export const mockShowItemInFolder = jest.fn();
|
||||
export const mockEq = jest.fn();
|
||||
let downloadsFilePathColumn: unknown;
|
||||
|
||||
export const MANAGED_PATH_STATE = {
|
||||
ERROR: 'error',
|
||||
MANAGED: 'managed',
|
||||
UNMANAGED: 'unmanaged',
|
||||
} as const;
|
||||
|
||||
export type ManagedPathState =
|
||||
(typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE];
|
||||
|
||||
export function getHandler(channel: string): IpcHandler {
|
||||
const handler = mockRegisteredHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected IPC handler for ${channel}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function createDownloadRow(status: string) {
|
||||
return {
|
||||
filePath: '/downloads/resume.mp4',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupDownloadsEventsHarness(): Promise<void> {
|
||||
jest.resetModules();
|
||||
mockRegisteredHandlers.clear();
|
||||
mockGetDatabase.mockReset();
|
||||
mockRemoveDownloadFromRuntime.mockReset();
|
||||
mockBroadcastDownloadUpdate.mockReset();
|
||||
mockRemovePartialDownloadFile.mockReset();
|
||||
mockPauseDownload.mockReset();
|
||||
mockResumeDownloadRequest.mockReset();
|
||||
mockExistsSync.mockReset();
|
||||
mockOpenPath.mockReset().mockResolvedValue('');
|
||||
mockShowItemInFolder.mockReset();
|
||||
mockEq.mockReset();
|
||||
|
||||
jest.doMock('node:fs', () => ({
|
||||
...jest.requireActual<typeof import('node:fs')>('node:fs'),
|
||||
existsSync: mockExistsSync,
|
||||
}));
|
||||
jest.doMock('drizzle-orm', () => {
|
||||
const actual =
|
||||
jest.requireActual<typeof import('drizzle-orm')>('drizzle-orm');
|
||||
mockEq.mockImplementation(actual.eq);
|
||||
return {
|
||||
...actual,
|
||||
eq: mockEq,
|
||||
};
|
||||
});
|
||||
jest.doMock('electron', () => ({
|
||||
app: {
|
||||
getPath: jest.fn((name: string) =>
|
||||
name === 'userData' ? '/user-data' : '/downloads'
|
||||
),
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: jest.fn(),
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: IpcHandler) => {
|
||||
mockRegisteredHandlers.set(channel, handler);
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
openPath: mockOpenPath,
|
||||
showItemInFolder: mockShowItemInFolder,
|
||||
},
|
||||
}));
|
||||
jest.doMock('../../database/connection', () => ({
|
||||
getDatabase: mockGetDatabase,
|
||||
}));
|
||||
jest.doMock('./download-file-path', () => ({
|
||||
removePartialDownloadFile: mockRemovePartialDownloadFile,
|
||||
}));
|
||||
jest.doMock('./download-runtime', () => ({
|
||||
broadcastDownloadUpdate: mockBroadcastDownloadUpdate,
|
||||
cancelDownload: jest.fn(),
|
||||
pauseDownload: mockPauseDownload,
|
||||
removeDownloadFromRuntime: mockRemoveDownloadFromRuntime,
|
||||
setMainWindow: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-requests', () => ({
|
||||
resumeDownloadRequest: mockResumeDownloadRequest,
|
||||
retryDownloadRequest: jest.fn(),
|
||||
startDownloadRequest: jest.fn(),
|
||||
}));
|
||||
jest.doMock('./download-recovery', () => ({
|
||||
resetStaleDownloads: jest.fn(),
|
||||
}));
|
||||
|
||||
await import('./downloads.events');
|
||||
const schema = await import('../../database/schema');
|
||||
downloadsFilePathColumn = schema.downloads.filePath;
|
||||
}
|
||||
|
||||
export function mockManagedPath(state: ManagedPathState) {
|
||||
const limit = jest.fn(() => {
|
||||
if (state === MANAGED_PATH_STATE.ERROR) {
|
||||
return Promise.reject(new Error('database unavailable'));
|
||||
}
|
||||
return Promise.resolve(
|
||||
state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : []
|
||||
);
|
||||
});
|
||||
const where = jest.fn((_predicate: unknown) => ({ limit }));
|
||||
const from = jest.fn(() => ({ where }));
|
||||
const select = jest.fn(() => ({ from }));
|
||||
const db = { select };
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { from, limit, select, where };
|
||||
}
|
||||
|
||||
export function expectManagedPathLookup(
|
||||
lookup: ReturnType<typeof mockManagedPath>,
|
||||
filePath: string
|
||||
) {
|
||||
expect(mockGetDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.select).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.from).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.where).toHaveBeenCalledTimes(1);
|
||||
expect(mockEq).toHaveBeenCalledTimes(1);
|
||||
expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true);
|
||||
expect(mockEq.mock.calls[0][1]).toBe(filePath);
|
||||
expect(
|
||||
lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value
|
||||
).toBe(true);
|
||||
expect(lookup.limit).toHaveBeenCalledTimes(1);
|
||||
expect(lookup.limit).toHaveBeenCalledWith(1);
|
||||
}
|
||||
|
||||
export function mockDownloadRow(row: { filePath: string | null; status: string }) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn(() => ({
|
||||
limit: jest.fn().mockResolvedValue([row]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
export function mockTerminalRows(
|
||||
rows: Array<{ filePath: string | null; status: string }>
|
||||
) {
|
||||
const deleteWhere = jest.fn().mockResolvedValue(undefined);
|
||||
const selectWhere = jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
rows.map((row, index) => ({ id: index + 1, ...row }))
|
||||
);
|
||||
const db = {
|
||||
delete: jest.fn(() => ({ where: deleteWhere })),
|
||||
select: jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: selectWhere,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
mockGetDatabase.mockResolvedValue(db);
|
||||
return { db, deleteWhere, selectWhere };
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
bootstrapRemoteControl as bootstrapRemoteControlWith,
|
||||
createBodyAtByteLength,
|
||||
getIpcListener,
|
||||
invokeHttpHandler,
|
||||
JSON_HEADERS,
|
||||
METHOD_NOT_ALLOWED_RESPONSE,
|
||||
mockFirstRendererSend,
|
||||
mockGetAllWindows,
|
||||
mockIpcHandle,
|
||||
mockIpcOn,
|
||||
mockRegisterRemoteControlHandler,
|
||||
mockSecondRendererSend,
|
||||
mockStartHttpServer,
|
||||
mockStoreGet,
|
||||
REMOTE_CONTROL_PATHS,
|
||||
resetRemoteControlMocks,
|
||||
SUCCESS_RESPONSE,
|
||||
type RemoteControlSettings,
|
||||
} from './remote-control.test-helpers';
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
getAllWindows: mockGetAllWindows,
|
||||
},
|
||||
ipcMain: {
|
||||
handle: mockIpcHandle,
|
||||
on: mockIpcOn,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../server/http-server', () => ({
|
||||
httpServer: {
|
||||
registerRemoteControlHandler: mockRegisterRemoteControlHandler,
|
||||
start: mockStartHttpServer,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../services/store.service', () => ({
|
||||
store: {
|
||||
get: mockStoreGet,
|
||||
},
|
||||
}));
|
||||
|
||||
import { RemoteControlEvents } from './remote-control.events';
|
||||
|
||||
const bootstrapRemoteControl = (settings?: RemoteControlSettings) =>
|
||||
bootstrapRemoteControlWith(RemoteControlEvents, settings);
|
||||
|
||||
describe('RemoteControlEvents HTTP endpoints', () => {
|
||||
let consoleLog: jest.SpyInstance;
|
||||
let consoleWarn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
resetRemoteControlMocks();
|
||||
consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
consoleWarn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
consoleLog.mockRestore();
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects POST on the status endpoint', async () => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE);
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('floors a finite positive channel number and dispatches it', async () => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ number: 7.9 }),
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.response).toEqual(SUCCESS_RESPONSE);
|
||||
expect(mockFirstRendererSend).toHaveBeenCalledWith(
|
||||
'REMOTE_CONTROL_COMMAND',
|
||||
{
|
||||
type: 'channel-select-number',
|
||||
number: 7,
|
||||
}
|
||||
);
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'missing', body: '{}' },
|
||||
{ label: 'zero', body: '{"number":0}' },
|
||||
{ label: 'negative', body: '{"number":-2}' },
|
||||
{ label: 'infinite', body: '{"number":1e309}' },
|
||||
{ label: 'nonnumeric', body: '{"number":"seven"}' },
|
||||
])('rejects a $label channel number', async ({ body }) => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 400,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Invalid channel number' }),
|
||||
});
|
||||
expect(mockGetAllWindows).not.toHaveBeenCalled();
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed JSON without dispatching', async () => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body: '{"number":',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 400,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Invalid JSON payload' }),
|
||||
});
|
||||
expect(mockGetAllWindows).not.toHaveBeenCalled();
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a valid JSON request at the 10,240-byte body limit', async () => {
|
||||
bootstrapRemoteControl();
|
||||
const body = createBodyAtByteLength(10 * 1024);
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
expect(Buffer.byteLength(body, 'utf8')).toBe(10_240);
|
||||
expect(result.response).toEqual(SUCCESS_RESPONSE);
|
||||
expect(mockFirstRendererSend).toHaveBeenCalledWith(
|
||||
'REMOTE_CONTROL_COMMAND',
|
||||
{
|
||||
type: 'channel-select-number',
|
||||
number: 7,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects and destroys a 10,241-byte request without dispatching', async () => {
|
||||
bootstrapRemoteControl();
|
||||
const body = Buffer.alloc(10 * 1024 + 1, 'x');
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
await result.requestClosed;
|
||||
|
||||
expect(body.byteLength).toBe(10_241);
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 413,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Payload too large' }),
|
||||
});
|
||||
expect(result.request.destroyed).toBe(true);
|
||||
expect(result.requestErrors).toEqual([]);
|
||||
expect(mockGetAllWindows).not.toHaveBeenCalled();
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges partial status updates and refreshes updatedAt', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z'));
|
||||
bootstrapRemoteControl();
|
||||
const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE');
|
||||
|
||||
jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z'));
|
||||
updateStatus(
|
||||
{},
|
||||
{
|
||||
portal: 'm3u',
|
||||
isLiveView: true,
|
||||
channelName: 'News',
|
||||
volume: 35,
|
||||
}
|
||||
);
|
||||
jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z'));
|
||||
updateStatus({}, { channelName: 'Sports', muted: true });
|
||||
|
||||
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 200,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({
|
||||
portal: 'm3u',
|
||||
isLiveView: true,
|
||||
supportsVolume: false,
|
||||
updatedAt: '2026-07-25T10:02:00.000Z',
|
||||
channelName: 'Sports',
|
||||
volume: 35,
|
||||
muted: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
path: REMOTE_CONTROL_PATHS.CHANNEL_UP,
|
||||
warning: 'No browser windows found to send channel change',
|
||||
},
|
||||
{
|
||||
path: REMOTE_CONTROL_PATHS.VOLUME_UP,
|
||||
warning: 'No browser windows found to send remote command',
|
||||
},
|
||||
])(
|
||||
'returns success and warns instead of throwing when $path has no renderer',
|
||||
async ({ path, warning }) => {
|
||||
mockGetAllWindows.mockReturnValue([]);
|
||||
bootstrapRemoteControl();
|
||||
|
||||
await expect(
|
||||
invokeHttpHandler(path, { method: 'POST' })
|
||||
).resolves.toMatchObject({
|
||||
response: SUCCESS_RESPONSE,
|
||||
});
|
||||
expect(consoleWarn).toHaveBeenCalledWith(warning);
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1,81 +1,23 @@
|
||||
import type * as http from 'node:http';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
type HttpHandler = (
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse
|
||||
) => void;
|
||||
type IpcCallback = (...args: unknown[]) => unknown;
|
||||
|
||||
interface RemoteControlSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface ResponseSnapshot {
|
||||
statusCode: number;
|
||||
headers: http.OutgoingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface ResponseRecorder {
|
||||
response: http.ServerResponse;
|
||||
completed: Promise<ResponseSnapshot>;
|
||||
}
|
||||
|
||||
interface RequestInvocation {
|
||||
response: ResponseSnapshot;
|
||||
request: PassThrough;
|
||||
requestClosed: Promise<void>;
|
||||
requestErrors: Error[];
|
||||
}
|
||||
|
||||
interface InvokeOptions {
|
||||
method: string;
|
||||
body?: string | Buffer;
|
||||
}
|
||||
|
||||
const REMOTE_CONTROL_PATHS = {
|
||||
STATUS: '/api/remote-control/status',
|
||||
SELECT_NUMBER: '/api/remote-control/channel/select-number',
|
||||
CHANNEL_UP: '/api/remote-control/channel/up',
|
||||
CHANNEL_DOWN: '/api/remote-control/channel/down',
|
||||
VOLUME_UP: '/api/remote-control/volume/up',
|
||||
VOLUME_DOWN: '/api/remote-control/volume/down',
|
||||
VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute',
|
||||
} as const;
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' } as const;
|
||||
const SUCCESS_RESPONSE = {
|
||||
statusCode: 200,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ success: true }),
|
||||
} as const;
|
||||
const METHOD_NOT_ALLOWED_RESPONSE = {
|
||||
statusCode: 405,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Method not allowed' }),
|
||||
} as const;
|
||||
|
||||
const mockHttpHandlers = new Map<string, HttpHandler>();
|
||||
const mockIpcHandlers = new Map<string, IpcCallback>();
|
||||
const mockIpcListeners = new Map<string, IpcCallback>();
|
||||
const mockRegisterRemoteControlHandler = jest.fn(
|
||||
(path: string, handler: HttpHandler) => {
|
||||
mockHttpHandlers.set(path, handler);
|
||||
}
|
||||
);
|
||||
const mockStartHttpServer = jest.fn();
|
||||
const mockStoreGet = jest.fn();
|
||||
const mockFirstRendererSend = jest.fn();
|
||||
const mockSecondRendererSend = jest.fn();
|
||||
const mockGetAllWindows = jest.fn();
|
||||
const mockIpcHandle = jest.fn((channel: string, handler: IpcCallback) => {
|
||||
mockIpcHandlers.set(channel, handler);
|
||||
});
|
||||
const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => {
|
||||
mockIpcListeners.set(channel, listener);
|
||||
});
|
||||
import {
|
||||
bootstrapRemoteControl as bootstrapRemoteControlWith,
|
||||
getIpcHandler,
|
||||
invokeHttpHandler,
|
||||
METHOD_NOT_ALLOWED_RESPONSE,
|
||||
mockFirstRendererSend,
|
||||
mockGetAllWindows,
|
||||
mockHttpHandlers,
|
||||
mockIpcHandle,
|
||||
mockIpcHandlers,
|
||||
mockIpcOn,
|
||||
mockRegisterRemoteControlHandler,
|
||||
mockSecondRendererSend,
|
||||
mockStartHttpServer,
|
||||
mockStoreGet,
|
||||
REMOTE_CONTROL_PATHS,
|
||||
resetRemoteControlMocks,
|
||||
SUCCESS_RESPONSE,
|
||||
type RemoteControlSettings,
|
||||
} from './remote-control.test-helpers';
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
BrowserWindow: {
|
||||
@@ -102,162 +44,15 @@ jest.mock('../services/store.service', () => ({
|
||||
|
||||
import { RemoteControlEvents } from './remote-control.events';
|
||||
|
||||
function createResponseRecorder(): ResponseRecorder {
|
||||
let statusCode = 0;
|
||||
let headers: http.OutgoingHttpHeaders = {};
|
||||
let response: http.ServerResponse;
|
||||
let responseEnded = false;
|
||||
let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => {
|
||||
throw new Error('Response completion promise is not initialized');
|
||||
};
|
||||
const completed = new Promise<ResponseSnapshot>((resolve) => {
|
||||
resolveCompleted = resolve;
|
||||
});
|
||||
const bootstrapRemoteControl = (settings?: RemoteControlSettings) =>
|
||||
bootstrapRemoteControlWith(RemoteControlEvents, settings);
|
||||
|
||||
response = {
|
||||
writeHead: (
|
||||
nextStatusCode: number,
|
||||
nextHeaders?: http.OutgoingHttpHeaders
|
||||
) => {
|
||||
statusCode = nextStatusCode;
|
||||
headers = { ...nextHeaders };
|
||||
return response;
|
||||
},
|
||||
end: (chunk?: string | Uint8Array) => {
|
||||
if (responseEnded) {
|
||||
throw new Error('Response ended more than once');
|
||||
}
|
||||
responseEnded = true;
|
||||
const body =
|
||||
typeof chunk === 'string'
|
||||
? chunk
|
||||
: chunk
|
||||
? Buffer.from(chunk).toString('utf8')
|
||||
: '';
|
||||
resolveCompleted({ statusCode, headers, body });
|
||||
return response;
|
||||
},
|
||||
} as unknown as http.ServerResponse;
|
||||
|
||||
return { response, completed };
|
||||
}
|
||||
|
||||
function getHttpHandler(path: string): HttpHandler {
|
||||
const handler = mockHttpHandlers.get(path);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected HTTP handler for ${path}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
function getIpcHandler(channel: string): IpcCallback {
|
||||
const handler = mockIpcHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected IPC handler for ${channel}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
function getIpcListener(channel: string): IpcCallback {
|
||||
const listener = mockIpcListeners.get(channel);
|
||||
if (!listener) {
|
||||
throw new Error(`Expected IPC listener for ${channel}`);
|
||||
}
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
function bootstrapRemoteControl(
|
||||
settings: RemoteControlSettings = { enabled: false, port: 8765 }
|
||||
): RemoteControlEvents {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown => {
|
||||
if (key === 'remoteControl') {
|
||||
return settings.enabled;
|
||||
}
|
||||
if (key === 'remoteControlPort') {
|
||||
return settings.port;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
);
|
||||
|
||||
const events = new RemoteControlEvents();
|
||||
events.bootstrapRemoteControlEvents();
|
||||
return events;
|
||||
}
|
||||
|
||||
async function invokeHttpHandler(
|
||||
path: string,
|
||||
options: InvokeOptions
|
||||
): Promise<RequestInvocation> {
|
||||
const request = new PassThrough();
|
||||
const requestErrors: Error[] = [];
|
||||
request.on('error', (error: Error) => {
|
||||
requestErrors.push(error);
|
||||
});
|
||||
const requestClosed = new Promise<void>((resolve) => {
|
||||
request.once('close', resolve);
|
||||
});
|
||||
const incomingMessage = Object.assign(request, {
|
||||
method: options.method,
|
||||
url: path,
|
||||
}) as unknown as http.IncomingMessage;
|
||||
const recorder = createResponseRecorder();
|
||||
|
||||
getHttpHandler(path)(incomingMessage, recorder.response);
|
||||
request.end(options.body);
|
||||
|
||||
return {
|
||||
response: await recorder.completed,
|
||||
request,
|
||||
requestClosed,
|
||||
requestErrors,
|
||||
};
|
||||
}
|
||||
|
||||
function createBodyAtByteLength(byteLength: number): string {
|
||||
const prefix = '{"number":7,"padding":"';
|
||||
const suffix = '"}';
|
||||
const paddingLength =
|
||||
byteLength -
|
||||
Buffer.byteLength(prefix, 'utf8') -
|
||||
Buffer.byteLength(suffix, 'utf8');
|
||||
if (paddingLength < 0) {
|
||||
throw new Error(`Cannot create a JSON body at ${byteLength} bytes`);
|
||||
}
|
||||
|
||||
const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`;
|
||||
if (Buffer.byteLength(body, 'utf8') !== byteLength) {
|
||||
throw new Error(`Expected a ${byteLength}-byte JSON body`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
describe('RemoteControlEvents', () => {
|
||||
describe('RemoteControlEvents bootstrap and dispatch', () => {
|
||||
let consoleLog: jest.SpyInstance;
|
||||
let consoleWarn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHttpHandlers.clear();
|
||||
mockIpcHandlers.clear();
|
||||
mockIpcListeners.clear();
|
||||
mockRegisterRemoteControlHandler.mockClear();
|
||||
mockStartHttpServer.mockReset();
|
||||
mockStoreGet.mockReset();
|
||||
mockFirstRendererSend.mockReset();
|
||||
mockSecondRendererSend.mockReset();
|
||||
mockGetAllWindows.mockReset();
|
||||
mockIpcHandle.mockClear();
|
||||
mockIpcOn.mockClear();
|
||||
mockGetAllWindows.mockReturnValue([
|
||||
{ webContents: { send: mockFirstRendererSend } },
|
||||
{ webContents: { send: mockSecondRendererSend } },
|
||||
]);
|
||||
resetRemoteControlMocks();
|
||||
consoleLog = jest
|
||||
.spyOn(console, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
@@ -390,199 +185,4 @@ describe('RemoteControlEvents', () => {
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects POST on the status endpoint', async () => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE);
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('floors a finite positive channel number and dispatches it', async () => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ number: 7.9 }),
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.response).toEqual(SUCCESS_RESPONSE);
|
||||
expect(mockFirstRendererSend).toHaveBeenCalledWith(
|
||||
'REMOTE_CONTROL_COMMAND',
|
||||
{
|
||||
type: 'channel-select-number',
|
||||
number: 7,
|
||||
}
|
||||
);
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: 'missing', body: '{}' },
|
||||
{ label: 'zero', body: '{"number":0}' },
|
||||
{ label: 'negative', body: '{"number":-2}' },
|
||||
{ label: 'infinite', body: '{"number":1e309}' },
|
||||
{ label: 'nonnumeric', body: '{"number":"seven"}' },
|
||||
])('rejects a $label channel number', async ({ body }) => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 400,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Invalid channel number' }),
|
||||
});
|
||||
expect(mockGetAllWindows).not.toHaveBeenCalled();
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects malformed JSON without dispatching', async () => {
|
||||
bootstrapRemoteControl();
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body: '{"number":',
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 400,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Invalid JSON payload' }),
|
||||
});
|
||||
expect(mockGetAllWindows).not.toHaveBeenCalled();
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a valid JSON request at the 10,240-byte body limit', async () => {
|
||||
bootstrapRemoteControl();
|
||||
const body = createBodyAtByteLength(10 * 1024);
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
expect(Buffer.byteLength(body, 'utf8')).toBe(10_240);
|
||||
expect(result.response).toEqual(SUCCESS_RESPONSE);
|
||||
expect(mockFirstRendererSend).toHaveBeenCalledWith(
|
||||
'REMOTE_CONTROL_COMMAND',
|
||||
{
|
||||
type: 'channel-select-number',
|
||||
number: 7,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects and destroys a 10,241-byte request without dispatching', async () => {
|
||||
bootstrapRemoteControl();
|
||||
const body = Buffer.alloc(10 * 1024 + 1, 'x');
|
||||
|
||||
const result = await invokeHttpHandler(
|
||||
REMOTE_CONTROL_PATHS.SELECT_NUMBER,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
}
|
||||
);
|
||||
await result.requestClosed;
|
||||
|
||||
expect(body.byteLength).toBe(10_241);
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 413,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Payload too large' }),
|
||||
});
|
||||
expect(result.request.destroyed).toBe(true);
|
||||
expect(result.requestErrors).toEqual([]);
|
||||
expect(mockGetAllWindows).not.toHaveBeenCalled();
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges partial status updates and refreshes updatedAt', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z'));
|
||||
bootstrapRemoteControl();
|
||||
const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE');
|
||||
|
||||
jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z'));
|
||||
updateStatus(
|
||||
{},
|
||||
{
|
||||
portal: 'm3u',
|
||||
isLiveView: true,
|
||||
channelName: 'News',
|
||||
volume: 35,
|
||||
}
|
||||
);
|
||||
jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z'));
|
||||
updateStatus({}, { channelName: 'Sports', muted: true });
|
||||
|
||||
const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(result.response).toEqual({
|
||||
statusCode: 200,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({
|
||||
portal: 'm3u',
|
||||
isLiveView: true,
|
||||
supportsVolume: false,
|
||||
updatedAt: '2026-07-25T10:02:00.000Z',
|
||||
channelName: 'Sports',
|
||||
volume: 35,
|
||||
muted: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
path: REMOTE_CONTROL_PATHS.CHANNEL_UP,
|
||||
warning: 'No browser windows found to send channel change',
|
||||
},
|
||||
{
|
||||
path: REMOTE_CONTROL_PATHS.VOLUME_UP,
|
||||
warning: 'No browser windows found to send remote command',
|
||||
},
|
||||
])(
|
||||
'returns success and warns instead of throwing when $path has no renderer',
|
||||
async ({ path, warning }) => {
|
||||
mockGetAllWindows.mockReturnValue([]);
|
||||
bootstrapRemoteControl();
|
||||
|
||||
await expect(
|
||||
invokeHttpHandler(path, { method: 'POST' })
|
||||
).resolves.toMatchObject({
|
||||
response: SUCCESS_RESPONSE,
|
||||
});
|
||||
expect(consoleWarn).toHaveBeenCalledWith(warning);
|
||||
expect(mockFirstRendererSend).not.toHaveBeenCalled();
|
||||
expect(mockSecondRendererSend).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import type * as http from 'node:http';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
/**
|
||||
* Shared harness for the remote-control specs.
|
||||
*
|
||||
* Every exported mock is `mock`-prefixed so the spec files can reference them
|
||||
* from their hoisted `jest.mock()` factories.
|
||||
*/
|
||||
|
||||
export type HttpHandler = (
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse
|
||||
) => void;
|
||||
export type IpcCallback = (...args: unknown[]) => unknown;
|
||||
|
||||
export interface RemoteControlSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface ResponseSnapshot {
|
||||
statusCode: number;
|
||||
headers: http.OutgoingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ResponseRecorder {
|
||||
response: http.ServerResponse;
|
||||
completed: Promise<ResponseSnapshot>;
|
||||
}
|
||||
|
||||
export interface RequestInvocation {
|
||||
response: ResponseSnapshot;
|
||||
request: PassThrough;
|
||||
requestClosed: Promise<void>;
|
||||
requestErrors: Error[];
|
||||
}
|
||||
|
||||
export interface InvokeOptions {
|
||||
method: string;
|
||||
body?: string | Buffer;
|
||||
}
|
||||
|
||||
export const REMOTE_CONTROL_PATHS = {
|
||||
STATUS: '/api/remote-control/status',
|
||||
SELECT_NUMBER: '/api/remote-control/channel/select-number',
|
||||
CHANNEL_UP: '/api/remote-control/channel/up',
|
||||
CHANNEL_DOWN: '/api/remote-control/channel/down',
|
||||
VOLUME_UP: '/api/remote-control/volume/up',
|
||||
VOLUME_DOWN: '/api/remote-control/volume/down',
|
||||
VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute',
|
||||
} as const;
|
||||
|
||||
export const JSON_HEADERS = { 'Content-Type': 'application/json' } as const;
|
||||
export const SUCCESS_RESPONSE = {
|
||||
statusCode: 200,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ success: true }),
|
||||
} as const;
|
||||
export const METHOD_NOT_ALLOWED_RESPONSE = {
|
||||
statusCode: 405,
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ error: 'Method not allowed' }),
|
||||
} as const;
|
||||
|
||||
export const mockHttpHandlers = new Map<string, HttpHandler>();
|
||||
export const mockIpcHandlers = new Map<string, IpcCallback>();
|
||||
export const mockIpcListeners = new Map<string, IpcCallback>();
|
||||
export const mockRegisterRemoteControlHandler = jest.fn(
|
||||
(path: string, handler: HttpHandler) => {
|
||||
mockHttpHandlers.set(path, handler);
|
||||
}
|
||||
);
|
||||
export const mockStartHttpServer = jest.fn();
|
||||
export const mockStoreGet = jest.fn();
|
||||
export const mockFirstRendererSend = jest.fn();
|
||||
export const mockSecondRendererSend = jest.fn();
|
||||
export const mockGetAllWindows = jest.fn();
|
||||
export const mockIpcHandle = jest.fn(
|
||||
(channel: string, handler: IpcCallback) => {
|
||||
mockIpcHandlers.set(channel, handler);
|
||||
}
|
||||
);
|
||||
export const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => {
|
||||
mockIpcListeners.set(channel, listener);
|
||||
});
|
||||
|
||||
/** Restore every mock to the state each spec's `beforeEach` expects. */
|
||||
export function resetRemoteControlMocks(): void {
|
||||
mockHttpHandlers.clear();
|
||||
mockIpcHandlers.clear();
|
||||
mockIpcListeners.clear();
|
||||
mockRegisterRemoteControlHandler.mockClear();
|
||||
mockStartHttpServer.mockReset();
|
||||
mockStoreGet.mockReset();
|
||||
mockFirstRendererSend.mockReset();
|
||||
mockSecondRendererSend.mockReset();
|
||||
mockGetAllWindows.mockReset();
|
||||
mockIpcHandle.mockClear();
|
||||
mockIpcOn.mockClear();
|
||||
mockGetAllWindows.mockReturnValue([
|
||||
{ webContents: { send: mockFirstRendererSend } },
|
||||
{ webContents: { send: mockSecondRendererSend } },
|
||||
]);
|
||||
}
|
||||
|
||||
export function createResponseRecorder(): ResponseRecorder {
|
||||
let statusCode = 0;
|
||||
let headers: http.OutgoingHttpHeaders = {};
|
||||
let responseEnded = false;
|
||||
let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => {
|
||||
throw new Error('Response completion promise is not initialized');
|
||||
};
|
||||
const completed = new Promise<ResponseSnapshot>((resolve) => {
|
||||
resolveCompleted = resolve;
|
||||
});
|
||||
|
||||
const response = {
|
||||
writeHead: (
|
||||
nextStatusCode: number,
|
||||
nextHeaders?: http.OutgoingHttpHeaders
|
||||
) => {
|
||||
statusCode = nextStatusCode;
|
||||
headers = { ...nextHeaders };
|
||||
return response;
|
||||
},
|
||||
end: (chunk?: string | Uint8Array) => {
|
||||
if (responseEnded) {
|
||||
throw new Error('Response ended more than once');
|
||||
}
|
||||
responseEnded = true;
|
||||
const body =
|
||||
typeof chunk === 'string'
|
||||
? chunk
|
||||
: chunk
|
||||
? Buffer.from(chunk).toString('utf8')
|
||||
: '';
|
||||
resolveCompleted({ statusCode, headers, body });
|
||||
return response;
|
||||
},
|
||||
} as unknown as http.ServerResponse;
|
||||
|
||||
return { response, completed };
|
||||
}
|
||||
|
||||
export function getHttpHandler(path: string): HttpHandler {
|
||||
const handler = mockHttpHandlers.get(path);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected HTTP handler for ${path}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function getIpcHandler(channel: string): IpcCallback {
|
||||
const handler = mockIpcHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`Expected IPC handler for ${channel}`);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
export function getIpcListener(channel: string): IpcCallback {
|
||||
const listener = mockIpcListeners.get(channel);
|
||||
if (!listener) {
|
||||
throw new Error(`Expected IPC listener for ${channel}`);
|
||||
}
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct and bootstrap the events class under test. The constructor is
|
||||
* injected so this module never imports the subject — the spec files own that
|
||||
* import order relative to their `jest.mock()` calls.
|
||||
*/
|
||||
export function bootstrapRemoteControl<
|
||||
T extends { bootstrapRemoteControlEvents(): unknown },
|
||||
>(
|
||||
RemoteControlEvents: new () => T,
|
||||
settings: RemoteControlSettings = { enabled: false, port: 8765 }
|
||||
): T {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown => {
|
||||
if (key === 'remoteControl') {
|
||||
return settings.enabled;
|
||||
}
|
||||
if (key === 'remoteControlPort') {
|
||||
return settings.port;
|
||||
}
|
||||
|
||||
return fallbackValue;
|
||||
}
|
||||
);
|
||||
|
||||
const events = new RemoteControlEvents();
|
||||
events.bootstrapRemoteControlEvents();
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function invokeHttpHandler(
|
||||
path: string,
|
||||
options: InvokeOptions
|
||||
): Promise<RequestInvocation> {
|
||||
const request = new PassThrough();
|
||||
const requestErrors: Error[] = [];
|
||||
request.on('error', (error: Error) => {
|
||||
requestErrors.push(error);
|
||||
});
|
||||
const requestClosed = new Promise<void>((resolve) => {
|
||||
request.once('close', resolve);
|
||||
});
|
||||
const incomingMessage = Object.assign(request, {
|
||||
method: options.method,
|
||||
url: path,
|
||||
}) as unknown as http.IncomingMessage;
|
||||
const recorder = createResponseRecorder();
|
||||
|
||||
getHttpHandler(path)(incomingMessage, recorder.response);
|
||||
request.end(options.body);
|
||||
|
||||
return {
|
||||
response: await recorder.completed,
|
||||
request,
|
||||
requestClosed,
|
||||
requestErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBodyAtByteLength(byteLength: number): string {
|
||||
const prefix = '{"number":7,"padding":"';
|
||||
const suffix = '"}';
|
||||
const paddingLength =
|
||||
byteLength -
|
||||
Buffer.byteLength(prefix, 'utf8') -
|
||||
Buffer.byteLength(suffix, 'utf8');
|
||||
if (paddingLength < 0) {
|
||||
throw new Error(`Cannot create a JSON body at ${byteLength} bytes`);
|
||||
}
|
||||
|
||||
const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`;
|
||||
if (Buffer.byteLength(body, 'utf8') !== byteLength) {
|
||||
throw new Error(`Expected a ${byteLength}-byte JSON body`);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import * as http from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, posix, win32 } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { HttpServer, resolveStaticFilePath } from './http-server';
|
||||
import { HttpServer } from './http-server';
|
||||
|
||||
interface CapturedServer {
|
||||
listening: Promise<void>;
|
||||
@@ -119,77 +119,6 @@ async function getAvailablePort(): Promise<number> {
|
||||
return port;
|
||||
}
|
||||
|
||||
describe('resolveStaticFilePath', () => {
|
||||
const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control';
|
||||
const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control';
|
||||
|
||||
it('rejects a Windows double-leading-slash traversal', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
WINDOWS_STATIC_ROOT,
|
||||
'//../../outside-secret.txt',
|
||||
win32
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects encoded traversal segments after one decode', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/%2e%2e/outside-secret.txt',
|
||||
posix
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed for malformed percent encoding without throwing', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects decoded NUL bytes', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/assets/%00secret.js',
|
||||
posix
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a valid Windows-style asset inside the static root', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
WINDOWS_STATIC_ROOT,
|
||||
'/assets\\app.js?version=1',
|
||||
win32
|
||||
)
|
||||
).toBe('C:\\iptvnator\\remote-control\\assets\\app.js');
|
||||
});
|
||||
|
||||
it('ignores query and fragment data for filesystem resolution', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/data.json?cache=1#ignored',
|
||||
posix
|
||||
)
|
||||
).toBe('/opt/iptvnator/remote-control/data.json');
|
||||
});
|
||||
|
||||
it('decodes the URL pathname exactly once', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/%252e%252e/asset.js',
|
||||
posix
|
||||
)
|
||||
).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpServer', () => {
|
||||
let capturedServers: CapturedServer[];
|
||||
let distPath: string;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { posix, win32 } from 'node:path';
|
||||
|
||||
import { resolveStaticFilePath } from './http-server';
|
||||
|
||||
describe('resolveStaticFilePath', () => {
|
||||
const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control';
|
||||
const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control';
|
||||
|
||||
it('rejects a Windows double-leading-slash traversal', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
WINDOWS_STATIC_ROOT,
|
||||
'//../../outside-secret.txt',
|
||||
win32
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects encoded traversal segments after one decode', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/%2e%2e/outside-secret.txt',
|
||||
posix
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed for malformed percent encoding without throwing', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects decoded NUL bytes', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/assets/%00secret.js',
|
||||
posix
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves a valid Windows-style asset inside the static root', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
WINDOWS_STATIC_ROOT,
|
||||
'/assets\\app.js?version=1',
|
||||
win32
|
||||
)
|
||||
).toBe('C:\\iptvnator\\remote-control\\assets\\app.js');
|
||||
});
|
||||
|
||||
it('ignores query and fragment data for filesystem resolution', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/data.json?cache=1#ignored',
|
||||
posix
|
||||
)
|
||||
).toBe('/opt/iptvnator/remote-control/data.json');
|
||||
});
|
||||
|
||||
it('decodes the URL pathname exactly once', () => {
|
||||
expect(
|
||||
resolveStaticFilePath(
|
||||
POSIX_STATIC_ROOT,
|
||||
'/%252e%252e/asset.js',
|
||||
posix
|
||||
)
|
||||
).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user