diff --git a/.changes/electron-remote-static-paths.md b/.changes/electron-remote-static-paths.md new file mode 100644 index 000000000..70313de33 --- /dev/null +++ b/.changes/electron-remote-static-paths.md @@ -0,0 +1,7 @@ +--- +type: fix +area: electron +--- + +The desktop remote-control server now blocks crafted static paths from escaping +bundled web files on Windows. 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 ccced7e98..090eecabb 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 @@ -7,6 +7,20 @@ 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); @@ -34,7 +48,24 @@ describe('downloads events', () => { 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) => @@ -50,8 +81,8 @@ describe('downloads events', () => { }), }, shell: { - openPath: jest.fn(), - showItemInFolder: jest.fn(), + openPath: mockOpenPath, + showItemInFolder: mockShowItemInFolder, }, })); jest.doMock('../../database/connection', () => ({ @@ -77,8 +108,45 @@ describe('downloads events', () => { })); await import('./downloads.events'); + const schema = await import('../../database/schema'); + downloadsFilePathColumn = schema.downloads.filePath; }); + 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 = { @@ -101,7 +169,9 @@ describe('downloads events', () => { const deleteWhere = jest.fn().mockResolvedValue(undefined); const selectWhere = jest .fn() - .mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row }))); + .mockResolvedValue( + rows.map((row, index) => ({ id: index + 1, ...row })) + ); const db = { delete: jest.fn(() => ({ where: deleteWhere })), select: jest.fn(() => ({ @@ -117,9 +187,11 @@ describe('downloads events', () => { it('removes queued resumed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( @@ -127,7 +199,9 @@ describe('downloads events', () => { ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] - ).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]); + ).toBeLessThan( + mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0] + ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); @@ -137,9 +211,11 @@ describe('downloads events', () => { it('removes completed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('completed')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( '/downloads/resume.mp4' @@ -328,4 +404,127 @@ describe('downloads events', () => { 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/remote-control.events.spec.ts b/apps/electron-backend/src/app/events/remote-control.events.spec.ts new file mode 100644 index 000000000..67713fe93 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.events.spec.ts @@ -0,0 +1,588 @@ +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); +}); + +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'; + +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; + }); + + 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', () => { + 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 } }, + ]); + consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + }); + + it('registers exactly the seven remote-control HTTP endpoints', () => { + bootstrapRemoteControl(); + + expect(mockRegisterRemoteControlHandler).toHaveBeenCalledTimes(7); + expect([...mockHttpHandlers.keys()].sort()).toEqual( + Object.values(REMOTE_CONTROL_PATHS).sort() + ); + }); + + it('starts the HTTP server once with the stored port when enabled', () => { + bootstrapRemoteControl({ enabled: true, port: 9987 }); + + expect(mockStoreGet).toHaveBeenNthCalledWith(1, 'remoteControl', false); + expect(mockStoreGet).toHaveBeenNthCalledWith( + 2, + 'remoteControlPort', + 8765 + ); + expect(mockStartHttpServer).toHaveBeenCalledTimes(1); + expect(mockStartHttpServer).toHaveBeenCalledWith(9987); + }); + + it('does not start the HTTP server when remote control is disabled', () => { + bootstrapRemoteControl({ enabled: false, port: 9987 }); + + expect(mockStartHttpServer).not.toHaveBeenCalled(); + }); + + it('registers and dispatches both main-app channel IPC handlers', () => { + bootstrapRemoteControl(); + + expect(mockIpcHandle).toHaveBeenCalledTimes(2); + expect([...mockIpcHandlers.keys()].sort()).toEqual([ + 'REMOTE_CONTROL_CHANNEL_DOWN', + 'REMOTE_CONTROL_CHANNEL_UP', + ]); + + const ipcEventPlaceholder = {}; + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_UP')(ipcEventPlaceholder) + ).toBeUndefined(); + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_DOWN')(ipcEventPlaceholder) + ).toBeUndefined(); + + expect(mockFirstRendererSend).toHaveBeenCalledTimes(2); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 1, + 'CHANNEL_CHANGE', + { direction: 'up' } + ); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 2, + 'CHANNEL_CHANGE', + { direction: 'down' } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'up' }, + }, + { + path: REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-up' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_DOWN, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-toggle-mute' }, + }, + ] as const)( + 'dispatches POST $path to the first renderer', + async ({ path, channel, payload }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'POST' }); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledTimes(1); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + channel, + payload + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); + + it.each([ + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + REMOTE_CONTROL_PATHS.CHANNEL_UP, + REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_UP, + REMOTE_CONTROL_PATHS.VOLUME_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + ])('rejects GET on POST-only endpoint %s', async (path) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'GET' }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + 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.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index 366284ade..9f20d8bec 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -26,7 +26,7 @@ interface RemoteControlStatus { updatedAt?: string; } -class RemoteControlEvents { +export class RemoteControlEvents { private remoteControlStatus: RemoteControlStatus = { portal: 'unknown', isLiveView: false, diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index fa0825ea3..d115c0514 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -1,45 +1,196 @@ -const handlers = new Map unknown>(); +import type { Settings } from '@iptvnator/shared/interfaces'; + +type ExternalPlayerArgumentsSetting = string | readonly unknown[]; +type SettingsUpdatePayload = Omit< + Partial, + | 'embeddedMpvFrameCopy' + | 'language' + | 'mpvPlayerArguments' + | 'vlcPlayerArguments' +> & { + embeddedMpvFrameCopy?: boolean | number; + language?: string; + mpvPlayerArguments?: ExternalPlayerArgumentsSetting; + vlcPlayerArguments?: ExternalPlayerArgumentsSetting; +}; +type SettingsUpdateHandler = ( + event: unknown, + settings: SettingsUpdatePayload +) => unknown; + +const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; +const STORE_KEYS = { + EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY', + MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', + MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', +} as const; + +const handlers = new Map(); +const mockStoreGet = jest.fn(); +const mockStoreSet = jest.fn(); +const mockUpdateSettings = jest.fn(); +const mockIpcHandle = jest.fn( + (channel: string, handler: SettingsUpdateHandler): void => { + handlers.set(channel, handler); + } +); jest.mock('electron', () => ({ ipcMain: { - handle: jest.fn( - (channel: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(channel, handler); - } - ), + handle: mockIpcHandle, }, })); jest.mock('../services/store.service', () => ({ - MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', - MPV_REUSE_INSTANCE: 'mpvReuseInstance', - VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', - VLC_REUSE_INSTANCE: 'vlcReuseInstance', - store: { get: jest.fn(), set: jest.fn() }, + EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS, + MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE, + VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, + VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + store: { + get: mockStoreGet, + set: mockStoreSet, + }, })); jest.mock('../server/http-server', () => ({ - httpServer: { updateSettings: jest.fn() }, + httpServer: { + updateSettings: mockUpdateSettings, + }, })); -describe('SETTINGS_UPDATE logging', () => { +describe('SETTINGS_UPDATE', () => { + let consoleLogSpy: jest.SpiedFunction; + let settingsUpdateHandler: SettingsUpdateHandler; + beforeEach(async () => { - jest.spyOn(console, 'log').mockImplementation(() => undefined); + handlers.clear(); + jest.resetModules(); + mockIpcHandle.mockClear(); + mockStoreGet.mockReset(); + mockStoreSet.mockReset(); + mockUpdateSettings.mockReset(); + mockStoreGet.mockImplementation( + (_key: string, fallbackValue: unknown): unknown => fallbackValue + ); + consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + await import('./settings.events'); + + const registeredHandler = handlers.get(SETTINGS_UPDATE); + if (!registeredHandler) { + throw new Error(`Missing ipcMain handler for ${SETTINGS_UPDATE}`); + } + settingsUpdateHandler = registeredHandler; }); afterEach(() => { - jest.restoreAllMocks(); + jest.useRealTimers(); + consoleLogSpy.mockRestore(); + }); + + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { + settingsUpdateHandler( + {}, + { + mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '], + mpvReuseInstance: false, + vlcPlayerArguments: + ' --network-caching=1000 \r\n\n --fullscreen ', + vlcReuseInstance: false, + } + ); + + expect(mockStoreSet.mock.calls).toEqual([ + [STORE_KEYS.MPV_PLAYER_ARGUMENTS, '--screen=1\n--hwdec=auto-safe'], + [ + STORE_KEYS.VLC_PLAYER_ARGUMENTS, + '--network-caching=1000\n--fullscreen', + ], + [STORE_KEYS.MPV_REUSE_INSTANCE, false], + [STORE_KEYS.VLC_REUSE_INSTANCE, false], + ]); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('does not persist or reconcile an empty settings update', () => { + settingsUpdateHandler({}, {}); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet).not.toHaveBeenCalled(); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('coerces an enabled embedded-MPV frame-copy value to boolean true', () => { + settingsUpdateHandler({}, { embeddedMpvFrameCopy: 1 }); + + expect(mockStoreSet).toHaveBeenCalledTimes(1); + expect(mockStoreSet).toHaveBeenCalledWith( + STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + true + ); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('reconciles an enabled remote-control update with the stored port', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControlPort' ? 9988 : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControl: true }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControlPort', 8765]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControl', true]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988); + }); + + it('reconciles a remote-control port update with the stored enabled state', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControl' ? true : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControlPort: 9123 }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControl', false]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControlPort', 9123]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123); + }); + + it('preserves explicit false and zero remote-control values', () => { + settingsUpdateHandler( + {}, + { remoteControl: false, remoteControlPort: 0 } + ); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet.mock.calls).toEqual([ + ['remoteControl', false], + ['remoteControlPort', 0], + ]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(false, 0); }); it('does not print a TMDB apiKey while retaining useful fields', () => { const apiKey = 'tmdb-settings-api-key-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } }); + settingsUpdateHandler( + {}, + { + language: 'de', + tmdb: { apiKey, enabled: true }, + } + ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(apiKey); expect(output).toContain('language'); expect(output).toContain('de'); @@ -49,10 +200,8 @@ describe('SETTINGS_UPDATE logging', () => { it('does not print credentials embedded in external player arguments', () => { const authorizationSecret = 'player-authorization-secret'; const cookieSecret = 'player-cookie-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.( + settingsUpdateHandler( {}, { language: 'de', @@ -61,7 +210,7 @@ describe('SETTINGS_UPDATE logging', () => { } ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(authorizationSecret); expect(output).not.toContain(cookieSecret); expect(output).toContain('language'); diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts new file mode 100644 index 000000000..88f3d8d38 --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -0,0 +1,448 @@ +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +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 { HttpServer, resolveStaticFilePath } from './http-server'; + +interface CapturedServer { + listening: Promise; + listeningEvents: number; + server: http.Server; +} + +interface TestResponse { + body: Buffer; + contentType: string | undefined; + statusCode: number | undefined; +} + +const INDEX_BODY = Buffer.from('
IPTVnator remote control
'); +const ASSETS = { + '/app.js': { + body: Buffer.from('globalThis.remoteControlLoaded = true;'), + contentType: 'application/javascript', + }, + '/styles.css': { + body: Buffer.from('main { color: rebeccapurple; }'), + contentType: 'text/css', + }, + '/data.json': { + body: Buffer.from('{"ready":true}'), + contentType: 'application/json', + }, + '/asset.unknown': { + body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]), + contentType: 'application/octet-stream', + }, +} as const; + +jest.setTimeout(15_000); + +function request(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const clientRequest = http.request( + { + host: '127.0.0.1', + method: 'GET', + path, + port, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on('end', () => { + const contentType = response.headers['content-type']; + resolve({ + body: Buffer.concat(chunks), + contentType: + typeof contentType === 'string' + ? contentType + : undefined, + statusCode: response.statusCode, + }); + }); + response.on('error', reject); + } + ); + + clientRequest.on('error', reject); + clientRequest.end(); + }); +} + +function getAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the HTTP server to have an IP address'); + } + return address; +} + +async function closeServer(server: http.Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +async function getAvailablePort(): Promise { + const probe = http.createServer(); + + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', resolve); + }); + + const port = getAddress(probe).port; + await closeServer(probe); + 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; + let server: HttpServer; + let tempRoot: string; + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + + capturedServers = []; + tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-')); + distPath = join(tempRoot, 'static', 'app'); + mkdirSync(distPath, { recursive: true }); + writeFileSync(join(distPath, 'index.html'), INDEX_BODY); + + for (const [assetPath, asset] of Object.entries(ASSETS)) { + writeFileSync(join(distPath, assetPath), asset.body); + } + + writeFileSync( + join(tempRoot, 'outside-secret.txt'), + 'must-not-leave-the-static-directory' + ); + + server = new HttpServer({ + createServer: (requestListener) => { + const nodeServer = http.createServer(requestListener); + const captured: CapturedServer = { + listening: once(nodeServer, 'listening').then( + () => undefined + ), + listeningEvents: 0, + server: nodeServer, + }; + nodeServer.on('listening', () => { + captured.listeningEvents += 1; + }); + capturedServers.push(captured); + return nodeServer; + }, + distPath, + }); + }); + + afterEach(async () => { + let closeError: unknown; + + try { + await Promise.all( + capturedServers.map(({ server: nodeServer }) => + closeServer(nodeServer) + ) + ); + } catch (error) { + closeError = error; + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + jest.restoreAllMocks(); + } + + if (closeError) { + throw closeError; + } + }); + + async function startServer(): Promise { + const serverIndex = capturedServers.length; + server.start(0); + const captured = capturedServers[serverIndex]; + if (!captured) { + throw new Error('Expected HttpServer.start() to create a server'); + } + + await captured.listening; + return captured; + } + + it('serves the exact index document at the root', async () => { + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('asks the operating system for an ephemeral port when started with zero', async () => { + const captured = await startServer(); + + expect(getAddress(captured.server).port).not.toBe(8765); + }); + + it.each(Object.entries(ASSETS))( + 'serves %s with its expected MIME type', + async (assetPath, asset) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + assetPath + ); + + expect(response).toEqual({ + body: asset.body, + contentType: asset.contentType, + statusCode: 200, + }); + } + ); + + it('falls back to the exact index document for a client route', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/channels/favorites' + ); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('returns a plain-text 404 when the index document is missing', async () => { + unlinkSync(join(distPath, 'index.html')); + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + }); + + it('dispatches a registered API request without serving a static file', async () => { + let receivedRequest: http.IncomingMessage | undefined; + server.registerRemoteControlHandler( + '/api/remote-control/status', + (incomingRequest, response) => { + receivedRequest = incomingRequest; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"source":"api-handler"}'); + } + ); + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/status' + ); + + expect(receivedRequest?.url).toBe('/api/remote-control/status'); + expect(response).toEqual({ + body: Buffer.from('{"source":"api-handler"}'), + contentType: 'application/json', + statusCode: 200, + }); + expect(response.body).not.toEqual(INDEX_BODY); + }); + + it('returns a JSON 404 for an unknown remote-control API endpoint', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/missing' + ); + + expect(response).toEqual({ + body: Buffer.from('{"error":"Endpoint not found"}'), + contentType: 'application/json', + statusCode: 404, + }); + }); + + it.each([ + '/../../outside-secret.txt', + '//../../outside-secret.txt', + '/%2e%2e/%2e%2e/outside-secret.txt', + '/%E0%A4%A', + ])( + 'rejects the invalid static request target %s without serving an outside file', + async (requestTarget) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + requestTarget + ); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + expect(response.body.toString()).not.toContain( + 'must-not-leave-the-static-directory' + ); + } + ); + + it('creates and listens on only one server when started twice', async () => { + server.start(0); + server.start(0); + + expect(capturedServers).toHaveLength(1); + await capturedServers[0].listening; + expect(capturedServers[0].listeningEvents).toBe(1); + }); + + it('stops the running server when disabled', async () => { + const captured = await startServer(); + const closed = once(captured.server, 'close'); + + server.updateSettings(false, 0); + await closed; + + expect(captured.server.listening).toBe(false); + expect(capturedServers).toHaveLength(1); + }); + + it('stops once and starts once on the new port when the port changes', async () => { + const first = await startServer(); + const newPort = await getAvailablePort(); + let closeEvents = 0; + first.server.on('close', () => { + closeEvents += 1; + }); + const firstClosed = once(first.server, 'close'); + + server.updateSettings(true, newPort); + const second = capturedServers[1]; + if (!second) { + throw new Error('Expected a replacement HTTP server'); + } + await Promise.all([firstClosed, second.listening]); + + expect(capturedServers).toHaveLength(2); + expect(closeEvents).toBe(1); + expect(first.server.listening).toBe(false); + expect(second.listeningEvents).toBe(1); + expect(getAddress(second.server).port).toBe(newPort); + await expect(request(newPort, '/')).resolves.toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); +}); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts index 23a235133..a6b24ce37 100644 --- a/apps/electron-backend/src/app/server/http-server.ts +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -3,10 +3,61 @@ import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} + +/** + * Resolve an HTTP request target within the configured static root. + * The path implementation is injectable so platform-specific semantics remain testable. + */ +export function resolveStaticFilePath( + staticRoot: string, + requestTarget: string, + pathImplementation: path.PlatformPath = path +): string | null { + const separatorIndex = requestTarget.search(/[?#]/); + const encodedPathname = + separatorIndex === -1 + ? requestTarget + : requestTarget.slice(0, separatorIndex); + + let pathname: string; + try { + pathname = decodeURIComponent(encodedPathname); + } catch { + return null; + } + + if (pathname.includes('\0')) { + return null; + } + + const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html'; + const resolvedRoot = pathImplementation.resolve(staticRoot); + const candidate = pathImplementation.resolve( + resolvedRoot, + relativeCandidate + ); + + if ( + candidate === resolvedRoot || + candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`) + ) { + return candidate; + } + + return null; +} + /** * HTTP server for serving the remote control web app and providing REST API endpoints */ export class HttpServer { + private readonly createServer: HttpServerFactory; private server: http.Server | null = null; private port = 8765; private isEnabled = false; @@ -16,6 +67,11 @@ export class HttpServer { (req: http.IncomingMessage, res: http.ServerResponse) => void > = new Map(); + constructor(options: HttpServerOptions = {}) { + this.createServer = options.createServer ?? http.createServer; + this.distPath = options.distPath ?? null; + } + /** * Get the path to the remote-control-web static files. * Lazily computed to avoid calling Electron APIs before app is ready. @@ -43,11 +99,7 @@ export class HttpServer { } else { // Production mode - files are bundled with the app // electron-builder copies remote-control-web/**/* directly to app root - this.distPath = path.join( - appPath, - 'remote-control-web', - 'browser' - ); + this.distPath = path.join(appPath, 'remote-control-web', 'browser'); } console.log('[HTTP Server] Serving from:', this.distPath); @@ -58,7 +110,7 @@ export class HttpServer { * Start the HTTP server */ start(port?: number): void { - if (port) { + if (port !== undefined) { this.port = port; } @@ -67,7 +119,7 @@ export class HttpServer { return; } - this.server = http.createServer((req, res) => { + this.server = this.createServer((req, res) => { this.handleRequest(req, res); }); @@ -153,18 +205,21 @@ export class HttpServer { * Serve static files */ private serveStaticFile(url: string, res: http.ServerResponse): void { - // Default to index.html for root path - let filePath = url === '/' ? '/index.html' : url; - - // Security: prevent directory traversal - filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); - - const fullPath = path.join(this.getDistPath(), filePath); + const distPath = this.getDistPath(); + const fullPath = resolveStaticFilePath(distPath, url); + if (!fullPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } fs.readFile(fullPath, (err, data) => { if (err) { // If file not found, try serving index.html (for Angular routing) - if (err.code === 'ENOENT' && filePath !== '/index.html') { + if ( + err.code === 'ENOENT' && + fullPath !== path.resolve(distPath, 'index.html') + ) { this.serveStaticFile('/', res); return; } diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 60b1b4993..f57609e37 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -28,11 +28,11 @@ Current capabilities: 1. User opens remote web UI (`http://:`). 2. Remote web app calls `/api/remote-control/*`. 3. Electron main handles API request and sends IPC to renderer: - - `CHANNEL_CHANGE` for up/down - - `REMOTE_CONTROL_COMMAND` for numeric/volume commands + - `CHANNEL_CHANGE` for up/down + - `REMOTE_CONTROL_COMMAND` for numeric/volume commands 4. Renderer-specific feature module (M3U/Xtream/Stalker) applies action. 5. Renderer pushes status snapshots back to main via: - - `REMOTE_CONTROL_STATUS_UPDATE` + - `REMOTE_CONTROL_STATUS_UPDATE` 6. Remote web app polls `/api/remote-control/status` and updates UI. ## Backend (Electron Main) @@ -41,11 +41,16 @@ Current capabilities: - File: `apps/electron-backend/src/app/server/http-server.ts` - Responsibilities: - - Serves static remote app from: - - dev: `dist/apps/remote-control-web/browser` - - prod: `/remote-control-web/browser` - - Routes `/api/remote-control/*` to registered handlers. - - Starts/stops/restarts on settings updates. + - Serves static remote app from: + - dev: `dist/apps/remote-control-web/browser` + - prod: `/remote-control-web/browser` + - Keeps every non-API request inside that configured static root: the request + pathname is decoded once, malformed encoding and NUL bytes fail closed, + and the platform-specific resolved path must remain the root or its + descendant. Angular route fallback may serve only that root's `index.html`; + it must never bypass the containment check. + - Routes `/api/remote-control/*` to registered handlers. + - Starts/stops/restarts on settings updates. ### Remote control event module @@ -66,8 +71,8 @@ IPC emitted to renderer: - `CHANNEL_CHANGE` payload: `{ direction: 'up' | 'down' }` - `REMOTE_CONTROL_COMMAND` payload: - - `{ type: 'channel-select-number', number }` - - `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }` + - `{ type: 'channel-select-number', number }` + - `{ type: 'volume-up' | 'volume-down' | 'volume-toggle-mute' }` Status ingestion from renderer: @@ -78,7 +83,7 @@ Status ingestion from renderer: - Main handler: `apps/electron-backend/src/app/events/settings.events.ts` - On `SETTINGS_UPDATE`, reads `remoteControl` and `remoteControlPort`, persists to store, and calls: - - `httpServer.updateSettings(enabled, port)` + - `httpServer.updateSettings(enabled, port)` ## Preload Bridge @@ -122,23 +127,23 @@ Used by M3U, Xtream, and Stalker live integrations. Implemented behavior: - Subscribes to: - - `onChannelChange` (up/down) - - `onRemoteControlCommand` (number + volume) + - `onChannelChange` (up/down) + - `onRemoteControlCommand` (number + volume) - Applies channel up/down by active channel URL over `channels$` - Applies number select through existing `switchToChannelByNumber(...)` - Dispatches remote channel changes as explicit playback requests so MPV/VLC starts immediately even when mouse channel rows require double-click before external playback. - Applies volume commands: - - up/down in 0.1 increments - - toggle mute with last non-zero volume restore - - persists to `localStorage` - - propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent` - - does not control external MPV/VLC sessions or the experimental Embedded MPV player + - up/down in 0.1 increments + - toggle mute with last non-zero volume restore + - persists to `localStorage` + - propagates to built-in inline players: Video.js, HTML5, ArtPlayer, and radio `AudioPlayerComponent` + - does not control external MPV/VLC sessions or the experimental Embedded MPV player - Publishes status snapshots via `updateRemoteControlStatus(...)`: - - `portal: 'm3u'` - - `isLiveView: true` - - channel name/number - - EPG now fields - - `supportsVolume: true`, `volume`, `muted` + - `portal: 'm3u'` + - `isLiveView: true` + - channel name/number + - EPG now fields + - `supportsVolume: true`, `volume`, `muted` - Cleans listeners/subscriptions in `ngOnDestroy`. ## Xtream integration (live view) @@ -148,20 +153,20 @@ Implemented behavior: Implemented behavior: - Subscribes to: - - `onChannelChange` for up/down - - `onRemoteControlCommand` for number select + - `onChannelChange` for up/down + - `onRemoteControlCommand` for number select - Up/down: - - Uses selected live item `selectedItem().xtream_id` - - Navigates inside `selectItemsFromSelectedCategory()` - - Calls `playLive(nextItem, true)` so remote actions explicitly start playback + - Uses selected live item `selectedItem().xtream_id` + - Navigates inside `selectItemsFromSelectedCategory()` + - Calls `playLive(nextItem, true)` so remote actions explicitly start playback - Number select: - - Maps number to item in current category list - - Calls `playLive(channel, true)` so remote actions explicitly start playback + - Maps number to item in current category list + - Calls `playLive(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - - `portal: 'xtream'` - - `isLiveView` only when selected content type is `live` and item is selected - - channel name/number + current EPG item - - `supportsVolume: false` + - `portal: 'xtream'` + - `isLiveView` only when selected content type is `live` and item is selected + - channel name/number + current EPG item + - `supportsVolume: false` - Cleans listeners in `ngOnDestroy`. ## Stalker integration (ITV live view) @@ -171,20 +176,20 @@ Implemented behavior: Implemented behavior: - Subscribes to: - - `onChannelChange` for up/down - - `onRemoteControlCommand` for number select + - `onChannelChange` for up/down + - `onRemoteControlCommand` for number select - Up/down: - - Uses `selectedItem().id` - - Navigates inside `itvChannels()` - - Calls `playChannel(nextItem, true)` so remote actions explicitly start playback + - Uses `selectedItem().id` + - Navigates inside `itvChannels()` + - Calls `playChannel(nextItem, true)` so remote actions explicitly start playback - Number select: - - Maps number into `itvChannels()` - - Calls `playChannel(channel, true)` so remote actions explicitly start playback + - Maps number into `itvChannels()` + - Calls `playChannel(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - - `portal: 'stalker'` - - `isLiveView` only for selected content type `itv` with active item - - channel name/number + current EPG item - - `supportsVolume: false` + - `portal: 'stalker'` + - `isLiveView` only for selected content type `itv` with active item + - channel name/number + current EPG item + - `supportsVolume: false` - Cleans listeners in `ngOnDestroy`. ## Remote Web App @@ -199,11 +204,11 @@ Implemented behavior: ### Shared remote UI library - Component: - - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` - - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html` - - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.html` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.component.scss` - Service: - - `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts` + - `libs/ui/remote-control/src/lib/remote-control/remote-control.service.ts` Implemented UI behavior: @@ -217,23 +222,23 @@ Implemented UI behavior: ## Settings UI and discoverability - Files: - - `apps/web/src/app/settings/settings.component.ts` - - `apps/web/src/app/settings/settings.component.html` + - `apps/web/src/app/settings/settings.component.ts` + - `apps/web/src/app/settings/settings.component.html` - Features: - - Toggle `remoteControl` - - Configure `remoteControlPort` - - Display local URLs and QR codes for remote access - - Local IP list loaded via `getLocalIpAddresses()` + - Toggle `remoteControl` + - Configure `remoteControlPort` + - Display local URLs and QR codes for remote access + - Local IP list loaded via `getLocalIpAddresses()` ## Feature Matrix (Current) -| Capability | M3U | Xtream Live | Stalker ITV | -|---|---|---|---| -| Channel up/down | Yes | Yes | Yes | -| Number select | Yes | Yes | Yes | -| Status publish | Yes | Yes | Yes | -| Volume command handling | Yes, for built-in inline M3U players | No | No | -| `supportsVolume` in status | true | false | false | +| Capability | M3U | Xtream Live | Stalker ITV | +| -------------------------- | ------------------------------------ | ----------- | ----------- | +| Channel up/down | Yes | Yes | Yes | +| Number select | Yes | Yes | Yes | +| Status publish | Yes | Yes | Yes | +| Volume command handling | Yes, for built-in inline M3U players | No | No | +| `supportsVolume` in status | true | false | false | ## Known limitations diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 139fea90a..c41c042e9 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -55,11 +55,32 @@ Docs-only changes (Markdown, `docs/`, `.plans/`, `.codex/`, `.claude/`) and PRs no E2E validation runs in CI, which is intentional: they cannot affect app behavior. -| Tier | Rule | Validation | -| --- | --- | --- | -| A | Product/runtime Angular, Electron, backend, data-access, portal, playlist, workspace, playback, EPG, and shared UI code collects source coverage. | `pnpm run coverage:ci` | -| B | Validate behavior without percentage coverage, such as `website`, `packaging`, and Playwright E2E projects. | `pnpm nx test website`, `pnpm nx test packaging`, or the closest E2E target | -| C | Excluded from the source coverage baseline, such as mock servers, test helper libraries, and untested feature shells. | Validate through dependent flows, or add focused tests when changing behavior directly | +Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits +nonzero on a `Failed to collect coverage` marker, a missing or invalid project +report, or a runtime-owning production TypeScript file absent from that report. +`coverage:merge` requires every configured Tier A report before replacing the +merged output. Strict health validation also requires the merged Istanbul map +itself to contain usable instrumentation for every runtime-owning Tier A file, +recomputes its summary, and then applies aggregate and selected critical-file +ratchets. + +Runtime-owning files are discovered from the TypeScript AST. Specs, +declarations, test setup and stubs, generated and environment files, `index.ts`, +type-only files, and pure re-export shims are excluded. Ratchets live under +`reporting.coverageRatchet` in `tools/coverage/coverage-policy.json`; update +them only from a fresh full `coverage:ci` report when every value stays level +or rises, and never lower one to accept a regression. The only exception is an +explicitly reviewed production source shrink: `minimumCovered` may follow a +lower total statement count when the PR documents the removed executable +statements and fresh coverage proves that the corresponding +`minimumPercent`, every aggregate ratchet, and the remaining behavioral +coverage do not decrease. + +| Tier | Rule | Validation | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| A | Product/runtime Angular, Electron, backend, data-access, portal, playlist, workspace, playback, EPG, and shared UI code collects source coverage. | `pnpm run coverage:ci` | +| B | Validate behavior without percentage coverage, such as `website`, `packaging`, and Playwright E2E projects. | `pnpm nx test website`, `pnpm nx test packaging`, or the closest E2E target | +| C | Excluded from the source coverage baseline, such as mock servers, test helper libraries, and untested feature shells. | Validate through dependent flows, or add focused tests when changing behavior directly | `apps/website` is an Astro marketing site. Its useful signal is a successful static build plus targeted output checks, not a merged code coverage percentage. @@ -69,9 +90,10 @@ Projects with a test target but no specs, such as `remote-control-web` and For local coverage inspection: ```bash +pnpm run coverage:tools:test pnpm run coverage:unit:ci pnpm run coverage:merge -pnpm run coverage:health +pnpm run coverage:health -- --require-report ``` The merged report is written to `coverage/merged/` as HTML, LCOV, Cobertura, diff --git a/docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md b/docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md new file mode 100644 index 000000000..9a2543990 --- /dev/null +++ b/docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md @@ -0,0 +1,2003 @@ +# Coverage Integrity And Electron Runtime Boundaries Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Tier A coverage fail closed and add meaningful regression coverage for the HTTP server, remote control, settings, and download-file Electron boundaries. + +**Architecture:** A shared pure Node module classifies runtime-owning TypeScript, validates per-project Istanbul reports, scans child output for collection failures, and evaluates aggregate/per-file ratchets. Existing Electron contracts stay unchanged except for the TDD-backed rejection of a discovered static-path traversal escape; tests use real loopback/filesystem behavior where practical and mock only process, persistence, or native-shell boundaries. + +**Tech Stack:** Node.js ESM and `node:test`, TypeScript compiler API, Istanbul coverage maps, Jest/ts-jest, Electron main-process IPC, Node HTTP streams, Angular Jest configuration, Nx, Playwright. + +**Execution note (2026-07-26):** The branch was rebased onto +`origin/master` commit `7e8c2ccce16b71d72a64c45f1d96dd090dc8e077` +before the final ratchet run. A real-loopback HTTP regression exposed a Windows +double-leading-slash traversal escape; the implementation adds a tested pure +resolver and a user-facing Electron fix note. A final integrity follow-up also +validates the merged Istanbul map itself before evaluating its recomputed +summary and ratchets. + +--- + +## Scope And File Map + +Create: + +- `tools/coverage/coverage-integrity.mjs` — shared source classification, + report validation, output scanning, and ratchet evaluation. +- `tools/coverage/coverage-integrity.test.mjs` — deterministic Node tests using + temporary source/report fixtures. +- `apps/electron-backend/src/app/server/http-server.spec.ts` — real loopback + and temporary-directory HTTP server contracts. +- `apps/electron-backend/src/app/events/remote-control.events.spec.ts` — + registered HTTP handler, IPC, status, validation, and size-limit contracts. + +Modify: + +- `tools/coverage/run-tier-a-coverage.mjs` — stream child output, detect + collection failures, require complete project reports. +- `tools/coverage/merge-coverage.mjs` — reject missing/invalid Tier A inputs + before touching merged output. +- `tools/coverage/coverage-health.mjs` — independently validate completeness + and ratchets. +- `tools/coverage/coverage-policy.json` — store achieved aggregate and + critical-file ratchets. +- `package.json` — run coverage-tool unit tests inside `coverage:ci`. +- `libs/m3u-state/tsconfig.spec.json` — use bundler resolution and include the + shared Electron window declaration so `effects.ts` instruments. +- `apps/electron-backend/src/app/server/http-server.ts` — optional static-root + and server-factory seam, port `0` support for loopback tests, and a tested + static-root containment resolver. +- `apps/electron-backend/src/app/events/remote-control.events.ts` — export the + class for isolated instances while preserving the default singleton. +- `apps/electron-backend/src/app/events/settings.events.spec.ts` — persistence, + normalization, remote-control reconciliation, and redaction contracts. +- `apps/electron-backend/src/app/events/database/downloads.events.spec.ts` — + managed-path checks before native shell access. +- `docs/architecture/validation-map.md` — canonical fail-closed and ratchet + maintenance workflow. +- `docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md` + — retain the corrected Istanbul-rounded baseline values. + +Do not modify: + +- `apps/electron-backend/src/app/workers/database.worker.ts`; +- workspace dashboard or Stalker search production code; +- type-only/shared-interface source for percentage gain; +- `AGENTS.md` or `CLAUDE.md`, unless execution discovers that the implemented + commands contradict text already present there. + +### Task 0: Rebase And Bootstrap Immediately Before Implementation + +**Files:** + +- Verify only: `package.json` +- Verify only: `pnpm-lock.yaml` +- Verify only: `tools/coverage/coverage-policy.json` + +- [ ] **Step 1: Load the execution skills** + +Read completely before code changes: + +```text +using-superpowers +executing-plans +test-driven-development +electron-pro +typescript +angular-testing +iptvnator-nx-architecture +``` + +Use `systematic-debugging` before changing code in response to any unexpected +failure, and `verification-before-completion`, `release-notes`, and +`github-pr` during the final tasks. + +- [ ] **Step 2: Confirm a clean branch and rebase onto fresh master** + +Run: + +```bash +git status --short --branch +git fetch origin master --prune +git rebase origin/master +git status --short --branch +git log -2 --oneline --decorate +``` + +Expected: the branch is `agent/coverage-integrity-runtime-boundaries`, the +rebase exits 0, and only the committed design/plan are ahead of the latest +`origin/master`. If master changed a scoped source, spec, Jest config, or +coverage tool, re-run the baseline checks below and update this plan's numeric +seed before implementation rather than resolving by assumption. + +- [ ] **Step 3: Refresh locked dependencies and Nx discovery** + +Run: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects +pnpm nx show projects --withTarget test +``` + +Expected: all commands exit 0; the project list contains +`electron-backend`, `electron-backend-e2e`, and `m3u-state`; the lockfile stays +unchanged. + +- [ ] **Step 4: Reconfirm the fresh baseline** + +Run even when Step 2 reports no new master commits: + +```bash +NX_SKIP_NX_CACHE=true pnpm run coverage:ci +``` + +Expected on unchanged +`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077`: the command exits 0 while printing +`Failed to collect coverage` for `libs/m3u-state/src/lib/effects.ts`, and the +30 project reports merge 709 files while omitting that runtime-owning source. +The aggregate remains 69.27/58.92/67.44/69.57, with HTTP and remote control at +0%, settings at 16 / 27 (59.25%), and downloads at 69 / 147 (46.93%). Record +the fresh aggregate and selected-file values. If master changed them, replace +the numeric ratchet seed in Task 3; do not lower a baseline already present on +newer master. + +### Task 1: Build And Unit-Test The Shared Coverage Integrity Module + +**Files:** + +- Create: `tools/coverage/coverage-integrity.test.mjs` +- Create: `tools/coverage/coverage-integrity.mjs` + +- [ ] **Step 1: Write the failing source-classification and output-scanner tests** + +Create `tools/coverage/coverage-integrity.test.mjs` with Node built-ins and +these contracts: + +```javascript +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + createCoverageOutputScanner, + evaluateCoverageRatchets, + hasRuntimeOwnedStatement, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('hasRuntimeOwnedStatement', () => { + it('excludes type-only, import-only, and pure re-export source', () => { + assert.equal( + hasRuntimeOwnedStatement( + 'import type { Settings } from "./settings";' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement('interface Settings { enabled: boolean }'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export type Mode = "live" | "vod";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export { value } from "./value";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('declare const injected: string;'), + false + ); + }); + + it('includes emitted declarations and executable statements', () => { + assert.equal( + hasRuntimeOwnedStatement('export const enabled = true;'), + true + ); + assert.equal( + hasRuntimeOwnedStatement( + 'export class RuntimeBoundary { start() {} }' + ), + true + ); + assert.equal(hasRuntimeOwnedStatement('console.log("runtime");'), true); + }); +}); + +describe('createCoverageOutputScanner', () => { + it('detects an ANSI-colored marker split across chunks', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('\u001b[31mFailed to collect'); + scanner.push(' coverage from /workspace/effects.ts\u001b[39m'); + + assert.equal(scanner.collectionFailed, true); + }); + + it('keeps only a bounded rolling tail', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push('x'.repeat(256)); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, false); + }); +}); +``` + +- [ ] **Step 2: Add failing temporary-report tests** + +In the same file, add helpers that write one runtime source plus a synthetic +Istanbul entry: + +```javascript +function makeProjectFixture() { + const workspaceRoot = mkdtempSync( + path.join(tmpdir(), 'iptvnator-coverage-integrity-') + ); + temporaryRoots.push(workspaceRoot); + const sourceRoot = 'libs/example/src'; + const projectRoot = 'libs/example'; + const sourcePath = path.join(workspaceRoot, sourceRoot, 'runtime.ts'); + const reportPath = path.join( + workspaceRoot, + 'coverage', + projectRoot, + 'coverage-final.json' + ); + + mkdirSync(path.dirname(sourcePath), { recursive: true }); + mkdirSync(path.dirname(reportPath), { recursive: true }); + writeFileSync(sourcePath, 'export const runtime = true;\n'); + + const project = { + name: 'example', + root: projectRoot, + sourceRoot, + }; + + return { project, reportPath, sourcePath, workspaceRoot }; +} + +function coverageEntry(filePath, hits = [0]) { + const statementMap = Object.fromEntries( + hits.map((_, index) => [ + index, + { + start: { line: index + 1, column: 0 }, + end: { line: index + 1, column: 28 }, + }, + ]) + ); + const statementHits = Object.fromEntries( + hits.map((hit, index) => [index, hit]) + ); + + return { + path: filePath, + statementMap, + fnMap: {}, + branchMap: {}, + s: statementHits, + f: {}, + b: {}, + }; +} + +describe('validateProjectCoverage', () => { + it('accepts a report containing every runtime-owning file', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + }); + + it('reports a runtime-owning source omitted from a valid report', () => { + const fixture = makeProjectFixture(); + writeFileSync(fixture.reportPath, '{}'); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /runtime\.ts/); + }); + + it('reports missing and invalid project reports', () => { + const missing = makeProjectFixture(); + const invalid = makeProjectFixture(); + writeFileSync(invalid.reportPath, '{ invalid json'); + + assert.match( + validateProjectCoverage(missing).errors[0], + /did not produce/ + ); + assert.match( + validateProjectCoverage(invalid).errors[0], + /invalid JSON/ + ); + }); + + it('requires every configured Tier A report', () => { + const fixture = makeProjectFixture(); + + const result = validateRequiredProjectReports({ + projects: [fixture.project], + workspaceRoot: fixture.workspaceRoot, + }); + + assert.equal(result.reports.length, 0); + assert.match(result.errors[0], /example/); + }); +}); +``` + +- [ ] **Step 3: Add failing aggregate and critical-file ratchet tests** + +Add: + +```javascript +describe('evaluateCoverageRatchets', () => { + it('reports aggregate percentage regression', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 68, total: 100, pct: 68 }, + branches: { covered: 57, total: 100, pct: 57 }, + functions: { covered: 67, total: 100, pct: 67 }, + lines: { covered: 69, total: 100, pct: 69 }, + }, + ratchet: { + merged: { + statements: 68.86, + branches: 58.66, + functions: 67.19, + lines: 69.2, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 4); + assert.match(errors[0], /statements.*68.*68\.86/); + }); + + it('requires both covered statements and percentage for a critical file', () => { + const workspaceRoot = '/workspace'; + const filePath = path.join(workspaceRoot, 'apps/runtime.ts'); + const errors = evaluateCoverageRatchets({ + coverageData: { + [filePath]: coverageEntry(filePath, [1, 0]), + }, + mergedSummary: { + statements: { covered: 1, total: 1, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 1, total: 1, pct: 100 }, + }, + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/runtime.ts', + statements: { + minimumCovered: 2, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.equal(errors.length, 2); + assert.match(errors[0], /apps\/runtime\.ts/); + }); + + it('reports a critical file missing from merged coverage', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 0, total: 0, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 0, total: 0, pct: 100 }, + }, + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing.ts', + statements: { + minimumCovered: 1, + minimumPercent: 0, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.match(errors[0], /missing from merged coverage/); + }); +}); +``` + +- [ ] **Step 4: Run the Node tests to verify RED** + +Run: + +```bash +node --test tools/coverage/coverage-integrity.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for +`tools/coverage/coverage-integrity.mjs`. + +- [ ] **Step 5: Implement source classification and the bounded scanner** + +Create `tools/coverage/coverage-integrity.mjs`. Use `unknown`-equivalent +runtime validation rather than trusting parsed JSON shapes. The core +classification and scanner must follow: + +```javascript +import { createRequire } from 'node:module'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); + +export const COVERAGE_COLLECTION_FAILURE = 'Failed to collect coverage'; +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function hasDeclareModifier(statement) { + const modifiers = ts.canHaveModifiers(statement) + ? (ts.getModifiers(statement) ?? []) + : []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword + ); +} + +export function hasRuntimeOwnedStatement(sourceText, fileName = 'source.ts') { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + return source.statements.some((statement) => { + if (hasDeclareModifier(statement)) { + return false; + } + return !( + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isExportDeclaration(statement) || + ts.isEmptyStatement(statement) + ); + }); +} + +export function createCoverageOutputScanner(maxCharacters = 4096) { + let tail = ''; + let collectionFailed = false; + + return { + get collectionFailed() { + return collectionFailed; + }, + get tail() { + return tail; + }, + push(chunk) { + tail = `${tail}${String(chunk).replace(ANSI_ESCAPE, '')}`.slice( + -maxCharacters + ); + if (tail.includes(COVERAGE_COLLECTION_FAILURE)) { + collectionFailed = true; + } + }, + }; +} +``` + +Add recursive enumeration with exactly these exclusions: + +```javascript +function isExcludedSource(filePath) { + const file = toPosix(filePath); + return ( + /\.(spec|test)\.ts$/.test(file) || + file.endsWith('.d.ts') || + file.endsWith('/test-setup.ts') || + file.includes('/test-stubs/') || + /\.generated\./.test(file) || + file.includes('/environments/') || + file.endsWith('/index.ts') + ); +} +``` + +`runtimeOwningSourceFiles(workspaceRoot, sourceRoot)` must recursively read +only `.ts` files, apply `isExcludedSource`, parse the file, and return sorted +absolute paths whose source has a runtime-owned statement. + +- [ ] **Step 6: Implement report validation** + +Implement these stable return shapes: + +```javascript +// validateProjectCoverage(...) +{ + errors: string[], + report: undefined | { + data: Record, + path: string, + project: object, + }, +} + +// validateRequiredProjectReports(...) +{ + errors: string[], + reports: Array<{ + data: Record, + path: string, + project: object, + }>, +} +``` + +`validateProjectCoverage({ workspaceRoot, project })` must: + +1. resolve `coverage//coverage-final.json`; +2. report a missing file using the project name and relative report path; +3. parse JSON and reject arrays, `null`, and invalid JSON; +4. normalize report keys with `path.resolve`; +5. compare them to `runtimeOwningSourceFiles`; and +6. emit one diagnostic per missing source, including project and relative + source path. + +`validateRequiredProjectReports` must call it for every project and preserve +policy order in `reports`. + +- [ ] **Step 7: Implement ratchet evaluation** + +`evaluateCoverageRatchets` must: + +- compare each merged metric with its numeric minimum; +- build an Istanbul map from `coverageData`; +- require every critical path to exist; +- obtain `fileCoverage.toSummary().toJSON().statements`; +- compare both `covered` and `pct`; and +- return diagnostics rather than exiting. + +Use messages of the form: + +```text +Merged statements coverage regressed: observed 68%, required at least 68.86%. +Critical coverage apps/runtime.ts statements covered regressed: observed 1, required at least 2. +Critical coverage apps/runtime.ts statements percent regressed: observed 50%, required at least 75%. +``` + +- [ ] **Step 8: Run the module tests to verify GREEN** + +Run: + +```bash +node --test tools/coverage/coverage-integrity.test.mjs +``` + +Expected: all classification, marker, report, and ratchet tests pass. + +- [ ] **Step 9: Commit the pure integrity module** + +Run: + +```bash +git add \ + tools/coverage/coverage-integrity.mjs \ + tools/coverage/coverage-integrity.test.mjs +git commit -m "test(coverage): add integrity primitives" +``` + +Expected: one commit containing only the pure module and its Node tests. + +### Task 2: Wire The Gate And Fix The Real `m3u-state` Instrumentation Failure + +**Files:** + +- Modify: `tools/coverage/run-tier-a-coverage.mjs` +- Modify: `tools/coverage/merge-coverage.mjs` +- Modify: `tools/coverage/coverage-health.mjs` +- Modify: `package.json` +- Modify: `libs/m3u-state/tsconfig.spec.json` + +- [ ] **Step 1: Replace synchronous inherited child output with streamed scanning** + +In `run-tier-a-coverage.mjs`, replace `spawnSync` with `spawn`, import +`createCoverageOutputScanner` and `validateProjectCoverage`, and add: + +```javascript +async function runCoverageProject(project) { + const args = buildNxArgs(project); + console.log(`\n==> Collecting coverage for ${project.name}`); + console.log(`pnpm ${args.join(' ')}`); + + const scanner = createCoverageOutputScanner(); + const child = spawn('pnpm', args, { + cwd: workspaceRoot, + env: { + ...process.env, + CI: process.env.CI ?? 'true', + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', + }, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + for (const [stream, destination] of [ + [child.stdout, process.stdout], + [child.stderr, process.stderr], + ]) { + stream.on('data', (chunk) => { + scanner.push(chunk); + destination.write(chunk); + }); + } + + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + + if (result.code !== 0 || result.signal) { + return result.code ?? 1; + } + if (scanner.collectionFailed) { + console.error( + `Coverage collection failed while testing ${project.name}.` + ); + return 1; + } + + const validation = validateProjectCoverage({ + project, + workspaceRoot, + }); + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + return validation.errors.length === 0 ? 0 : 1; +} +``` + +Replace the final `spawnSync` loop with top-level awaited sequential calls. +Exit immediately after a nonzero project result so later projects cannot hide +the failed owner. + +- [ ] **Step 2: Make merge input fail closed before output mutation** + +In `merge-coverage.mjs`, remove the `.filter(existsSync)` construction. Call: + +```javascript +const validation = validateRequiredProjectReports({ + projects: policy.unitCoverage.tierA, + workspaceRoot, +}); + +if (validation.errors.length > 0) { + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + process.exit(1); +} + +const coverageInputs = validation.reports; +``` + +Perform this before `rmSync(outputDir, ...)`. Merge each `input.data` instead +of reopening its path. Log `coverageInputs.length`, which must be exactly 30 +on the current policy. + +- [ ] **Step 3: Add independent health validation** + +In `coverage-health.mjs`: + +- import `evaluateCoverageRatchets`, `validateMergedCoverage`, + `validateProjectCoverage`, and `validateRequiredProjectReports`; +- under `--require-report`, validate every Tier A report and append all + diagnostics to `errors`; +- without `--require-report`, validate only report files that actually exist, + treating invalid JSON or missing runtime source in a present report as an + error rather than printing 30 missing-report warnings; +- after loading merged `coverage-summary.json`, load and independently validate + merged `coverage-final.json` for every runtime-owning Tier A file; +- recompute the aggregate summary from the validated merged map, compare it + with the serialized summary, and evaluate + `policy.reporting.coverageRatchet` when the policy has one. + +Do not change existing source-root/spec ownership checks, E2E tag warnings, or +changed-file warnings. + +- [ ] **Step 4: Run targeted `m3u-state` coverage to verify the real RED** + +Run: + +```bash +node --test tools/coverage/coverage-integrity.test.mjs +pnpm run coverage:unit:ci -- --projects=m3u-state +``` + +Expected: + +- Node tests PASS. +- Targeted coverage exits nonzero after relaying the existing TypeScript + diagnostics and reports `Coverage collection failed while testing +m3u-state`. + +This is the real regression proof: before the runner wiring, the same Jest +failure exited 0. + +- [ ] **Step 5: Align the `m3u-state` spec compiler configuration** + +Replace `libs/m3u-state/tsconfig.spec.json` with: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "preserve", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "files": ["src/test-setup.ts", "../../global.d.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} +``` + +This matches the working Angular services test compiler mode, lets TypeScript +honor Angular Material package exports, and includes the shared +`Window.electron` contract. Do not add a local fake Material declaration to +`m3u-state`. + +- [ ] **Step 6: Add coverage-tool tests to `coverage:ci`** + +In `package.json`, add: + +```json +"coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs" +``` + +Change `coverage:ci` so it starts with: + +```json +"coverage:ci": "pnpm run coverage:tools:test && pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report" +``` + +- [ ] **Step 7: Verify targeted instrumentation is GREEN and complete** + +Run: + +```bash +pnpm run coverage:tools:test +pnpm nx test m3u-state --skip-nx-cache --runInBand +pnpm run coverage:unit:ci -- --projects=m3u-state +node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const report=JSON.parse(fs.readFileSync("coverage/libs/m3u-state/coverage-final.json","utf8")); const target=path.resolve("libs/m3u-state/src/lib/effects.ts"); if (!report[target]) throw new Error(`${target} missing`); console.log("effects.ts present");' +``` + +Expected: all commands exit 0, no `Failed to collect coverage` appears, and +the final command prints `effects.ts present`. + +- [ ] **Step 8: Commit the fail-closed runner and instrumentation fix** + +Run: + +```bash +git add \ + package.json \ + tools/coverage/run-tier-a-coverage.mjs \ + tools/coverage/merge-coverage.mjs \ + tools/coverage/coverage-health.mjs \ + libs/m3u-state/tsconfig.spec.json +git commit -m "fix(coverage): fail on incomplete instrumentation" +``` + +Expected: one commit with the gate wiring and the real `effects.ts` fix. + +### Task 3: Establish A Real Failing Critical-File Ratchet + +**Files:** + +- Modify but do not yet commit: + `tools/coverage/coverage-policy.json` + +- [ ] **Step 1: Add the provisional ratchet** + +Under `reporting`, add: + +```json +"coverageRatchet": { + "merged": { + "statements": 68.86, + "branches": 58.66, + "functions": 67.19, + "lines": 69.2 + }, + "criticalFiles": [ + { + "path": "apps/electron-backend/src/app/server/http-server.ts", + "statements": { + "minimumCovered": 1, + "minimumPercent": 0 + } + }, + { + "path": "apps/electron-backend/src/app/events/remote-control.events.ts", + "statements": { + "minimumCovered": 1, + "minimumPercent": 0 + } + }, + { + "path": "apps/electron-backend/src/app/events/settings.events.ts", + "statements": { + "minimumCovered": 17, + "minimumPercent": 59.25 + } + }, + { + "path": "apps/electron-backend/src/app/events/database/downloads.events.ts", + "statements": { + "minimumCovered": 70, + "minimumPercent": 46.93 + } + } + ] +} +``` + +The existing files use their fresh master percentage and require one +additional covered statement. The two 0% files require one covered statement. +These are minimal progress probes, not final thresholds. + +- [ ] **Step 2: Verify the critical acceptance test is RED** + +Use the fresh baseline reports from Task 0: + +```bash +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: merge succeeds with exactly 30 reports; health exits nonzero and +names all selected files still below the provisional ratchet. In particular, +`http-server.ts` and `remote-control.events.ts` report zero covered +statements. + +- [ ] **Step 3: Keep the provisional policy change unstaged** + +Run: + +```bash +git status --short +``` + +Expected: only `tools/coverage/coverage-policy.json` is intentionally dirty. +Do not stage it in Tasks 4–7; it remains the real red acceptance test until +the behavioral suites raise the observed values. + +### Task 4: Cover The HTTP Server Through Real Loopback And Filesystem Contracts + +**Files:** + +- Create: `apps/electron-backend/src/app/server/http-server.spec.ts` +- Modify: `apps/electron-backend/src/app/server/http-server.ts` + +- [ ] **Step 1: Write the complete HTTP contract suite before the seam exists** + +The spec must: + +- create a temporary static directory with `index.html`, `app.js`, + `styles.css`, `data.json`, and an unknown-extension asset; +- inject a real `http.createServer` wrapper that captures the Node server; +- call `start(0)`, await `listening`, and request + `127.0.0.1:`; +- stop and remove the temporary tree in `afterEach`. + +Use these test helpers: + +```typescript +interface HttpResponseSnapshot { + body: Buffer; + contentType: string | undefined; + statusCode: number; +} + +function request( + port: number, + requestPath: string, + method = 'GET' +): Promise { + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + method, + path: requestPath, + port, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => + resolve({ + body: Buffer.concat(chunks), + contentType: res.headers['content-type'], + statusCode: res.statusCode ?? 0, + }) + ); + } + ); + req.once('error', reject); + req.end(); + }); +} +``` + +Add assertions for: + +1. `/` returns the exact index body as `text/html`; +2. `.js`, `.css`, `.json`, and an unknown extension return the expected MIME; +3. a client route falls back to the exact index body; +4. missing index returns plain-text 404; +5. a registered API handler receives the request and bypasses static serving; +6. unknown `/api/remote-control/...` returns JSON 404; +7. `/../../outside-secret.txt` never returns an outside file; +8. duplicate `start` creates/listens once; +9. disable stops the server; and +10. an enabled port change stops then starts once with the new port. + +- [ ] **Step 2: Run the focused spec to verify RED** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=http-server.spec.ts \ + --runInBand +``` + +Expected: FAIL at TypeScript compilation because `HttpServer` does not yet +accept the injected `distPath` and `createServer` options, and `start(0)` +currently ignores port zero. + +- [ ] **Step 3: Add the minimal constructor seam** + +In `http-server.ts`, add flat types: + +```typescript +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} +``` + +Add a factory field and constructor: + +```typescript +private readonly createServer: HttpServerFactory; + +constructor(options: HttpServerOptions = {}) { + this.createServer = + options.createServer ?? + ((requestListener) => http.createServer(requestListener)); + this.distPath = options.distPath ?? null; +} +``` + +Replace: + +```typescript +if (port) { +``` + +with: + +```typescript +if (port !== undefined) { +``` + +and replace the direct `http.createServer` call with `this.createServer`. +Leave the production singleton as: + +```typescript +export const httpServer = new HttpServer(); +``` + +No production caller passes port zero or constructor options, so normal +runtime behavior stays unchanged. + +- [ ] **Step 4: Run HTTP tests and Electron coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=http-server.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: + +- the focused suite PASSes; +- `http-server.ts` is nonzero; +- health remains RED only for the not-yet-expanded selected boundaries and any + aggregate dip caused by adding `effects.ts`. + +- [ ] **Step 5: Commit only the HTTP slice** + +Run: + +```bash +git add \ + apps/electron-backend/src/app/server/http-server.ts \ + apps/electron-backend/src/app/server/http-server.spec.ts +git commit -m "test(electron): cover remote HTTP server" +``` + +Expected: the provisional policy remains unstaged. + +### Task 5: Cover Remote-Control HTTP, IPC, And Renderer Dispatch + +**Files:** + +- Create: + `apps/electron-backend/src/app/events/remote-control.events.spec.ts` +- Modify: + `apps/electron-backend/src/app/events/remote-control.events.ts` + +- [ ] **Step 1: Write the isolated remote-control suite** + +Mock only: + +- `BrowserWindow.getAllWindows`; +- `ipcMain.handle` and `ipcMain.on`; +- `httpServer.registerRemoteControlHandler` and `httpServer.start`; and +- `store.get`. + +Import a named `RemoteControlEvents` class and instantiate it in each test. +Capture registered handlers in maps. Use a `PassThrough` request for JSON body +tests and a response recorder with typed `writeHead`/`end` methods. + +Use this complete exchange helper: + +```typescript +interface ResponseSnapshot { + body: string; + headers: Record; + statusCode: number; +} + +function createExchange(method: string) { + const requestStream = new PassThrough(); + Object.defineProperty(requestStream, 'method', { value: method }); + + let resolveResponse!: (snapshot: ResponseSnapshot) => void; + const snapshot: ResponseSnapshot = { + body: '', + headers: {}, + statusCode: 0, + }; + const completed = new Promise((resolve) => { + resolveResponse = resolve; + }); + const response = { + writeHead(statusCode: number, headers: Record) { + snapshot.statusCode = statusCode; + snapshot.headers = headers; + return response; + }, + end(body?: string) { + snapshot.body = body ?? ''; + resolveResponse(snapshot); + return response; + }, + }; + + return { + completed, + request: requestStream as unknown as http.IncomingMessage, + requestStream, + response: response as unknown as http.ServerResponse, + }; +} + +function getHttpHandler(path: string) { + const handler = mockRegisteredHttpHandlers.get(path); + if (!handler) { + throw new Error(`Expected remote-control HTTP handler for ${path}`); + } + return handler; +} +``` + +Add exact contract cases: + +```typescript +it.each([ + ['/api/remote-control/channel/up', 'CHANNEL_CHANGE', { direction: 'up' }], + [ + '/api/remote-control/channel/down', + 'CHANNEL_CHANGE', + { direction: 'down' }, + ], + [ + '/api/remote-control/volume/up', + 'REMOTE_CONTROL_COMMAND', + { type: 'volume-up' }, + ], + [ + '/api/remote-control/volume/down', + 'REMOTE_CONTROL_COMMAND', + { type: 'volume-down' }, + ], + [ + '/api/remote-control/volume/toggle-mute', + 'REMOTE_CONTROL_COMMAND', + { type: 'volume-toggle-mute' }, + ], +] as const)( + 'dispatches POST %s to the first renderer', + async (path, channel, payload) => { + const exchange = createExchange('POST'); + + getHttpHandler(path)(exchange.request, exchange.response); + exchange.requestStream.end(); + const response = await exchange.completed; + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(JSON.stringify({ success: true })); + expect(mockWebContentsSend).toHaveBeenCalledWith(channel, payload); + } +); +``` + +Also test: + +- all seven endpoint paths are registered; +- stored enabled/port starts HTTP once; disabled does not; +- GET on POST-only handlers and POST on status return 405 without dispatch; +- select-number accepts `7.9` and sends integer `7`; +- missing, zero, negative, `Infinity`, and nonnumeric values return 400; +- malformed JSON returns 400 and sends nothing; +- a 10,241-byte body returns 413, destroys the request, and sends nothing; +- partial status IPC updates merge with previous state and refresh + `updatedAt`; +- GET status returns that merged state; and +- no BrowserWindow logs a warning but does not throw. + +- [ ] **Step 2: Run the focused spec to verify RED** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=remote-control.events.spec.ts \ + --runInBand +``` + +Expected: FAIL because `RemoteControlEvents` is not exported as a named class. + +- [ ] **Step 3: Export the existing class without changing its singleton** + +Change: + +```typescript +class RemoteControlEvents { +``` + +to: + +```typescript +export class RemoteControlEvents { +``` + +Keep: + +```typescript +export default new RemoteControlEvents(); +``` + +Do not change endpoint names, methods, payloads, body limit, stored defaults, +or renderer channels unless a correct regression assertion exposes an actual +defect. If that happens, invoke `systematic-debugging`, keep the failing test, +and document the separately justified behavior fix. + +- [ ] **Step 4: Verify remote-control behavior and coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=remote-control.events.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: the focused suite PASSes, `remote-control.events.ts` is nonzero, and +health no longer reports either formerly 0% selected file. + +- [ ] **Step 5: Commit only the remote-control slice** + +Run: + +```bash +git add \ + apps/electron-backend/src/app/events/remote-control.events.ts \ + apps/electron-backend/src/app/events/remote-control.events.spec.ts +git commit -m "test(electron): cover remote control events" +``` + +Expected: the provisional policy remains unstaged. + +### Task 6: Expand Settings Runtime Reconciliation Coverage + +**Files:** + +- Modify: + `apps/electron-backend/src/app/events/settings.events.spec.ts` +- Verify only: + `apps/electron-backend/src/app/events/settings.events.ts` + +- [ ] **Step 1: Make the existing test harness deterministic** + +Keep real `normalizeExternalPlayerArguments`. Add stable mocks: + +```typescript +type SettingsUpdateHandler = ( + event: unknown, + settings: Record +) => void; + +const mockStoreGet = jest.fn(); +const mockStoreSet = jest.fn(); +const mockUpdateSettings = jest.fn(); +let handler: SettingsUpdateHandler; +``` + +Include every imported key in the store mock: + +```typescript +EMBEDDED_MPV_FRAME_COPY: 'embeddedMpvFrameCopy', +MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', +MPV_REUSE_INSTANCE: 'mpvReuseInstance', +VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', +VLC_REUSE_INSTANCE: 'vlcReuseInstance', +store: { + get: mockStoreGet, + set: mockStoreSet, +}, +``` + +In `beforeEach`, clear the handler map, reset modules and mocks, silence +`console.log`, import `./settings.events`, and retrieve the registered +`SETTINGS_UPDATE` handler. Preserve both existing credential-redaction tests. + +- [ ] **Step 2: Confirm the provisional settings ratchet is RED before new cases** + +Run: + +```bash +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: settings reports fewer than the provisional 17 covered statements or +less than its retained 59.25% floor. + +- [ ] **Step 3: Add normalization and defined-only persistence assertions** + +Add: + +```typescript +it('normalizes player arguments and preserves explicit false reuse flags', () => { + handler( + {}, + { + mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '], + mpvReuseInstance: false, + vlcPlayerArguments: ' --fullscreen \n\n --no-video-title-show ', + vlcReuseInstance: false, + } + ); + + expect(mockStoreSet).toHaveBeenCalledWith( + 'mpvPlayerArguments', + '--screen=1\n--hwdec=auto-safe' + ); + expect(mockStoreSet).toHaveBeenCalledWith('mpvReuseInstance', false); + expect(mockStoreSet).toHaveBeenCalledWith( + 'vlcPlayerArguments', + '--fullscreen\n--no-video-title-show' + ); + expect(mockStoreSet).toHaveBeenCalledWith('vlcReuseInstance', false); +}); + +it('does not persist undefined settings', () => { + handler({}, {}); + + expect(mockStoreSet).not.toHaveBeenCalled(); + expect(mockUpdateSettings).not.toHaveBeenCalled(); +}); + +it('coerces the frame-copy startup preference to boolean', () => { + handler({}, { embeddedMpvFrameCopy: 1 }); + + expect(mockStoreSet).toHaveBeenCalledWith('embeddedMpvFrameCopy', true); +}); +``` + +- [ ] **Step 4: Add partial remote-control reconciliation assertions** + +Add two tests: + +```typescript +it('uses the stored port when only remote-control enabled changes', () => { + mockStoreGet.mockImplementation((key: string) => + key === 'remoteControlPort' ? 9988 : undefined + ); + + handler({}, { remoteControl: true }); + + expect(mockStoreSet).toHaveBeenCalledWith('remoteControl', true); + expect(mockStoreSet).not.toHaveBeenCalledWith( + 'remoteControlPort', + expect.anything() + ); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988); +}); + +it('uses stored enabled state when only the remote-control port changes', () => { + mockStoreGet.mockImplementation((key: string) => + key === 'remoteControl' ? true : undefined + ); + + handler({}, { remoteControlPort: 9123 }); + + expect(mockStoreSet).toHaveBeenCalledWith('remoteControlPort', 9123); + expect(mockStoreSet).not.toHaveBeenCalledWith( + 'remoteControl', + expect.anything() + ); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123); +}); +``` + +- [ ] **Step 5: Run settings tests and refresh Electron coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=settings.events.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: all settings tests PASS; settings covered statements and percentage +are above the fresh master baseline. These are characterization tests for +unchanged behavior, so no production edit is expected. + +- [ ] **Step 6: Commit only the settings spec** + +Run: + +```bash +git add apps/electron-backend/src/app/events/settings.events.spec.ts +git commit -m "test(electron): cover settings runtime updates" +``` + +Expected: the provisional policy remains unstaged. + +### Task 7: Expand Download Managed-Path Security Coverage + +**Files:** + +- Modify: + `apps/electron-backend/src/app/events/database/downloads.events.spec.ts` +- Verify only: + `apps/electron-backend/src/app/events/database/downloads.events.ts` + +- [ ] **Step 1: Add filesystem and shell spies to the existing harness** + +Add: + +```typescript +const mockExistsSync = jest.fn(); +const mockOpenPath = jest.fn(); +const mockShowItemInFolder = jest.fn(); +``` + +Return the two shell spies from the existing Electron mock. Reset them in +`beforeEach`; make `mockOpenPath` resolve an empty string by default. +Before importing `downloads.events`, register: + +```typescript +jest.doMock('node:fs', () => ({ + existsSync: mockExistsSync, +})); +``` + +Add a database helper whose `limit(1)` returns either `[{ id: 42 }]`, `[]`, or +rejects: + +```typescript +function mockManagedPath(result: 'managed' | 'unmanaged' | 'error') { + const limit = + result === 'error' + ? jest.fn().mockRejectedValue(new Error('database unavailable')) + : jest + .fn() + .mockResolvedValue(result === 'managed' ? [{ id: 42 }] : []); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ limit })), + })), + })), + }); +} +``` + +- [ ] **Step 2: Confirm the provisional downloads ratchet is RED** + +Run: + +```bash +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: downloads is below the provisional 70-covered-statement target or +its retained 46.93% floor. + +- [ ] **Step 3: Add the managed-path rejection cases** + +For both `DOWNLOADS_REVEAL_FILE` and `DOWNLOADS_PLAY_FILE`, assert: + +```typescript +it.each(['DOWNLOADS_REVEAL_FILE', 'DOWNLOADS_PLAY_FILE'])( + '%s rejects a path not owned by a download row', + async (channel) => { + mockManagedPath('unmanaged'); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler(channel)(null, '/tmp/unmanaged.mp4') + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + } +); + +it.each(['DOWNLOADS_REVEAL_FILE', 'DOWNLOADS_PLAY_FILE'])( + '%s rejects a managed path missing on disk', + async (channel) => { + mockManagedPath('managed'); + mockExistsSync.mockReturnValue(false); + + await expect( + getHandler(channel)(null, '/downloads/missing.mp4') + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + } +); +``` + +- [ ] **Step 4: Add the authorized shell and database-failure cases** + +Add one reveal success, one play success, and a parameterized database failure: + +```typescript +it('reveals only a managed file that exists', async () => { + mockManagedPath('managed'); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_REVEAL_FILE')(null, '/downloads/movie.mp4') + ).resolves.toEqual({ success: true }); + + expect(mockShowItemInFolder).toHaveBeenCalledWith('/downloads/movie.mp4'); +}); + +it('opens only a managed file that exists', async () => { + mockManagedPath('managed'); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_PLAY_FILE')(null, '/downloads/movie.mp4') + ).resolves.toEqual({ success: true }); + + expect(mockOpenPath).toHaveBeenCalledWith('/downloads/movie.mp4'); +}); +``` + +For the database-error cases, silence `console.error`, use +`mockManagedPath('error')`, expect the existing structured not-found response, +and assert neither shell spy ran. + +- [ ] **Step 5: Run download tests and refresh Electron coverage** + +Run: + +```bash +pnpm nx test electron-backend \ + --testPathPattern=downloads.events.spec.ts \ + --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend +pnpm run coverage:merge +node tools/coverage/coverage-health.mjs --require-report +``` + +Expected: all existing destructive cleanup/pause/resume tests and all new +managed-path tests PASS. Downloads covered statements and percentage exceed +the fresh master values. + +- [ ] **Step 6: Commit only the downloads spec** + +Run: + +```bash +git add \ + apps/electron-backend/src/app/events/database/downloads.events.spec.ts +git commit -m "test(electron): cover managed download paths" +``` + +Expected: the provisional policy remains the only unstaged source change. + +### Task 8: Record Achieved Ratchets And Document The Integrity Contract + +**Files:** + +- Modify: + `tools/coverage/coverage-policy.json` +- Modify: + `docs/architecture/validation-map.md` +- Modify: + `docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md` +- Modify: + `docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md` +- Create: + `.changes/electron-remote-static-paths.md` + +- [ ] **Step 1: Generate a complete fresh candidate report** + +Run: + +```bash +NX_SKIP_NX_CACHE=true pnpm run coverage:ci +``` + +Expected: 30 Tier A reports, no `Failed to collect coverage`, no missing +runtime-owning files, and all four aggregate metrics at or above the fresh +master baseline. If any metric is lower, do not lower the baseline. Inspect +the approved boundary cases for a missing meaningful assertion, add that +failing assertion, and rerun the directly affected suite before repeating this +command. + +- [ ] **Step 2: Print exact achieved aggregate and per-file values** + +Run: + +```bash +node --input-type=module <<'NODE' +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); +const data = JSON.parse( + fs.readFileSync('coverage/merged/coverage-final.json', 'utf8') +); +const map = createCoverageMap(data); +const targets = [ + 'apps/electron-backend/src/app/server/http-server.ts', + 'apps/electron-backend/src/app/events/remote-control.events.ts', + 'apps/electron-backend/src/app/events/settings.events.ts', + 'apps/electron-backend/src/app/events/database/downloads.events.ts', +]; +const criticalFiles = targets.map((file) => { + const statements = map + .fileCoverageFor(path.resolve(file)) + .toSummary() + .toJSON().statements; + return { + path: file, + statements: { + minimumCovered: statements.covered, + minimumPercent: statements.pct, + }, + }; +}); +const summary = map.getCoverageSummary().toJSON(); +console.log( + JSON.stringify( + { + merged: { + statements: summary.statements.pct, + branches: summary.branches.pct, + functions: summary.functions.pct, + lines: summary.lines.pct, + }, + criticalFiles, + }, + null, + 4 + ) +); +NODE +``` + +Expected: a complete `coverageRatchet` value with nonzero HTTP/remote values +and settings/download values no lower than their baseline. Copy this exact +JSON object over the provisional `reporting.coverageRatchet`; do not round +manually or retain the provisional one-statement probes. + +- [ ] **Step 3: Verify the achieved ratchet passes and rejects regressions** + +Run: + +```bash +pnpm run coverage:health -- --require-report +node --test tools/coverage/coverage-integrity.test.mjs +``` + +Expected: both exit 0. The synthetic Node tests still prove below-ratchet +metrics, missing critical files, missing reports, and collection markers fail. + +- [ ] **Step 4: Update the canonical validation map** + +Under `## Coverage Tiers` in `docs/architecture/validation-map.md`, add: + +```markdown +Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits +nonzero on `Failed to collect coverage`, a missing/invalid project report, or +a runtime-owning production TypeScript file absent from that report. +`coverage:merge` requires every configured Tier A report before replacing the +merged output, and `coverage:health --require-report` repeats completeness plus +aggregate and critical-file ratchets. + +Runtime-owning files are discovered from the TypeScript AST. Specs, declarations, +test setup/stubs, generated/environment files, `index.ts`, type-only files, and +pure re-export shims do not count as executable coverage inputs. + +Ratchets live under `reporting.coverageRatchet` in +`tools/coverage/coverage-policy.json`. Update them only from a fresh full +`coverage:ci` report when every value stays level or rises; never lower a +ratchet to accept a regression. The only exception is a reviewed production +source shrink: `minimumCovered` may follow a lower statement total when the PR +identifies the removed executable statements and fresh coverage proves that +the file percentage, aggregate ratchets, and remaining behavioral coverage do +not decrease. +``` + +Also include `pnpm run coverage:tools:test` in the local coverage command block. + +- [ ] **Step 5: Confirm documentation and release-note decisions** + +Because the real-loopback regression exposed and fixed an observable Windows +static-path traversal escape, add `.changes/electron-remote-static-paths.md`: + +```markdown +--- +type: fix +area: electron +--- + +The desktop remote-control server now blocks crafted static paths from escaping +bundled web files on Windows. +``` + +Run: + +```bash +pnpm exec prettier --check \ + docs/architecture/validation-map.md \ + docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md \ + docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md \ + .changes/electron-remote-static-paths.md +pnpm run release:notes:validate +``` + +Expected: both pass. The release note records the narrow user-visible security +fix; do not apply `no-release-note` in Task 10. + +- [ ] **Step 6: Commit ratchets and docs** + +Run: + +```bash +git add \ + tools/coverage/coverage-policy.json \ + docs/architecture/validation-map.md \ + docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md \ + docs/superpowers/plans/2026-07-25-coverage-integrity-runtime-boundaries.md \ + .changes/electron-remote-static-paths.md +git commit -m "ci(coverage): ratchet runtime boundaries" +``` + +Expected: the provisional policy is replaced by achieved values, the security +fix has a valid release note, and the worktree becomes clean. + +### Task 9: Run Fresh Validation And Audit The Final Diff + +**Files:** + +- Verify only: all changed files +- Verify only: + `apps/electron-backend-e2e/src/downloads.e2e.ts` +- Verify only: + `apps/electron-backend-e2e/src/remote-control.e2e.ts` +- Verify only: + `apps/electron-backend-e2e/src/settings.e2e.ts` + +- [ ] **Step 1: Run targeted unit, lint, and type checks** + +Run: + +```bash +pnpm run coverage:tools:test +pnpm nx test electron-backend --skip-nx-cache --runInBand +pnpm nx test m3u-state --skip-nx-cache --runInBand +pnpm nx lint electron-backend --skip-nx-cache +pnpm nx lint m3u-state --skip-nx-cache +pnpm run typecheck:backend +``` + +Expected: every command exits 0; no Jest suite, lint rule, or backend type +check fails. + +- [ ] **Step 2: Run the three existing Electron E2E contracts** + +Run: + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/remote-control.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts --skip-nx-cache +``` + +Expected: all targets PASS. These prove the real packaged-style Electron IPC, +native folder/download flow, remote loopback server, renderer command, and +settings persistence contracts beyond unit mocks. + +- [ ] **Step 3: Build the production Electron target** + +Run: + +```bash +pnpm nx build electron-backend \ + --configuration=production \ + --skip-nx-cache +``` + +Expected: exit 0, including worker, web, and remote-control-web dependencies. + +- [ ] **Step 4: Run final full coverage from scratch** + +Run: + +```bash +NX_SKIP_NX_CACHE=true pnpm run coverage:ci +``` + +Expected: + +- all coverage-tool tests PASS; +- policy sees 30 Tier A projects; +- all 30 project reports are merged; +- `effects.ts` is present; +- no collection marker or runtime-owning source omission is reported; +- achieved aggregate and critical-file ratchets PASS. + +- [ ] **Step 5: Inspect exact metrics and scoped diff** + +Run: + +```bash +git diff origin/master...HEAD --check +git diff --stat origin/master...HEAD +git diff --name-only origin/master...HEAD +git status --short --branch +``` + +Expected: + +- no whitespace errors; +- only the files in this plan appear; +- no database worker, dashboard, Stalker search, type-only interface, lockfile, + AGENTS, or CLAUDE changes appear; +- the worktree is clean. + +- [ ] **Step 6: Apply verification-before-completion** + +Read and follow `verification-before-completion`. Record, without paraphrasing +away failures: + +- exact unit suite/test counts; +- exact E2E results; +- lint/typecheck/build exit results; +- 30/30 Tier A report integrity; +- aggregate before/after; +- all selected-file before/after values; +- docs and release-note decisions. + +Do not claim completion from cached or earlier output. + +### Task 10: Recheck Master, Push, And Open The Draft PR + +**Files:** + +- Verify only: current branch history and GitHub PR metadata + +- [ ] **Step 1: Recheck master before publication** + +Run: + +```bash +git fetch origin master --prune +git log --oneline HEAD..origin/master +``` + +Expected: no output. If master advanced, rebase, rerun dependency bootstrap and +all of Task 9, then refresh ratchets only upward before publication. + +- [ ] **Step 2: Review conventional history** + +Run: + +```bash +git log --oneline origin/master..HEAD +git status --short --branch +``` + +Expected: concise conventional commits, including the design and plan docs, +with a clean worktree. + +- [ ] **Step 3: Push the agent branch** + +Run: + +```bash +git push -u origin agent/coverage-integrity-runtime-boundaries +``` + +Expected: push succeeds and sets the upstream. + +- [ ] **Step 4: Create a draft PR** + +Read and follow `github-pr`. Create a draft targeting `master` with title: + +```text +test(electron): harden runtime coverage integrity +``` + +The body must contain: + +- fresh baseline commit and aggregate metrics; +- exact after metrics; +- selected-file before/after table; +- behavioral contracts for HTTP, remote control, settings, and downloads; +- the two behavior-preserving production seams; +- the `effects.ts` compiler/instrumentation correction; +- runner, merge, health, and ratchet behavior; +- exact validation commands/results; +- docs update; +- the Electron fix release note for Windows static-path containment; and +- deferred `database.worker.ts`, dashboard rails, and Stalker search. + +Use: + +```bash +gh pr create \ + --draft \ + --base master \ + --head agent/coverage-integrity-runtime-boundaries \ + --title "test(electron): harden runtime coverage integrity" \ + --body-file - +``` + +Provide the fully populated body on standard input; do not leave metric or +validation placeholders. + +- [ ] **Step 5: Verify release-note metadata** + +Run: + +```bash +gh pr view --json url,isDraft,headRefName,baseRefName,labels,files,commits,statusCheckRollup +``` + +Expected: the PR is draft, head/base are correct, +`.changes/electron-remote-static-paths.md` is included, `no-release-note` is +absent, and the command prints the PR URL. + +- [ ] **Step 6: Return the exact handoff** + +Return: + +- draft PR URL; +- branch; +- final commit SHA; +- aggregate and selected-file before/after metrics; +- exact command results and any explicit environment-only skip; +- docs updated (`docs/architecture/validation-map.md`); +- release note added (`.changes/electron-remote-static-paths.md`) with no + `no-release-note` label; +- deferred zero-coverage follow-ups. diff --git a/docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md b/docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md new file mode 100644 index 000000000..b6ebb5ee6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-coverage-integrity-runtime-boundaries-design.md @@ -0,0 +1,447 @@ +# Coverage Integrity And Electron Runtime Boundaries Design + +## Status + +Approved in the delegated coverage-audit task on 2026-07-25. + +Before recording the final ratchets, the implementation branch was rebased +onto fresh `origin/master` commit +`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077`. + +## Goal + +Increase confidence in security-sensitive and runtime-sensitive Electron +behavior while making the merged Tier A coverage report fail closed when Jest +cannot instrument executable production source. + +This is a risk-weighted first change. It does not try to maximize a global +percentage by testing type-only modules, re-export shims, or low-risk code. + +## Fresh Baseline + +An uncached `pnpm run coverage:ci` on +`7e8c2ccce16b71d72a64c45f1d96dd090dc8e077` completed successfully and reported: + +| Metric | Covered / total | Percent | +| ---------- | --------------: | ------: | +| Statements | 24,982 / 36,062 | 69.27% | +| Branches | 17,465 / 29,637 | 58.92% | +| Functions | 5,785 / 8,577 | 67.44% | +| Lines | 24,323 / 34,959 | 69.57% | + +The successful command nevertheless printed `Failed to collect coverage` for +`libs/m3u-state/src/lib/effects.ts`. TypeScript could not resolve +`@angular/material/snack-bar`, inferred the injected snack bar as `unknown`, +and did not see the shared `window.electron` declaration under the project's +spec compiler configuration (`TS2307` once, `TS2571` twice, and `TS2551` +once). The file was absent from both the project report and +`coverage/merged/coverage-final.json`. + +All 30 Tier A projects produced a `coverage-final.json`, so 31 report files +including the merged report existed. The current merge included all 30 project +reports and 709 files while still missing an executable file. This demonstrates +that project-level report existence is necessary but not sufficient. + +The audit file status on the same baseline is: + +| File | Statement coverage | +| --------------------------------------------------------------------------------------- | -----------------: | +| `apps/electron-backend/src/app/workers/database.worker.ts` | 0 / 207 (0%) | +| `apps/electron-backend/src/app/events/remote-control.events.ts` | 0 / 115 (0%) | +| `apps/electron-backend/src/app/server/http-server.ts` | 0 / 73 (0%) | +| `apps/electron-backend/src/app/events/database/downloads.events.ts` | 69 / 147 (46.93%) | +| `apps/electron-backend/src/app/events/settings.events.ts` | 16 / 27 (59.25%) | +| `libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts` | 0 / 206 (0%) | +| `libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts` | 0 / 166 (0%) | +| `libs/m3u-state/src/lib/effects.ts` | absent | + +The original audit is therefore partly stale: `downloads.events.ts` gained a +meaningful regression suite on master, and `settings.events.ts` already had +logging coverage. Both remain in this PR because their uncovered IPC +contracts are part of the approved Electron boundary, but new tests must cover +missing behavior rather than repeat existing assertions. + +## Scope + +### Included + +- Add behavioral coverage for `HttpServer` routing, static serving, traversal + containment, MIME responses, and lifecycle. +- Add behavioral coverage for the remote-control HTTP and IPC boundary: + method checks, renderer commands, status, channel-number validation, + malformed JSON, and the 10 KiB body limit. +- Extend settings IPC coverage for argument normalization, defined-only + persistence, frame-copy boolean normalization, remote-control fallback, and + runtime server reconciliation while preserving redacted logging. +- Extend downloads IPC coverage around the managed-file authorization boundary + before revealing or opening local paths. Preserve the existing destructive + cleanup and pause/resume regression tests. +- Fix the `m3u-state` spec compiler configuration so `effects.ts` can be + instrumented and appears in its report. +- Add a fail-closed integrity gate for instrumentation errors, missing Tier A + reports, missing executable source files, merged metric regressions, and + regressions in the selected critical Electron files. +- Document the resulting coverage workflow in + `docs/architecture/validation-map.md`. + +### Deferred + +- `database.worker.ts`: its 950-line worker-thread dispatcher and database + operation surface deserve a dedicated worker-contract design and PR. +- `workspace-dashboard-rails.component.ts`: the existing same-name spec tests + extracted helpers but never instantiates the 660-line component. Component + coverage belongs in a focused Angular dashboard change. +- `stalker-search.component.ts`: the 510-line search surface and portal store + interactions belong in a focused Stalker search change. +- Broadly raising every current 0% Tier A file. +- Adding tests to type-only/shared interfaces or pure re-export modules to + inflate aggregate metrics. +- Unrelated production behavior changes. + +## Coverage Integrity Architecture + +### Shared integrity module + +Create a pure Node module under `tools/coverage/` that owns: + +- source-file exclusion rules; +- TypeScript AST classification of runtime-owning files; +- coverage path normalization; +- project- and merged-report completeness checks; +- instrumentation-error marker detection; +- merged and critical-file ratchet evaluation; and +- diagnostic formatting. + +The runner, merge script, health script, and Node unit tests use this module so +the definition of an expected executable file does not drift between stages. + +### Runtime-owning source classification + +For each Tier A `sourceRoot`, recursively consider production `.ts` files. +Exclude: + +- `*.spec.ts` and `*.test.ts`; +- `*.d.ts`; +- `test-setup.ts`, test stubs, and generated files; +- environment files; and +- `index.ts`, matching the existing Jest collection exclusions. + +Parse each candidate with the TypeScript compiler API. A file is expected in +coverage when it has at least one top-level runtime-owning statement. +Import declarations, interfaces, type aliases, pure export declarations, +empty statements, and ambient `declare` statements do not make a file +runtime-owning by themselves. Classes, functions, variables, enums, +expressions, control flow, and other emitted statements do. + +This rule intentionally checks executable ownership rather than all TypeScript +text. It excludes type-only contracts and re-export shims without maintaining +a large hand-authored manifest. A discovery prototype over the fresh baseline +classified 591 of 655 Tier A TypeScript source files as runtime-owning and +identified exactly one absent file: `libs/m3u-state/src/lib/effects.ts`. + +Coverage keys and expected source paths are normalized to absolute POSIX-style +paths before comparison so diagnostics are stable across supported CI +platforms. + +### Runner behavior + +`run-tier-a-coverage.mjs` must continue relaying each child process's stdout and +stderr as it arrives. It also scans a bounded rolling text window, with ANSI +escapes removed, for Jest's `Failed to collect coverage` marker. + +After each project command finishes, the runner must fail if: + +- the Nx/Jest command exits nonzero or terminates by signal; +- the output contains an instrumentation failure marker; +- `coverage//coverage-final.json` is missing or invalid; or +- a runtime-owning file under that project's source root is absent from the + report. + +The implementation should use asynchronous child-process streams rather than +buffering the full multi-project Jest output in memory. + +### Merge behavior + +`merge-coverage.mjs` must require exactly one valid report for every configured +Tier A project. It must list all missing or invalid reports and exit before +deleting or rewriting `coverage/merged`. + +The current `.filter(existsSync)` behavior is removed because it silently +merges partial input. + +### Health behavior + +`coverage-health.mjs --require-report` independently reruns: + +- per-project report existence and JSON validity; +- runtime-owning source completeness; +- merged-report completeness and usable instrumentation for every + runtime-owning Tier A file; +- merged metric ratchets; and +- selected critical-file ratchets. + +It computes the aggregate summary from that validated merged map before +checking the serialized summary and ratchets. This defense in depth catches +stale, incomplete, or manually merged reports even when the runner was not the +process that generated them. Local health checks without `--require-report` +retain their current warning behavior when report artifacts are not available, +but any present invalid report remains an error. + +### Ratchets + +The policy stores four merged minimum percentages and per-file statement +minimums for: + +- `http-server.ts`; +- `remote-control.events.ts`; +- `settings.events.ts`; and +- `downloads.events.ts`. + +The implementation begins with the fresh master aggregate baseline shown +above. After the approved behavioral suites and the `effects.ts` +instrumentation fix are complete, the policy is updated to the reproducible +post-change values. Those final values become the ratchet: they may stay level +or rise in future changes, but must not be lowered merely to make CI green. + +Per-file minimums are likewise recorded from the achieved behavioral suites, +not from an arbitrary aspirational percentage. The two currently uncovered +files must become nonzero; the existing `settings` and `downloads` coverage +must not regress. A reviewed production source shrink is the sole exception to +the normally monotonic absolute `minimumCovered`: a PR may lower it only when +it identifies the removed executable statements and a fresh full report proves +that the file's `minimumPercent`, all aggregate ratchets, and coverage of the +remaining behavior stay level or rise. + +## Behavioral Test Design + +### HTTP server + +Use a temporary static directory and a real loopback Node HTTP server. Verify: + +- `/` serves `index.html`; +- known static assets return their content and correct MIME type; +- an unknown client-side route falls back to `index.html`; +- a missing `index.html` returns a plain-text 404; +- registered remote-control API routes bypass static serving; +- unknown remote-control API routes return JSON 404; +- normalized traversal input cannot escape the configured static root; +- `start`, duplicate `start`, `stop`, enable/disable, and port-change restart + preserve the current lifecycle contract. + +Tests should prefer public behavior over direct calls to private methods. If +an ephemeral port or temporary static root is not observable through the +current API, introduce only the smallest constructor dependency seam required +to use real Node HTTP and filesystem behavior. The production singleton keeps +identical defaults. + +### Remote control events + +Capture the HTTP handlers registered during bootstrap and exercise them with +real readable request streams plus response-contract assertions. Verify: + +- bootstrap registers every documented endpoint and starts the server only + when stored settings enable it; +- channel up/down and volume commands accept only their documented HTTP + methods and send the expected renderer event; +- channel selection accepts finite positive values, floors fractional input, + and rejects missing, non-finite, or sub-one values; +- status updates merge partial IPC state and refresh `updatedAt`; +- status reads return the latest merged state; +- malformed JSON returns 400 without dispatching a command; +- payloads over 10 KiB return 413, destroy the request, and do not dispatch; + and +- missing BrowserWindows are contained without throwing. + +The existing Electron E2E remote-control suite remains the end-to-end check for +real volume commands and renderer status. + +### Settings events + +Extend the existing same-name spec. Use the real external-player argument +normalizer and mocked persistent store/server boundaries to verify: + +- MPV and VLC arguments are normalized before persistence; +- undefined values are not written; +- reuse flags preserve explicit false values; +- frame-copy persistence coerces to boolean; +- a partial remote-control update reads the missing half from the store; +- only explicitly supplied remote-control fields are persisted; and +- `httpServer.updateSettings` receives the resolved enabled/port pair. + +Retain the existing assertions that logging redacts TMDB and player-argument +credentials. + +### Downloads events + +Extend the current event suite rather than replacing its destructive cleanup +coverage. Verify the local-file boundary: + +- a path not present in the downloads database is never revealed or opened, + even when it exists on disk; +- a database-managed path missing on disk is never revealed or opened; +- only a database-managed path that exists reaches + `shell.showItemInFolder` or `shell.openPath`; and +- a database lookup failure returns the existing structured not-found response + and never reaches the shell. + +Folder authorization internals remain covered by +`download-directory-authorization.spec.ts`; this event suite tests only the +IPC-to-authorizer and managed-path integration where it adds distinct signal. + +## TDD Sequence + +1. Add failing Node tests for runtime classification, ANSI instrumentation + marker detection, missing/invalid reports, partial merge input, aggregate + ratchets, and critical-file ratchets. +2. Implement the shared integrity module and wire the runner, merge, and health + scripts until those tests pass. +3. Enable the selected critical-file policy; confirm fresh targeted coverage + fails because `http-server.ts` and `remote-control.events.ts` are 0%. +4. For each Electron boundary, add one contract test at a time, observe the + intended failure, and make only the smallest testability refactor needed to + pass it. +5. Add a failing integrity assertion for the absent `effects.ts`, align the + `m3u-state` spec compiler configuration, and confirm the real file appears. +6. If a behavioral test exposes a production defect, add a direct regression + assertion first and make a separately justified minimal fix. Otherwise + preserve production behavior. +7. Record fresh aggregate and per-file post-change ratchets only after all + behavioral suites are green. + +## Error Handling And Diagnostics + +Integrity failures must identify: + +- the project; +- the expected report path; +- each missing runtime-owning source path; +- the metric or critical file that fell below its ratchet; and +- the observed and required values. + +The runner must preserve the original Nx/Jest output and child exit semantics. +Integrity diagnostics are additive and must not hide the underlying +instrumentation error. + +Tests use temporary directories and deterministic synthetic coverage maps. +They must not depend on a previously generated workspace `coverage/` tree. + +## Success Criteria + +- Fresh `pnpm run coverage:ci` exits nonzero for synthetic or real + instrumentation collection failures. +- All 30 Tier A project reports are mandatory merge inputs. +- Every runtime-owning Tier A TypeScript source file is present in its project + report. +- `libs/m3u-state/src/lib/effects.ts` is present in coverage. +- The four selected Electron boundary files meet their recorded behavioral + statement ratchets; `http-server.ts` and `remote-control.events.ts` are no + longer 0%. +- Final merged statements, branches, functions, and lines are each at least + the fresh master baseline and become the new ratchet. +- Assertions verify observable contracts rather than merely invoking mocks for + line execution. +- No untested production behavior change or unrelated source change is + included. +- Deferred database worker, dashboard, and Stalker search coverage is listed + in the draft PR. + +## Implementation Outcome + +The fresh uncached implementation run merged all 30 required Tier A reports +into the 31st `coverage-final.json` and included 710 files, with no +collection-failure marker or missing runtime-owning source. The achieved +ratchets are: + +| Metric | Covered / total | Percent | +| ---------- | --------------: | ------: | +| Statements | 25,204 / 36,241 | 69.54% | +| Branches | 17,573 / 29,748 | 59.07% | +| Functions | 5,824 / 8,635 | 67.44% | +| Lines | 24,543 / 35,136 | 69.85% | + +The selected Electron boundary statement coverage changed as follows: + +| File | Baseline | After | +| -------------------------- | ----------------: | -----------------: | +| `http-server.ts` | 0 / 73 (0%) | 83 / 92 (90.21%) | +| `remote-control.events.ts` | 0 / 115 (0%) | 112 / 116 (96.55%) | +| `settings.events.ts` | 16 / 27 (59.25%) | 26 / 27 (96.29%) | +| `downloads.events.ts` | 69 / 147 (46.93%) | 86 / 147 (58.50%) | + +The integrity correction makes the denominator honest: +`libs/m3u-state/src/lib/effects.ts` is now present at 0 / 159 statements +instead of silently disappearing. The aggregate improvement therefore holds +even after surfacing those 159 previously unreported uncovered statements. +Strict health validation also rechecks the merged map itself, so a +runtime-owning file cannot disappear between valid project reports and the +merged artifact while permissive percentages still pass. + +Two production seams were sufficient for isolated contract tests: +`HttpServer` accepts an optional server factory and static distribution path, +and `RemoteControlEvents` is a named export while the production singleton is +unchanged. Real loopback HTTP tests also exposed a Windows +double-leading-slash traversal escape in the prior static-path normalization. +A direct failing regression preceded the narrow fix: production now uses the +pure, platform-testable `resolveStaticFilePath` resolver to decode once, reject +malformed or NUL paths, strip leading separators, resolve against the static +root, and enforce containment. This is a tested behavior and security fix, not +a behavior-preserving refactor. + +The release decision follows that observable fix: +`.changes/electron-remote-static-paths.md` records it as an Electron bug fix, +so the draft PR must not carry `no-release-note`. The canonical coverage +workflow is documented in `docs/architecture/validation-map.md`; `AGENTS.md` +and `CLAUDE.md` do not describe the changed coverage contract or HTTP path +logic and require no update. + +The deferred production files remain uncovered in this change: +`database.worker.ts` at 0 / 207, `workspace-dashboard-rails.component.ts` at +0 / 206, and `stalker-search.component.ts` at 0 / 166. They retain the +separate follow-up scopes described above. + +## Validation Ladder + +Run fresh, uncached validation in this order: + +```bash +node --test tools/coverage/*.test.mjs +pnpm nx test electron-backend --skip-nx-cache --runInBand +pnpm nx test m3u-state --skip-nx-cache --runInBand +pnpm run coverage:unit:ci -- --projects=electron-backend,m3u-state +pnpm nx lint electron-backend --skip-nx-cache +pnpm nx lint m3u-state --skip-nx-cache +pnpm run typecheck:backend +pnpm nx run electron-backend-e2e:e2e-ci--src/downloads.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/remote-control.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/settings.e2e.ts +pnpm nx build electron-backend --configuration=production --skip-nx-cache +pnpm run coverage:ci +``` + +The final report records exact command results, aggregate before/after metrics, +and before/after metrics for the selected critical files. If an environment +prevents an Electron E2E or production build, report the exact blocker and run +the strongest available lower-level contract validation; do not silently omit +it. + +## Documentation Impact + +Update `docs/architecture/validation-map.md` to make the fail-closed coverage +contract and ratchet maintenance workflow canonical. + +`AGENTS.md` and `CLAUDE.md` do not currently describe coverage commands or +integrity behavior, so no update is expected unless implementation changes a +process they do describe. + +## Draft PR Handoff + +The draft PR body will include: + +- fresh master baseline and post-change aggregate metrics; +- selected-file before/after metrics; +- behavioral contracts added; +- any production-only testability refactor and why it preserves behavior; +- coverage integrity and ratchet changes; +- all validation commands and results; +- the documentation decision; and +- deferred 0%-coverage follow-ups. diff --git a/libs/m3u-state/tsconfig.spec.json b/libs/m3u-state/tsconfig.spec.json index cc2617958..19497995e 100644 --- a/libs/m3u-state/tsconfig.spec.json +++ b/libs/m3u-state/tsconfig.spec.json @@ -2,12 +2,12 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "module": "commonjs", + "module": "preserve", "target": "es2016", "types": ["jest", "node"], - "moduleResolution": "node10" + "moduleResolution": "bundler" }, - "files": ["src/test-setup.ts"], + "files": ["src/test-setup.ts", "../../global.d.ts"], "include": [ "jest.config.ts", "src/**/*.test.ts", diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts index aa7ae36bd..0c4185104 100644 --- a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -8,6 +8,7 @@ import { buildTimelineBlocks, hasProgramsForDateKey, TIMELINE_MINUTE_MS, + TimelineBlock, } from './epg-timeline.utils'; function programAt( @@ -52,15 +53,73 @@ describe('TimelineScrollController', () => { }); }); + describe('focusCurrentProgram (target selection)', () => { + const axisStartMs = Date.UTC(2026, 0, 1); + const nowMs = axisStartMs + 3 * TIMELINE_MINUTE_MS; + const currentBlock: TimelineBlock = { + program: programAt(0, 2), + key: 'current', + startMs: axisStartMs + TIMELINE_MINUTE_MS, + stopMs: axisStartMs + 3 * TIMELINE_MINUTE_MS, + when: 'now', + offsetMin: 1, + durationMin: 2, + }; + + function controllerWith( + blocks: readonly TimelineBlock[] + ): TimelineScrollController { + return new TimelineScrollController({ + ribbon: () => undefined, + scale: () => 1, + axis: () => ({ + startMs: axisStartMs, + endMs: axisStartMs + 24 * 60 * TIMELINE_MINUTE_MS, + }), + blocks: () => blocks, + nowMs: () => nowMs, + viewDayKey: () => '2026-01-01', + commitDay: () => undefined, + hasProgramsForDay: () => true, + }); + } + + it('centres the midpoint of the current programme', () => { + const controller = controllerWith([currentBlock]); + const scrollSpy = jest + .spyOn(controller, 'scrollToOffset') + .mockImplementation(() => undefined); + + controller.focusCurrentProgram(false); + + expect(scrollSpy).toHaveBeenCalledWith(2, 0.5, false); + }); + + it('centres now when no programme is currently airing', () => { + const controller = controllerWith([ + { ...currentBlock, when: 'future' }, + ]); + const scrollSpy = jest + .spyOn(controller, 'scrollToOffset') + .mockImplementation(() => undefined); + + controller.focusCurrentProgram(false); + + expect(scrollSpy).toHaveBeenCalledWith(3, 0.5, false); + }); + }); + describe('maybeAutoFocus (channel-select centring)', () => { let programs: EpgProgram[]; let controller: TimelineScrollController; let scrollSpy: jest.SpyInstance; let commitDaySpy: jest.Mock; + let dateNowSpy: jest.SpyInstance; beforeEach(() => { programs = []; const nowMs = Date.now(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(nowMs); commitDaySpy = jest.fn(); controller = new TimelineScrollController({ ribbon: () => undefined, @@ -83,6 +142,8 @@ describe('TimelineScrollController', () => { .mockImplementation(() => undefined); }); + afterEach(() => dateNowSpy.mockRestore()); + function focus( scroller: HTMLElement | undefined, next: EpgProgram[] = programs diff --git a/package.json b/package.json index 8fa71c67d..020a8a60e 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,12 @@ "test:backend": "nx test electron-backend", "test:unit:all": "nx run-many --target=test --all --parallel=3", "test:unit:ci": "nx run-many --target=test --all --parallel=3 --output-style=static", + "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", "coverage:policy:check": "node tools/coverage/check-coverage-policy.mjs", - "coverage:ci": "pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report", + "coverage:ci": "pnpm run coverage:tools:test && pnpm run coverage:policy:check && pnpm run coverage:unit:ci && pnpm run coverage:merge && node tools/coverage/coverage-health.mjs --require-report", "coverage:e2e:summary": "node tools/coverage/e2e-semantic-summary.mjs", "coverage:e2e:v8:web": "node tools/coverage/e2e-v8-web.mjs", "typecheck:web": "tsc -p apps/web/tsconfig.app.json --noEmit", diff --git a/tools/coverage/coverage-health.mjs b/tools/coverage/coverage-health.mjs index 555b75b32..705623e72 100644 --- a/tools/coverage/coverage-health.mjs +++ b/tools/coverage/coverage-health.mjs @@ -10,6 +10,21 @@ import { import path from 'node:path'; import process from 'node:process'; +import { + evaluateCoverageRatchets, + validateMergedCoverage, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const COVERAGE_METRICS = [ + 'statements', + 'branches', + 'functions', + 'lines', +]; +const COVERAGE_SUMMARY_FIELDS = ['covered', 'total', 'pct']; + const workspaceRoot = process.cwd(); const args = new Set(process.argv.slice(2)); const requireReport = args.has('--require-report'); @@ -37,6 +52,15 @@ function projectJsonPath(project) { return path.join(workspaceRoot, project.root, 'project.json'); } +function projectCoveragePath(project) { + return path.join( + workspaceRoot, + 'coverage', + project.root, + 'coverage-final.json' + ); +} + function verifyTierAProjects() { for (const project of policy.unitCoverage.tierA) { const filePath = projectJsonPath(project); @@ -58,14 +82,63 @@ function verifyTierAProjects() { } } +function verifyProjectCoverageReports() { + if (requireReport) { + const validation = validateRequiredProjectReports({ + projects: policy.unitCoverage.tierA, + workspaceRoot, + }); + errors.push(...validation.errors); + return; + } + + for (const project of policy.unitCoverage.tierA) { + if (!existsSync(projectCoveragePath(project))) { + continue; + } + + const validation = validateProjectCoverage({ + project, + workspaceRoot, + }); + errors.push(...validation.errors); + } +} + +function reportCoverageSummaryMismatches(reportedSummary, computedSummary) { + for (const metric of COVERAGE_METRICS) { + const reported = reportedSummary[metric]; + const computed = computedSummary[metric]; + if ( + COVERAGE_SUMMARY_FIELDS.every( + (field) => reported?.[field] === computed?.[field] + ) + ) { + continue; + } + + errors.push( + `Merged coverage summary mismatch for ${metric}: coverage-summary.json reports covered ${reported?.covered}, total ${reported?.total}, pct ${reported?.pct}; coverage-final.json computes covered ${computed?.covered}, total ${computed?.total}, pct ${computed?.pct}.` + ); + } +} + function verifyCoverageReport() { const summaryPath = path.join( workspaceRoot, policy.reporting.mergedCoverageDir, 'coverage-summary.json' ); + const ratchet = policy.reporting.coverageRatchet; + const coveragePath = path.join( + workspaceRoot, + policy.reporting.mergedCoverageDir, + 'coverage-final.json' + ); + const summaryExists = existsSync(summaryPath); + const coverageExists = existsSync(coveragePath); - if (!existsSync(summaryPath)) { + if (!summaryExists && !coverageExists) { const message = `Merged coverage summary not found at ${path.relative(workspaceRoot, summaryPath)}.`; if (requireReport) { errors.push(message); @@ -75,25 +148,86 @@ function verifyCoverageReport() { return; } - const summary = readJson(summaryPath).total; - console.log( - `Merged coverage: statements ${summary.statements.pct}%, branches ${summary.branches.pct}%, functions ${summary.functions.pct}%, lines ${summary.lines.pct}%.` - ); + if (summaryExists !== coverageExists) { + const existingPath = summaryExists ? summaryPath : coveragePath; + const missingPath = summaryExists ? coveragePath : summaryPath; + errors.push( + `Merged coverage artifacts are incomplete: found ${path.relative(workspaceRoot, existingPath)} but ${path.relative(workspaceRoot, missingPath)} is missing.` + ); + return; + } - if (requireReport) { - for (const project of policy.unitCoverage.tierA) { - const projectCoveragePath = path.join( + let summaryDocument; + try { + summaryDocument = readJson(summaryPath); + } catch (error) { + errors.push( + `Merged coverage summary ${path.relative(workspaceRoot, summaryPath)} contains invalid JSON: ${error.message}` + ); + return; + } + + const summary = summaryDocument?.total; + if ( + !summary || + COVERAGE_METRICS.some( + (metric) => + !summary[metric] || + !Number.isFinite(summary[metric].pct) + ) + ) { + errors.push( + `Merged coverage summary ${path.relative(workspaceRoot, summaryPath)} does not contain usable total coverage metrics.` + ); + return; + } + + let coverageData; + try { + coverageData = readJson(coveragePath); + } catch (error) { + errors.push( + `Merged coverage report ${path.relative(workspaceRoot, coveragePath)} contains invalid JSON: ${error.message}` + ); + return; + } + + const mergedValidation = validateMergedCoverage({ + coverageData, + projects: policy.unitCoverage.tierA, + reportPath: coveragePath, + workspaceRoot, + }); + errors.push(...mergedValidation.errors); + if (!mergedValidation.coverageMap) { + return; + } + const computedSummary = mergedValidation.coverageMap + .getCoverageSummary() + .toJSON(); + + console.log( + `Merged coverage: statements ${computedSummary.statements.pct}%, branches ${computedSummary.branches.pct}%, functions ${computedSummary.functions.pct}%, lines ${computedSummary.lines.pct}%.` + ); + reportCoverageSummaryMismatches(summary, computedSummary); + + if (mergedValidation.errors.length > 0 || ratchet === undefined) { + return; + } + + try { + errors.push( + ...evaluateCoverageRatchets({ + coverageData, + mergedSummary: computedSummary, + ratchet, workspaceRoot, - 'coverage', - project.root, - 'coverage-final.json' - ); - if (!existsSync(projectCoveragePath)) { - errors.push( - `Tier A project ${project.name} did not produce ${path.relative(workspaceRoot, projectCoveragePath)}.` - ); - } - } + }) + ); + } catch (error) { + errors.push( + `Merged coverage report ${path.relative(workspaceRoot, coveragePath)} is not valid Istanbul coverage: ${error.message}` + ); } } @@ -216,6 +350,7 @@ function reportChangedCriticalFiles() { } verifyTierAProjects(); +verifyProjectCoverageReports(); verifyCoverageReport(); scanE2ETags(); reportChangedCriticalFiles(); diff --git a/tools/coverage/coverage-integrity.mjs b/tools/coverage/coverage-integrity.mjs new file mode 100644 index 000000000..b73e035b4 --- /dev/null +++ b/tools/coverage/coverage-integrity.mjs @@ -0,0 +1,579 @@ +import { createRequire } from 'node:module'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); +const { createCoverageMap } = require('istanbul-lib-coverage'); + +export const COVERAGE_COLLECTION_FAILURE = 'Failed to collect coverage'; +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; +const COVERAGE_METRICS = [ + 'statements', + 'branches', + 'functions', + 'lines', +]; + +function toPosix(filePath) { + return filePath.split(path.sep).join('/'); +} + +function hasDeclareModifier(statement) { + const modifiers = ts.canHaveModifiers(statement) + ? (ts.getModifiers(statement) ?? []) + : []; + return modifiers.some( + (modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword + ); +} + +export function hasRuntimeOwnedStatement(sourceText, fileName = 'source.ts') { + const source = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + return source.statements.some((statement) => { + if (hasDeclareModifier(statement)) { + return false; + } + + return !( + ts.isImportDeclaration(statement) || + ts.isImportEqualsDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isExportDeclaration(statement) || + ts.isEmptyStatement(statement) + ); + }); +} + +export function createCoverageOutputScanner(maxCharacters = 4096) { + let tail = ''; + let collectionFailed = false; + let ansiState = 'text'; + + function stripAnsi(chunk) { + const input = String(chunk).replace(ANSI_ESCAPE, ''); + let output = ''; + + for (let index = 0; index < input.length; index += 1) { + const character = input[index]; + const code = character.charCodeAt(0); + + if (ansiState === 'text') { + if (code === 0x1b) { + ansiState = 'escape'; + } else { + output += character; + } + continue; + } + + if (ansiState === 'escape') { + if (character === '[') { + ansiState = 'parameters'; + } else { + output += '\u001b'; + ansiState = 'text'; + index -= 1; + } + continue; + } + + if (ansiState === 'parameters') { + if (code >= 0x30 && code <= 0x3f) { + continue; + } + if (code >= 0x20 && code <= 0x2f) { + ansiState = 'intermediates'; + continue; + } + if (code >= 0x40 && code <= 0x7e) { + ansiState = 'text'; + continue; + } + + ansiState = 'text'; + index -= 1; + continue; + } + + if (code >= 0x20 && code <= 0x2f) { + continue; + } + if (code >= 0x40 && code <= 0x7e) { + ansiState = 'text'; + continue; + } + + ansiState = 'text'; + index -= 1; + } + + return output; + } + + return { + get collectionFailed() { + return collectionFailed; + }, + get tail() { + return tail; + }, + push(chunk) { + const output = `${tail}${stripAnsi(chunk)}`; + if (output.includes(COVERAGE_COLLECTION_FAILURE)) { + collectionFailed = true; + } + tail = output.slice(-maxCharacters); + }, + }; +} + +function isExcludedSource(filePath) { + const file = toPosix(filePath); + return ( + /\.(spec|test)\.ts$/.test(file) || + file.endsWith('.d.ts') || + file.endsWith('/test-setup.ts') || + file.includes('/test-stubs/') || + /\.generated\./.test(file) || + file.includes('/environments/') || + file.endsWith('/index.ts') + ); +} + +function runtimeOwningSourceFiles(workspaceRoot, sourceRoot) { + const absoluteSourceRoot = path.resolve(workspaceRoot, sourceRoot); + const runtimeFiles = []; + const directories = [absoluteSourceRoot]; + + while (directories.length > 0) { + const directory = directories.pop(); + for (const name of readdirSync(directory)) { + const filePath = path.join(directory, name); + const stats = statSync(filePath); + + if (stats.isDirectory()) { + directories.push(filePath); + continue; + } + if ( + !stats.isFile() || + !filePath.endsWith('.ts') || + isExcludedSource(filePath) + ) { + continue; + } + if ( + hasRuntimeOwnedStatement( + readFileSync(filePath, 'utf8'), + filePath + ) + ) { + runtimeFiles.push(path.resolve(filePath)); + } + } + } + + return runtimeFiles.sort(); +} + +function isObjectRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot) { + const filesByAbsolutePath = new Map(); + const duplicateAbsolutePaths = new Set(); + + for (const reportedPath of coverageMap.files().sort()) { + const absolutePath = path.resolve(workspaceRoot, reportedPath); + if (filesByAbsolutePath.has(absolutePath)) { + duplicateAbsolutePaths.add(absolutePath); + continue; + } + filesByAbsolutePath.set(absolutePath, reportedPath); + } + + return { + duplicateAbsolutePaths: [...duplicateAbsolutePaths].sort(), + filesByAbsolutePath, + }; +} + +export function validateProjectCoverage({ workspaceRoot, project }) { + const reportPath = path.resolve( + workspaceRoot, + 'coverage', + project.root, + 'coverage-final.json' + ); + const relativeReportPath = toPosix( + path.relative(workspaceRoot, reportPath) + ); + + if (!existsSync(reportPath)) { + return { + errors: [ + `${project.name} did not produce ${relativeReportPath}.`, + ], + report: undefined, + }; + } + + let data; + try { + data = JSON.parse(readFileSync(reportPath, 'utf8')); + } catch (error) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} contains invalid JSON: ${error.message}`, + ], + report: undefined, + }; + } + + if ( + !isObjectRecord(data) || + Object.values(data).some((entry) => !isObjectRecord(entry)) + ) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} must contain a JSON object mapping source paths to JSON objects.`, + ], + report: undefined, + }; + } + + let coverageMap; + try { + coverageMap = createCoverageMap(data); + for (const filePath of coverageMap.files()) { + coverageMap.fileCoverageFor(filePath).toSummary(); + } + } catch (error) { + return { + errors: [ + `${project.name} coverage report ${relativeReportPath} is not valid Istanbul coverage: ${error.message}`, + ], + report: undefined, + }; + } + + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return { + errors: duplicateAbsolutePaths.map( + (filePath) => + `${project.name} coverage report ${relativeReportPath} contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + report: undefined, + }; + } + + const errors = []; + for (const filePath of runtimeOwningSourceFiles( + workspaceRoot, + project.sourceRoot + )) { + const reportedPath = reportedFiles.get(filePath); + const relativeSourcePath = toPosix( + path.relative(workspaceRoot, filePath) + ); + if (reportedPath === undefined) { + errors.push( + `${project.name} coverage report is missing runtime-owning source ${relativeSourcePath}.` + ); + continue; + } + + const summary = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON(); + const hasUsableInstrumentation = [ + summary.statements, + summary.functions, + summary.branches, + ].some((metric) => metric.total > 0); + if (!hasUsableInstrumentation) { + errors.push( + `${project.name} coverage report ${relativeReportPath} has no usable instrumentation for runtime-owning source ${relativeSourcePath}.` + ); + } + } + + return { + errors, + report: { + data, + path: reportPath, + project, + }, + }; +} + +export function validateRequiredProjectReports({ projects, workspaceRoot }) { + const errors = []; + const reports = []; + + for (const project of projects) { + const result = validateProjectCoverage({ project, workspaceRoot }); + errors.push(...result.errors); + if (result.report) { + reports.push(result.report); + } + } + + return { errors, reports }; +} + +export function validateMergedCoverage({ + coverageData, + projects, + reportPath, + workspaceRoot, +}) { + const relativeReportPath = toPosix( + path.relative(workspaceRoot, reportPath) + ); + const reportDescription = `Merged coverage report ${relativeReportPath}`; + + if ( + !isObjectRecord(coverageData) || + Object.values(coverageData).some( + (entry) => !isObjectRecord(entry) + ) + ) { + return { + coverageMap: undefined, + errors: [ + `${reportDescription} must contain a JSON object mapping source paths to JSON objects.`, + ], + }; + } + + let coverageMap; + try { + coverageMap = createCoverageMap(coverageData); + for (const filePath of coverageMap.files()) { + coverageMap.fileCoverageFor(filePath).toSummary(); + } + } catch (error) { + return { + coverageMap: undefined, + errors: [ + `${reportDescription} is not valid Istanbul coverage: ${error.message}`, + ], + }; + } + + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return { + coverageMap: undefined, + errors: duplicateAbsolutePaths.map( + (filePath) => + `${reportDescription} contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + }; + } + + const errors = []; + for (const project of projects) { + for (const filePath of runtimeOwningSourceFiles( + workspaceRoot, + project.sourceRoot + )) { + const relativeSourcePath = toPosix( + path.relative(workspaceRoot, filePath) + ); + const reportedPath = reportedFiles.get(filePath); + if (reportedPath === undefined) { + errors.push( + `${reportDescription} is missing runtime-owning source ${relativeSourcePath} from Tier A project ${project.name}.` + ); + continue; + } + + const summary = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON(); + const hasUsableInstrumentation = [ + summary.statements, + summary.functions, + summary.branches, + ].some((metric) => metric.total > 0); + if (!hasUsableInstrumentation) { + errors.push( + `${reportDescription} has no usable instrumentation for runtime-owning source ${relativeSourcePath} from Tier A project ${project.name}.` + ); + } + } + } + + return { coverageMap, errors }; +} + +function formatConfigurationValue(value) { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +function validateCoverageRatchetConfiguration(ratchet) { + const errors = []; + + for (const metric of COVERAGE_METRICS) { + const value = ratchet?.merged?.[metric]; + if ( + typeof value !== 'number' || + !Number.isFinite(value) || + value < 0 || + value > 100 + ) { + errors.push( + `Invalid coverage ratchet merged.${metric}: expected a finite number between 0 and 100, received ${formatConfigurationValue(value)}.` + ); + } + } + + const criticalFiles = ratchet?.criticalFiles; + if (!Array.isArray(criticalFiles)) { + errors.push( + `Invalid coverage ratchet criticalFiles: expected an array, received ${formatConfigurationValue(criticalFiles)}.` + ); + return errors; + } + + for (const [index, criticalFile] of criticalFiles.entries()) { + const criticalPath = criticalFile?.path; + const hasValidPath = + typeof criticalPath === 'string' && + criticalPath.trim().length > 0; + if (!hasValidPath) { + errors.push( + `Invalid coverage ratchet criticalFiles[${index}].path: expected a non-empty string, received ${formatConfigurationValue(criticalPath)}.` + ); + } + const filePath = hasValidPath + ? toPosix(criticalPath) + : `criticalFiles[${index}]`; + const minimumCovered = + criticalFile?.statements?.minimumCovered; + if ( + typeof minimumCovered !== 'number' || + !Number.isFinite(minimumCovered) || + !Number.isInteger(minimumCovered) || + minimumCovered < 0 + ) { + errors.push( + `Invalid coverage ratchet ${filePath} statements.minimumCovered: expected a non-negative integer, received ${formatConfigurationValue(minimumCovered)}.` + ); + } + + const minimumPercent = + criticalFile?.statements?.minimumPercent; + if ( + typeof minimumPercent !== 'number' || + !Number.isFinite(minimumPercent) || + minimumPercent < 0 || + minimumPercent > 100 + ) { + errors.push( + `Invalid coverage ratchet ${filePath} statements.minimumPercent: expected a finite number between 0 and 100, received ${formatConfigurationValue(minimumPercent)}.` + ); + } + } + + return errors; +} + +export function evaluateCoverageRatchets({ + coverageData, + mergedSummary, + ratchet, + workspaceRoot, +}) { + const configurationErrors = + validateCoverageRatchetConfiguration(ratchet); + if (configurationErrors.length > 0) { + return configurationErrors; + } + + const errors = []; + + for (const metric of COVERAGE_METRICS) { + const required = ratchet.merged[metric]; + const observed = mergedSummary[metric]?.pct; + if (!Number.isFinite(observed) || observed < required) { + errors.push( + `Merged ${metric} coverage regressed: observed ${observed}%, required at least ${required}%.` + ); + } + } + + const coverageMap = createCoverageMap(coverageData); + const { + duplicateAbsolutePaths, + filesByAbsolutePath: reportedFiles, + } = indexCoverageFilesByAbsolutePath(coverageMap, workspaceRoot); + if (duplicateAbsolutePaths.length > 0) { + return [ + ...errors, + ...duplicateAbsolutePaths.map( + (filePath) => + `Merged coverage contains a duplicate entry for ${toPosix( + path.relative(workspaceRoot, filePath) + )} after path normalization.` + ), + ]; + } + + for (const criticalFile of ratchet.criticalFiles) { + const filePath = path.resolve(workspaceRoot, criticalFile.path); + const relativePath = toPosix(path.relative(workspaceRoot, filePath)); + const reportedPath = reportedFiles.get(filePath); + if (reportedPath === undefined) { + errors.push( + `Critical coverage ${relativePath} is missing from merged coverage.` + ); + continue; + } + + const statements = coverageMap + .fileCoverageFor(reportedPath) + .toSummary() + .toJSON().statements; + const minimumCovered = criticalFile.statements.minimumCovered; + if (statements.covered < minimumCovered) { + errors.push( + `Critical coverage ${relativePath} statements covered regressed: observed ${statements.covered}, required at least ${minimumCovered}.` + ); + } + + const minimumPercent = criticalFile.statements.minimumPercent; + if (statements.pct < minimumPercent) { + errors.push( + `Critical coverage ${relativePath} statements percent regressed: observed ${statements.pct}%, required at least ${minimumPercent}%.` + ); + } + } + + return errors; +} diff --git a/tools/coverage/coverage-integrity.test.mjs b/tools/coverage/coverage-integrity.test.mjs new file mode 100644 index 000000000..5dcc87304 --- /dev/null +++ b/tools/coverage/coverage-integrity.test.mjs @@ -0,0 +1,904 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + createCoverageOutputScanner, + evaluateCoverageRatchets, + hasRuntimeOwnedStatement, + validateMergedCoverage, + validateProjectCoverage, + validateRequiredProjectReports, +} from './coverage-integrity.mjs'; + +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function makeProjectFixture({ + name = 'example', + projectRoot = `libs/${name}`, + workspaceRoot = undefined, +} = {}) { + const ownsWorkspace = workspaceRoot === undefined; + const fixtureRoot = + workspaceRoot ?? + mkdtempSync(path.join(tmpdir(), 'iptvnator-coverage-integrity-')); + if (ownsWorkspace) { + temporaryRoots.push(fixtureRoot); + } + + const sourceRoot = `${projectRoot}/src`; + const sourcePath = path.join(fixtureRoot, sourceRoot, 'runtime.ts'); + const reportPath = path.join( + fixtureRoot, + 'coverage', + projectRoot, + 'coverage-final.json' + ); + + mkdirSync(path.dirname(sourcePath), { recursive: true }); + mkdirSync(path.dirname(reportPath), { recursive: true }); + writeFileSync(sourcePath, 'export const runtime = true;\n'); + + const project = { + name, + root: projectRoot, + sourceRoot, + }; + + return { + project, + reportPath, + sourcePath, + workspaceRoot: fixtureRoot, + }; +} + +function coverageEntry(filePath, hits = [0]) { + const statementMap = Object.fromEntries( + hits.map((_, index) => [ + index, + { + start: { line: index + 1, column: 0 }, + end: { line: index + 1, column: 28 }, + }, + ]) + ); + const statementHits = Object.fromEntries( + hits.map((hit, index) => [index, hit]) + ); + + return { + path: filePath, + statementMap, + fnMap: {}, + branchMap: {}, + s: statementHits, + f: {}, + b: {}, + }; +} + +function functionOnlyCoverageEntry(filePath) { + return { + path: filePath, + statementMap: {}, + fnMap: { + 0: { + name: 'runtime', + decl: { + start: { line: 1, column: 7 }, + end: { line: 1, column: 15 }, + }, + loc: { + start: { line: 1, column: 7 }, + end: { line: 1, column: 28 }, + }, + line: 1, + }, + }, + branchMap: {}, + s: {}, + f: { 0: 1 }, + b: {}, + }; +} + +function writeCompleteReport(fixture, reportKey = fixture.sourcePath) { + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [reportKey]: coverageEntry(fixture.sourcePath), + }) + ); +} + +function fullyCoveredSummary() { + return { + statements: { covered: 1, total: 1, pct: 100 }, + branches: { covered: 0, total: 0, pct: 100 }, + functions: { covered: 0, total: 0, pct: 100 }, + lines: { covered: 1, total: 1, pct: 100 }, + }; +} + +function ratchetWithCriticalFiles(criticalFiles) { + return { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles, + }; +} + +describe('hasRuntimeOwnedStatement', () => { + it('excludes type-only, import-only, and pure re-export source', () => { + assert.equal( + hasRuntimeOwnedStatement( + 'import type { Settings } from "./settings";' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement( + 'import settings = require("./settings");' + ), + false + ); + assert.equal( + hasRuntimeOwnedStatement('interface Settings { enabled: boolean }'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export type Mode = "live" | "vod";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('export { value } from "./value";'), + false + ); + assert.equal( + hasRuntimeOwnedStatement('declare const injected: string;'), + false + ); + }); + + it('includes emitted declarations and executable statements', () => { + assert.equal( + hasRuntimeOwnedStatement('export const enabled = true;'), + true + ); + assert.equal( + hasRuntimeOwnedStatement( + 'export class RuntimeBoundary { start() {} }' + ), + true + ); + assert.equal(hasRuntimeOwnedStatement('console.log("runtime");'), true); + }); +}); + +describe('createCoverageOutputScanner', () => { + it('detects an ANSI-colored marker split across chunks', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('\u001b[31mFailed to collect'); + scanner.push(' coverage from /workspace/effects.ts\u001b[39m'); + + assert.equal(scanner.collectionFailed, true); + }); + + it('detects a marker across a split ANSI escape sequence', () => { + const scanner = createCoverageOutputScanner(128); + + scanner.push('Failed to \u001b[3'); + scanner.push('1mcollect coverage from /workspace/effects.ts'); + + assert.equal(scanner.collectionFailed, true); + assert.equal(scanner.tail.includes('\u001b'), false); + }); + + it('keeps only a bounded rolling tail', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push('x'.repeat(256)); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, false); + }); + + it('detects the marker before trimming a long chunk', () => { + const scanner = createCoverageOutputScanner(32); + + scanner.push(`Failed to collect coverage${'x'.repeat(256)}`); + + assert.equal(scanner.tail.length, 32); + assert.equal(scanner.collectionFailed, true); + }); +}); + +describe('validateProjectCoverage', () => { + it('accepts a report containing every runtime-owning file', () => { + const fixture = makeProjectFixture(); + const relativeReportKey = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + writeCompleteReport(fixture, relativeReportKey); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + assert.equal(result.report?.path, fixture.reportPath); + }); + + it('reports a runtime-owning source omitted from a valid report', () => { + const fixture = makeProjectFixture(); + writeFileSync(fixture.reportPath, '{}'); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /example/); + assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/); + }); + + it('rejects a malformed Istanbul coverage entry', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ [fixture.sourcePath]: {} }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.report, undefined); + assert.match( + result.errors[0], + /example.*coverage\/libs\/example\/coverage-final\.json.*valid Istanbul/ + ); + }); + + it('rejects duplicate report paths after normalization', () => { + const fixture = makeProjectFixture(); + const relativePath = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [relativePath]: coverageEntry(relativePath), + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.report, undefined); + assert.equal(result.errors.length, 1); + assert.match( + result.errors[0], + /example.*coverage\/libs\/example\/coverage-final\.json.*duplicate.*libs\/example\/src\/runtime\.ts.*normalization/ + ); + }); + + it('rejects a runtime source without usable instrumentation', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, []), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /example/); + assert.match(result.errors[0], /libs\/example\/src\/runtime\.ts/); + assert.match(result.errors[0], /usable instrumentation/); + }); + + it('accepts function-only instrumentation for a runtime source', () => { + const fixture = makeProjectFixture(); + writeFileSync( + fixture.sourcePath, + 'export function runtime() {}\n' + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: functionOnlyCoverageEntry( + fixture.sourcePath + ), + }) + ); + + const result = validateProjectCoverage(fixture); + + assert.deepEqual(result.errors, []); + assert.equal(result.report?.project.name, 'example'); + }); + + it('does not require excluded or type-only TypeScript sources', () => { + const fixture = makeProjectFixture(); + const excludedSources = [ + 'feature.spec.ts', + 'feature.test.ts', + 'ambient.d.ts', + 'test-setup.ts', + 'test-stubs/runtime.ts', + 'feature.generated.ts', + 'environments/environment.ts', + 'index.ts', + ]; + for (const relativePath of excludedSources) { + const filePath = path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + relativePath + ); + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, 'export const omitted = true;\n'); + } + writeFileSync( + path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + 'contract.ts' + ), + 'export interface Contract { enabled: boolean }\n' + ); + writeCompleteReport(fixture); + + assert.deepEqual(validateProjectCoverage(fixture).errors, []); + }); + + it('reports missing, malformed, and non-object project reports', () => { + const missing = makeProjectFixture(); + const malformed = makeProjectFixture(); + const array = makeProjectFixture(); + const nullValue = makeProjectFixture(); + writeFileSync(malformed.reportPath, '{ invalid json'); + writeFileSync(array.reportPath, '[]'); + writeFileSync(nullValue.reportPath, 'null'); + + assert.match( + validateProjectCoverage(missing).errors[0], + /example.*did not produce.*coverage\/libs\/example\/coverage-final\.json/ + ); + assert.match( + validateProjectCoverage(malformed).errors[0], + /example.*invalid JSON/ + ); + assert.match( + validateProjectCoverage(array).errors[0], + /example.*JSON object/ + ); + assert.match( + validateProjectCoverage(nullValue).errors[0], + /example.*JSON object/ + ); + }); + + it('requires every configured report and preserves policy order', () => { + const first = makeProjectFixture({ + name: 'first', + projectRoot: 'libs/first', + }); + const missing = makeProjectFixture({ + name: 'missing', + projectRoot: 'libs/missing', + workspaceRoot: first.workspaceRoot, + }); + const last = makeProjectFixture({ + name: 'last', + projectRoot: 'libs/last', + workspaceRoot: first.workspaceRoot, + }); + writeCompleteReport(first); + writeCompleteReport(last); + + const result = validateRequiredProjectReports({ + projects: [last.project, missing.project, first.project], + workspaceRoot: first.workspaceRoot, + }); + + assert.deepEqual( + result.reports.map((report) => report.project.name), + ['last', 'first'] + ); + assert.equal(result.errors.length, 1); + assert.match(result.errors[0], /missing/); + }); +}); + +describe('validateMergedCoverage', () => { + it('reports the exact runtime source omitted from an otherwise valid merged map', () => { + const included = makeProjectFixture({ + name: 'included', + projectRoot: 'libs/included', + }); + const omitted = makeProjectFixture({ + name: 'omitted', + projectRoot: 'libs/omitted', + workspaceRoot: included.workspaceRoot, + }); + writeCompleteReport(included); + writeCompleteReport(omitted); + + assert.deepEqual(validateProjectCoverage(included).errors, []); + assert.deepEqual(validateProjectCoverage(omitted).errors, []); + + const result = validateMergedCoverage({ + coverageData: { + [included.sourcePath]: coverageEntry(included.sourcePath), + }, + projects: [included.project, omitted.project], + reportPath: path.join( + included.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: included.workspaceRoot, + }); + + assert.deepEqual(result.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json is missing runtime-owning source libs/omitted/src/runtime.ts from Tier A project omitted.', + ]); + }); + + it('fails completeness when permissive ratchets accept a recomputed summary', () => { + const fixture = makeProjectFixture({ + name: 'm3u-state', + projectRoot: 'libs/m3u-state', + }); + const effectsPath = path.join( + fixture.workspaceRoot, + fixture.project.sourceRoot, + 'effects.ts' + ); + writeFileSync( + effectsPath, + 'export const effects = () => "runtime";\n' + ); + writeFileSync( + fixture.reportPath, + JSON.stringify({ + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, [1]), + [effectsPath]: coverageEntry(effectsPath, [0]), + }) + ); + assert.deepEqual(validateProjectCoverage(fixture).errors, []); + + const coverageData = { + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, [1]), + }; + const completeness = validateMergedCoverage({ + coverageData, + projects: [fixture.project], + reportPath: path.join( + fixture.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: fixture.workspaceRoot, + }); + const recomputedSummary = completeness.coverageMap + .getCoverageSummary() + .toJSON(); + + assert.deepEqual( + evaluateCoverageRatchets({ + coverageData, + mergedSummary: recomputedSummary, + ratchet: ratchetWithCriticalFiles([]), + workspaceRoot: fixture.workspaceRoot, + }), + [] + ); + assert.deepEqual(completeness.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json is missing runtime-owning source libs/m3u-state/src/effects.ts from Tier A project m3u-state.', + ]); + }); + + it('accepts a complete merged map with normalized relative and absolute paths', () => { + const relative = makeProjectFixture({ + name: 'relative', + projectRoot: 'libs/relative', + }); + const absolute = makeProjectFixture({ + name: 'absolute', + projectRoot: 'libs/absolute', + workspaceRoot: relative.workspaceRoot, + }); + const relativePath = path.relative( + relative.workspaceRoot, + relative.sourcePath + ); + + const result = validateMergedCoverage({ + coverageData: { + [relativePath]: coverageEntry(relativePath), + [absolute.sourcePath]: coverageEntry(absolute.sourcePath), + }, + projects: [relative.project, absolute.project], + reportPath: path.join( + relative.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: relative.workspaceRoot, + }); + + assert.deepEqual(result.errors, []); + }); + + it('rejects unusable merged instrumentation for a runtime source', () => { + const fixture = makeProjectFixture(); + + const result = validateMergedCoverage({ + coverageData: { + [fixture.sourcePath]: coverageEntry(fixture.sourcePath, []), + }, + projects: [fixture.project], + reportPath: path.join( + fixture.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: fixture.workspaceRoot, + }); + + assert.deepEqual(result.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json has no usable instrumentation for runtime-owning source libs/example/src/runtime.ts from Tier A project example.', + ]); + }); + + it('rejects duplicate merged paths after normalization', () => { + const fixture = makeProjectFixture(); + const relativePath = path.relative( + fixture.workspaceRoot, + fixture.sourcePath + ); + + const result = validateMergedCoverage({ + coverageData: { + [relativePath]: coverageEntry(relativePath), + [fixture.sourcePath]: coverageEntry(fixture.sourcePath), + }, + projects: [fixture.project], + reportPath: path.join( + fixture.workspaceRoot, + 'coverage/merged/coverage-final.json' + ), + workspaceRoot: fixture.workspaceRoot, + }); + + assert.deepEqual(result.errors, [ + 'Merged coverage report coverage/merged/coverage-final.json contains a duplicate entry for libs/example/src/runtime.ts after path normalization.', + ]); + }); +}); + +describe('evaluateCoverageRatchets', () => { + it('reports all aggregate percentage regressions', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: { + statements: { covered: 68, total: 100, pct: 68 }, + branches: { covered: 57, total: 100, pct: 57 }, + functions: { covered: 67, total: 100, pct: 67 }, + lines: { covered: 69, total: 100, pct: 69 }, + }, + ratchet: { + merged: { + statements: 68.86, + branches: 58.66, + functions: 67.19, + lines: 69.2, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, [ + 'Merged statements coverage regressed: observed 68%, required at least 68.86%.', + 'Merged branches coverage regressed: observed 57%, required at least 58.66%.', + 'Merged functions coverage regressed: observed 67%, required at least 67.19%.', + 'Merged lines coverage regressed: observed 69%, required at least 69.2%.', + ]); + }); + + it('rejects missing, non-numeric, and out-of-range aggregate minima', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + branches: '58.66', + functions: -1, + lines: 101, + }, + criticalFiles: [], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 4); + assert.match(errors[0], /merged\.statements.*undefined/); + assert.match(errors[1], /merged\.branches.*"58\.66"/); + assert.match(errors[2], /merged\.functions.*-1/); + assert.match(errors[3], /merged\.lines.*101/); + }); + + it('accepts an explicitly empty criticalFiles array', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles([]), + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, []); + }); + + it('requires an explicit criticalFiles array', () => { + const merged = ratchetWithCriticalFiles([]).merged; + const ratchets = [ + { merged }, + { merged, criticalFiles: null }, + ]; + + for (const ratchet of ratchets) { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles.*array/); + } + }); + + it('rejects a non-array criticalFiles container without throwing', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles({}), + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles.*array.*object/); + }); + + it('requires a non-empty string path for every critical file', () => { + for (const invalidPath of [undefined, null, 42, '', ' ']) { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: ratchetWithCriticalFiles([ + { + path: invalidPath, + statements: { + minimumCovered: 0, + minimumPercent: 0, + }, + }, + ]), + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 1); + assert.match(errors[0], /criticalFiles\[0\]\.path/); + } + }); + + it('rejects invalid critical-file statement minima', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing-values.ts', + statements: {}, + }, + { + path: 'apps/string-values.ts', + statements: { + minimumCovered: '2', + minimumPercent: '75', + }, + }, + { + path: 'apps/out-of-range.ts', + statements: { + minimumCovered: -1, + minimumPercent: 101, + }, + }, + { + path: 'apps/fractional.ts', + statements: { + minimumCovered: 1.5, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.equal(errors.length, 7); + assert.match( + errors[0], + /apps\/missing-values\.ts.*minimumCovered.*undefined/ + ); + assert.match( + errors[1], + /apps\/missing-values\.ts.*minimumPercent.*undefined/ + ); + assert.match( + errors[2], + /apps\/string-values\.ts.*minimumCovered.*"2"/ + ); + assert.match( + errors[3], + /apps\/string-values\.ts.*minimumPercent.*"75"/ + ); + assert.match( + errors[4], + /apps\/out-of-range\.ts.*minimumCovered.*-1/ + ); + assert.match( + errors[5], + /apps\/out-of-range\.ts.*minimumPercent.*101/ + ); + assert.match( + errors[6], + /apps\/fractional\.ts.*minimumCovered.*1\.5/ + ); + }); + + it('requires both covered statements and percentage for a critical file', () => { + const workspaceRoot = '/workspace'; + const filePath = path.join(workspaceRoot, 'apps/runtime.ts'); + const errors = evaluateCoverageRatchets({ + coverageData: { + [filePath]: coverageEntry(filePath, [1, 0]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/runtime.ts', + statements: { + minimumCovered: 2, + minimumPercent: 75, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.deepEqual(errors, [ + 'Critical coverage apps/runtime.ts statements covered regressed: observed 1, required at least 2.', + 'Critical coverage apps/runtime.ts statements percent regressed: observed 50%, required at least 75%.', + ]); + }); + + it('looks up a relative Istanbul key by its normalized critical path', () => { + const workspaceRoot = process.cwd(); + const relativePath = 'apps/runtime.ts'; + const errors = evaluateCoverageRatchets({ + coverageData: { + [relativePath]: coverageEntry(relativePath, [1, 1]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: relativePath, + statements: { + minimumCovered: 2, + minimumPercent: 100, + }, + }, + ], + }, + workspaceRoot, + }); + + assert.deepEqual(errors, []); + }); + + it('rejects duplicate Istanbul keys after path normalization', () => { + const workspaceRoot = process.cwd(); + const relativePath = 'apps/runtime.ts'; + const absolutePath = path.join(workspaceRoot, relativePath); + const errors = evaluateCoverageRatchets({ + coverageData: { + [relativePath]: coverageEntry(relativePath, [1]), + [absolutePath]: coverageEntry(absolutePath, [1]), + }, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [], + }, + workspaceRoot, + }); + + assert.equal(errors.length, 1); + assert.match( + errors[0], + /duplicate.*apps\/runtime\.ts.*normalization/ + ); + }); + + it('reports a critical file missing from merged coverage', () => { + const errors = evaluateCoverageRatchets({ + coverageData: {}, + mergedSummary: fullyCoveredSummary(), + ratchet: { + merged: { + statements: 0, + branches: 0, + functions: 0, + lines: 0, + }, + criticalFiles: [ + { + path: 'apps/missing.ts', + statements: { + minimumCovered: 1, + minimumPercent: 0, + }, + }, + ], + }, + workspaceRoot: '/workspace', + }); + + assert.deepEqual(errors, [ + 'Critical coverage apps/missing.ts is missing from merged coverage.', + ]); + }); +}); diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index 2c1b8011e..03128351e 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -3,7 +3,45 @@ "reporting": { "mergedCoverageDir": "coverage/merged", "e2eSummaryDir": "coverage/e2e", - "unitCodecovFlag": "unit" + "unitCodecovFlag": "unit", + "coverageRatchet": { + "merged": { + "statements": 69.54, + "branches": 59.07, + "functions": 67.44, + "lines": 69.85 + }, + "criticalFiles": [ + { + "path": "apps/electron-backend/src/app/server/http-server.ts", + "statements": { + "minimumCovered": 83, + "minimumPercent": 90.21 + } + }, + { + "path": "apps/electron-backend/src/app/events/remote-control.events.ts", + "statements": { + "minimumCovered": 112, + "minimumPercent": 96.55 + } + }, + { + "path": "apps/electron-backend/src/app/events/settings.events.ts", + "statements": { + "minimumCovered": 26, + "minimumPercent": 96.29 + } + }, + { + "path": "apps/electron-backend/src/app/events/database/downloads.events.ts", + "statements": { + "minimumCovered": 86, + "minimumPercent": 58.5 + } + } + ] + } }, "unitCoverage": { "description": "Tier A projects collect and trend source coverage. Tier B projects are validated without percentage coverage. Tier C projects are excluded from the source coverage baseline.", diff --git a/tools/coverage/merge-coverage.mjs b/tools/coverage/merge-coverage.mjs index 36b006dfb..901907005 100644 --- a/tools/coverage/merge-coverage.mjs +++ b/tools/coverage/merge-coverage.mjs @@ -2,7 +2,6 @@ import { createRequire } from 'node:module'; import { - existsSync, mkdirSync, readFileSync, rmSync, @@ -11,6 +10,8 @@ import { import path from 'node:path'; import process from 'node:process'; +import { validateRequiredProjectReports } from './coverage-integrity.mjs'; + const require = createRequire(import.meta.url); const { createCoverageMap } = require('istanbul-lib-coverage'); const libReport = require('istanbul-lib-report'); @@ -22,11 +23,20 @@ const policy = JSON.parse( ); const outputDir = path.join(workspaceRoot, policy.reporting.mergedCoverageDir); -const coverageFiles = policy.unitCoverage.tierA - .map((project) => path.join(workspaceRoot, 'coverage', project.root, 'coverage-final.json')) - .filter((coverageFile) => existsSync(coverageFile)); +const validation = validateRequiredProjectReports({ + projects: policy.unitCoverage.tierA, + workspaceRoot, +}); -if (coverageFiles.length === 0) { +if (validation.errors.length > 0) { + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + process.exit(1); +} + +const coverageInputs = validation.reports; +if (coverageInputs.length === 0) { console.error('No Tier A coverage-final.json files found under coverage/.'); process.exit(1); } @@ -36,9 +46,8 @@ mkdirSync(outputDir, { recursive: true }); const coverageMap = createCoverageMap({}); -for (const coverageFile of coverageFiles) { - const data = JSON.parse(readFileSync(coverageFile, 'utf8')); - coverageMap.merge(data); +for (const input of coverageInputs) { + coverageMap.merge(input.data); } const context = libReport.createContext({ @@ -56,7 +65,7 @@ writeFileSync( `${JSON.stringify(summary, null, 4)}\n` ); -console.log(`Merged ${coverageFiles.length} coverage files into ${policy.reporting.mergedCoverageDir}`); +console.log(`Merged ${coverageInputs.length} coverage files into ${policy.reporting.mergedCoverageDir}`); console.log( `Statements: ${summary.statements.pct}% | Branches: ${summary.branches.pct}% | Functions: ${summary.functions.pct}% | Lines: ${summary.lines.pct}%` ); diff --git a/tools/coverage/run-tier-a-coverage.mjs b/tools/coverage/run-tier-a-coverage.mjs index ecf553363..c9dda4974 100644 --- a/tools/coverage/run-tier-a-coverage.mjs +++ b/tools/coverage/run-tier-a-coverage.mjs @@ -1,10 +1,15 @@ #!/usr/bin/env node -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { + createCoverageOutputScanner, + validateProjectCoverage, +} from './coverage-integrity.mjs'; + const workspaceRoot = process.cwd(); const policyPath = path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'); const policy = JSON.parse(readFileSync(policyPath, 'utf8')); @@ -120,6 +125,64 @@ function buildNxArgs(project) { ); } +function spawnCoverage(args, scanner) { + return new Promise((resolve, reject) => { + const child = spawn('pnpm', args, { + cwd: workspaceRoot, + env: { + ...process.env, + CI: process.env.CI ?? 'true', + NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', + }, + stdio: ['inherit', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + scanner.push(chunk); + process.stdout.write(chunk); + }); + child.stderr.on('data', (chunk) => { + scanner.push(chunk); + process.stderr.write(chunk); + }); + child.once('error', reject); + child.once('close', (code, signal) => { + resolve({ code, signal }); + }); + }); +} + +async function collectProjectCoverage(project) { + const args = buildNxArgs(project); + console.log(`\n==> Collecting coverage for ${project.name}`); + console.log(`pnpm ${args.join(' ')}`); + + const scanner = createCoverageOutputScanner(); + const result = await spawnCoverage(args, scanner); + let failed = result.code !== 0 || result.signal !== null; + + if (scanner.collectionFailed) { + console.error( + `Coverage collection failed while testing ${project.name}.` + ); + failed = true; + } + + if (failed) { + return result.code && result.code !== 0 ? result.code : 1; + } + + const validation = validateProjectCoverage({ + project, + workspaceRoot, + }); + for (const error of validation.errors) { + console.error(`Error: ${error}`); + } + + return validation.errors.length === 0 ? 0 : 1; +} + for (const project of tierAProjects) { const coverageDir = path.join(workspaceRoot, 'coverage', project.root); rmSync(coverageDir, { recursive: true, force: true }); @@ -136,21 +199,8 @@ if (requestedProjects.size === 0) { } for (const project of tierAProjects) { - const args = buildNxArgs(project); - console.log(`\n==> Collecting coverage for ${project.name}`); - console.log(`pnpm ${args.join(' ')}`); - - const result = spawnSync('pnpm', args, { - cwd: workspaceRoot, - env: { - ...process.env, - CI: process.env.CI ?? 'true', - NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', - }, - stdio: 'inherit', - }); - - if (result.status !== 0) { - process.exit(result.status ?? 1); + const status = await collectProjectCoverage(project); + if (status !== 0) { + process.exit(status); } }