diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d076e5d9d..bf4da6b24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,14 +70,8 @@ jobs: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false - - name: Run Tier B/C unit tests without coverage - run: | - projects="$(node tools/coverage/check-coverage-policy.mjs --list-non-tier-a)" - if [ -n "$projects" ]; then - pnpm nx run-many --target=test --projects "$projects" --parallel=3 --output-style=static - else - echo "No Tier B/C projects with a test target." - fi + - name: Run Tier B/C validation commands + run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a env: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 089d52686..387bfcea7 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -44,9 +44,11 @@ the baseline. Use `tools/coverage/coverage-policy.json` as the source of truth for coverage ownership. Every project with a `test` target must be classified in a tier; `pnpm run coverage:policy:check` (part of `coverage:ci`) fails CI when a new -project is missing from the policy or a listed project no longer exists. -CI runs Tier A with coverage (uploaded to Codecov) and Tier B/C tests without -coverage. +project is missing from the policy, a listed project no longer exists, or a +Tier A entry has no test target. CI runs Tier A with coverage (uploaded to +Codecov) and each Tier B/C project's `validationCommand` (falling back to +`nx test`) without coverage; projects with an `e2e` target are skipped there +because the E2E workflow already runs them on every PR. | Tier | Rule | Validation | | --- | --- | --- | diff --git a/tools/coverage/check-coverage-policy.mjs b/tools/coverage/check-coverage-policy.mjs index f33e29284..3fe6e5bf0 100644 --- a/tools/coverage/check-coverage-policy.mjs +++ b/tools/coverage/check-coverage-policy.mjs @@ -9,13 +9,16 @@ * * Usage: * node tools/coverage/check-coverage-policy.mjs - * Validate the policy. Exits non-zero on unclassified or stale entries. - * node tools/coverage/check-coverage-policy.mjs --list-non-tier-a - * Print a comma-separated list of Tier B/C projects that have a test - * target, for running them in CI without coverage. + * Validate the policy. Exits non-zero on unclassified projects, stale + * entries, or Tier A entries without a test target. + * node tools/coverage/check-coverage-policy.mjs --run-non-tier-a + * Run the validation command for each Tier B/C project. Uses the + * entry's `validationCommand` when present, falling back to + * `pnpm nx test `. Projects with an `e2e` target are skipped + * because the E2E workflow already runs them on every PR. */ -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -36,11 +39,59 @@ function showProjects(extraArgs = []) { ['nx', 'show', 'projects', '--json', ...extraArgs], { cwd: workspaceRoot, encoding: 'utf8' } ); - return new Set(JSON.parse(output.slice(output.indexOf('[')))); + const start = output.indexOf('['); + if (start === -1) { + throw new Error( + `nx show projects --json produced no JSON array.\nRaw output:\n${output}` + ); + } + return new Set(JSON.parse(output.slice(start))); } const testProjects = showProjects(['--withTarget', 'test']); const allProjects = showProjects(); +const e2eProjects = showProjects(['--withTarget', 'e2e']); + +function runNonTierA() { + let failed = false; + + for (const project of [...tiers.tierB, ...tiers.tierC]) { + if (e2eProjects.has(project.name)) { + console.log( + `==> Skipping ${project.name}: covered by the E2E workflow.` + ); + continue; + } + + const command = + project.validationCommand ?? + (testProjects.has(project.name) + ? `pnpm nx test ${project.name}` + : null); + if (!command) { + console.log( + `==> Skipping ${project.name}: no validation command or test target.` + ); + continue; + } + + console.log(`\n==> Validating ${project.name}: ${command}`); + const result = spawnSync(command, { + cwd: workspaceRoot, + shell: true, + stdio: 'inherit', + }); + if (result.status !== 0) { + failed = true; + } + } + + process.exit(failed ? 1 : 0); +} + +if (process.argv.includes('--run-non-tier-a')) { + runNonTierA(); +} const classified = new Map( Object.entries(tiers).flatMap(([tier, projects]) => @@ -48,16 +99,11 @@ const classified = new Map( ) ); -if (process.argv.includes('--list-non-tier-a')) { - const nonTierA = [...tiers.tierB, ...tiers.tierC] - .map((project) => project.name) - .filter((name) => testProjects.has(name)); - process.stdout.write(nonTierA.join(',')); - process.exit(0); -} - const unclassified = [...testProjects].filter((name) => !classified.has(name)); const stale = [...classified.keys()].filter((name) => !allProjects.has(name)); +const tierAWithoutTest = tiers.tierA + .map((project) => project.name) + .filter((name) => !testProjects.has(name)); if (unclassified.length > 0) { console.error( @@ -72,13 +118,23 @@ if (unclassified.length > 0) { if (stale.length > 0) { console.error( - 'Coverage policy lists projects that no longer have a test target:\n' + + 'Coverage policy lists projects that do not exist in the workspace:\n' + stale.map((name) => ` - ${name}`).join('\n') + '\n\nRemove them from tools/coverage/coverage-policy.json.' ); process.exit(1); } +if (tierAWithoutTest.length > 0) { + console.error( + 'Tier A projects must have a test target, but these do not:\n' + + tierAWithoutTest.map((name) => ` - ${name}`).join('\n') + + '\n\nRestore the test target or move the project to Tier B/C in ' + + 'tools/coverage/coverage-policy.json.' + ); + process.exit(1); +} + console.log( `Coverage policy OK: ${testProjects.size} test projects classified ` + `(${tiers.tierA.length} Tier A, ${tiers.tierB.length} Tier B, ${tiers.tierC.length} Tier C).` diff --git a/tools/eslint/generate-max-lines-baseline.mjs b/tools/eslint/generate-max-lines-baseline.mjs index bf288ac49..a92204f12 100644 --- a/tools/eslint/generate-max-lines-baseline.mjs +++ b/tools/eslint/generate-max-lines-baseline.mjs @@ -25,7 +25,7 @@ function collectTsFiles(dir, results) { collectTsFiles(path.join(dir, entry.name), results); } } else if ( - entry.name.endsWith('.ts') && + (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) && !entry.name.endsWith('.d.ts') ) { results.push(path.join(dir, entry.name));