mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
1ab82b04a1
commit
08b868d6c1
20 files changed
+5866
-166
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}%`
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user