[codex] Add scoped coverage reporting (#1024)

* add scoped coverage reporting

* fix coverage review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
This commit is contained in:
4grayand4gray authored and GitHub committed 2026-06-06 17:41:40 +02:00
1 parent ade043d427
commit ef900d0f2a
31 files changed
+1433 -45

No files matched your search

+24 -3
View File
@@ -13,7 +13,7 @@ jobs:
unit-and-typecheck:
name: Unit Tests and Typechecks
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
steps:
- name: Checkout code
@@ -37,8 +37,29 @@ jobs:
- name: Check i18n drift
run: pnpm run i18n:check
- name: Run curated Nx unit test suite
run: pnpm run test:unit:ci
- name: Run Tier A unit coverage suite
run: pnpm run coverage:ci
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
- name: Upload unit coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-coverage
path: |
coverage/merged/
retention-days: 14
- name: Upload unit coverage to Codecov
if: always()
uses: codecov/codecov-action@v6
with:
files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml
flags: unit
name: iptvnator-unit
fail_ci_if_error: false
handle_no_reports_found: true
disable_search: true
token: ${{ secrets.CODECOV_TOKEN }}
+18 -4
View File
@@ -52,12 +52,19 @@ jobs:
env:
CI: true
- name: Summarize Electron E2E semantic coverage
if: always()
run: pnpm run coverage:e2e:summary -- --project=electron-backend-e2e
- name: Upload Electron Test Results
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-electron-${{ matrix.os }}
path: dist/playwright-report/electron-backend-e2e/
path: |
dist/playwright-report/electron-backend-e2e/
dist/test-results/electron-backend-e2e/
coverage/e2e/
retention-days: 7
web-e2e-tests:
@@ -89,10 +96,17 @@ jobs:
env:
CI: true
- name: Summarize Web E2E semantic coverage
if: always()
run: pnpm run coverage:e2e:summary -- --project=web-e2e
- name: Upload Web Test Results
if: failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-web-ubuntu
path: dist/.playwright/apps/web-e2e/playwright-report/
path: |
dist/playwright-report/web-e2e/
dist/test-results/web-e2e/
coverage/e2e/
retention-days: 7
@@ -27,6 +27,13 @@ export default defineConfig({
'../../dist/playwright-report/electron-backend-e2e',
},
],
[
'json',
{
outputFile:
'../../dist/test-results/electron-backend-e2e/results.json',
},
],
],
/* Shared settings for all the projects below */
use: {
+2 -2
View File
@@ -29,7 +29,7 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
`;
test.describe('Electron EPG', () => {
test('adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
test('@epg @electron adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
@@ -85,7 +85,7 @@ test.describe('Electron EPG', () => {
}
});
test('uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
dataDir,
}) => {
const playlistServer = await createMutableTextServer(
@@ -43,7 +43,7 @@ import {
} from './portal-mock-fixtures';
test.describe('Electron Favorites', () => {
test('shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
}) => {
const playlistTitle = 'm3u-favorites-source.m3u';
@@ -172,7 +172,7 @@ test.describe('Electron Favorites', () => {
}
});
test('shows Xtream live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
test('@persistence @xtream @electron shows Xtream live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
request,
}) => {
@@ -374,7 +374,7 @@ test.describe('Electron Favorites', () => {
}
});
test('shows Stalker live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
test('@persistence @stalker @electron shows Stalker live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
request,
}) => {
@@ -8,7 +8,7 @@ import {
} from './electron-test-fixtures';
test.describe('Electron Native Playlist Import', () => {
test('imports an M3U playlist via the native file picker path', async ({
test('@m3u @electron imports an M3U playlist via the native file picker path', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
@@ -19,7 +19,7 @@ import {
} from './electron-test-fixtures';
test.describe('Electron Provider Smoke Tests', () => {
test('loads Xtream content through the Electron IPC path', async ({
test('@xtream @electron loads Xtream content through the Electron IPC path', async ({
dataDir,
request,
}) => {
@@ -42,7 +42,7 @@ test.describe('Electron Provider Smoke Tests', () => {
}
});
test('loads Stalker content through the Electron IPC path', async ({
test('@stalker @electron loads Stalker content through the Electron IPC path', async ({
dataDir,
request,
}) => {
@@ -67,7 +67,7 @@ test.describe('Electron Provider Smoke Tests', () => {
}
});
test('shows refresh overlay immediately from the dashboard Xtream source menu', async ({
test('@xtream @electron shows refresh overlay immediately from the dashboard Xtream source menu', async ({
dataDir,
request,
}) => {
+3 -3
View File
@@ -89,7 +89,7 @@ test.describe('Electron Recently Viewed', () => {
}
});
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({
test('@persistence @m3u @electron tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({
dataDir,
}) => {
const playlistTitle = 'm3u-recent-source.m3u';
@@ -219,7 +219,7 @@ test.describe('Electron Recently Viewed', () => {
}
});
test('tracks Xtream live, movie, and series history across playlist and all-playlists scope, persists after restart, and supports clearing', async ({
test('@persistence @xtream @electron tracks Xtream live, movie, and series history across playlist and all-playlists scope, persists after restart, and supports clearing', async ({
dataDir,
request,
}) => {
@@ -425,7 +425,7 @@ test.describe('Electron Recently Viewed', () => {
}
});
test('tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({
test('@persistence @stalker @electron tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({
dataDir,
request,
}) => {
@@ -41,7 +41,7 @@ const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
`;
test.describe('Electron Settings', () => {
test('gates external MPV playback behind double-click when enabled', async ({
test('@settings @electron gates external MPV playback behind double-click when enabled', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
@@ -121,7 +121,7 @@ test.describe('Electron Settings', () => {
}
});
test('persists changed desktop settings across app restart', async ({
test('@settings @persistence @electron persists changed desktop settings across app restart', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
@@ -207,7 +207,7 @@ test.describe('Electron Settings', () => {
}
});
test('starts on sources when dashboard is disabled', async ({ dataDir }) => {
test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => {
const firstLaunch = await launchElectronApp(dataDir);
try {
+3 -3
View File
@@ -6,7 +6,7 @@ import {
} from './electron-test-fixtures';
test.describe('Electron App Smoke Test', () => {
test('app should start and display the dashboard', async ({ dataDir }) => {
test('@critical @electron app should start and display the dashboard', async ({ dataDir }) => {
const app = await launchElectronApp(dataDir);
try {
@@ -27,7 +27,7 @@ test.describe('Electron App Smoke Test', () => {
}
});
test('app should expose the expected main window properties', async ({
test('@critical @electron app should expose the expected main window properties', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
@@ -57,7 +57,7 @@ test.describe('Electron App Smoke Test', () => {
}
});
test('app should render workspace content', async ({ dataDir }) => {
test('@critical @electron app should render workspace content', async ({ dataDir }) => {
const app = await launchElectronApp(dataDir);
try {
@@ -22,7 +22,7 @@ const epgCredentials = {
};
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
test(`renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({
test(`@epg @xtream @electron renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({
dataDir,
request,
}) => {
+15
View File
@@ -28,6 +28,21 @@ const reuseExistingWebServer = isStaticPwaE2E ? false : !process.env['CI'];
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
testMatch: ['**/*.e2e.ts'],
reporter: [
['list'],
[
'html',
{
outputFolder: '../../dist/playwright-report/web-e2e',
},
],
[
'json',
{
outputFile: '../../dist/test-results/web-e2e/results.json',
},
],
],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL,
+3 -3
View File
@@ -1,9 +1,9 @@
import { expect, test } from '@playwright/test';
import { join } from 'path';
import { expect, test } from './fixtures';
const fixturePath = join(__dirname, 'fixtures/test.m3u');
test('basic test', async ({ page }) => {
test('@web @m3u basic playlist import flow', async ({ page }) => {
await page.goto('/');
// Basic checks
@@ -31,7 +31,7 @@ test('basic test', async ({ page }) => {
await expect(page.getByText('4. HappyKids TV')).toBeVisible();
});
test('keyboard shortcuts help opens from question mark', async ({ page }) => {
test('@web keyboard shortcuts help opens from question mark', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open keyboard shortcuts' }).focus();
+41
View File
@@ -0,0 +1,41 @@
import { test as base, expect, type Page } from '@playwright/test';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
function safeArtifactName(value: string): string {
return value
.replace(/[^a-z0-9]+/gi, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120);
}
export const test = base.extend<{ page: Page }>({
page: async ({ page }, use, testInfo) => {
if (process.env['IPTVNATOR_E2E_V8_COVERAGE'] !== '1') {
await use(page);
return;
}
await page.coverage.startJSCoverage({ resetOnNavigation: false });
await page.coverage.startCSSCoverage({ resetOnNavigation: false });
try {
await use(page);
} finally {
const [js, css] = await Promise.all([
page.coverage.stopJSCoverage(),
page.coverage.stopCSSCoverage(),
]);
const coverageDir = path.join(testInfo.outputDir, 'v8-coverage');
const artifactName = safeArtifactName(testInfo.titlePath.join(' '));
await mkdir(coverageDir, { recursive: true });
await writeFile(
path.join(coverageDir, `${artifactName || 'test'}.json`),
`${JSON.stringify({ css, js }, null, 4)}\n`
);
}
},
});
export { expect };
+2 -2
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { expect, test } from './fixtures';
// eslint-disable-next-line playwright/no-skipped-test -- Static PWA assertions require the built service worker artifact.
test.skip(
@@ -6,7 +6,7 @@ test.skip(
'Static PWA stylesheet regression test only runs against the built PWA output.'
);
test('@pwa-static PWA build applies the full stylesheet under CSP', async ({
test('@pwa @pwa-static PWA build applies the full stylesheet under CSP', async ({
page,
}) => {
const cspConsoleErrors: string[] = [];
+2 -1
View File
@@ -1,4 +1,5 @@
import { expect, type Locator, type Page, test } from '@playwright/test';
import type { Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures';
const WEB_BACKEND_URL = 'http://localhost:3333';
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
+6 -5
View File
@@ -1,5 +1,6 @@
import { expect, Page, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { join } from 'path';
import { expect, test } from './fixtures';
async function openSettings(page: Page) {
await page.locator('a[href$="/workspace/settings"]').click();
@@ -22,12 +23,12 @@ test.describe('Settings', () => {
await page.goto('/');
});
test('Check settings page', async ({ page }) => {
test('@settings @web Check settings page', async ({ page }) => {
await openSettings(page);
await page.locator('.settings-back-button').click();
});
test('Change video player', async ({ page }) => {
test('@settings @web Change video player', async ({ page }) => {
await openSettings(page);
const playerSelect = page.locator('[data-test-id="select-video-player"]');
@@ -47,7 +48,7 @@ test.describe('Settings', () => {
);
});
test('Change app theme', async ({ page }) => {
test('@settings @web Change app theme', async ({ page }) => {
await openSettings(page);
// v0.22 compact theme picker exposes the segmented control as a
// radiogroup with options labelled just "Light"/"Dark"/"System".
@@ -72,7 +73,7 @@ test.describe('Settings', () => {
).toHaveAttribute('aria-checked', 'true');
});
test('Change app language', async ({ page }) => {
test('@settings @web Change app language', async ({ page }) => {
await openSettings(page);
const languageSelect = page.locator('[data-test-id="select-language"]');
+1 -2
View File
@@ -1,10 +1,9 @@
import {
expect,
type APIRequestContext,
type Locator,
type Page,
test,
} from '@playwright/test';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
+2 -1
View File
@@ -1,4 +1,5 @@
import { APIRequestContext, test, expect, Page } from '@playwright/test';
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
+14 -3
View File
@@ -6,6 +6,18 @@ const angularEsmPreset = createEsmPreset({
tsconfig: '<rootDir>/tsconfig.spec.json',
});
const coverageReporters = ['json', 'json-summary', 'lcovonly', 'text-summary'];
const collectCoverageFrom = [
'src/**/*.{ts,js,mjs,html}',
'!src/**/*.{spec,test}.ts',
'!src/**/test-setup.ts',
'!src/**/test-stubs/**',
'!src/**/*.generated.*',
'!src/**/environments/**',
'!src/**/index.ts',
];
export default {
...nxPreset,
...angularEsmPreset,
@@ -33,7 +45,6 @@ export default {
transform: angularEsmPreset.transform,
transformIgnorePatterns: [],
extensionsToTreatAsEsm: angularEsmPreset.extensionsToTreatAsEsm,
coverageReporters: [
...(nxPreset.coverageReporters ?? []),
],
coverageReporters,
collectCoverageFrom,
};
+45
View File
@@ -23,6 +23,34 @@ pnpm nx show projects --withTarget e2e
| Shared SQLite schema/connection | `pnpm nx test database` |
| Packaging metadata | `pnpm nx test packaging` |
## Coverage Tiers
Use `tools/coverage/coverage-policy.json` as the source of truth for coverage
ownership.
| 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.
Projects with a test target but no specs, such as `remote-control-web` and
`remote-control` today, should not be in Tier A until focused specs exist.
For local coverage inspection:
```bash
pnpm run coverage:unit:ci
pnpm run coverage:merge
pnpm run coverage:health
```
The merged report is written to `coverage/merged/` as HTML, LCOV, Cobertura,
and JSON summary output. CI uploads the merged Tier A report to Codecov with the
`unit` flag and keeps the HTML report as a GitHub artifact.
## E2E
| Area | Command |
@@ -33,6 +61,23 @@ pnpm nx show projects --withTarget e2e
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
Playwright coverage is measured semantically by tags and critical journeys, not
by a source-line percentage. E2E reports should use tags such as `@critical`,
`@electron`, `@web`, `@xtream`, `@stalker`, `@m3u`, `@search`, `@epg`,
`@persistence`, `@settings`, `@pwa`, and `@self-hosted`.
After an E2E run, generate the semantic summary with:
```bash
pnpm run coverage:e2e:summary
```
For local investigation only, Chromium browser V8 coverage can be explored with:
```bash
pnpm run coverage:e2e:v8:web
```
## I18n
```bash
+17 -1
View File
@@ -1,3 +1,19 @@
const nxPreset = require('@nx/jest/preset').default;
module.exports = { ...nxPreset };
const coverageReporters = ['json', 'json-summary', 'lcovonly', 'text-summary'];
const collectCoverageFrom = [
'src/**/*.{ts,js,mjs,html}',
'!src/**/*.{spec,test}.ts',
'!src/**/test-setup.ts',
'!src/**/test-stubs/**',
'!src/**/*.generated.*',
'!src/**/environments/**',
'!src/**/index.ts',
];
module.exports = {
...nxPreset,
coverageReporters,
collectCoverageFrom,
};
+3 -1
View File
@@ -6,6 +6,8 @@ const angularEsmPreset = createEsmPreset({
tsconfig: '<rootDir>/apps/web/tsconfig.spec.json',
});
const coverageReporters = ['json', 'json-summary', 'lcovonly', 'text-summary'];
export default {
...nxPreset,
...angularEsmPreset,
@@ -37,5 +39,5 @@ export default {
extensionsToTreatAsEsm: angularEsmPreset.extensionsToTreatAsEsm,
modulePathIgnorePatterns: ['<rootDir>/dist/', '<rootDir>/.nx/'],
watchPathIgnorePatterns: ['<rootDir>/dist/', '<rootDir>/.nx/'],
coverageReporters: [...(nxPreset.coverageReporters ?? [])],
coverageReporters,
};
+9
View File
@@ -33,6 +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 --projects=electron-backend,web,services,m3u-state,portal-stalker-data-access,portal-shared-util,playlist-shared-ui,portal-xtream-data-access,portal-xtream-feature,workspace-dashboard-data-access,components,@iptvnator/pipes,packaging,epg-data-access,workspace-shell-util,database --parallel=3 --output-style=static",
"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:ci": "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",
"typecheck:backend": "tsc -p apps/electron-backend/tsconfig.app.json --noEmit",
"typecheck:ci": "pnpm run typecheck:web && pnpm run typecheck:backend",
@@ -184,6 +190,9 @@
"eslint-plugin-playwright": "^1.6.2",
"express": "5.2.1",
"globals": "15.9.0",
"istanbul-lib-coverage": "3.2.2",
"istanbul-lib-report": "3.0.1",
"istanbul-reports": "3.2.0",
"jest": "^30.0.2",
"jest-environment-jsdom": "^30.0.2",
"jest-environment-node": "^30.0.2",
+9
View File
@@ -383,6 +383,15 @@ importers:
globals:
specifier: 15.9.0
version: 15.9.0
istanbul-lib-coverage:
specifier: 3.2.2
version: 3.2.2
istanbul-lib-report:
specifier: 3.0.1
version: 3.0.1
istanbul-reports:
specifier: 3.2.0
version: 3.2.0
jest:
specifier: ^30.0.2
version: 30.2.0(@types/node@20.19.9)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.27.0))(ts-node@10.9.2(@swc/core@1.15.8(@swc/helpers@0.5.18))(@types/node@20.19.9)(typescript@5.9.3))
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import {
existsSync,
readFileSync,
readdirSync,
statSync,
} from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const workspaceRoot = process.cwd();
const args = new Set(process.argv.slice(2));
const requireReport = args.has('--require-report');
const policy = JSON.parse(
readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8')
);
const warnings = [];
const errors = [];
function warn(message) {
warnings.push(message);
if (process.env.GITHUB_ACTIONS) {
console.log(`::warning::${message}`);
} else {
console.warn(`Warning: ${message}`);
}
}
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf8'));
}
function projectJsonPath(project) {
return path.join(workspaceRoot, project.root, 'project.json');
}
function verifyTierAProjects() {
for (const project of policy.unitCoverage.tierA) {
const filePath = projectJsonPath(project);
if (!existsSync(filePath)) {
errors.push(`Tier A project ${project.name} is missing ${project.root}/project.json.`);
continue;
}
const projectJson = readJson(filePath);
if (!projectJson.targets?.test) {
errors.push(`Tier A project ${project.name} must have a test target.`);
}
if (!project.sourceRoot || !existsSync(path.join(workspaceRoot, project.sourceRoot))) {
errors.push(`Tier A project ${project.name} has an invalid sourceRoot: ${project.sourceRoot}`);
}
if (project.sourceRoot && !hasSpecUnder(project.sourceRoot)) {
errors.push(`Tier A project ${project.name} has no unit specs under ${project.sourceRoot}. Move it to Tier B/C or add focused tests.`);
}
}
}
function verifyCoverageReport() {
const summaryPath = path.join(
workspaceRoot,
policy.reporting.mergedCoverageDir,
'coverage-summary.json'
);
if (!existsSync(summaryPath)) {
const message = `Merged coverage summary not found at ${path.relative(workspaceRoot, summaryPath)}.`;
if (requireReport) {
errors.push(message);
} else {
warn(message);
}
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 (requireReport) {
for (const project of policy.unitCoverage.tierA) {
const projectCoveragePath = path.join(
workspaceRoot,
'coverage',
project.root,
'coverage-final.json'
);
if (!existsSync(projectCoveragePath)) {
errors.push(
`Tier A project ${project.name} did not produce ${path.relative(workspaceRoot, projectCoveragePath)}.`
);
}
}
}
}
function listFiles(directory, predicate) {
if (!existsSync(directory)) {
return [];
}
const files = [];
for (const entry of readdirSync(directory)) {
const fullPath = path.join(directory, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...listFiles(fullPath, predicate));
} else if (predicate(fullPath)) {
files.push(fullPath);
}
}
return files;
}
function hasSpecUnder(sourceRoot) {
return listFiles(path.join(workspaceRoot, sourceRoot), (file) =>
/\.(spec|test)\.ts$/.test(file)
).length > 0;
}
function scanE2ETags() {
const e2eFiles = [
...listFiles(path.join(workspaceRoot, 'apps/web-e2e/src'), (file) => file.endsWith('.e2e.ts')),
...listFiles(path.join(workspaceRoot, 'apps/electron-backend-e2e/src'), (file) =>
file.endsWith('.e2e.ts')
),
];
const tags = new Set();
for (const file of e2eFiles) {
const contents = readFileSync(file, 'utf8');
for (const match of contents.matchAll(/@[a-z0-9-]+/gi)) {
tags.add(match[0]);
}
}
for (const tag of policy.e2eSemanticCoverage.trackedTags) {
if (!tags.has(tag)) {
warn(`Tracked E2E tag ${tag} is not present in current Playwright specs yet.`);
}
}
}
function changedFiles() {
const changedFileSet = new Set();
const commands = [];
if (process.env.GITHUB_BASE_REF) {
commands.push(['git', ['diff', '--name-only', `origin/${process.env.GITHUB_BASE_REF}...HEAD`]]);
} else {
commands.push(['git', ['diff', '--name-only', 'HEAD']]);
commands.push(['git', ['ls-files', '--others', '--exclude-standard']]);
commands.push(['git', ['diff', '--name-only', 'HEAD~1...HEAD']]);
}
for (const [command, commandArgs] of commands) {
try {
const output = execFileSync(command, commandArgs, {
cwd: workspaceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const outputFiles = output
.split('\n')
.map((file) => file.trim())
.filter(Boolean);
for (const file of outputFiles) {
changedFileSet.add(file);
}
} catch {
// Try the next diff source.
}
}
return Array.from(changedFileSet);
}
function sourceOwner(file) {
return policy.unitCoverage.tierA.find((project) =>
file.startsWith(`${project.sourceRoot}/`)
);
}
function hasNearbySpec(file) {
const parsed = path.parse(file);
const candidateNames = [
path.join(parsed.dir, `${parsed.name}.spec.ts`),
path.join(parsed.dir, `${parsed.name}.test.ts`),
];
return candidateNames.some((candidate) => existsSync(path.join(workspaceRoot, candidate)));
}
function reportChangedCriticalFiles() {
const files = changedFiles().filter(
(file) =>
/\.(ts|html)$/.test(file) &&
!/\.(spec|test)\.ts$/.test(file) &&
!file.endsWith('test-setup.ts')
);
for (const file of files) {
const owner = sourceOwner(file);
if (!owner) {
continue;
}
if (!hasNearbySpec(file)) {
warn(
`${file} is Tier A source without a same-name unit spec. Preferred validation: ${owner.validationCommand}; related E2E tags: ${owner.e2eTags.join(', ')}.`
);
}
}
}
verifyTierAProjects();
verifyCoverageReport();
scanE2ETags();
reportChangedCriticalFiles();
if (errors.length > 0) {
for (const error of errors) {
console.error(`Error: ${error}`);
}
process.exit(1);
}
console.log(
warnings.length === 0
? 'Coverage health checks passed.'
: `Coverage health completed with ${warnings.length} warning(s).`
);
+346
View File
@@ -0,0 +1,346 @@
{
"version": 1,
"reporting": {
"mergedCoverageDir": "coverage/merged",
"e2eSummaryDir": "coverage/e2e",
"unitCodecovFlag": "unit"
},
"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.",
"tierA": [
{
"name": "web",
"root": "apps/web",
"sourceRoot": "apps/web/src",
"jestRootMode": "project",
"validationCommand": "pnpm nx test web",
"e2eTags": ["@web", "@m3u", "@pwa", "@settings"]
},
{
"name": "electron-backend",
"root": "apps/electron-backend",
"sourceRoot": "apps/electron-backend/src",
"validationCommand": "pnpm nx test electron-backend",
"e2eTags": ["@electron", "@critical", "@persistence"]
},
{
"name": "web-backend",
"root": "apps/web-backend",
"sourceRoot": "apps/web-backend/src",
"validationCommand": "pnpm nx test web-backend",
"e2eTags": ["@self-hosted", "@web"]
},
{
"name": "services",
"root": "libs/services",
"sourceRoot": "libs/services/src",
"validationCommand": "pnpm nx test services",
"e2eTags": ["@m3u", "@persistence", "@settings"]
},
{
"name": "epg-data-access",
"root": "libs/epg/data-access",
"sourceRoot": "libs/epg/data-access/src",
"validationCommand": "pnpm nx test epg-data-access",
"e2eTags": ["@epg"]
},
{
"name": "m3u-state",
"root": "libs/m3u-state",
"sourceRoot": "libs/m3u-state/src",
"validationCommand": "pnpm nx test m3u-state",
"e2eTags": ["@m3u", "@persistence"]
},
{
"name": "playlist-import-feature",
"root": "libs/playlist/import/feature",
"sourceRoot": "libs/playlist/import/feature/src",
"validationCommand": "pnpm nx test playlist-import-feature",
"e2eTags": ["@m3u", "@file-import"]
},
{
"name": "playlist-m3u-feature-player",
"root": "libs/playlist/m3u/feature-player",
"sourceRoot": "libs/playlist/m3u/feature-player/src",
"validationCommand": "pnpm nx test playlist-m3u-feature-player",
"e2eTags": ["@m3u", "@playback"]
},
{
"name": "playlist-shared-ui",
"root": "libs/playlist/shared/ui",
"sourceRoot": "libs/playlist/shared/ui/src",
"validationCommand": "pnpm nx test playlist-shared-ui",
"e2eTags": ["@m3u"]
},
{
"name": "playlist-shared-util",
"root": "libs/playlist/shared/util",
"sourceRoot": "libs/playlist/shared/util/src",
"validationCommand": "pnpm nx test playlist-shared-util",
"e2eTags": ["@m3u"]
},
{
"name": "portal-catalog-feature",
"root": "libs/portal/catalog/feature",
"sourceRoot": "libs/portal/catalog/feature/src",
"validationCommand": "pnpm nx test portal-catalog-feature",
"e2eTags": ["@xtream", "@stalker"]
},
{
"name": "portal-shared-data-access",
"root": "libs/portal/shared/data-access",
"sourceRoot": "libs/portal/shared/data-access/src",
"validationCommand": "pnpm nx test portal-shared-data-access",
"e2eTags": ["@xtream", "@stalker", "@persistence"]
},
{
"name": "portal-shared-ui",
"root": "libs/portal/shared/ui",
"sourceRoot": "libs/portal/shared/ui/src",
"validationCommand": "pnpm nx test portal-shared-ui",
"e2eTags": ["@xtream", "@stalker"]
},
{
"name": "portal-shared-util",
"root": "libs/portal/shared/util",
"sourceRoot": "libs/portal/shared/util/src",
"validationCommand": "pnpm nx test portal-shared-util",
"e2eTags": ["@xtream", "@stalker"]
},
{
"name": "portal-stalker-data-access",
"root": "libs/portal/stalker/data-access",
"sourceRoot": "libs/portal/stalker/data-access/src",
"validationCommand": "pnpm nx test portal-stalker-data-access",
"e2eTags": ["@stalker"]
},
{
"name": "portal-stalker-feature",
"root": "libs/portal/stalker/feature",
"sourceRoot": "libs/portal/stalker/feature/src",
"validationCommand": "pnpm nx test portal-stalker-feature",
"e2eTags": ["@stalker"]
},
{
"name": "portal-xtream-data-access",
"root": "libs/portal/xtream/data-access",
"sourceRoot": "libs/portal/xtream/data-access/src",
"validationCommand": "pnpm nx test portal-xtream-data-access",
"e2eTags": ["@xtream"]
},
{
"name": "portal-xtream-feature",
"root": "libs/portal/xtream/feature",
"sourceRoot": "libs/portal/xtream/feature/src",
"validationCommand": "pnpm nx test portal-xtream-feature",
"e2eTags": ["@xtream"]
},
{
"name": "database",
"root": "libs/shared/database",
"sourceRoot": "libs/shared/database/src",
"validationCommand": "pnpm nx test database",
"e2eTags": ["@electron", "@persistence"]
},
{
"name": "shared-interfaces",
"root": "libs/shared/interfaces",
"sourceRoot": "libs/shared/interfaces/src",
"validationCommand": "pnpm nx test shared-interfaces",
"e2eTags": ["@m3u", "@xtream", "@stalker"]
},
{
"name": "m3u-utils",
"root": "libs/shared/m3u-utils",
"sourceRoot": "libs/shared/m3u-utils/src",
"validationCommand": "pnpm nx test m3u-utils",
"e2eTags": ["@m3u"]
},
{
"name": "components",
"root": "libs/ui/components",
"sourceRoot": "libs/ui/components/src",
"validationCommand": "pnpm nx test components",
"e2eTags": ["@m3u", "@search", "@persistence"]
},
{
"name": "ui-epg",
"root": "libs/ui/epg",
"sourceRoot": "libs/ui/epg/src",
"validationCommand": "pnpm nx test ui-epg",
"e2eTags": ["@epg"]
},
{
"name": "@iptvnator/pipes",
"root": "libs/ui/pipes",
"sourceRoot": "libs/ui/pipes/src",
"validationCommand": "pnpm nx test @iptvnator/pipes",
"e2eTags": ["@epg", "@settings"]
},
{
"name": "ui-playback",
"root": "libs/ui/playback",
"sourceRoot": "libs/ui/playback/src",
"validationCommand": "pnpm nx test ui-playback",
"e2eTags": ["@playback", "@m3u"]
},
{
"name": "shared-portals",
"root": "libs/ui/shared-portals",
"sourceRoot": "libs/ui/shared-portals/src",
"validationCommand": "pnpm nx test shared-portals",
"e2eTags": ["@xtream", "@stalker"]
},
{
"name": "workspace-dashboard-data-access",
"root": "libs/workspace/dashboard/data-access",
"sourceRoot": "libs/workspace/dashboard/data-access/src",
"validationCommand": "pnpm nx test workspace-dashboard-data-access",
"e2eTags": ["@dashboard", "@persistence"]
},
{
"name": "workspace-dashboard-feature",
"root": "libs/workspace/dashboard/feature",
"sourceRoot": "libs/workspace/dashboard/feature/src",
"validationCommand": "pnpm nx test workspace-dashboard-feature",
"e2eTags": ["@dashboard", "@persistence"]
},
{
"name": "workspace-shell-feature",
"root": "libs/workspace/shell/feature",
"sourceRoot": "libs/workspace/shell/feature/src",
"validationCommand": "pnpm nx test workspace-shell-feature",
"e2eTags": ["@search", "@settings", "@persistence"]
},
{
"name": "workspace-shell-util",
"root": "libs/workspace/shell/util",
"sourceRoot": "libs/workspace/shell/util/src",
"validationCommand": "pnpm nx test workspace-shell-util",
"e2eTags": ["@search", "@settings"]
}
],
"tierB": [
{
"name": "website",
"root": "apps/website",
"validationCommand": "pnpm nx test website",
"reason": "Astro marketing site; build output and targeted static checks are more meaningful than source percentage coverage."
},
{
"name": "packaging",
"root": "tools/packaging",
"validationCommand": "pnpm nx test packaging",
"reason": "Node package identity checks validate generated packaging metadata."
},
{
"name": "remote-control-web",
"root": "apps/remote-control-web",
"validationCommand": "pnpm nx build remote-control-web",
"reason": "Product surface without unit specs today; validate build output and add focused tests when behavior changes."
},
{
"name": "web-e2e",
"root": "apps/web-e2e",
"validationCommand": "pnpm nx run web-e2e:e2e -- --project=chromium",
"reason": "Playwright semantic flow coverage is reported separately."
},
{
"name": "electron-backend-e2e",
"root": "apps/electron-backend-e2e",
"validationCommand": "pnpm nx run electron-backend-e2e:e2e",
"reason": "Playwright semantic flow coverage is reported separately."
}
],
"tierC": [
{
"name": "xtream-mock-server",
"root": "apps/xtream-mock-server",
"reason": "Validated through Playwright API and user-flow tests unless mock behavior changes directly."
},
{
"name": "stalker-mock-server",
"root": "apps/stalker-mock-server",
"reason": "Validated through Playwright API and user-flow tests unless mock behavior changes directly."
},
{
"name": "shared-testing",
"root": "libs/shared/testing",
"reason": "Test helper library."
},
{
"name": "portal-downloads-feature",
"root": "libs/portal/downloads/feature",
"reason": "No test target yet; add focused tests when feature work changes this area."
},
{
"name": "remote-control",
"root": "libs/ui/remote-control",
"reason": "No unit specs yet; validate through remote-control flows or add focused tests when behavior changes directly."
}
]
},
"e2eSemanticCoverage": {
"trackedTags": [
"@critical",
"@electron",
"@web",
"@xtream",
"@stalker",
"@m3u",
"@search",
"@epg",
"@persistence",
"@settings",
"@pwa",
"@self-hosted"
],
"criticalJourneys": [
{
"name": "Electron app starts and renders workspace",
"projects": ["electron-backend-e2e"],
"matchAnyTags": ["@critical"]
},
{
"name": "Xtream portal import, browse, and cached access",
"projects": ["web-e2e", "electron-backend-e2e"],
"matchAnyTags": ["@xtream"]
},
{
"name": "Stalker portal import and browse",
"projects": ["web-e2e", "electron-backend-e2e"],
"matchAnyTags": ["@stalker"]
},
{
"name": "M3U import, playback, and playlist state",
"projects": ["web-e2e", "electron-backend-e2e"],
"matchAnyTags": ["@m3u"]
},
{
"name": "Workspace search across providers",
"projects": ["electron-backend-e2e"],
"matchAnyTags": ["@search"]
},
{
"name": "EPG rendering and source management",
"projects": ["electron-backend-e2e"],
"matchAnyTags": ["@epg"]
},
{
"name": "Favorites, recent items, and restart persistence",
"projects": ["electron-backend-e2e"],
"matchAnyTags": ["@persistence"]
},
{
"name": "Settings persistence and playback options",
"projects": ["web-e2e", "electron-backend-e2e"],
"matchAnyTags": ["@settings"]
},
{
"name": "Self-hosted PWA/backend integration",
"projects": ["web-e2e"],
"matchAnyTags": ["@self-hosted", "@pwa"]
}
]
}
}
+245
View File
@@ -0,0 +1,245 @@
#!/usr/bin/env node
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const workspaceRoot = process.cwd();
const args = process.argv.slice(2);
const projectArg = valueFor('--project');
const inputArg = valueFor('--input');
const policy = JSON.parse(
readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8')
);
const outputDir = path.join(workspaceRoot, policy.reporting.e2eSummaryDir);
function valueFor(flag) {
const prefixed = args.find((arg) => arg.startsWith(`${flag}=`));
if (prefixed) {
return prefixed.slice(flag.length + 1);
}
const index = args.indexOf(flag);
return index >= 0 ? args[index + 1] : undefined;
}
function listFiles(directory, predicate) {
if (!existsSync(directory)) {
return [];
}
const files = [];
for (const entry of readdirSync(directory)) {
const fullPath = path.join(directory, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...listFiles(fullPath, predicate));
} else if (predicate(fullPath)) {
files.push(fullPath);
}
}
return files;
}
function normalizeTag(tag) {
const normalized = tag.startsWith('@') ? tag : `@${tag}`;
return normalized.toLowerCase();
}
function tagsFromTitle(title) {
return Array.from(
new Set((title.match(/@[a-z0-9-]+/gi) ?? []).map(normalizeTag).sort())
);
}
function collectFromPlaywrightJson(filePath, projectName) {
const report = JSON.parse(readFileSync(filePath, 'utf8'));
const tests = [];
function walkSuite(suite, inheritedFile) {
const file = suite.file ?? inheritedFile;
for (const spec of suite.specs ?? []) {
const title = [...(spec.titlePath ?? []), spec.title].filter(Boolean).join(' ');
const tags = new Set([
...(spec.tags ?? []).map(normalizeTag),
...tagsFromTitle(title),
]);
const statuses = (spec.tests ?? []).flatMap((test) =>
(test.results ?? []).map((result) => result.status)
);
const status = statuses.includes('failed')
? 'failed'
: statuses.includes('timedOut')
? 'failed'
: statuses.includes('skipped')
? 'skipped'
: statuses.length > 1 && statuses.includes('passed')
? 'flaky'
: statuses[0] ?? 'unknown';
tests.push({
project: projectName,
file,
title,
status,
tags: Array.from(tags).sort(),
});
}
for (const child of suite.suites ?? []) {
walkSuite(child, file);
}
}
for (const suite of report.suites ?? []) {
walkSuite(suite, suite.file);
}
return tests;
}
function collectFromSource(projectName) {
const root =
projectName === 'electron-backend-e2e'
? 'apps/electron-backend-e2e/src'
: 'apps/web-e2e/src';
const files = listFiles(path.join(workspaceRoot, root), (file) => file.endsWith('.e2e.ts'));
const tests = [];
for (const file of files) {
const relativeFile = path.relative(workspaceRoot, file);
const contents = readFileSync(file, 'utf8');
const regex = /\btest(?:\.describe)?\s*\(\s*(['"`])([\s\S]*?)\1/g;
for (const match of contents.matchAll(regex)) {
const title = match[2].replace(/\s+/g, ' ').trim();
const tags = tagsFromTitle(title);
if (tags.length > 0) {
tests.push({
project: projectName,
file: relativeFile,
title,
status: 'not-run',
tags,
});
}
}
}
return tests;
}
function defaultInputFor(projectName) {
return path.join(workspaceRoot, 'dist/test-results', projectName, 'results.json');
}
function collectTests(projectName) {
const inputPath = inputArg
? path.resolve(workspaceRoot, inputArg)
: defaultInputFor(projectName);
if (existsSync(inputPath)) {
return collectFromPlaywrightJson(inputPath, projectName);
}
return collectFromSource(projectName);
}
function statusCounts(tests) {
return tests.reduce((counts, test) => {
counts[test.status] = (counts[test.status] ?? 0) + 1;
return counts;
}, {});
}
function tagCounts(tests) {
const counts = {};
for (const test of tests) {
for (const tag of test.tags) {
counts[tag] = (counts[tag] ?? 0) + 1;
}
}
return counts;
}
function journeyMatches(journey, tests) {
return tests.filter(
(test) =>
journey.projects.includes(test.project) &&
journey.matchAnyTags.some((tag) => test.tags.includes(tag))
);
}
function markdownFor(projectName, tests) {
const counts = statusCounts(tests);
const countsText = Object.entries(counts)
.map(([status, count]) => `${status}: ${count}`)
.join(', ');
const tagRows = Object.entries(tagCounts(tests))
.sort(([left], [right]) => left.localeCompare(right))
.map(([tag, count]) => `| ${tag} | ${count} |`)
.join('\n');
const journeys = policy.e2eSemanticCoverage.criticalJourneys
.filter((journey) => !projectName || journey.projects.includes(projectName))
.map((journey) => {
const matches = journeyMatches(journey, tests);
const failed = matches.some((test) => test.status === 'failed');
const status = matches.length === 0 ? 'missing' : failed ? 'failing' : 'covered';
return `| ${journey.name} | ${journey.matchAnyTags.join(', ')} | ${matches.length} | ${status} |`;
})
.join('\n');
return `# E2E Semantic Coverage${projectName ? `: ${projectName}` : ''}
Source: ${tests.some((test) => test.status === 'not-run') ? 'spec source scan' : 'Playwright JSON report'}
Total tracked tests: ${tests.length}
Statuses: ${countsText || 'none'}
## Tags
| Tag | Tests |
| --- | ---: |
${tagRows || '| _none_ | 0 |'}
## Critical Journeys
| Journey | Matching tags | Tests | Status |
| --- | --- | ---: | --- |
${journeys || '| _none_ | _n/a_ | 0 | missing |'}
`;
}
const projects = projectArg ? [projectArg] : ['web-e2e', 'electron-backend-e2e'];
const allTests = projects.flatMap((projectName) => collectTests(projectName));
mkdirSync(outputDir, { recursive: true });
if (projectArg) {
const content = markdownFor(projectArg, allTests);
writeFileSync(path.join(outputDir, `${projectArg}-semantic-summary.md`), content);
writeFileSync(
path.join(outputDir, `${projectArg}-semantic-summary.json`),
`${JSON.stringify(allTests, null, 4)}\n`
);
if (process.env.GITHUB_STEP_SUMMARY) {
writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' });
}
console.log(`Wrote ${policy.reporting.e2eSummaryDir}/${projectArg}-semantic-summary.md`);
} else {
const content = markdownFor(undefined, allTests);
writeFileSync(path.join(outputDir, 'semantic-summary.md'), content);
writeFileSync(
path.join(outputDir, 'semantic-summary.json'),
`${JSON.stringify(allTests, null, 4)}\n`
);
if (process.env.GITHUB_STEP_SUMMARY) {
writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' });
}
console.log(`Wrote ${policy.reporting.e2eSummaryDir}/semantic-summary.md`);
}
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import process from 'node:process';
console.warn(
'Experimental: browser V8 coverage for existing Playwright specs requires Chromium and is intended for local investigation, not CI gating.'
);
const workspaceRoot = process.cwd();
const result = spawnSync(
'pnpm',
[
'nx',
'run',
'web-e2e:e2e',
'--',
'--project=chromium',
],
{
cwd: process.cwd(),
env: {
...process.env,
IPTVNATOR_E2E_V8_COVERAGE: '1',
},
stdio: 'inherit',
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
function listFiles(directory, predicate) {
if (!existsSync(directory)) {
return [];
}
const files = [];
for (const entry of readdirSync(directory)) {
const fullPath = path.join(directory, entry);
const stats = statSync(fullPath);
if (stats.isDirectory()) {
files.push(...listFiles(fullPath, predicate));
} else if (predicate(fullPath)) {
files.push(fullPath);
}
}
return files;
}
function mergeRanges(ranges) {
const sorted = ranges
.filter((range) => range.count > 0)
.map((range) => [range.startOffset, range.endOffset])
.sort(([leftStart], [rightStart]) => leftStart - rightStart);
const merged = [];
for (const [start, end] of sorted) {
const previous = merged.at(-1);
if (previous && start <= previous[1]) {
previous[1] = Math.max(previous[1], end);
} else {
merged.push([start, end]);
}
}
return merged;
}
function summarizeJsEntry(entry) {
const sourceLength = entry.source?.length ?? 0;
const ranges = mergeRanges((entry.functions ?? []).flatMap((fn) => fn.ranges ?? []));
const usedBytes = ranges.reduce((total, [start, end]) => total + Math.max(0, end - start), 0);
return {
url: entry.url,
sourceLength,
usedBytes,
pct: sourceLength > 0 ? Math.round((usedBytes / sourceLength) * 10000) / 100 : 0,
};
}
const rawCoverageRoot = path.join(workspaceRoot, 'apps/web-e2e/test-results');
const rawFiles = listFiles(rawCoverageRoot, (file) =>
file.endsWith('.json') && file.includes(`${path.sep}v8-coverage${path.sep}`)
);
const entriesByUrl = new Map();
for (const file of rawFiles) {
const raw = JSON.parse(readFileSync(file, 'utf8'));
for (const entry of raw.js ?? []) {
if (!entry.url || entry.url.startsWith('extensions::')) {
continue;
}
const summary = summarizeJsEntry(entry);
const existing = entriesByUrl.get(summary.url);
if (!existing || summary.usedBytes > existing.usedBytes) {
entriesByUrl.set(summary.url, summary);
}
}
}
const summaries = Array.from(entriesByUrl.values()).sort((left, right) =>
left.url.localeCompare(right.url)
);
const outputDir = path.join(workspaceRoot, 'coverage/e2e-v8/web');
mkdirSync(outputDir, { recursive: true });
writeFileSync(
path.join(outputDir, 'summary.json'),
`${JSON.stringify(
{
rawCoverageRoot: path.relative(workspaceRoot, rawCoverageRoot),
rawFiles: rawFiles.length,
scripts: summaries,
},
null,
4
)}\n`
);
writeFileSync(
path.join(outputDir, 'summary.md'),
`# Web E2E V8 Coverage
Raw coverage root: ${path.relative(workspaceRoot, rawCoverageRoot)}
Raw coverage files: ${rawFiles.length}
This experimental report is Chromium/V8 byte coverage from Playwright's browser
coverage API. Use it for local investigation; rely on Tier A unit coverage and
E2E semantic coverage for CI gates.
| Script URL | Used bytes | Source bytes | Byte coverage |
| --- | ---: | ---: | ---: |
${summaries
.map(
(entry) =>
`| ${entry.url || '_anonymous_'} | ${entry.usedBytes} | ${entry.sourceLength} | ${entry.pct}% |`
)
.join('\n') || '| _none_ | 0 | 0 | 0% |'}
`
);
console.log(`Wrote coverage/e2e-v8/web/summary.md from ${rawFiles.length} raw coverage files.`);
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { createRequire } from 'node:module';
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const require = createRequire(import.meta.url);
const { createCoverageMap } = require('istanbul-lib-coverage');
const libReport = require('istanbul-lib-report');
const reports = require('istanbul-reports');
const workspaceRoot = process.cwd();
const policy = JSON.parse(
readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8')
);
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));
if (coverageFiles.length === 0) {
console.error('No Tier A coverage-final.json files found under coverage/.');
process.exit(1);
}
rmSync(outputDir, { recursive: true, force: true });
mkdirSync(outputDir, { recursive: true });
const coverageMap = createCoverageMap({});
for (const coverageFile of coverageFiles) {
const data = JSON.parse(readFileSync(coverageFile, 'utf8'));
coverageMap.merge(data);
}
const context = libReport.createContext({
dir: outputDir,
coverageMap,
});
for (const reporter of ['json', 'json-summary', 'lcovonly', 'cobertura', 'html', 'text-summary']) {
reports.create(reporter).execute(context);
}
const summary = coverageMap.getCoverageSummary().toJSON();
writeFileSync(
path.join(outputDir, 'coverage-summary.pretty.json'),
`${JSON.stringify(summary, null, 4)}\n`
);
console.log(`Merged ${coverageFiles.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}%`
);
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const workspaceRoot = process.cwd();
const policyPath = path.join(workspaceRoot, 'tools/coverage/coverage-policy.json');
const policy = JSON.parse(readFileSync(policyPath, 'utf8'));
const requestedProjects = new Set(
process.argv
.slice(2)
.flatMap((arg) =>
arg.startsWith('--projects=')
? arg.slice('--projects='.length).split(',')
: []
)
.map((project) => project.trim())
.filter(Boolean)
);
const tierAProjects = policy.unitCoverage.tierA.filter(
(project) => requestedProjects.size === 0 || requestedProjects.has(project.name)
);
if (tierAProjects.length === 0) {
console.error('No Tier A coverage projects matched the requested filters.');
process.exit(1);
}
function readProjectJson(project) {
const projectJsonPath = path.join(workspaceRoot, project.root, 'project.json');
if (!existsSync(projectJsonPath)) {
throw new Error(`Missing project.json for ${project.name}: ${projectJsonPath}`);
}
return JSON.parse(readFileSync(projectJsonPath, 'utf8'));
}
function toPosix(filePath) {
return filePath.split(path.sep).join('/');
}
function coverageDirFor(project, jestRootMode) {
const workspaceCoverageDir = path.join('coverage', project.root);
if (jestRootMode === 'project') {
return toPosix(path.relative(project.root, workspaceCoverageDir));
}
return toPosix(workspaceCoverageDir);
}
function collectCoverageArgs(project, jestRootMode) {
const sourceGlob =
jestRootMode === 'project'
? 'src/**/*.{ts,js,mjs,html}'
: `${project.sourceRoot}/**/*.{ts,js,mjs,html}`;
const sourcePrefix =
jestRootMode === 'project' ? 'src' : project.sourceRoot;
return [
'--coverage',
`--coverageDirectory=${coverageDirFor(project, jestRootMode)}`,
`--collectCoverageFrom=${sourceGlob}`,
`--collectCoverageFrom=!${sourcePrefix}/**/*.{spec,test}.ts`,
`--collectCoverageFrom=!${sourcePrefix}/**/test-setup.ts`,
`--collectCoverageFrom=!${sourcePrefix}/**/test-stubs/**`,
`--collectCoverageFrom=!${sourcePrefix}/**/*.generated.*`,
`--collectCoverageFrom=!${sourcePrefix}/**/environments/**`,
`--collectCoverageFrom=!${sourcePrefix}/**/index.ts`,
];
}
function jestRootModeFor(project) {
const mode = project.jestRootMode ?? 'workspace';
if (mode !== 'workspace' && mode !== 'project') {
throw new Error(
`Tier A project ${project.name} has unsupported jestRootMode: ${mode}`
);
}
return mode;
}
function buildNxArgs(project) {
const projectJson = readProjectJson(project);
const testTarget = projectJson.targets?.test;
if (!testTarget) {
throw new Error(`Tier A project ${project.name} has no test target.`);
}
if (testTarget.executor === '@nx/jest:jest') {
return [
'nx',
'run',
`${project.name}:test`,
'--configuration=ci',
'--codeCoverage',
`--coverageDirectory=${coverageDirFor(project, 'workspace')}`,
'--output-style=static',
];
}
if (testTarget.executor === 'nx:run-commands') {
const jestRootMode = jestRootModeFor(project);
return [
'nx',
'run',
`${project.name}:test`,
'--output-style=static',
'--',
...collectCoverageArgs(project, jestRootMode),
];
}
throw new Error(
`Tier A project ${project.name} uses unsupported test executor: ${testTarget.executor}`
);
}
for (const project of tierAProjects) {
const coverageDir = path.join(workspaceRoot, 'coverage', project.root);
rmSync(coverageDir, { recursive: true, force: true });
}
if (requestedProjects.size === 0) {
for (const project of [...policy.unitCoverage.tierB, ...policy.unitCoverage.tierC]) {
if (!project.root) {
continue;
}
const coverageDir = path.join(workspaceRoot, 'coverage', project.root);
rmSync(coverageDir, { recursive: true, force: true });
}
}
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);
}
}