test(electron): harden runtime boundary coverage (#1267)

Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.

The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.

Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.

Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
This commit is contained in:
4gray authored and GitHub committed 2026-07-26 22:27:50 +02:00
1 parent 1ab82b04a1
commit 08b868d6c1
20 files changed
+5866 -166

No files matched your search

+7
View File
@@ -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.
@@ -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<typeof import('node:fs')>('node:fs'),
existsSync: mockExistsSync,
}));
jest.doMock('drizzle-orm', () => {
const actual =
jest.requireActual<typeof import('drizzle-orm')>('drizzle-orm');
mockEq.mockImplementation(actual.eq);
return {
...actual,
eq: mockEq,
};
});
jest.doMock('electron', () => ({
app: {
getPath: jest.fn((name: string) =>
@@ -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<typeof mockManagedPath>,
filePath: string
) {
expect(mockGetDatabase).toHaveBeenCalledTimes(1);
expect(lookup.select).toHaveBeenCalledTimes(1);
expect(lookup.from).toHaveBeenCalledTimes(1);
expect(lookup.where).toHaveBeenCalledTimes(1);
expect(mockEq).toHaveBeenCalledTimes(1);
expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true);
expect(mockEq.mock.calls[0][1]).toBe(filePath);
expect(
lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value
).toBe(true);
expect(lookup.limit).toHaveBeenCalledTimes(1);
expect(lookup.limit).toHaveBeenCalledWith(1);
}
function mockDownloadRow(row: { filePath: string | null; status: string }) {
const deleteWhere = jest.fn().mockResolvedValue(undefined);
const db = {
@@ -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<string>((resolve) => {
resolveOpenPath = resolve;
});
mockOpenPath.mockReturnValue(openPathResult);
let responseSettled = false;
const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then(
(result) => {
responseSettled = true;
return result;
}
);
await new Promise<void>((resolve) => setImmediate(resolve));
expectManagedPathLookup(lookup, filePath);
expect(mockExistsSync).toHaveBeenCalledTimes(1);
expect(mockExistsSync).toHaveBeenCalledWith(filePath);
expect(mockOpenPath).toHaveBeenCalledTimes(1);
expect(mockOpenPath).toHaveBeenCalledWith(filePath);
expect(mockShowItemInFolder).not.toHaveBeenCalled();
expect(responseSettled).toBe(false);
resolveOpenPath('');
await expect(response).resolves.toEqual({ success: true });
});
});
@@ -0,0 +1,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<ResponseSnapshot>;
}
interface RequestInvocation {
response: ResponseSnapshot;
request: PassThrough;
requestClosed: Promise<void>;
requestErrors: Error[];
}
interface InvokeOptions {
method: string;
body?: string | Buffer;
}
const REMOTE_CONTROL_PATHS = {
STATUS: '/api/remote-control/status',
SELECT_NUMBER: '/api/remote-control/channel/select-number',
CHANNEL_UP: '/api/remote-control/channel/up',
CHANNEL_DOWN: '/api/remote-control/channel/down',
VOLUME_UP: '/api/remote-control/volume/up',
VOLUME_DOWN: '/api/remote-control/volume/down',
VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute',
} as const;
const JSON_HEADERS = { 'Content-Type': 'application/json' } as const;
const SUCCESS_RESPONSE = {
statusCode: 200,
headers: JSON_HEADERS,
body: JSON.stringify({ success: true }),
} as const;
const METHOD_NOT_ALLOWED_RESPONSE = {
statusCode: 405,
headers: JSON_HEADERS,
body: JSON.stringify({ error: 'Method not allowed' }),
} as const;
const mockHttpHandlers = new Map<string, HttpHandler>();
const mockIpcHandlers = new Map<string, IpcCallback>();
const mockIpcListeners = new Map<string, IpcCallback>();
const mockRegisterRemoteControlHandler = jest.fn(
(path: string, handler: HttpHandler) => {
mockHttpHandlers.set(path, handler);
}
);
const mockStartHttpServer = jest.fn();
const mockStoreGet = jest.fn();
const mockFirstRendererSend = jest.fn();
const mockSecondRendererSend = jest.fn();
const mockGetAllWindows = jest.fn();
const mockIpcHandle = jest.fn((channel: string, handler: IpcCallback) => {
mockIpcHandlers.set(channel, handler);
});
const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => {
mockIpcListeners.set(channel, listener);
});
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<ResponseSnapshot>((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<RequestInvocation> {
const request = new PassThrough();
const requestErrors: Error[] = [];
request.on('error', (error: Error) => {
requestErrors.push(error);
});
const requestClosed = new Promise<void>((resolve) => {
request.once('close', resolve);
});
const incomingMessage = Object.assign(request, {
method: options.method,
url: path,
}) as unknown as http.IncomingMessage;
const recorder = createResponseRecorder();
getHttpHandler(path)(incomingMessage, recorder.response);
request.end(options.body);
return {
response: await recorder.completed,
request,
requestClosed,
requestErrors,
};
}
function createBodyAtByteLength(byteLength: number): string {
const prefix = '{"number":7,"padding":"';
const suffix = '"}';
const paddingLength =
byteLength -
Buffer.byteLength(prefix, 'utf8') -
Buffer.byteLength(suffix, 'utf8');
if (paddingLength < 0) {
throw new Error(`Cannot create a JSON body at ${byteLength} bytes`);
}
const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`;
if (Buffer.byteLength(body, 'utf8') !== byteLength) {
throw new Error(`Expected a ${byteLength}-byte JSON body`);
}
return body;
}
describe('RemoteControlEvents', () => {
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();
}
);
});
@@ -26,7 +26,7 @@ interface RemoteControlStatus {
updatedAt?: string;
}
class RemoteControlEvents {
export class RemoteControlEvents {
private remoteControlStatus: RemoteControlStatus = {
portal: 'unknown',
isLiveView: false,
@@ -1,45 +1,196 @@
const handlers = new Map<string, (...args: unknown[]) => unknown>();
import type { Settings } from '@iptvnator/shared/interfaces';
type ExternalPlayerArgumentsSetting = string | readonly unknown[];
type SettingsUpdatePayload = Omit<
Partial<Settings>,
| '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<string, SettingsUpdateHandler>();
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<typeof console.log>;
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');
@@ -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<void>;
listeningEvents: number;
server: http.Server;
}
interface TestResponse {
body: Buffer;
contentType: string | undefined;
statusCode: number | undefined;
}
const INDEX_BODY = Buffer.from('<main>IPTVnator remote control</main>');
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<TestResponse> {
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<void> {
if (!server.listening) {
return;
}
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
} else {
resolve();
}
});
});
}
async function getAvailablePort(): Promise<number> {
const probe = http.createServer();
await new Promise<void>((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<CapturedServer> {
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,
});
});
});
@@ -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;
}
+67 -62
View File
@@ -28,11 +28,11 @@ Current capabilities:
1. User opens remote web UI (`http://<local-ip>:<port>`).
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: `<appPath>/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: `<appPath>/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
+28 -6
View File
@@ -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,
File diff suppressed because it is too large. Load diff
@@ -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/<project-root>/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.
+3 -3
View File
@@ -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",
@@ -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
+2 -1
View File
@@ -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",
+153 -18
View File
@@ -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();
+579
View File
@@ -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;
}
+904
View File
@@ -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.',
]);
});
});
+39 -1
View File
@@ -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.",
+18 -9
View File
@@ -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}%`
);
+67 -17
View File
@@ -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);
}
}