mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
add scoped coverage reporting
This commit is contained in:
1 parent
ade043d427
commit
fb576d7a78
31 files changed
+1407
-45
No files matched your search
@@ -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).`
|
||||
);
|
||||
@@ -0,0 +1,345 @@
|
||||
{
|
||||
"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",
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/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 rawFiles = listFiles(path.join(workspaceRoot, 'dist'), (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({ rawFiles: rawFiles.length, scripts: summaries }, null, 4)}\n`
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(outputDir, 'summary.md'),
|
||||
`# Web E2E V8 Coverage
|
||||
|
||||
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.`);
|
||||
@@ -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}%`
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/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 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', '--output-style=static'];
|
||||
}
|
||||
|
||||
if (testTarget.executor === 'nx:run-commands') {
|
||||
const command = Array.isArray(testTarget.options?.command)
|
||||
? testTarget.options.command.join(' ')
|
||||
: String(testTarget.options?.command ?? '');
|
||||
const jestRootMode = command.includes('apps/web/jest.config.ts')
|
||||
? 'project'
|
||||
: 'workspace';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user