diff --git a/.claudesync/reusable-gha-code-coverage-gate/RFC-template.md b/.claudesync/reusable-gha-code-coverage-gate/RFC-template.md
new file mode 100644
index 00000000..caeb18b8
--- /dev/null
+++ b/.claudesync/reusable-gha-code-coverage-gate/RFC-template.md
@@ -0,0 +1,25 @@
+# RFC Title
+
+## Summary
+A brief overview of the proposed feature or change.
+
+## Motivation / Why
+Explain the problem or limitation this RFC addresses.
+
+## Proposed Solution / Design
+Detailed description of the new API, syntax, or feature. Include examples if applicable.
+
+## Technical Details
+In-depth explanation of implementation, internals, and integration with existing Angular features.
+
+## Benefits
+List advantages such as improved developer experience, performance, or alignment with modern standards.
+
+## Drawbacks / Alternatives
+Potential downsides or alternative solutions considered.
+
+## Community Feedback
+Summary or invitation for community feedback to refine the proposal.
+
+## Conclusion
+Final thoughts and next steps.
diff --git a/.claudesync/reusable-gha-code-coverage-gate/Reuasble GHA Code Coverage Gate.vtt b/.claudesync/reusable-gha-code-coverage-gate/Reuasble GHA Code Coverage Gate.vtt
new file mode 100644
index 00000000..4c102c22
--- /dev/null
+++ b/.claudesync/reusable-gha-code-coverage-gate/Reuasble GHA Code Coverage Gate.vtt
@@ -0,0 +1,327 @@
+WEBVTT
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/7-0
+00:00:12.155 --> 00:00:12.875
+Oh, there you go.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/9-0
+00:00:18.215 --> 00:00:19.175
+You're sharing something.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/11-0
+00:00:22.775 --> 00:00:23.575
+Yeah, I do share.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/15-0
+00:00:25.405 --> 00:00:27.085
+So the sharing started.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/17-0
+00:00:28.225 --> 00:00:29.065
+Yeah. Yeah, so.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/34-0
+00:00:30.645 --> 00:00:35.939
+OK,
+so now as we want to create a reusable
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/34-1
+00:00:35.939 --> 00:00:40.125
+GitHub coverage here new workflow.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/69-0
+00:00:42.895 --> 00:00:45.882
+So inside the test,
+where do we start with?
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/69-1
+00:00:45.882 --> 00:00:50.431
+We have to create a first.
+We have to create a new workflow in the
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/69-2
+00:00:50.431 --> 00:00:54.775
+GitHub action repo or we want to start
+with the C Plus AP Repo.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/88-0
+00:00:57.695 --> 00:01:04.423
+We should start with the get up actions
+repo because that's where all the
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/83-0
+00:01:01.685 --> 00:01:01.885
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/88-1
+00:01:04.423 --> 00:01:07.695
+reusable git up actions are defined.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/104-0
+00:01:09.215 --> 00:01:15.423
+And all the Fe reports users the get up
+actions feasible get up actions from the
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/104-1
+00:01:15.423 --> 00:01:17.415
+get Up actions repository.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/120-0
+00:01:18.965 --> 00:01:24.934
+So we need to start from the get up
+actions depository and that we already
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/120-1
+00:01:24.934 --> 00:01:26.685
+have the core quality.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/127-0
+00:01:28.245 --> 00:01:30.725
+Core flow and which is on workflow call.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/132-0
+00:01:30.675 --> 00:01:32.755
+Yep, Yep.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/147-0
+00:01:32.565 --> 00:01:38.536
+And we need similar to the code quality
+workflow but with the coverage and
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/147-1
+00:01:38.536 --> 00:01:40.845
+coverage get get added to it.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/150-0
+00:01:43.385 --> 00:01:44.305
+And the governor?
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/178-0
+00:01:44.675 --> 00:01:49.986
+The secret for the workflow for now,
+we will be calling same thing like as the
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/178-1
+00:01:49.986 --> 00:01:55.297
+same code quality we will be calling
+something like runmini master that we are
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/178-2
+00:01:55.297 --> 00:01:57.515
+already calling in the unit test.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/192-0
+00:01:59.085 --> 00:02:05.780
+Something similar to that inside that we
+will be also adding another param like
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/192-1
+00:02:05.780 --> 00:02:06.365
+secret.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/207-0
+00:02:07.965 --> 00:02:13.537
+Where we will be having the threshold
+configuration for each library for that
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/207-1
+00:02:13.537 --> 00:02:13.965
+reple.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/257-0
+00:02:16.115 --> 00:02:22.043
+I think we need to switch.
+I mean we need to switch between normal
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/257-1
+00:02:22.043 --> 00:02:26.289
+code quality and one with the code
+quality kit.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/257-2
+00:02:26.289 --> 00:02:33.366
+So I think we could do that with this
+secret which you mentioned GitHub secret.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/235-0
+00:02:27.045 --> 00:02:27.245
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/247-0
+00:02:31.595 --> 00:02:31.715
+And.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/257-3
+00:02:33.366 --> 00:02:40.355
+So if the GitHub secret as well you and
+if it is defined then we can take the.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/262-0
+00:02:40.445 --> 00:02:40.645
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/263-0
+00:02:41.365 --> 00:02:43.405
+Code coverage workflow.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/280-0
+00:02:44.585 --> 00:02:49.643
+If not, then we we take the code quality,
+the existing one.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/278-0
+00:02:48.835 --> 00:02:49.995
+Yeah. OK.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/280-1
+00:02:49.643 --> 00:02:52.425
+So this we could split the logic.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/287-0
+00:02:54.325 --> 00:02:58.365
+Mm hmm.
+Then do we need another workflow for that?
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/299-0
+00:03:00.845 --> 00:03:03.702
+Yeah,
+we need to create a new workflow file.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/299-1
+00:03:03.702 --> 00:03:04.845
+We can call it as.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/331-0
+00:03:06.485 --> 00:03:12.733
+Code quality kit. Yeah,
+code quality kit and in the code quality
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/308-0
+00:03:06.885 --> 00:03:08.325
+Average K dot it.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/317-0
+00:03:11.325 --> 00:03:11.525
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/331-1
+00:03:12.733 --> 00:03:19.365
+git we can add this coverage flag to the
+existing GIST unit test so.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/337-0
+00:03:21.215 --> 00:03:22.935
+And this will run for all.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/343-0
+00:03:24.525 --> 00:03:26.365
+All apps and lips the affected.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/374-0
+00:03:28.885 --> 00:03:35.357
+MX apps and lips and produces the
+coverage and some of the coverage
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/374-1
+00:03:35.357 --> 00:03:43.066
+threshold need to be elevated against
+this git up secret and this git up secret.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/374-2
+00:03:43.066 --> 00:03:45.445
+It should be an adjacent.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/386-0
+00:03:47.285 --> 00:03:52.405
+Object and this should provide the app
+name or Lib name.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/382-0
+00:03:49.295 --> 00:03:49.495
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/388-0
+00:03:53.405 --> 00:03:54.005
+With that.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/405-0
+00:03:56.045 --> 00:03:58.595
+Coverage,
+line coverage and statement coverage and
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/405-1
+00:03:58.595 --> 00:04:01.245
+functions coverage and franchise coverage
+threshold.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/417-0
+00:04:05.945 --> 00:04:10.905
+And then the new quality gate,
+our workflow this needs to be elevated.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/422-0
+00:04:12.445 --> 00:04:13.325
+And the pipeline.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/439-0
+00:04:15.045 --> 00:04:19.355
+Should be green if it satisfies the
+threshold. If not,
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/439-1
+00:04:19.355 --> 00:04:24.605
+the pipeline should fail,
+so they should be our target to achieve.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/458-0
+00:04:28.225 --> 00:04:32.612
+So for this we need to create an A master
+plan. How to implement it?
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/446-0
+00:04:28.375 --> 00:04:29.495
+Mm hmm. OK.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/458-1
+00:04:32.612 --> 00:04:36.745
+Probably we could use the claw rate to
+generate the master plan.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/454-0
+00:04:34.525 --> 00:04:34.725
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/465-0
+00:04:38.765 --> 00:04:41.165
+And let the cloud to guide us in the
+process.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/468-0
+00:04:39.665 --> 00:04:44.025
+Yeah, makes sense.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/489-0
+00:04:46.345 --> 00:04:54.544
+OK. And maybe this is sloven is here,
+I will share him share with the road map
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/489-1
+00:04:54.544 --> 00:04:57.865
+that we have and we'll get some.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/486-0
+00:04:55.905 --> 00:04:56.105
+Mm hmm.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/502-0
+00:05:00.925 --> 00:05:05.765
+Yeah. So maybe I stopped the recording.
+You can use the transcript already for.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/500-0
+00:05:03.725 --> 00:05:03.925
+Yep.
+
+d2af6f38-3883-44ee-8f52-3ec1c2e894d4/503-0
+00:05:07.685 --> 00:05:07.805
+Yep.
\ No newline at end of file
diff --git a/tools/Reusable GHA PR Workflow for Coverage Gate(Unit Test).md b/.claudesync/reusable-gha-code-coverage-gate/Reusable GHA PR Workflow for Coverage Gate(Unit Test).md
similarity index 100%
rename from tools/Reusable GHA PR Workflow for Coverage Gate(Unit Test).md
rename to .claudesync/reusable-gha-code-coverage-gate/Reusable GHA PR Workflow for Coverage Gate(Unit Test).md
diff --git a/.claudesync/reusable-gha-code-coverage-gate/coverage-gate-implementation-plan.md b/.claudesync/reusable-gha-code-coverage-gate/coverage-gate-implementation-plan.md
new file mode 100644
index 00000000..150599ae
--- /dev/null
+++ b/.claudesync/reusable-gha-code-coverage-gate/coverage-gate-implementation-plan.md
@@ -0,0 +1,499 @@
+# Coverage Gate Implementation Plan
+
+## 1. Project Structure
+
+We'll modify/create these files:
+- Modify: `.github/workflows/fe-code-quality.yml` - Enhance existing workflow
+- Create: `tools/scripts/run-many/coverage-evaluator.ts` - Module for evaluating coverage reports
+- Create: `tools/scripts/run-many/threshold-handler.ts` - Module to parse and handle thresholds
+
+## 2. Detailed Implementation Steps
+
+### 2.1. Create Coverage Threshold Handler Module
+
+First, create `tools/scripts/run-many/threshold-handler.ts`:
+
+```typescript
+import * as core from '@actions/core';
+
+export interface CoverageThreshold {
+ lines?: number;
+ statements?: number;
+ functions?: number;
+ branches?: number;
+}
+
+export interface ThresholdConfig {
+ global: CoverageThreshold;
+ projects: Record;
+}
+
+/**
+ * Parses the COVERAGE_THRESHOLDS environment variable
+ */
+export function getCoverageThresholds(): ThresholdConfig {
+ if (!process.env.COVERAGE_THRESHOLDS) {
+ return { global: {}, projects: {} };
+ }
+
+ try {
+ return JSON.parse(process.env.COVERAGE_THRESHOLDS);
+ } catch (error) {
+ core.error(`Error parsing COVERAGE_THRESHOLDS: ${error.message}`);
+ return { global: {}, projects: {} };
+ }
+}
+
+/**
+ * Gets thresholds for a specific project
+ */
+export function getProjectThresholds(project: string, thresholds: ThresholdConfig): CoverageThreshold | null {
+ // If project explicitly set to null, return null to skip
+ if (thresholds.projects && thresholds.projects[project] === null) {
+ return null;
+ }
+
+ // If project has specific thresholds, use those
+ if (thresholds.projects && thresholds.projects[project]) {
+ return thresholds.projects[project];
+ }
+
+ // Otherwise, use global thresholds if available
+ if (thresholds.global) {
+ return thresholds.global;
+ }
+
+ // If no thresholds defined, return null
+ return null;
+}
+```
+
+### 2.2. Create Coverage Evaluator Module
+
+Next, create `tools/scripts/run-many/coverage-evaluator.ts`:
+
+```typescript
+import * as fs from 'fs';
+import * as path from 'path';
+import * as core from '@actions/core';
+import { CoverageThreshold, getProjectThresholds, ThresholdConfig } from './threshold-handler';
+
+interface CoverageSummary {
+ lines: { pct: number };
+ statements: { pct: number };
+ functions: { pct: number };
+ branches: { pct: number };
+}
+
+interface ProjectCoverageResult {
+ project: string;
+ thresholds: CoverageThreshold | null;
+ actual: {
+ lines: number;
+ statements: number;
+ functions: number;
+ branches: number;
+ } | null;
+ status: 'PASSED' | 'FAILED' | 'SKIPPED';
+}
+
+/**
+ * Evaluates coverage for all projects against their thresholds
+ */
+export function evaluateCoverage(projects: string[], thresholds: ThresholdConfig): boolean {
+ if (!process.env.COVERAGE_THRESHOLDS) {
+ return true; // No thresholds defined, pass by default
+ }
+
+ let allProjectsPassed = true;
+ const coverageResults: ProjectCoverageResult[] = [];
+
+ for (const project of projects) {
+ const projectThresholds = getProjectThresholds(project, thresholds);
+
+ // Skip projects with null thresholds
+ if (projectThresholds === null) {
+ core.info(`Coverage evaluation skipped for ${project}`);
+ coverageResults.push({
+ project,
+ thresholds: null,
+ actual: null,
+ status: 'SKIPPED'
+ });
+ continue;
+ }
+
+ const coveragePath = path.resolve(process.cwd(), `coverage/${project}/coverage-summary.json`);
+
+ if (!fs.existsSync(coveragePath)) {
+ core.warning(`No coverage report found for ${project} at ${coveragePath}`);
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: null,
+ status: 'FAILED' // Mark as failed if no coverage report is found
+ });
+ allProjectsPassed = false;
+ continue;
+ }
+
+ try {
+ const coverageData = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
+ const summary = coverageData.total as CoverageSummary;
+
+ const projectPassed =
+ (!projectThresholds.lines || summary.lines.pct >= projectThresholds.lines) &&
+ (!projectThresholds.statements || summary.statements.pct >= projectThresholds.statements) &&
+ (!projectThresholds.functions || summary.functions.pct >= projectThresholds.functions) &&
+ (!projectThresholds.branches || summary.branches.pct >= projectThresholds.branches);
+
+ if (!projectPassed) {
+ allProjectsPassed = false;
+ }
+
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: {
+ lines: summary.lines.pct,
+ statements: summary.statements.pct,
+ functions: summary.functions.pct,
+ branches: summary.branches.pct
+ },
+ status: projectPassed ? 'PASSED' : 'FAILED'
+ });
+ } catch (error) {
+ core.error(`Error processing coverage for ${project}: ${error.message}`);
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: null,
+ status: 'FAILED'
+ });
+ allProjectsPassed = false;
+ }
+ }
+
+ // Post results to PR comment
+ postCoverageComment(coverageResults);
+
+ return allProjectsPassed;
+}
+
+/**
+ * Formats the coverage results into a markdown table
+ */
+function formatCoverageComment(results: ProjectCoverageResult[], artifactUrl: string): string {
+ let comment = '## Test Coverage Results\n\n';
+ comment += '| Project | Metric | Threshold | Actual | Status |\n';
+ comment += '|---------|--------|-----------|--------|--------|\n';
+
+ results.forEach(result => {
+ if (result.status === 'SKIPPED') {
+ comment += `| ${result.project} | All | N/A | N/A | ⏩ SKIPPED |\n`;
+ } else if (result.actual === null) {
+ comment += `| ${result.project} | All | Defined | No Data | ❌ FAILED |\n`;
+ } else {
+ const metrics = ['lines', 'statements', 'functions', 'branches'];
+ metrics.forEach((metric, index) => {
+ // Skip metrics that don't have a threshold
+ if (!result.thresholds[metric]) return;
+
+ const threshold = result.thresholds[metric];
+ const actual = result.actual[metric].toFixed(2);
+ const status = actual >= threshold ? '✅ PASSED' : '❌ FAILED';
+
+ // Only include project name in the first row for this project
+ const projectCell = index === 0 ? result.project : '';
+
+ comment += `| ${projectCell} | ${metric} | ${threshold}% | ${actual}% | ${status} |\n`;
+ });
+ }
+ });
+
+ // Add overall status
+ const overallStatus = results.every(r => r.status !== 'FAILED') ? '✅ PASSED' : '❌ FAILED';
+ comment += `\n### Overall Status: ${overallStatus}\n`;
+
+ // Add link to detailed HTML reports
+ if (artifactUrl) {
+ comment += `\n📊 [View Detailed HTML Coverage Reports](${artifactUrl})\n`;
+ }
+
+ return comment;
+}
+
+/**
+ * Writes the coverage results to a file for PR comment
+ */
+function postCoverageComment(results: ProjectCoverageResult[]): void {
+ // The actual artifact URL will be provided by GitHub Actions in the workflow
+ const artifactUrl = process.env.COVERAGE_ARTIFACT_URL || '';
+
+ const comment = formatCoverageComment(results, artifactUrl);
+
+ // Write to a file that will be used by thollander/actions-comment-pull-request action
+ const gitHubCommentsFile = path.resolve(process.cwd(), 'coverage-report.txt');
+ fs.writeFileSync(gitHubCommentsFile, comment);
+
+ core.info('Coverage results saved for PR comment');
+}
+```
+
+### 2.3. Modify run-many.ts Script
+
+Update `tools/scripts/run-many/run-many.ts` to incorporate the coverage gate functionality:
+
+```typescript
+import { getAffectedProjects } from './affected-projects';
+import { execSync } from 'child_process';
+import * as core from '@actions/core';
+import * as path from 'path';
+import { getCoverageThresholds } from './threshold-handler';
+import { evaluateCoverage } from './coverage-evaluator';
+
+function getE2ECommand(command: string, base: string): string {
+ command = command.concat(` -c ci --base=${base} --verbose`);
+ return command;
+}
+
+function runCommand(command: string): void {
+ core.info(`Running > ${command}`);
+
+ try {
+ const output = execSync(command, { stdio: 'pipe', maxBuffer: 1024 * 1024 * 1024, encoding: 'utf-8' }); // 1GB
+ core.info(output.toString())
+ } catch (error) {
+ if (error.signal === 'SIGTERM') {
+ core.error('Timed out');
+ } else if (error.code === 'ENOBUFS') {
+ core.error('Buffer exceeded');
+ }
+ core.info(error.stdout.toString());
+ core.error(error.stderr.toString());
+ core.error(`Error message: ${error.message}`);
+ core.error(`Error name: ${error.name}`);
+ core.error(`Stacktrace:\n${error.stack}`);
+ core.setFailed(error);
+ }
+}
+
+function main() {
+ const target = process.argv[2];
+ const jobIndex = Number(process.argv[3]);
+ const jobCount = Number(process.argv[4]);
+ let base = process.argv[5];
+
+ // in case base is not a SHA1 commit hash add origin
+ if (!/\b[0-9a-f]{5,40}\b/.test(base)) base = 'origin/' + base;
+ if(base.includes('0000000000000000')){
+ base = execSync(`git rev-parse --abbrev-ref origin/HEAD `).toString().trim();
+ }
+ const ref = process.argv[6];
+
+ core.info(`Inputs:\n target ${target},\n jobIndex: ${jobIndex},\n jobCount ${jobCount},\n base ${base},\n ref ${ref}`)
+
+ const projectsString = getAffectedProjects(target, jobIndex, jobCount, base, ref);
+ const projects = projectsString ? projectsString.split(',') : [];
+
+ // Check if coverage gate is enabled
+ const coverageEnabled = !!process.env.COVERAGE_THRESHOLDS;
+
+ // Modified command construction
+ const runManyProjectsCmd = `npx nx run-many --targets=${target} --projects="${projectsString}"`;
+ let cmd = `${runManyProjectsCmd} --parallel=false --prod`;
+
+ // Add coverage flag if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ // Add coverage reporters for HTML, JSON, and JUnit output
+ cmd += ' --coverage --coverageReporters=json,lcov,text,clover,html,json-summary --reporters=default,jest-junit';
+ }
+
+ if (target.includes('e2e')) {
+ cmd = getE2ECommand(cmd, base);
+ }
+
+ if (projects.length > 0) {
+ runCommand(cmd);
+
+ // Evaluate coverage if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ const thresholds = getCoverageThresholds();
+ const passed = evaluateCoverage(projects, thresholds);
+
+ if (!passed) {
+ core.setFailed('One or more projects failed to meet coverage thresholds');
+ process.exit(1);
+ }
+ }
+ } else {
+ core.info('No affected projects :)');
+ }
+}
+
+main();
+```
+
+### 2.4. Modify Existing Workflow File for Code Quality
+
+Update the existing `.github/workflows/fe-code-quality.yml`:
+
+```yaml
+name: Frontend Code Quality Workflow
+
+on:
+ workflow_call:
+ secrets:
+ COVERAGE_THRESHOLDS:
+ required: false
+
+jobs:
+ code-quality:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ target: [ 'test' ]
+ jobIndex: [ 1, 2, 3, 4 ]
+ env:
+ jobCount: 4
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.ref }}
+ fetch-depth: 0
+
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 18.19.1
+ - name: Cache Node Modules
+ id: npm-cache
+ uses: actions/cache@v4
+ with:
+ path: '**/node_modules'
+ key: ${{ runner.os }}-modules-${{ hashFiles('**/package-lock.json') }}
+
+ - name: Formatter
+ run: npx nx format:check --base=origin/${{ github.event.pull_request.base.ref }}
+
+ - name: Linter
+ run: npx nx affected --target=lint --parallel --configuration=dev --base=origin/${{ github.event.pull_request.base.ref }}
+
+ - name: Unit Tests
+ uses: collaborationFactory/github-actions/.github/actions/run-many@master
+ with:
+ target: ${{ matrix.target }}
+ jobIndex: ${{ matrix.jobIndex }}
+ jobCount: ${{ env.jobCount }}
+ base: ${{ github.event.pull_request.base.ref }}
+ ref: ${{ github.event.pull_request.head.ref }}
+ env:
+ COVERAGE_THRESHOLDS: ${{ secrets.COVERAGE_THRESHOLDS }}
+
+ - name: Upload Coverage Reports
+ if: always() && matrix.jobIndex == 1 && secrets.COVERAGE_THRESHOLDS != ''
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-reports
+ path: coverage/
+ retention-days: 7
+
+ - name: Set Artifact URL
+ if: github.event_name == 'pull_request' && always() && matrix.jobIndex == 1 && secrets.COVERAGE_THRESHOLDS != ''
+ run: |
+ echo "COVERAGE_ARTIFACT_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV
+
+ - name: Comment PR with Coverage Report
+ if: github.event_name == 'pull_request' && always() && matrix.jobIndex == 1 && secrets.COVERAGE_THRESHOLDS != ''
+ uses: thollander/actions-comment-pull-request@v3
+ with:
+ file_path: 'coverage-report.txt'
+ comment_tag: 'coverage-report'
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+## 3. Usage Instructions
+
+### 3.1. Setting Up Coverage Thresholds
+
+Project maintainers will need to create a GitHub repository secret named `COVERAGE_THRESHOLDS` with JSON content like:
+
+```json
+{
+ "global": {
+ "lines": 80,
+ "statements": 80,
+ "functions": 75,
+ "branches": 70
+ },
+ "projects": {
+ "cf-platform": {
+ "lines": 85,
+ "statements": 85,
+ "functions": 80,
+ "branches": 75
+ },
+ "cf-components": null,
+ "cf-utils": {
+ "lines": 70,
+ "statements": 70,
+ "functions": 65,
+ "branches": 60
+ }
+ }
+}
+```
+
+### 3.2. Enabling the Coverage Gate
+
+To enable the coverage gate in a project:
+
+1. Configure the `COVERAGE_THRESHOLDS` secret in your repository settings
+2. Make sure your Jest configuration is compatible with the coverage reporters
+3. No changes to workflows needed - the existing code quality workflow will automatically use coverage gates when the secret is set
+
+## 4. Testing Plan
+
+### 4.1. Unit Tests
+
+Create unit tests for:
+- Threshold parsing logic
+- Coverage evaluation logic
+- PR comment formatting
+
+### 4.2. Integration Tests
+
+1. Test with valid thresholds and passing coverage
+2. Test with valid thresholds and failing coverage
+3. Test with empty or invalid threshold configurations
+4. Test with missing coverage reports
+
+### 4.3. End-to-End Tests
+
+Create a sample PR with:
+- Projects that pass the thresholds
+- Projects that fail the thresholds
+- Projects exempt from coverage evaluation
+
+## 5. Implementation Timeline
+
+- Day 1: Implement threshold handling and coverage evaluation modules
+- Day 2: Update run-many.ts and modify workflow file
+- Day 3: Testing and bug fixes
+- Day 4: Documentation and final review
+
+## 6. Future Improvements
+
+- Add trend analysis to show coverage improvement/regression over time
+- Support different thresholds for different branches
+- Add visual charts to the PR comments
+- Integration with code quality tools like SonarQube
+
+## 7. Key Benefits
+
+- Enforces coverage standards across the codebase
+- Provides immediate feedback to developers about coverage issues
+- Uses existing tooling and workflow to minimize disruption
+- Allows customization of thresholds per project or globally
+- Can skip evaluation for projects where coverage isn't relevant
+
+This implementation will help maintain or improve code quality by ensuring adequate test coverage across the codebase while providing flexibility for different project requirements.
diff --git a/.claudesync/reusable-gha-code-coverage-gate/coverage-gate-master-plan.md b/.claudesync/reusable-gha-code-coverage-gate/coverage-gate-master-plan.md
new file mode 100644
index 00000000..1dcae7c1
--- /dev/null
+++ b/.claudesync/reusable-gha-code-coverage-gate/coverage-gate-master-plan.md
@@ -0,0 +1,435 @@
+### Key Benefits
+
+- Enforces coverage standards across the codebase
+- Provides immediate feedback to developers about coverage issues
+- Uses existing tooling and workflow to minimize disruption
+- Allows customization of thresholds per project or globally
+- Can skip evaluation for projects where coverage isn't relevant
+
+This implementation will help maintain or improve code quality by ensuring adequate test coverage across the codebase while providing flexibility for different project requirements.# Master Plan: GitHub Actions Coverage Gate Implementation
+
+## Overview
+
+This master plan outlines the implementation of a Jest test coverage quality gate for GitHub Actions workflows. The gate will enforce configurable coverage thresholds on affected projects in the PR pipeline, blocking PRs that don't meet standards.
+
+## 1. JSON Structure for Coverage Thresholds
+
+The coverage thresholds will be stored as a GitHub repository secret (`COVERAGE_THRESHOLDS`) with the following JSON structure:
+
+```json
+{
+ "global": {
+ "lines": 80,
+ "statements": 80,
+ "functions": 75,
+ "branches": 70
+ },
+ "projects": {
+ "cf-platform": {
+ "lines": 85,
+ "statements": 85,
+ "functions": 80,
+ "branches": 75
+ },
+ "cf-components": null, // Skip coverage evaluation for this project
+ "cf-utils": {
+ "lines": 70,
+ "statements": 70,
+ "functions": 65,
+ "branches": 60
+ }
+ }
+}
+```
+
+Key features:
+- `global`: Default thresholds applied to any project without specific settings
+- `projects`: Project-specific thresholds that override global defaults
+- `null` values: Explicitly skip coverage evaluation for specific projects
+
+## 2. Implementation in run-many.ts
+
+We'll modify the existing `tools/scripts/run-many/run-many.ts` script to:
+1. Check for the presence of `COVERAGE_THRESHOLDS` environment variable
+2. If present, parse it as JSON and extract thresholds
+3. Add coverage collection to the test command
+4. Evaluate coverage results against thresholds
+5. Generate a coverage report for PR commenting
+6. Fail the build if any project doesn't meet its thresholds
+
+### Key Functions to Implement:
+
+#### 2.1 Parse Coverage Thresholds
+
+```typescript
+function getCoverageThresholds(): any {
+ if (!process.env.COVERAGE_THRESHOLDS) {
+ return { global: {}, projects: {} };
+ }
+
+ try {
+ return JSON.parse(process.env.COVERAGE_THRESHOLDS);
+ } catch (error) {
+ core.error(`Error parsing COVERAGE_THRESHOLDS: ${error.message}`);
+ return { global: {}, projects: {} };
+ }
+}
+
+function getProjectThresholds(project: string, thresholds: any): any {
+ // If project explicitly set to null, return null to skip
+ if (thresholds.projects && thresholds.projects[project] === null) {
+ return null;
+ }
+
+ // If project has specific thresholds, use those
+ if (thresholds.projects && thresholds.projects[project]) {
+ return thresholds.projects[project];
+ }
+
+ // Otherwise, use global thresholds if available
+ if (thresholds.global) {
+ return thresholds.global;
+ }
+
+ // If no thresholds defined, return null
+ return null;
+}
+```
+
+#### 2.2 Evaluate Coverage Against Thresholds
+
+```typescript
+function evaluateCoverage(projects: string[], thresholds: any): boolean {
+ if (!process.env.COVERAGE_THRESHOLDS) {
+ return true; // No thresholds defined, pass by default
+ }
+
+ let allProjectsPassed = true;
+ const coverageResults = [];
+
+ for (const project of projects) {
+ const projectThresholds = getProjectThresholds(project, thresholds);
+
+ // Skip projects with null thresholds
+ if (projectThresholds === null) {
+ core.info(`Coverage evaluation skipped for ${project}`);
+ coverageResults.push({
+ project,
+ thresholds: null,
+ actual: null,
+ status: 'SKIPPED'
+ });
+ continue;
+ }
+
+ const coveragePath = path.resolve(process.cwd(), `coverage/${project}/coverage-summary.json`);
+
+ if (!fs.existsSync(coveragePath)) {
+ core.warning(`No coverage report found for ${project} at ${coveragePath}`);
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: null,
+ status: 'FAILED' // Mark as failed if no coverage report is found
+ });
+ allProjectsPassed = false;
+ continue;
+ }
+
+ const coverageData = JSON.parse(fs.readFileSync(coveragePath, 'utf8'));
+ const summary = coverageData.total; // Use the summary from Jest coverage report
+
+ const projectPassed =
+ summary.lines.pct >= projectThresholds.lines &&
+ summary.statements.pct >= projectThresholds.statements &&
+ summary.functions.pct >= projectThresholds.functions &&
+ summary.branches.pct >= projectThresholds.branches;
+
+ if (!projectPassed) {
+ allProjectsPassed = false;
+ }
+
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: {
+ lines: summary.lines.pct,
+ statements: summary.statements.pct,
+ functions: summary.functions.pct,
+ branches: summary.branches.pct
+ },
+ status: projectPassed ? 'PASSED' : 'FAILED'
+ });
+ }
+
+ // Post results to PR comment
+ postCoverageComment(coverageResults);
+
+ return allProjectsPassed;
+}
+```
+
+#### 2.3 Generate PR Comment with Tabular Results
+
+```typescript
+function formatCoverageComment(results: any[], artifactUrl: string): string {
+ let comment = '## Test Coverage Results\n\n';
+ comment += '| Project | Metric | Threshold | Actual | Status |\n';
+ comment += '|---------|--------|-----------|--------|--------|\n';
+
+ results.forEach(result => {
+ if (result.status === 'SKIPPED') {
+ comment += `| ${result.project} | All | N/A | N/A | ⏩ SKIPPED |\n`;
+ } else if (result.actual === null) {
+ comment += `| ${result.project} | All | Defined | No Data | ❌ FAILED |\n`;
+ } else {
+ const metrics = ['lines', 'statements', 'functions', 'branches'];
+ metrics.forEach((metric, index) => {
+ const threshold = result.thresholds[metric];
+ const actual = result.actual[metric].toFixed(2);
+ const status = actual >= threshold ? '✅ PASSED' : '❌ FAILED';
+
+ // Only include project name in the first row for this project
+ const projectCell = index === 0 ? result.project : '';
+
+ comment += `| ${projectCell} | ${metric} | ${threshold}% | ${actual}% | ${status} |\n`;
+ });
+ }
+ });
+
+ // Add overall status
+ const overallStatus = results.every(r => r.status !== 'FAILED') ? '✅ PASSED' : '❌ FAILED';
+ comment += `\n### Overall Status: ${overallStatus}\n`;
+
+ // Add link to detailed HTML reports
+ if (artifactUrl) {
+ comment += `\n📊 [View Detailed HTML Coverage Reports](${artifactUrl})\n`;
+ }
+
+ return comment;
+}
+
+function postCoverageComment(results: any[]): void {
+ // The actual artifact URL will be provided by GitHub Actions in the workflow
+ const artifactUrl = process.env.COVERAGE_ARTIFACT_URL || '';
+
+ const comment = formatCoverageComment(results, artifactUrl);
+
+ // Write to a file that will be used by thollander/actions-comment-pull-request action
+ const gitHubCommentsFile = path.resolve(process.cwd(), 'coverage-report.txt');
+ fs.writeFileSync(gitHubCommentsFile, comment);
+
+ core.info('Coverage results saved for PR comment');
+}
+```
+
+#### 2.4 Modified Main Function
+
+```typescript
+function main() {
+ const target = process.argv[2];
+ const jobIndex = Number(process.argv[3]);
+ const jobCount = Number(process.argv[4]);
+ let base = process.argv[5];
+
+ // in case base is not a SHA1 commit hash add origin
+ if (!/\b[0-9a-f]{5,40}\b/.test(base)) base = 'origin/' + base;
+ if(base.includes('0000000000000000')){
+ base = execSync(`git rev-parse --abbrev-ref origin/HEAD `).toString().trim();
+ }
+ const ref = process.argv[6];
+
+ core.info(`Inputs:\n target ${target},\n jobIndex: ${jobIndex},\n jobCount ${jobCount},\n base ${base},\n ref ${ref}`)
+
+ const projectsString = getAffectedProjects(target, jobIndex, jobCount, base, ref);
+ const projects = projectsString ? projectsString.split(',') : [];
+
+ // Check if coverage gate is enabled
+ const coverageEnabled = !!process.env.COVERAGE_THRESHOLDS;
+
+ // Modified command construction
+ const runManyProjectsCmd = `npx nx run-many --targets=${target} --projects="${projectsString}"`;
+ let cmd = `${runManyProjectsCmd} --parallel=false --prod`;
+
+ // Add coverage flag if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ // Add coverage reporters for HTML, JSON, and JUnit output
+ cmd += ' --coverage --coverageReporters=json,lcov,text,clover,html,junit --reporters=default,jest-junit';
+ }
+
+ if (target.includes('e2e')) {
+ cmd = getE2ECommand(cmd, base);
+ }
+
+ if (projects.length > 0) {
+ runCommand(cmd);
+
+ // Evaluate coverage if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ const thresholds = getCoverageThresholds();
+ const passed = evaluateCoverage(projects, thresholds);
+
+ if (!passed) {
+ core.setFailed('One or more projects failed to meet coverage thresholds');
+ process.exit(1);
+ }
+ }
+ } else {
+ core.info('No affected projects :)');
+ }
+}
+```
+
+## 3. Sample PR Comment Output
+
+The PR comment will look like this:
+
+```
+## Test Coverage Results
+
+| Project | Metric | Threshold | Actual | Status |
+|---------|--------|-----------|--------|--------|
+| cf-platform | lines | 85% | 87.42% | ✅ PASSED |
+| | statements | 85% | 86.10% | ✅ PASSED |
+| | functions | 80% | 82.23% | ✅ PASSED |
+| | branches | 75% | 77.18% | ✅ PASSED |
+| cf-components | lines | N/A | N/A | ⏩ SKIPPED |
+| | statements | N/A | N/A | ⏩ SKIPPED |
+| | functions | N/A | N/A | ⏩ SKIPPED |
+| | branches | N/A | N/A | ⏩ SKIPPED |
+| cf-utils | lines | 70% | 65.30% | ❌ FAILED |
+| | statements | 70% | 68.45% | ❌ FAILED |
+| | functions | 65% | 62.60% | ❌ FAILED |
+| | branches | 60% | 55.20% | ❌ FAILED |
+| some-lib | lines | 80% | 83.50% | ✅ PASSED |
+| | statements | 80% | 81.75% | ✅ PASSED |
+| | functions | 75% | 78.30% | ✅ PASSED |
+| | branches | 70% | 72.40% | ✅ PASSED |
+
+### Overall Status: ❌ FAILED (1 project failing)
+
+> Note: The build will continue, but this project should be fixed before merging.
+
+📊 [View Detailed HTML Coverage Reports](https://github.com/example/repo/actions/runs/12345)
+```
+
+## 4. Implementation Steps
+
+1. Update the `tools/scripts/run-many/run-many.ts` file:
+ - Add functions to parse coverage thresholds
+ - Implement thresholds evaluation logic
+ - Add coverage report generation
+ - Modify test command to generate HTML and JUnit reports
+
+2. Update the GitHub workflow file to:
+ - Pass the COVERAGE_THRESHOLDS secret as an environment variable to the run-many.ts script
+ - Add steps to upload coverage reports as artifacts
+ - Add a step to post the coverage report as a PR comment using thollander/actions-comment-pull-request@v3
+
+ ```yaml
+ # Example steps to add to the workflow file
+ - name: Upload Coverage Reports
+ if: always() && env.COVERAGE_THRESHOLDS != ''
+ uses: actions/upload-artifact@v3
+ with:
+ name: coverage-reports
+ path: coverage/
+ retention-days: 7
+
+ - name: Set Artifact URL
+ if: github.event_name == 'pull_request' && always() && env.COVERAGE_THRESHOLDS != ''
+ run: |
+ echo "COVERAGE_ARTIFACT_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV
+
+ - name: Comment PR with Coverage Report
+ if: github.event_name == 'pull_request' && always() && env.COVERAGE_THRESHOLDS != ''
+ uses: thollander/actions-comment-pull-request@v3
+ with:
+ message_path: 'coverage-report.txt'
+ comment_tag: 'coverage-report'
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ ```
+
+3. Update the Jest configuration if needed to ensure coverage reports are generated in the expected format and location.
+
+## 5. Testing Approach
+
+1. Local testing:
+ - Create mock coverage reports
+ - Test threshold evaluation logic
+ - Verify comment formatting
+
+2. PR testing:
+ - Create a test PR with varying levels of coverage
+ - Verify the workflow correctly evaluates thresholds
+ - Check that PR comments are formatted properly
+ - Confirm build fails when thresholds are not met
+
+## 6. Estimated Development Timeline
+
+1. Day 1: Implement and test coverage threshold parsing and evaluation
+2. Day 2: Implement PR comment generation and test output formatting
+3. Day 3: Integration testing and documentation
+
+## 7. Future Enhancements
+
+1. Add trending data to show coverage improvement or regression
+2. Support for different threshold levels for different branches
+3. Visual charts or graphs in PR comments
+4. Integration with SonarQube or other code quality tools
+
+## 8. Full Implementation Example
+
+Here's a brief example of the needed changes to GitHub workflow file:
+
+```yaml
+# Partial .github/workflows/fe-code-quality.yml
+name: Frontend Code Quality
+
+on:
+ workflow_call:
+ inputs:
+ # Existing inputs...
+ secrets:
+ # Existing secrets...
+ COVERAGE_THRESHOLDS:
+ required: false
+
+jobs:
+ # Existing jobs...
+
+ unit-test:
+ runs-on: ubuntu-latest
+ if: ${{ !inputs.skip_unit_tests }}
+ needs: [setup]
+ steps:
+ # Existing setup steps...
+
+ - name: Run Unit Tests
+ run: node tools/scripts/run-many/run-many.js test ${{ matrix.index }} ${{ strategy.job-total }} ${{ needs.setup.outputs.base }} ${{ github.head_ref || github.ref_name }}
+ env:
+ COVERAGE_THRESHOLDS: ${{ secrets.COVERAGE_THRESHOLDS }}
+
+ - name: Upload Coverage Reports
+ if: always() && env.COVERAGE_THRESHOLDS != ''
+ uses: actions/upload-artifact@v3
+ with:
+ name: coverage-reports
+ path: coverage/
+ retention-days: 7
+
+ - name: Set Artifact URL
+ if: github.event_name == 'pull_request' && always() && env.COVERAGE_THRESHOLDS != ''
+ run: |
+ echo "COVERAGE_ARTIFACT_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV
+
+ - name: Comment PR with Coverage Report
+ if: github.event_name == 'pull_request' && always() && env.COVERAGE_THRESHOLDS != ''
+ uses: thollander/actions-comment-pull-request@v3
+ with:
+ message_path: 'coverage-report.txt'
+ comment_tag: 'coverage-report'
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+```
+
+The complete implementation will modify the existing `run-many.ts` script to add coverage gate functionality while providing HTML reports as GitHub artifacts and clear feedback via PR comments using the thollander/actions-comment-pull-request action.
diff --git a/.claudesync/reusable-gha-pr-unit-test-coverage-gate.project.json b/.claudesync/reusable-gha-pr-unit-test-coverage-gate.project.json
index fcb65b44..8cfa4611 100644
--- a/.claudesync/reusable-gha-pr-unit-test-coverage-gate.project.json
+++ b/.claudesync/reusable-gha-pr-unit-test-coverage-gate.project.json
@@ -2,13 +2,16 @@
"project_name": "reusable-gha-pr-unit-test-coverage-gate",
"project_description": "Reusable github actions for unit test coverage gate with PR workflow",
"includes": [
+ "**/*"
+ ],
+ "excludes": [],
+ "use_ignore_files": false,
+ "push_roots": [
"tools",
".github",
+ ".claudesync",
"jest.config.ts",
"package.json",
"tsconfig.json"
- ],
- "excludes": [],
- "use_ignore_files": true,
- "push_roots": []
+ ]
}
diff --git a/.github/workflows/fe-code-quality.yml b/.github/workflows/fe-code-quality.yml
index 3b049fd3..71ebad20 100644
--- a/.github/workflows/fe-code-quality.yml
+++ b/.github/workflows/fe-code-quality.yml
@@ -1,6 +1,10 @@
name: Frontend Code Quality Workflow
-on: workflow_call
+on:
+ workflow_call:
+ secrets:
+ COVERAGE_THRESHOLDS:
+ required: false
jobs:
code-quality:
@@ -8,9 +12,11 @@ jobs:
strategy:
matrix:
target: [ 'test' ]
- jobIndex: [ 1, 2, 3,4 ]
+ jobIndex: [ 1, 2, 3, 4 ]
+ fail-fast: false # Ensure all jobs run even if one fails
env:
jobCount: 4
+ HAS_COVERAGE_THRESHOLDS: ${{ secrets.COVERAGE_THRESHOLDS != '' }}
steps:
- uses: actions/checkout@v4
with:
@@ -20,6 +26,7 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 18.19.1
+
- name: Cache Node Modules
id: npm-cache
uses: actions/cache@v4
@@ -33,11 +40,93 @@ jobs:
- name: Linter
run: npx nx affected --target=lint --parallel --configuration=dev --base=origin/${{ github.event.pull_request.base.ref }}
+ # Ensure coverage-report.txt exists before running tests (for job 1 only)
+ - name: Initialize Coverage Report
+ if: matrix.jobIndex == 1 && env.HAS_COVERAGE_THRESHOLDS == 'true'
+ run: |
+ mkdir -p coverage
+ echo "## Test Coverage Results" > coverage-report.txt
+ echo "" >> coverage-report.txt
+ echo "⏳ Coverage evaluation is in progress..." >> coverage-report.txt
+
- name: Unit Tests
- uses: collaborationFactory/github-actions/.github/actions/run-many@master
+ id: unit-tests
+ continue-on-error: true # Allow the step to complete even if it fails
+ uses: collaborationFactory/github-actions/.github/actions/run-many@improvement/PFM-TASK-6308-Create-reusable-GHA-for-code-coverage-gate
with:
target: ${{ matrix.target }}
jobIndex: ${{ matrix.jobIndex }}
jobCount: ${{ env.jobCount }}
base: ${{ github.event.pull_request.base.ref }}
ref: ${{ github.event.pull_request.head.ref }}
+ env:
+ COVERAGE_THRESHOLDS: ${{ secrets.COVERAGE_THRESHOLDS }}
+
+ # Use a step output to indicate if there are multiple failing projects
+ - name: Check for multiple failing projects
+ id: check-failures
+ run: |
+ if [[ "${{ steps.unit-tests.outcome }}" == "failure" ]]; then
+ echo "MULTIPLE_FAILURES=true" >> $GITHUB_ENV
+ echo "multiple_failures=true" >> $GITHUB_OUTPUT
+ else
+ echo "MULTIPLE_FAILURES=false" >> $GITHUB_ENV
+ echo "multiple_failures=false" >> $GITHUB_OUTPUT
+ fi
+
+ # Ensure coverage-report.txt still exists after tests (fallback)
+ - name: Ensure Coverage Report Exists
+ if: always() && matrix.jobIndex == 1 && env.HAS_COVERAGE_THRESHOLDS == 'true'
+ run: |
+ if [ ! -f coverage-report.txt ]; then
+ echo "## Test Coverage Results" > coverage-report.txt
+ echo "" >> coverage-report.txt
+ if [[ "${{ steps.unit-tests.outcome }}" == "failure" ]]; then
+ echo "❌ **Test execution failed**" >> coverage-report.txt
+ echo "" >> coverage-report.txt
+ echo "> Please check the test logs above for detailed error information." >> coverage-report.txt
+ else
+ echo "⏩ No coverage data available for this change." >> coverage-report.txt
+ fi
+ fi
+
+ - name: Upload Coverage Reports
+ if: always() && matrix.jobIndex == 1 && env.HAS_COVERAGE_THRESHOLDS == 'true'
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-reports
+ path: |
+ coverage/
+ coverage-report.txt
+ coverage-summary-debug.json
+ retention-days: 7
+
+ - name: Set Artifact URL
+ if: github.event_name == 'pull_request' && always() && matrix.jobIndex == 1 && env.HAS_COVERAGE_THRESHOLDS == 'true'
+ run: |
+ echo "COVERAGE_ARTIFACT_URL=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" >> $GITHUB_ENV
+
+ - name: Comment PR with Coverage Report
+ if: github.event_name == 'pull_request' && always() && matrix.jobIndex == 1 && env.HAS_COVERAGE_THRESHOLDS == 'true'
+ uses: thollander/actions-comment-pull-request@v3
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ with:
+ file-path: 'coverage-report.txt'
+ comment-tag: 'coverage-report'
+ mode: upsert
+
+ # Final job to check overall status and fail if multiple projects have coverage failures
+ check-coverage-status:
+ needs: code-quality
+ if: always() && needs.code-quality.result != 'cancelled'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check for multiple failing projects
+ run: |
+ if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
+ echo "::error::Multiple projects failed coverage thresholds - fix these issues before merging"
+ exit 1
+ else
+ echo "All tests completed - no multiple coverage failures detected"
+ fi
diff --git a/.gitignore b/.gitignore
index 24ae7c7c..c3c58d76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@
.claudeignore
.claudesync/*.project_id.json
.claudesync/active_project.json
+
+#claudesync
+.github
diff --git a/package-lock.json b/package-lock.json
index 7cd6ea3c..a7160147 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@types/jest": "29.5.14",
"@types/node": "^18.19.1",
"jest": "29.7.0",
+ "jest-junit": "^16.0.0",
"luxon": "^3.3.0",
"prettier": "2.8.1",
"ts-jest": "29.1.1",
@@ -4417,6 +4418,22 @@
"fsevents": "^2.3.2"
}
},
+ "node_modules/jest-junit": {
+ "version": "16.0.0",
+ "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jest-junit/-/jest-junit-16.0.0.tgz",
+ "integrity": "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "strip-ansi": "^6.0.1",
+ "uuid": "^8.3.2",
+ "xml": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
"node_modules/jest-leak-detector": {
"version": "29.7.0",
"resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
@@ -5542,6 +5559,19 @@
"node": "*"
}
},
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -6524,6 +6554,13 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
+ "node_modules/xml": {
+ "version": "1.0.1",
+ "resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/xml/-/xml-1.0.1.tgz",
+ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://cplace.jfrog.io/artifactory/api/npm/cplace-npm/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
index c43ae5e4..6551443c 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@types/jest": "29.5.14",
"@types/node": "^18.19.1",
"jest": "29.7.0",
+ "jest-junit": "^16.0.0",
"luxon": "^3.3.0",
"prettier": "2.8.1",
"ts-jest": "29.1.1",
diff --git a/tools/scripts/run-many/affected-projects.ts b/tools/scripts/run-many/affected-projects.ts
index 58e1d747..f4877edc 100644
--- a/tools/scripts/run-many/affected-projects.ts
+++ b/tools/scripts/run-many/affected-projects.ts
@@ -21,7 +21,7 @@ export function getAffectedProjects(
jobCount: number,
base: string,
ref: string
-) {
+): string {
let allAffectedProjects = [];
if (target === 'e2e' && ref === '') {
allAffectedProjects = Utils.getAllProjects(false, null, target);
@@ -32,5 +32,17 @@ export function getAffectedProjects(
const projects = distributeProjectsEvenly(allAffectedProjects, jobCount);
console.log(`Affected Projects:`);
console.table(projects);
+
+ // Handle case when no projects are assigned to this job index
+ if (jobIndex - 1 >= projects.length || !projects[jobIndex - 1] || projects[jobIndex - 1].length === 0) {
+ return '';
+ }
+
return projects[jobIndex - 1].join(',');
}
+
+// Check if there are any affected projects
+export function hasAffectedProjects(base: string, target?: string): boolean {
+ const projects = Utils.getAllProjects(true, base, target);
+ return projects.length > 0;
+}
diff --git a/tools/scripts/run-many/coverage-evaluator.spec.ts b/tools/scripts/run-many/coverage-evaluator.spec.ts
new file mode 100644
index 00000000..fc467e7a
--- /dev/null
+++ b/tools/scripts/run-many/coverage-evaluator.spec.ts
@@ -0,0 +1,565 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as core from '@actions/core';
+import { evaluateCoverage, generateEmptyCoverageReport } from './coverage-evaluator';
+import { getProjectThresholds } from './threshold-handler';
+
+// Mock dependencies
+jest.mock('fs', () => ({
+ existsSync: jest.fn(),
+ readFileSync: jest.fn(),
+ writeFileSync: jest.fn(),
+ readdirSync: jest.fn(),
+ statSync: jest.fn(),
+}));
+jest.mock('path', () => ({
+ resolve: jest.fn((...args) => args.join('/')),
+ join: jest.fn((...args) => args.join('/')),
+}));
+jest.mock('@actions/core', () => ({
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+}));
+jest.mock('./threshold-handler', () => ({
+ getProjectThresholds: jest.fn(),
+}));
+
+describe('coverage-evaluator', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = { ...originalEnv };
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80, statements: 80, functions: 75, branches: 70 },
+ projects: {}
+ });
+
+ // Setup default mocks for file system operations
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
+ (fs.readdirSync as jest.Mock).mockReturnValue([]);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false });
+ (fs.writeFileSync as jest.Mock).mockReturnValue(undefined);
+ (fs.readFileSync as jest.Mock).mockReturnValue('{}');
+ (path.resolve as jest.Mock).mockImplementation((...args) => args.join('/'));
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ describe('evaluateCoverage', () => {
+ it('should return zero failures when COVERAGE_THRESHOLDS is not set', () => {
+ delete process.env.COVERAGE_THRESHOLDS;
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(0);
+ expect(core.info).toHaveBeenCalledWith('No coverage thresholds defined, skipping evaluation');
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
+ });
+
+ it('should skip projects with null thresholds and not count them as failures', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds.mockReturnValue(null);
+
+ // Mock coverage directory exists but is empty
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ return filePath.includes('coverage') && !filePath.includes('coverage-summary.json');
+ });
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(0);
+ expect(core.info).toHaveBeenCalledWith('Coverage evaluation skipped for project-a');
+
+ // Should write coverage report with skipped project showing individual metrics
+ expect(fs.writeFileSync).toHaveBeenCalledWith(
+ expect.stringContaining('coverage-report.txt'),
+ expect.stringContaining('⏩ SKIPPED')
+ );
+
+ // Verify that the comment shows individual metrics for skipped project
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | statements | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | functions | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | branches | N/A | N/A | ⏩ SKIPPED |');
+ });
+
+ it('should count as one failure when coverage report is missing', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds.mockReturnValue({ lines: 80, statements: 75 });
+
+ // Mock coverage directory exists but no coverage files
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ return filePath.includes('coverage') && !filePath.includes('coverage-summary.json');
+ });
+ (fs.readdirSync as jest.Mock).mockReturnValue([]); // Empty directory
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(1);
+ expect(core.warning).toHaveBeenCalledWith('No coverage data found for project-a in any location');
+
+ // Verify that the comment shows individual thresholds with "No Data"
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | 80% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | statements | 75% | No Data | ❌ FAILED |');
+ expect(comment).toContain('⚠️ WARNING (1 project failing)');
+ });
+
+ it('should count one failure when coverage is below thresholds', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds.mockReturnValue({
+ lines: 80,
+ statements: 80,
+ functions: 75,
+ branches: 70
+ });
+
+ // Mock coverage directory and files exist
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ if (filePath.includes('coverage') && !filePath.includes('coverage-summary.json')) {
+ return true; // Coverage directory exists
+ }
+ return filePath.includes('coverage-summary.json'); // Coverage file exists
+ });
+ (fs.readdirSync as jest.Mock).mockReturnValue(['project-a']);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true });
+
+ const mockReadFileSync = fs.readFileSync as jest.Mock;
+ mockReadFileSync.mockReturnValue(JSON.stringify({
+ total: {
+ lines: { pct: 75 },
+ statements: { pct: 75 },
+ functions: { pct: 70 },
+ branches: { pct: 65 }
+ }
+ }));
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(1);
+ expect(core.error).toHaveBeenCalledWith(expect.stringContaining('Project project-a failed coverage thresholds'));
+
+ // Verify that the comment shows the failed metrics with correct values
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | 80% | 75.00% | ❌ FAILED |');
+ expect(comment).toContain('| | statements | 80% | 75.00% | ❌ FAILED |');
+ expect(comment).toContain('| | functions | 75% | 70.00% | ❌ FAILED |');
+ expect(comment).toContain('| | branches | 70% | 65.00% | ❌ FAILED |');
+ expect(comment).toContain('### Overall Status: ⚠️ WARNING (1 project failing)');
+ expect(comment).toContain('Note: The build will continue, but this project should be fixed before merging.');
+ });
+
+ it('should return zero failures when coverage meets thresholds', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds.mockReturnValue({
+ lines: 80,
+ statements: 80,
+ functions: 75,
+ branches: 70
+ });
+
+ // Mock coverage directory and files exist
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ if (filePath.includes('coverage') && !filePath.includes('coverage-summary.json')) {
+ return true; // Coverage directory exists
+ }
+ return filePath.includes('coverage-summary.json'); // Coverage file exists
+ });
+ (fs.readdirSync as jest.Mock).mockReturnValue(['project-a']);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true });
+
+ const mockReadFileSync = fs.readFileSync as jest.Mock;
+ mockReadFileSync.mockReturnValue(JSON.stringify({
+ total: {
+ lines: { pct: 85 },
+ statements: { pct: 85 },
+ functions: { pct: 80 },
+ branches: { pct: 75 }
+ }
+ }));
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(0);
+ expect(core.info).toHaveBeenCalledWith('Project project-a passed all coverage thresholds');
+
+ // Verify that the comment shows passing status with correct values
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | 80% | 85.00% | ✅ PASSED |');
+ expect(comment).toContain('| | statements | 80% | 85.00% | ✅ PASSED |');
+ expect(comment).toContain('| | functions | 75% | 80.00% | ✅ PASSED |');
+ expect(comment).toContain('| | branches | 70% | 75.00% | ✅ PASSED |');
+ expect(comment).toContain('### Overall Status: ✅ PASSED');
+ });
+
+ it('should count one failure for errors in coverage processing', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds.mockReturnValue({
+ lines: 80,
+ statements: 80,
+ functions: 75,
+ branches: 70
+ });
+
+ // Mock coverage directory and files exist
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ if (filePath.includes('coverage') && !filePath.includes('coverage-summary.json')) {
+ return true; // Coverage directory exists
+ }
+ return filePath.includes('coverage-summary.json'); // Coverage file exists
+ });
+ (fs.readdirSync as jest.Mock).mockReturnValue(['project-a']);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true });
+
+ const mockReadFileSync = fs.readFileSync as jest.Mock;
+ mockReadFileSync.mockImplementation(() => {
+ throw new Error('Test error');
+ });
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(1);
+ expect(core.error).toHaveBeenCalledWith('Error parsing coverage file for project-a: Test error');
+
+ // Verify that the comment shows individual thresholds with "No Data"
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | 80% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | statements | 80% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | functions | 75% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | branches | 70% | No Data | ❌ FAILED |');
+ expect(comment).toContain('### Overall Status: ⚠️ WARNING (1 project failing)');
+ });
+
+ it('should pass even when some metrics are missing from thresholds', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ // Only include lines and functions thresholds
+ mockGetProjectThresholds.mockReturnValue({
+ lines: 80,
+ functions: 75
+ });
+
+ // Mock coverage directory and files exist
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ if (filePath.includes('coverage') && !filePath.includes('coverage-summary.json')) {
+ return true; // Coverage directory exists
+ }
+ return filePath.includes('coverage-summary.json'); // Coverage file exists
+ });
+ (fs.readdirSync as jest.Mock).mockReturnValue(['project-a']);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true });
+
+ const mockReadFileSync = fs.readFileSync as jest.Mock;
+ mockReadFileSync.mockReturnValue(JSON.stringify({
+ total: {
+ lines: { pct: 85 },
+ statements: { pct: 85 },
+ functions: { pct: 80 },
+ branches: { pct: 75 }
+ }
+ }));
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ expect(result).toBe(0);
+
+ // Verify that only the defined thresholds are in the comment
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | 80% | 85.00% | ✅ PASSED |');
+ expect(comment).toContain('| | functions | 75% | 80.00% | ✅ PASSED |');
+ expect(comment).not.toContain('| | statements |');
+ expect(comment).not.toContain('| | branches |');
+ });
+
+ it('should skip projects with empty threshold objects', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ // Return an empty object instead of null
+ mockGetProjectThresholds.mockReturnValue({});
+
+ // Mock coverage directory exists but is empty
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ return filePath.includes('coverage') && !filePath.includes('coverage-summary.json');
+ });
+
+ const result = evaluateCoverage(['project-a'], { global: {}, projects: {} });
+
+ // Should skip because no specific thresholds were set (empty object)
+ expect(result).toBe(0);
+ expect(core.info).toHaveBeenCalledWith('Coverage evaluation skipped for project-a (no thresholds defined)');
+
+ // Verify that the comment shows skipped status
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+ expect(comment).toContain('| project-a | lines | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | statements | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | functions | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | branches | N/A | N/A | ⏩ SKIPPED |');
+ });
+
+ it('should correctly handle mix of skipped, passed, and failed projects', () => {
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds
+ .mockReturnValueOnce(null) // project-a: explicitly null (skip)
+ .mockReturnValueOnce({}) // project-b: empty thresholds (skip)
+ .mockReturnValueOnce({ lines: 80, statements: 80 }) // project-c: has thresholds
+ .mockReturnValueOnce({ lines: 90, statements: 90 }); // project-d: has thresholds
+
+ // Mock coverage directory and files exist for projects that aren't skipped
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ if (filePath.includes('coverage') && !filePath.includes('coverage-summary.json')) {
+ return true; // Coverage directory exists
+ }
+ // Coverage files exist for project-c only
+ return filePath.includes('project-c') && filePath.includes('coverage-summary.json');
+ });
+
+ (fs.readdirSync as jest.Mock).mockReturnValue(['project-c']);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true });
+
+ const mockReadFileSync = fs.readFileSync as jest.Mock;
+ mockReadFileSync.mockReturnValue(JSON.stringify({
+ total: {
+ lines: { pct: 85 },
+ statements: { pct: 85 },
+ functions: { pct: 80 },
+ branches: { pct: 75 }
+ }
+ }));
+
+ const result = evaluateCoverage(['project-a', 'project-b', 'project-c', 'project-d'], {
+ global: { lines: 80, statements: 80 },
+ projects: {}
+ });
+
+ // One project failed (project-d - no coverage data), two were skipped, one passed
+ expect(result).toBe(1);
+
+ // Verify logs
+ expect(core.info).toHaveBeenCalledWith('Coverage evaluation skipped for project-a (null thresholds)');
+ expect(core.info).toHaveBeenCalledWith('Coverage evaluation skipped for project-b (no thresholds defined)');
+ expect(core.info).toHaveBeenCalledWith('Project project-c passed all coverage thresholds');
+ expect(core.warning).toHaveBeenCalledWith('No coverage data found for project-d in any location');
+
+ // Verify that the comment shows correct status for each project
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+
+ // project-a: skipped (null)
+ expect(comment).toContain('| project-a | lines | N/A | N/A | ⏩ SKIPPED |');
+
+ // project-b: skipped (empty)
+ expect(comment).toContain('| project-b | lines | N/A | N/A | ⏩ SKIPPED |');
+
+ // project-c: passed
+ expect(comment).toContain('| project-c | lines | 80% | 85.00% | ✅ PASSED |');
+
+ // project-d: failed (no coverage data)
+ expect(comment).toContain('| project-d | lines | 90% | No Data | ❌ FAILED |');
+
+ // Overall status should show 1 project failing (warning level)
+ expect(comment).toContain('### Overall Status: ⚠️ WARNING (1 project failing)');
+ });
+
+ it('should count multiple failures correctly', () => {
+ // First project passes
+ // Second project is skipped
+ // Third project fails
+ // Fourth project fails because no coverage data
+ const mockGetProjectThresholds = getProjectThresholds as jest.Mock;
+ mockGetProjectThresholds
+ .mockReturnValueOnce({
+ lines: 80,
+ statements: 80,
+ functions: 75,
+ branches: 70
+ })
+ .mockReturnValueOnce(null) // Skip project-b
+ .mockReturnValueOnce({
+ lines: 70,
+ statements: 70,
+ functions: 65,
+ branches: 60
+ })
+ .mockReturnValueOnce({
+ lines: 90,
+ statements: 90,
+ functions: 90,
+ branches: 90
+ });
+
+ // Mock coverage directory exists
+ (fs.existsSync as jest.Mock).mockImplementation((filePath) => {
+ if (filePath.includes('coverage') && !filePath.includes('coverage-summary.json')) {
+ return true; // Coverage directory exists
+ }
+ // Coverage files exist for project-a and project-c
+ if (filePath.includes('project-a') || filePath.includes('project-c')) {
+ return filePath.includes('coverage-summary.json');
+ }
+ // No coverage files for project-d
+ return false;
+ });
+
+ (fs.readdirSync as jest.Mock).mockReturnValue(['project-a', 'project-c']);
+ (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true });
+
+ const mockReadFileSync = fs.readFileSync as jest.Mock;
+ mockReadFileSync
+ .mockReturnValueOnce(JSON.stringify({
+ total: {
+ lines: { pct: 85 },
+ statements: { pct: 85 },
+ functions: { pct: 80 },
+ branches: { pct: 75 }
+ }
+ }))
+ .mockReturnValueOnce(JSON.stringify({
+ total: {
+ lines: { pct: 65 },
+ statements: { pct: 65 },
+ functions: { pct: 60 },
+ branches: { pct: 55 }
+ }
+ }));
+
+ process.env.COVERAGE_ARTIFACT_URL = 'https://example.com/artifact';
+
+ const result = evaluateCoverage(['project-a', 'project-b', 'project-c', 'project-d'], {
+ global: { lines: 80, statements: 80, functions: 75, branches: 70 },
+ projects: {}
+ });
+
+ // Two projects failed
+ expect(result).toBe(2);
+
+ // Verify that the comment shows the correct status for each project
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const comment = coverageReportCall[1];
+
+ // Project A passes
+ expect(comment).toContain('| project-a | lines | 80% | 85.00% | ✅ PASSED |');
+
+ // Project B is skipped (now shows individual metrics)
+ expect(comment).toContain('| project-b | lines | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | statements | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | functions | N/A | N/A | ⏩ SKIPPED |');
+ expect(comment).toContain('| | branches | N/A | N/A | ⏩ SKIPPED |');
+
+ // Project C fails
+ expect(comment).toContain('| project-c | lines | 70% | 65.00% | ❌ FAILED |');
+
+ // Project D has no coverage data
+ expect(comment).toContain('| project-d | lines | 90% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | statements | 90% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | functions | 90% | No Data | ❌ FAILED |');
+ expect(comment).toContain('| | branches | 90% | No Data | ❌ FAILED |');
+
+ // Overall status is failed with multiple projects
+ expect(comment).toContain('### Overall Status: ❌ FAILED (2 projects failing)');
+ expect(comment).toContain('Note: Multiple projects fail coverage thresholds. This PR will be blocked until fixed.');
+
+ // Artifact URL is included
+ expect(comment).toContain('📊 [View Detailed HTML Coverage Reports](https://example.com/artifact)');
+ });
+ });
+
+ describe('generateEmptyCoverageReport', () => {
+ it('should generate an empty report when no projects are affected', () => {
+ generateEmptyCoverageReport();
+
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ expect(writeFileCalls.length).toBeGreaterThan(0);
+
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const [filePath, content] = coverageReportCall;
+ expect(filePath).toContain('coverage-report.txt');
+ expect(content).toContain('## Test Coverage Results');
+ expect(content).toContain('No projects were affected by this change that require coverage evaluation');
+
+ expect(core.info).toHaveBeenCalledWith('Empty coverage report generated (no affected projects)');
+ });
+ });
+
+ describe('generateTestFailureReport', () => {
+ it('should generate a test failure report with project names', () => {
+ const { generateTestFailureReport } = require('./coverage-evaluator');
+
+ generateTestFailureReport(['project-a', 'project-b']);
+
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ expect(writeFileCalls.length).toBeGreaterThan(0);
+
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const [filePath, content] = coverageReportCall;
+ expect(filePath).toContain('coverage-report.txt');
+ expect(content).toContain('## Test Coverage Results');
+ expect(content).toContain('Tests failed to execute');
+ expect(content).toContain('project-a, project-b');
+ expect(content).toContain('Overall Status: ❌ FAILED (Test execution failed)');
+
+ expect(core.info).toHaveBeenCalledWith('Test failure report generated for PR comment');
+ });
+
+ it('should generate a test failure report with no specific projects', () => {
+ const { generateTestFailureReport } = require('./coverage-evaluator');
+
+ generateTestFailureReport([]);
+
+ const writeFileCalls = (fs.writeFileSync as jest.Mock).mock.calls;
+ expect(writeFileCalls.length).toBeGreaterThan(0);
+
+ const coverageReportCall = writeFileCalls.find(call => call[0].includes('coverage-report.txt'));
+ expect(coverageReportCall).toBeDefined();
+
+ const [filePath, content] = coverageReportCall;
+ expect(filePath).toContain('coverage-report.txt');
+ expect(content).toContain('## Test Coverage Results');
+ expect(content).toContain('Tests failed to execute');
+ expect(content).toContain('affected projects');
+ expect(content).toContain('Overall Status: ❌ FAILED (Test execution failed)');
+
+ expect(core.info).toHaveBeenCalledWith('Test failure report generated for PR comment');
+ });
+ });
+});
diff --git a/tools/scripts/run-many/coverage-evaluator.ts b/tools/scripts/run-many/coverage-evaluator.ts
new file mode 100644
index 00000000..4ec18f1d
--- /dev/null
+++ b/tools/scripts/run-many/coverage-evaluator.ts
@@ -0,0 +1,482 @@
+import * as fs from 'fs';
+import * as path from 'path';
+import * as core from '@actions/core';
+import { CoverageThreshold, getProjectThresholds, ThresholdConfig } from './threshold-handler';
+
+interface CoverageSummary {
+ lines: { pct: number };
+ statements: { pct: number };
+ functions: { pct: number };
+ branches: { pct: number };
+}
+
+interface ProjectCoverageResult {
+ project: string;
+ thresholds: CoverageThreshold | null;
+ actual: {
+ lines: number;
+ statements: number;
+ functions: number;
+ branches: number;
+ } | null;
+ status: 'PASSED' | 'FAILED' | 'SKIPPED';
+}
+
+/**
+ * Tries to find coverage summary file in common locations
+ */
+function findCoverageSummaryFile(project: string): string | null {
+ const possiblePaths = [
+ // Standard Nx project structure
+ `coverage/${project}/coverage-summary.json`,
+ // Alternative coverage directory structure
+ `coverage/coverage-summary.json`,
+ // Project-specific coverage in different patterns
+ `coverage/apps/${project}/coverage-summary.json`,
+ `coverage/libs/${project}/coverage-summary.json`,
+ // Jest default location for the project
+ `coverage/lcov-report/coverage-summary.json`,
+ // Project-specific coverage in project directory
+ `apps/${project}/coverage/coverage-summary.json`,
+ `libs/${project}/coverage/coverage-summary.json`,
+ // Nx workspace structure variations
+ `dist/coverage/${project}/coverage-summary.json`,
+ `tmp/coverage/${project}/coverage-summary.json`
+ ];
+
+ for (const possiblePath of possiblePaths) {
+ const fullPath = path.resolve(process.cwd(), possiblePath);
+ if (fs.existsSync(fullPath)) {
+ core.info(`Found coverage file for ${project} at: ${fullPath}`);
+ return fullPath;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Tries to read coverage from a global coverage file that might contain multiple projects
+ */
+function tryReadFromGlobalCoverage(project: string): CoverageSummary | null {
+ const globalCoveragePath = path.resolve(process.cwd(), 'coverage/coverage-summary.json');
+
+ if (!fs.existsSync(globalCoveragePath)) {
+ return null;
+ }
+
+ try {
+ const globalCoverageData = JSON.parse(fs.readFileSync(globalCoveragePath, 'utf8'));
+
+ // Look for project-specific data in the global file
+ const projectPatterns = [
+ project,
+ `apps/${project}`,
+ `libs/${project}`,
+ `src/app/${project}`,
+ `projects/${project}`
+ ];
+
+ for (const pattern of projectPatterns) {
+ if (globalCoverageData[pattern]) {
+ core.info(`Found coverage data for ${project} under key '${pattern}' in global coverage file`);
+ return globalCoverageData[pattern];
+ }
+ }
+
+ // Try to find any key that contains the project name
+ const keys = Object.keys(globalCoverageData);
+ for (const key of keys) {
+ if (key.includes(project) && globalCoverageData[key] &&
+ globalCoverageData[key].lines && globalCoverageData[key].statements) {
+ core.info(`Found coverage data for ${project} under key '${key}' in global coverage file`);
+ return globalCoverageData[key];
+ }
+ }
+
+ return null;
+ } catch (error) {
+ core.warning(`Error reading global coverage file: ${error.message}`);
+ return null;
+ }
+}
+
+/**
+ * Lists all files in coverage directory for debugging
+ */
+function debugCoverageDirectory(): void {
+ const coverageDir = path.resolve(process.cwd(), 'coverage');
+
+ if (!fs.existsSync(coverageDir)) {
+ core.warning('Coverage directory does not exist');
+ return;
+ }
+
+ core.info('Coverage directory contents:');
+ try {
+ const listDirectory = (dir: string, depth = 0): void => {
+ if (depth > 3) return; // Prevent infinite recursion
+
+ const items = fs.readdirSync(dir);
+ items.forEach(item => {
+ const fullPath = path.join(dir, item);
+ const stats = fs.statSync(fullPath);
+ const indent = ' '.repeat(depth);
+
+ if (stats.isDirectory()) {
+ core.info(`${indent}📁 ${item}/`);
+ listDirectory(fullPath, depth + 1);
+ } else {
+ core.info(`${indent}📄 ${item}`);
+ }
+ });
+ };
+
+ listDirectory(coverageDir);
+ } catch (error) {
+ core.warning(`Error listing coverage directory: ${error.message}`);
+ }
+}
+
+/**
+ * Tries to extract coverage data from a coverage file, handling different formats
+ */
+function extractCoverageData(filePath: string, project: string): CoverageSummary | null {
+ try {
+ const coverageData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+
+ // Try different possible structures
+ if (coverageData.total) {
+ // Standard jest coverage format
+ return coverageData.total as CoverageSummary;
+ } else if (coverageData[project]) {
+ // Project-specific coverage in the same file
+ return coverageData[project] as CoverageSummary;
+ } else if (coverageData.lines && coverageData.statements && coverageData.functions && coverageData.branches) {
+ // Direct coverage data
+ return coverageData as CoverageSummary;
+ } else {
+ // Try to find any object with coverage data
+ const keys = Object.keys(coverageData);
+ for (const key of keys) {
+ const data = coverageData[key];
+ if (data && data.lines && data.statements && data.functions && data.branches) {
+ core.info(`Found coverage data under key '${key}' for project ${project}`);
+ return data as CoverageSummary;
+ }
+ }
+ }
+
+ core.warning(`Unrecognized coverage file format for ${project}. Keys found: ${Object.keys(coverageData).join(', ')}`);
+ return null;
+ } catch (error) {
+ core.error(`Error parsing coverage file for ${project}: ${error.message}`);
+ return null;
+ }
+}
+
+/**
+ * Determines if a project should be skipped based on its thresholds
+ */
+function shouldSkipProject(thresholds: CoverageThreshold | null): boolean {
+ return thresholds === null || (thresholds && Object.keys(thresholds).length === 0);
+}
+
+/**
+ * Evaluates coverage for all projects against their thresholds
+ */
+export function evaluateCoverage(projects: string[], thresholds: ThresholdConfig): number {
+ if (!process.env.COVERAGE_THRESHOLDS) {
+ core.info('No coverage thresholds defined, skipping evaluation');
+ return 0; // No thresholds defined, 0 failures
+ }
+
+ let failedProjectsCount = 0;
+ const coverageResults: ProjectCoverageResult[] = [];
+
+ core.info(`Evaluating coverage for ${projects.length} projects: ${projects.join(', ')}`);
+
+ // Debug: List coverage directory contents
+ debugCoverageDirectory();
+
+ for (const project of projects) {
+ const projectThresholds = getProjectThresholds(project, thresholds);
+
+ // Skip projects with null thresholds or explicitly empty thresholds
+ if (shouldSkipProject(projectThresholds)) {
+ const reason = projectThresholds === null ? 'null thresholds' : 'no thresholds defined';
+ core.info(`Coverage evaluation skipped for ${project} (${reason})`);
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: null,
+ status: 'SKIPPED'
+ });
+ continue;
+ }
+
+ // Try to find coverage file in various locations
+ const coveragePath = findCoverageSummaryFile(project);
+ let summary: CoverageSummary | null = null;
+
+ if (coveragePath) {
+ summary = extractCoverageData(coveragePath, project);
+ }
+
+ // If we didn't find project-specific coverage, try global coverage file
+ if (!summary) {
+ core.info(`Trying to find coverage data for ${project} in global coverage file`);
+ summary = tryReadFromGlobalCoverage(project);
+ }
+
+ if (!summary) {
+ core.warning(`No coverage data found for ${project} in any location`);
+
+ // Try to list what files exist for this project specifically
+ const projectSpecificDir = path.resolve(process.cwd(), `coverage/${project}`);
+ if (fs.existsSync(projectSpecificDir)) {
+ const files = fs.readdirSync(projectSpecificDir);
+ core.info(`Files in coverage/${project}/: ${files.join(', ')}`);
+ }
+
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: null,
+ status: 'FAILED' // Mark as failed if no coverage report is found
+ });
+ failedProjectsCount++;
+ continue;
+ }
+
+ // Log the actual coverage data found
+ core.info(`Coverage data for ${project}: lines=${summary.lines.pct}%, statements=${summary.statements.pct}%, functions=${summary.functions.pct}%, branches=${summary.branches.pct}%`);
+
+ let projectPassed = true;
+ const failedMetrics: string[] = [];
+
+ // Check each metric if threshold is defined
+ if (projectThresholds.lines !== undefined && summary.lines.pct < projectThresholds.lines) {
+ projectPassed = false;
+ failedMetrics.push(`lines: ${summary.lines.pct.toFixed(2)}% < ${projectThresholds.lines}%`);
+ }
+
+ if (projectThresholds.statements !== undefined && summary.statements.pct < projectThresholds.statements) {
+ projectPassed = false;
+ failedMetrics.push(`statements: ${summary.statements.pct.toFixed(2)}% < ${projectThresholds.statements}%`);
+ }
+
+ if (projectThresholds.functions !== undefined && summary.functions.pct < projectThresholds.functions) {
+ projectPassed = false;
+ failedMetrics.push(`functions: ${summary.functions.pct.toFixed(2)}% < ${projectThresholds.functions}%`);
+ }
+
+ if (projectThresholds.branches !== undefined && summary.branches.pct < projectThresholds.branches) {
+ projectPassed = false;
+ failedMetrics.push(`branches: ${summary.branches.pct.toFixed(2)}% < ${projectThresholds.branches}%`);
+ }
+
+ if (!projectPassed) {
+ core.error(`Project ${project} failed coverage thresholds: ${failedMetrics.join(', ')}`);
+ failedProjectsCount++;
+ } else {
+ core.info(`Project ${project} passed all coverage thresholds`);
+ }
+
+ coverageResults.push({
+ project,
+ thresholds: projectThresholds,
+ actual: {
+ lines: summary.lines.pct,
+ statements: summary.statements.pct,
+ functions: summary.functions.pct,
+ branches: summary.branches.pct
+ },
+ status: projectPassed ? 'PASSED' : 'FAILED'
+ });
+ }
+
+ // Post results to PR comment
+ postCoverageComment(coverageResults, failedProjectsCount);
+
+ return failedProjectsCount;
+}
+
+/**
+ * Formats the coverage results into a markdown table
+ */
+function formatCoverageComment(results: ProjectCoverageResult[], artifactUrl: string, failedProjectsCount: number): string {
+ let comment = '## Test Coverage Results\n\n';
+
+ if (results.length === 0) {
+ comment += 'No projects were evaluated for coverage.\n';
+ return comment;
+ }
+
+ comment += '| Project | Metric | Threshold | Actual | Status |\n';
+ comment += '|---------|--------|-----------|--------|--------|\n';
+
+ results.forEach(result => {
+ if (result.status === 'SKIPPED') {
+ // Show individual metrics for skipped projects to maintain consistent table format
+ const metrics = ['lines', 'statements', 'functions', 'branches'];
+ let firstRow = true;
+
+ metrics.forEach((metric) => {
+ // Only include project name in the first row for this project
+ const projectCell = firstRow ? result.project : '';
+ firstRow = false;
+
+ comment += `| ${projectCell} | ${metric} | N/A | N/A | ⏩ SKIPPED |\n`;
+ });
+ } else if (result.actual === null) {
+ // Show individual thresholds when coverage data is missing
+ const metrics = ['lines', 'statements', 'functions', 'branches'];
+ let hasAnyThreshold = false;
+ let firstRow = true;
+
+ metrics.forEach((metric) => {
+ // Skip metrics that don't have a threshold
+ if (!result.thresholds || !result.thresholds[metric]) return;
+
+ hasAnyThreshold = true;
+ const threshold = result.thresholds[metric];
+
+ // Only include project name in the first row for this project
+ const projectCell = firstRow ? result.project : '';
+ firstRow = false;
+
+ comment += `| ${projectCell} | ${metric} | ${threshold}% | No Data | ❌ FAILED |\n`;
+ });
+
+ // Fallback if no specific thresholds are defined
+ if (!hasAnyThreshold) {
+ comment += `| ${result.project} | All | Defined | No Data | ❌ FAILED |\n`;
+ }
+ } else {
+ // Show actual coverage data with results
+ const metrics = ['lines', 'statements', 'functions', 'branches'];
+ let firstRow = true;
+
+ metrics.forEach((metric) => {
+ // Skip metrics that don't have a threshold
+ if (!result.thresholds || !result.thresholds[metric]) return;
+
+ const threshold = result.thresholds[metric];
+ const actual = result.actual[metric].toFixed(2);
+ const status = parseFloat(actual) >= threshold ? '✅ PASSED' : '❌ FAILED';
+
+ // Only include project name in the first row for this project
+ const projectCell = firstRow ? result.project : '';
+ firstRow = false;
+
+ comment += `| ${projectCell} | ${metric} | ${threshold}% | ${actual}% | ${status} |\n`;
+ });
+ }
+ });
+
+ // Add overall status with failed project count
+ const overallStatus = failedProjectsCount === 0 ? '✅ PASSED' :
+ failedProjectsCount === 1 ? '⚠️ WARNING (1 project failing)' :
+ `❌ FAILED (${failedProjectsCount} projects failing)`;
+ comment += `\n### Overall Status: ${overallStatus}\n`;
+
+ if (failedProjectsCount === 1) {
+ comment += '\n> Note: The build will continue, but this project should be fixed before merging.\n';
+ } else if (failedProjectsCount > 1) {
+ comment += '\n> Note: Multiple projects fail coverage thresholds. This PR will be blocked until fixed.\n';
+ }
+
+ // Add link to detailed HTML reports
+ if (artifactUrl) {
+ comment += `\n📊 [View Detailed HTML Coverage Reports](${artifactUrl})\n`;
+ }
+
+ return comment;
+}
+
+/**
+ * Writes the coverage results to a file for PR comment
+ */
+function postCoverageComment(results: ProjectCoverageResult[], failedProjectsCount: number): void {
+ // The actual artifact URL will be provided by GitHub Actions in the workflow
+ const artifactUrl = process.env.COVERAGE_ARTIFACT_URL || '';
+
+ const comment = formatCoverageComment(results, artifactUrl, failedProjectsCount);
+
+ // Write to a file that will be used by thollander/actions-comment-pull-request action
+ const gitHubCommentsFile = path.resolve(process.cwd(), 'coverage-report.txt');
+ fs.writeFileSync(gitHubCommentsFile, comment);
+
+ core.info('Coverage results saved for PR comment');
+
+ // Also create a simple summary for debugging
+ createCoverageSummaryFile(results);
+}
+
+/**
+ * Creates a simple coverage summary file for debugging and artifacts
+ */
+function createCoverageSummaryFile(results: ProjectCoverageResult[]): void {
+ const summaryData = {
+ timestamp: new Date().toISOString(),
+ results: results.map(result => ({
+ project: result.project,
+ status: result.status,
+ thresholds: result.thresholds,
+ actual: result.actual
+ }))
+ };
+
+ const summaryFile = path.resolve(process.cwd(), 'coverage-summary-debug.json');
+ fs.writeFileSync(summaryFile, JSON.stringify(summaryData, null, 2));
+
+ core.info(`Coverage summary debug file created at: ${summaryFile}`);
+}
+
+/**
+ * Generates a coverage report when no projects are affected
+ */
+export function generateEmptyCoverageReport(): void {
+ const comment = '## Test Coverage Results\n\n⏩ No projects were affected by this change that require coverage evaluation.\n';
+
+ const gitHubCommentsFile = path.resolve(process.cwd(), 'coverage-report.txt');
+ fs.writeFileSync(gitHubCommentsFile, comment);
+
+ core.info('Empty coverage report generated (no affected projects)');
+}
+
+/**
+ * Generates a placeholder coverage report when job 1 has no projects but other jobs do
+ */
+export function generatePlaceholderCoverageReport(): void {
+ const comment = '## Test Coverage Results\n\n⏳ Coverage evaluation is in progress on other parallel jobs...\n\n' +
+ '> This report will be updated once all test jobs complete.\n';
+
+ const gitHubCommentsFile = path.resolve(process.cwd(), 'coverage-report.txt');
+ fs.writeFileSync(gitHubCommentsFile, comment);
+
+ core.info('Placeholder coverage report generated (projects running in other jobs)');
+}
+
+/**
+ * Generates a coverage report when tests fail to execute
+ */
+export function generateTestFailureReport(projects: string[]): void {
+ const projectsList = projects.length > 0 ? projects.join(', ') : 'affected projects';
+ const comment = `## Test Coverage Results
+
+❌ **Tests failed to execute** for: ${projectsList}
+
+The unit tests failed to run, likely due to compilation errors or configuration issues. Coverage evaluation cannot be performed until the tests can execute successfully.
+
+### Overall Status: ❌ FAILED (Test execution failed)
+
+> Note: Fix the test execution issues before coverage can be evaluated.
+`;
+
+ const gitHubCommentsFile = path.resolve(process.cwd(), 'coverage-report.txt');
+ fs.writeFileSync(gitHubCommentsFile, comment);
+
+ core.info('Test failure report generated for PR comment');
+}
diff --git a/tools/scripts/run-many/run-many.spec.ts b/tools/scripts/run-many/run-many.spec.ts
new file mode 100644
index 00000000..827c34bf
--- /dev/null
+++ b/tools/scripts/run-many/run-many.spec.ts
@@ -0,0 +1,538 @@
+import { execSync } from 'child_process';
+import * as core from '@actions/core';
+import * as fs from 'fs';
+import * as path from 'path';
+import { getAffectedProjects } from './affected-projects';
+import { getCoverageThresholds } from './threshold-handler';
+import { evaluateCoverage, generateEmptyCoverageReport, generateTestFailureReport } from './coverage-evaluator';
+
+// Define interfaces for custom error types we'll use
+interface CommandError extends Error {
+ stdout?: { toString(): string };
+ stderr?: { toString(): string };
+ signal?: string;
+ status?: number;
+ code?: string;
+}
+
+// Mock dependencies
+jest.mock('child_process', () => ({
+ execSync: jest.fn(),
+}));
+jest.mock('@actions/core', () => ({
+ info: jest.fn(),
+ warning: jest.fn(),
+ error: jest.fn(),
+ setFailed: jest.fn(),
+}));
+jest.mock('./affected-projects', () => ({
+ getAffectedProjects: jest.fn(),
+}));
+jest.mock('./threshold-handler', () => ({
+ getCoverageThresholds: jest.fn(),
+}));
+jest.mock('./coverage-evaluator', () => ({
+ evaluateCoverage: jest.fn(),
+ generateEmptyCoverageReport: jest.fn(),
+ generateTestFailureReport: jest.fn(),
+}));
+jest.mock('fs', () => ({
+ existsSync: jest.fn(),
+ mkdirSync: jest.fn(),
+}));
+jest.mock('path', () => ({
+ resolve: jest.fn((base, path) => `${base}/${path}`),
+}));
+
+describe('run-many', () => {
+ const originalEnv = process.env;
+ const originalExit = process.exit;
+ const originalArgv = process.argv;
+ let mockExit: jest.Mock;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = { ...originalEnv };
+ mockExit = jest.fn();
+ process.exit = mockExit as any;
+ process.argv = ['node', 'run-many.js', 'test', '1', '4', 'main', ''];
+
+ // Reset fs.existsSync mock behavior
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ process.exit = originalExit;
+ process.argv = originalArgv;
+ });
+
+ describe('runCommand function', () => {
+ it('should execute command successfully and log output', () => {
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('command output');
+
+ // Directly call the runCommand function
+ const result = runCommand('test command');
+
+ // Verify it was called with the right args
+ expect(mockExecSync).toHaveBeenCalledWith('test command', {
+ stdio: 'pipe',
+ maxBuffer: 1024 * 1024 * 1024,
+ encoding: 'utf-8'
+ });
+ expect(core.info).toHaveBeenCalledWith('Running > test command');
+ expect(core.info).toHaveBeenCalledWith('command output');
+ expect(result).toBe(true);
+ });
+
+ it('should handle command errors', () => {
+ const mockExecSync = execSync as jest.Mock;
+ const error: CommandError = new Error('Command failed');
+ error.stdout = { toString: () => 'stdout output' };
+ error.stderr = { toString: () => 'stderr output' };
+ mockExecSync.mockImplementation(() => {
+ throw error;
+ });
+
+ // Directly call the runCommand function
+ const result = runCommand('test command');
+
+ expect(core.info).toHaveBeenCalledWith('stdout output');
+ expect(core.error).toHaveBeenCalledWith('stderr output');
+ expect(core.setFailed).toHaveBeenCalledWith(error);
+ expect(result).toBe(false);
+ });
+
+ it('should handle timeout errors', () => {
+ const mockExecSync = execSync as jest.Mock;
+ const error: CommandError = new Error('Command timed out');
+ error.signal = 'SIGTERM';
+ error.stdout = { toString: () => 'stdout output' };
+ error.stderr = { toString: () => 'stderr output' };
+ mockExecSync.mockImplementation(() => {
+ throw error;
+ });
+
+ // Directly call the runCommand function
+ const result = runCommand('test command');
+
+ expect(core.error).toHaveBeenCalledWith('Timed out');
+ expect(core.info).toHaveBeenCalledWith('stdout output');
+ expect(core.error).toHaveBeenCalledWith('stderr output');
+ expect(core.setFailed).toHaveBeenCalledWith(error);
+ expect(result).toBe(false);
+ });
+
+ it('should handle buffer exceeded errors', () => {
+ const mockExecSync = execSync as jest.Mock;
+ const error: CommandError = new Error('Buffer exceeded');
+ error.code = 'ENOBUFS';
+ error.stdout = { toString: () => 'stdout output' };
+ error.stderr = { toString: () => 'stderr output' };
+ mockExecSync.mockImplementation(() => {
+ throw error;
+ });
+
+ // Directly call the runCommand function
+ const result = runCommand('test command');
+
+ expect(core.error).toHaveBeenCalledWith('Buffer exceeded');
+ expect(core.info).toHaveBeenCalledWith('stdout output');
+ expect(core.error).toHaveBeenCalledWith('stderr output');
+ expect(core.setFailed).toHaveBeenCalledWith(error);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('main function', () => {
+ it('should run without coverage when COVERAGE_THRESHOLDS is not set', () => {
+ delete process.env.COVERAGE_THRESHOLDS;
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a,project-b');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ // Run the main function
+ main();
+
+ // Should run nx run-many without coverage flags
+ expect(mockExecSync).toHaveBeenCalledWith(
+ 'npx nx run-many --targets=test --projects="project-a,project-b" --parallel=true --prod',
+ expect.any(Object)
+ );
+
+ // Coverage evaluation functions should not be called
+ expect(getCoverageThresholds).not.toHaveBeenCalled();
+ expect(evaluateCoverage).not.toHaveBeenCalled();
+ });
+
+ it('should add coverage flags when running tests with COVERAGE_THRESHOLDS set', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80 }
+ });
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a,project-b');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ const mockGetCoverageThresholds = getCoverageThresholds as jest.Mock;
+ mockGetCoverageThresholds.mockReturnValue({
+ global: { lines: 80 }
+ });
+
+ const mockEvaluateCoverage = evaluateCoverage as jest.Mock;
+ mockEvaluateCoverage.mockReturnValue(0); // No failures
+
+ // Run the main function
+ main();
+
+ // Should run nx run-many with coverage flags
+ expect(mockExecSync).toHaveBeenCalledWith(
+ 'npx nx run-many --targets=test --projects="project-a,project-b" --parallel=false --prod ' +
+ '--coverage --coverageReporters=json,lcov,text,clover,html,json-summary --reporters=default,jest-junit',
+ expect.any(Object)
+ );
+
+ // Coverage evaluation should be performed
+ expect(mockGetCoverageThresholds).toHaveBeenCalled();
+ expect(mockEvaluateCoverage).toHaveBeenCalledWith(['project-a', 'project-b'], expect.any(Object));
+ });
+
+ it('should allow tests to continue with one project failing coverage thresholds', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80 }
+ });
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a,project-b');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ const mockGetCoverageThresholds = getCoverageThresholds as jest.Mock;
+ mockGetCoverageThresholds.mockReturnValue({
+ global: { lines: 80 }
+ });
+
+ const mockEvaluateCoverage = evaluateCoverage as jest.Mock;
+ mockEvaluateCoverage.mockReturnValue(1); // One project failed
+
+ // Run the main function
+ main();
+
+ // Should run nx run-many with coverage flags
+ expect(mockExecSync).toHaveBeenCalledWith(
+ expect.stringContaining('--coverage'),
+ expect.any(Object)
+ );
+
+ // Should show warning but not fail the build
+ expect(core.warning).toHaveBeenCalledWith('One project failed to meet coverage thresholds - this should be fixed before merging');
+ expect(core.setFailed).not.toHaveBeenCalled();
+ expect(mockExit).not.toHaveBeenCalled();
+ });
+
+ it('should fail the build when multiple projects fail coverage thresholds', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80 }
+ });
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a,project-b,project-c');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ const mockGetCoverageThresholds = getCoverageThresholds as jest.Mock;
+ mockGetCoverageThresholds.mockReturnValue({
+ global: { lines: 80 }
+ });
+
+ const mockEvaluateCoverage = evaluateCoverage as jest.Mock;
+ mockEvaluateCoverage.mockReturnValue(2); // Two projects failed
+
+ // Run the main function
+ main();
+
+ // Should run nx run-many with coverage flags
+ expect(mockExecSync).toHaveBeenCalledWith(
+ expect.stringContaining('--coverage'),
+ expect.any(Object)
+ );
+
+ // Should fail the build
+ expect(core.setFailed).toHaveBeenCalledWith('Multiple projects (2) failed to meet coverage thresholds');
+ // Notice we're not exiting, just setting the status to failed
+ expect(mockExit).not.toHaveBeenCalled();
+ });
+
+ it('should add origin/ prefix to base branch that is not a SHA hash', () => {
+ process.argv[5] = 'develop';
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ // Run the main function
+ main();
+
+ // Should call getAffectedProjects with origin/develop
+ expect(mockGetAffectedProjects).toHaveBeenCalledWith(
+ 'test', 1, 4, 'origin/develop', ''
+ );
+ });
+
+ it('should not add origin/ prefix to SHA hash', () => {
+ process.argv[5] = 'abcdef1234567890abcdef1234567890abcdef12';
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ // Run the main function
+ main();
+
+ // Should call getAffectedProjects with the unchanged SHA
+ expect(mockGetAffectedProjects).toHaveBeenCalledWith(
+ 'test', 1, 4, 'abcdef1234567890abcdef1234567890abcdef12', ''
+ );
+ });
+
+ it('should handle 0000000 base by using git origin/HEAD', () => {
+ process.argv[5] = '0000000000000000';
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a');
+
+ const mockExecSync = execSync as jest.Mock;
+ // First call returns the git head
+ mockExecSync.mockReturnValueOnce('origin/main');
+ // Second call is for the run-many command
+ mockExecSync.mockReturnValueOnce('exec output');
+
+ // Run the main function
+ main();
+
+ // Should call git rev-parse to get the HEAD
+ expect(mockExecSync).toHaveBeenCalledWith('git rev-parse --abbrev-ref origin/HEAD ');
+
+ // Should call getAffectedProjects with origin/main
+ expect(mockGetAffectedProjects).toHaveBeenCalledWith(
+ 'test', 1, 4, 'origin/main', ''
+ );
+ });
+
+ it('should generate empty coverage report when no projects are affected', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80 }
+ });
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue(''); // No affected projects
+
+ const mockExecSync = execSync as jest.Mock;
+
+ const mockGenerateEmptyCoverageReport = generateEmptyCoverageReport as jest.Mock;
+
+ // Run the main function
+ main();
+
+ // Should log message about no affected projects
+ expect(core.info).toHaveBeenCalledWith('No affected projects in this job');
+
+ // Should not run nx run-many
+ expect(mockExecSync).not.toHaveBeenCalled();
+
+ // Should generate empty coverage report
+ expect(mockGenerateEmptyCoverageReport).toHaveBeenCalled();
+
+ // Should ensure coverage directory exists
+ expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining('coverage'));
+ });
+
+ it('should only generate empty report for first job', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80 }
+ });
+ process.argv[3] = '2'; // Not the first job
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue(''); // No affected projects
+
+ const mockExecSync = execSync as jest.Mock;
+
+ const mockGenerateEmptyCoverageReport = generateEmptyCoverageReport as jest.Mock;
+
+ // Run the main function
+ main();
+
+ // Should log message about no affected projects
+ expect(core.info).toHaveBeenCalledWith('No affected projects in this job');
+
+ // Should not run nx run-many
+ expect(mockExecSync).not.toHaveBeenCalled();
+
+ // Should NOT generate empty coverage report (not first job)
+ expect(mockGenerateEmptyCoverageReport).not.toHaveBeenCalled();
+ });
+
+ it('should create coverage directory if it does not exist', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ global: { lines: 80 }
+ });
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue(''); // No affected projects
+
+ // Mock fs.existsSync to return false for coverage directory
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
+
+ // Run the main function
+ main();
+
+ // Should attempt to create the coverage directory
+ expect(fs.mkdirSync).toHaveBeenCalledWith(
+ expect.stringContaining('coverage'),
+ { recursive: true }
+ );
+ });
+
+ it('should add e2e specific flags when target includes e2e', () => {
+ process.argv[2] = 'e2e';
+
+ const mockGetAffectedProjects = getAffectedProjects as jest.Mock;
+ mockGetAffectedProjects.mockReturnValue('project-a,project-b');
+
+ const mockExecSync = execSync as jest.Mock;
+ mockExecSync.mockReturnValue('exec output');
+
+ // Run the main function
+ main();
+
+ // Should run nx run-many with e2e specific flags
+ expect(mockExecSync).toHaveBeenCalledWith(
+ expect.stringContaining('-c ci --base=origin/main --verbose'),
+ expect.any(Object)
+ );
+ });
+ });
+
+ // Helper functions to simulate the actual module behavior
+ function runCommand(command: string): boolean {
+ try {
+ const mockExecSync = execSync as jest.Mock;
+ core.info(`Running > ${command}`);
+ const output = mockExecSync(command, {
+ stdio: 'pipe',
+ maxBuffer: 1024 * 1024 * 1024,
+ encoding: 'utf-8'
+ });
+ core.info(output.toString());
+ return true; // Command succeeded
+ } catch (error) {
+ if (error.signal === 'SIGTERM') {
+ core.error('Timed out');
+ } else if (error.code === 'ENOBUFS') {
+ core.error('Buffer exceeded');
+ }
+ core.info(error.stdout?.toString() || '');
+ core.error(error.stderr?.toString() || '');
+ core.error(`Error message: ${error.message}`);
+ core.setFailed(error);
+ return false; // Command failed
+ }
+ }
+
+ function main(): void {
+ const target = process.argv[2];
+ const jobIndex = Number(process.argv[3]);
+ const jobCount = Number(process.argv[4]);
+ let base = process.argv[5];
+
+ // in case base is not a SHA1 commit hash add origin
+ if (!/\b[0-9a-f]{5,40}\b/.test(base)) base = 'origin/' + base;
+ if (base.includes('0000000000000000')) {
+ base = (execSync as jest.Mock)('git rev-parse --abbrev-ref origin/HEAD ').toString().trim();
+ }
+ const ref = process.argv[6];
+
+ core.info(`Inputs:\n target ${target},\n jobIndex: ${jobIndex},\n jobCount ${jobCount},\n base ${base},\n ref ${ref}`);
+
+ // Check if coverage gate is enabled
+ const coverageEnabled = !!process.env.COVERAGE_THRESHOLDS;
+
+ // Get the affected projects
+ const projectsString = (getAffectedProjects as jest.Mock)(target, jobIndex, jobCount, base, ref);
+ const projects = projectsString ? projectsString.split(',') : [];
+
+ // Check if there are any affected projects
+ const areAffectedProjects = projects.length > 0;
+ const isFirstJob = jobIndex === 1;
+
+ // Modified command construction
+ const runManyProjectsCmd = `npx nx run-many --targets=${target} --projects="${projectsString}"`;
+ const parallelFlag = (coverageEnabled && target === 'test') ? '--parallel=false' : '--parallel=true';
+ let cmd = `${runManyProjectsCmd} ${parallelFlag} --prod`;
+
+ // Add coverage flag if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ core.info('Coverage gate is enabled');
+ cmd += ' --coverage --coverageReporters=json,lcov,text,clover,html,json-summary --reporters=default,jest-junit';
+ }
+
+ if (target.includes('e2e')) {
+ cmd += ` -c ci --base=${base} --verbose`;
+ }
+
+ if (areAffectedProjects) {
+ // Run the command
+ const commandSucceeded = runCommand(cmd);
+
+ // Always evaluate coverage or generate report if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ const thresholds = (getCoverageThresholds as jest.Mock)();
+ core.info('Coverage threshold configuration:');
+ core.info(JSON.stringify(thresholds, null, 2));
+
+ if (commandSucceeded) {
+ // Command succeeded, evaluate actual coverage
+ const failedProjectsCount = (evaluateCoverage as jest.Mock)(projects, thresholds);
+
+ if (failedProjectsCount > 1) {
+ core.setFailed(`Multiple projects (${failedProjectsCount}) failed to meet coverage thresholds`);
+ } else if (failedProjectsCount === 1) {
+ core.warning('One project failed to meet coverage thresholds - this should be fixed before merging');
+ }
+ } else {
+ // Command failed, generate a failure report for first job only
+ if (isFirstJob) {
+ (generateTestFailureReport as jest.Mock)(projects);
+ }
+ }
+ }
+ } else {
+ core.info('No affected projects in this job');
+
+ // For the first job, generate an appropriate coverage report when coverage is enabled
+ if (coverageEnabled && target === 'test' && isFirstJob) {
+ // Ensure coverage directory exists for artifact upload
+ const coverageDir = path.resolve(process.cwd(), 'coverage');
+ if (!fs.existsSync(coverageDir)) {
+ fs.mkdirSync(coverageDir, { recursive: true });
+ }
+
+ // Generate empty report
+ (generateEmptyCoverageReport as jest.Mock)();
+ }
+ }
+ }
+});
diff --git a/tools/scripts/run-many/run-many.ts b/tools/scripts/run-many/run-many.ts
index 08524a5f..2cc1a3f4 100644
--- a/tools/scripts/run-many/run-many.ts
+++ b/tools/scripts/run-many/run-many.ts
@@ -1,18 +1,24 @@
import { getAffectedProjects } from './affected-projects';
import { execSync } from 'child_process';
import * as core from '@actions/core';
+import * as fs from 'fs';
+import * as path from 'path';
+import { getCoverageThresholds } from './threshold-handler';
+import { evaluateCoverage, generateEmptyCoverageReport, generateTestFailureReport, generatePlaceholderCoverageReport } from './coverage-evaluator';
+import { Utils } from '../artifacts/utils';
function getE2ECommand(command: string, base: string): string {
command = command.concat(` -c ci --base=${base} --verbose`);
return command;
}
-function runCommand(command: string): void {
+function runCommand(command: string): boolean {
core.info(`Running > ${command}`);
try {
- const output = execSync(command, { stdio: 'pipe', maxBuffer: 1024 * 1024 * 1024, encoding: 'utf-8' }); // 10MB
+ const output = execSync(command, { stdio: 'pipe', maxBuffer: 1024 * 1024 * 1024, encoding: 'utf-8' }); // 1GB
core.info(output.toString())
+ return true; // Command succeeded
} catch (error) {
if (error.signal === 'SIGTERM') {
core.error('Timed out');
@@ -25,6 +31,13 @@ function runCommand(command: string): void {
core.error(`Error name: ${error.name}`);
core.error(`Stacktrace:\n${error.stack}`);
core.setFailed(error);
+ return false; // Command failed
+ }
+}
+
+function ensureDirectoryExists(dirPath: string): void {
+ if (!fs.existsSync(dirPath)) {
+ fs.mkdirSync(dirPath, { recursive: true });
}
}
@@ -43,19 +56,100 @@ function main() {
core.info(`Inputs:\n target ${target},\n jobIndex: ${jobIndex},\n jobCount ${jobCount},\n base ${base},\n ref ${ref}`)
- const projects = getAffectedProjects(target, jobIndex, jobCount, base, ref);
+ // Check if coverage gate is enabled
+ const coverageEnabled = !!process.env.COVERAGE_THRESHOLDS;
+
+ // Get the affected projects
+ const projectsString = getAffectedProjects(target, jobIndex, jobCount, base, ref);
+ const projects = projectsString ? projectsString.split(',') : [];
+
+ // Check if there are any affected projects (for first job only, to avoid duplicate reports)
+ const areAffectedProjects = projects.length > 0;
+ const isFirstJob = jobIndex === 1;
+
+ // For the first job with coverage enabled, ensure a coverage report is always created
+ // This handles cases where job 1 has no projects but other jobs do
+ if (coverageEnabled && target === 'test' && isFirstJob && !areAffectedProjects) {
+ // Ensure coverage directory exists for artifact upload
+ ensureDirectoryExists(path.resolve(process.cwd(), 'coverage'));
+
+ // Check if there are any affected projects across all jobs
+ const allAffectedProjects = Utils.getAllProjects(true, base, target);
+ if (allAffectedProjects.length === 0) {
+ // No projects affected at all
+ generateEmptyCoverageReport();
+ } else {
+ // Other jobs will have projects, so create a placeholder that indicates processing
+ generatePlaceholderCoverageReport();
+ }
+ }
+
+ // Modified command construction
+ const runManyProjectsCmd = `npx nx run-many --targets=${target} --projects="${projectsString}"`;
- const runManyProjectsCmd = `npx nx run-many --targets=${target} --projects="${projects}"`;
- let cmd = `${runManyProjectsCmd} --parallel=false --prod`;
+ // Disable parallel execution when coverage is enabled to avoid conflicts
+ const parallelFlag = (coverageEnabled && target === 'test') ? '--parallel=false' : '--parallel=true';
+ let cmd = `${runManyProjectsCmd} ${parallelFlag} --prod`;
+
+ // Add coverage flag if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ core.info('Coverage gate is enabled');
+ // Add coverage reporters for HTML, JSON, and JUnit output
+ // Note: Using individual project coverage directories
+ cmd += ' --coverage --coverageReporters=json,lcov,text,clover,html,json-summary --reporters=default,jest-junit';
+ }
if (target.includes('e2e')) {
cmd = getE2ECommand(cmd, base);
}
- if (projects.length > 0) {
- runCommand(cmd);
+ if (areAffectedProjects) {
+ const commandSucceeded = runCommand(cmd);
+
+ // Always evaluate coverage or generate report if enabled and target is test
+ if (coverageEnabled && target === 'test') {
+ const thresholds = getCoverageThresholds();
+
+ // Log the current coverage thresholds for debugging
+ core.info('Coverage threshold configuration:');
+ core.info(JSON.stringify(thresholds, null, 2));
+
+ if (commandSucceeded) {
+ // Command succeeded, evaluate actual coverage
+ const failedProjectsCount = evaluateCoverage(projects, thresholds);
+
+ if (failedProjectsCount > 1) {
+ core.setFailed(`Multiple projects (${failedProjectsCount}) failed to meet coverage thresholds`);
+ // Don't exit immediately - we set the failed status but continue running
+ } else if (failedProjectsCount === 1) {
+ core.warning('One project failed to meet coverage thresholds - this should be fixed before merging');
+ // Continue running, with a warning
+ }
+ } else {
+ // Command failed, generate a failure report for first job only
+ if (isFirstJob) {
+ generateTestFailureReport(projects);
+ }
+ }
+ }
} else {
- core.info('No affected projects :)');
+ core.info('No affected projects in this job');
+
+ // For the first job, generate an appropriate coverage report when coverage is enabled
+ if (coverageEnabled && target === 'test' && isFirstJob) {
+ // Ensure coverage directory exists for artifact upload
+ ensureDirectoryExists(path.resolve(process.cwd(), 'coverage'));
+
+ // Check if there are any affected projects across all jobs
+ const allAffectedProjects = Utils.getAllProjects(true, base, target);
+ if (allAffectedProjects.length === 0) {
+ // No projects affected at all
+ generateEmptyCoverageReport();
+ } else {
+ // Other jobs will handle the projects, let the workflow's fallback handle the report
+ core.info('Other jobs will process affected projects - coverage report will be generated by workflow fallback if needed');
+ }
+ }
}
}
diff --git a/tools/scripts/run-many/threshold-handler.spec.ts b/tools/scripts/run-many/threshold-handler.spec.ts
new file mode 100644
index 00000000..1fc723b3
--- /dev/null
+++ b/tools/scripts/run-many/threshold-handler.spec.ts
@@ -0,0 +1,198 @@
+import * as core from '@actions/core';
+import { getCoverageThresholds, getProjectThresholds, CoverageThreshold, ThresholdConfig } from './threshold-handler';
+
+// Mock @actions/core
+jest.mock('@actions/core', () => ({
+ error: jest.fn(),
+ info: jest.fn(),
+ warning: jest.fn(),
+}));
+
+describe('threshold-handler', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ describe('getCoverageThresholds', () => {
+ it('should return empty thresholds when COVERAGE_THRESHOLDS is not set', () => {
+ delete process.env.COVERAGE_THRESHOLDS;
+
+ const result = getCoverageThresholds();
+
+ expect(result).toEqual({ global: {}, projects: {} });
+ expect(core.info).toHaveBeenCalledWith('No coverage thresholds defined, using empty configuration');
+ });
+
+ it('should parse valid JSON from COVERAGE_THRESHOLDS', () => {
+ const thresholdConfig: ThresholdConfig = {
+ global: { lines: 80, statements: 80, functions: 75, branches: 70 },
+ projects: {
+ 'project-a': { lines: 90, statements: 90, functions: 85, branches: 80 },
+ 'project-b': null
+ }
+ };
+
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify(thresholdConfig);
+
+ const result = getCoverageThresholds();
+
+ expect(result).toEqual(thresholdConfig);
+ expect(core.info).toHaveBeenCalledWith('Successfully parsed coverage thresholds');
+ });
+
+ it('should handle invalid JSON in COVERAGE_THRESHOLDS', () => {
+ process.env.COVERAGE_THRESHOLDS = 'invalid-json';
+
+ const result = getCoverageThresholds();
+
+ expect(result).toEqual({ global: {}, projects: {} });
+ expect(core.error).toHaveBeenCalledWith(expect.stringContaining('Error parsing COVERAGE_THRESHOLDS'));
+ });
+
+ it('should log warning when global thresholds are missing', () => {
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify({
+ projects: {
+ 'project-a': { lines: 90 }
+ }
+ });
+
+ const result = getCoverageThresholds();
+
+ expect(result).toEqual({
+ projects: {
+ 'project-a': { lines: 90 }
+ }
+ });
+ expect(core.warning).toHaveBeenCalledWith('No global thresholds defined in configuration');
+ });
+
+ it('should handle thresholds with missing properties', () => {
+ // Only specify lines and branches
+ const thresholdConfig = {
+ global: { lines: 80, branches: 70 },
+ projects: {
+ 'project-a': { functions: 85 }
+ }
+ };
+
+ process.env.COVERAGE_THRESHOLDS = JSON.stringify(thresholdConfig);
+
+ const result = getCoverageThresholds();
+
+ expect(result).toEqual(thresholdConfig);
+ });
+ });
+
+ describe('getProjectThresholds', () => {
+ it('should return null for projects explicitly set to null', () => {
+ const thresholds: ThresholdConfig = {
+ global: { lines: 80 },
+ projects: { 'project-a': null }
+ };
+
+ const result = getProjectThresholds('project-a', thresholds);
+
+ expect(result).toBeNull();
+ expect(core.info).toHaveBeenCalledWith('Project project-a is set to null in config, skipping coverage evaluation');
+ });
+
+ it('should return project-specific thresholds when available', () => {
+ const projectThresholds: CoverageThreshold = {
+ lines: 90,
+ statements: 90,
+ functions: 85,
+ branches: 80
+ };
+
+ const thresholds: ThresholdConfig = {
+ global: { lines: 80, statements: 80, functions: 75, branches: 70 },
+ projects: {
+ 'project-a': projectThresholds
+ }
+ };
+
+ const result = getProjectThresholds('project-a', thresholds);
+
+ expect(result).toEqual(projectThresholds);
+ expect(core.info).toHaveBeenCalledWith('Using specific thresholds for project project-a');
+ });
+
+ it('should fall back to global thresholds when project-specific ones don\'t exist', () => {
+ const globalThresholds: CoverageThreshold = {
+ lines: 80,
+ statements: 80,
+ functions: 75,
+ branches: 70
+ };
+
+ const thresholds: ThresholdConfig = {
+ global: globalThresholds,
+ projects: {
+ 'project-a': { lines: 90, statements: 90, functions: 85, branches: 80 }
+ }
+ };
+
+ const result = getProjectThresholds('project-b', thresholds);
+
+ expect(result).toEqual(globalThresholds);
+ expect(core.info).toHaveBeenCalledWith('Using global thresholds for project project-b');
+ });
+
+ it('should return empty object when global exists but is empty', () => {
+ const thresholds: ThresholdConfig = {
+ global: {}, // Empty but exists
+ projects: {}
+ };
+
+ const result = getProjectThresholds('project-a', thresholds);
+
+ // Since global exists but is empty, it will return the empty object
+ expect(result).toEqual({});
+ expect(core.info).toHaveBeenCalledWith('Using global thresholds for project project-a');
+ });
+
+ it('should return null when global is undefined', () => {
+ // Create a config where global is undefined
+ const thresholds = {
+ projects: {}
+ } as ThresholdConfig;
+
+ const result = getProjectThresholds('project-a', thresholds);
+
+ expect(result).toBeNull();
+ expect(core.warning).toHaveBeenCalledWith('No thresholds defined for project project-a and no global thresholds available');
+ });
+
+ it('should handle partial project thresholds', () => {
+ const thresholds: ThresholdConfig = {
+ global: { lines: 80, statements: 80, functions: 75, branches: 70 },
+ projects: {
+ 'project-a': { lines: 90 } // Only line threshold specified
+ }
+ };
+
+ const result = getProjectThresholds('project-a', thresholds);
+
+ expect(result).toEqual({ lines: 90 });
+ expect(core.info).toHaveBeenCalledWith('Using specific thresholds for project project-a');
+ });
+
+ it('should handle missing projects object', () => {
+ const thresholds = {
+ global: { lines: 80, statements: 80, functions: 75, branches: 70 }
+ } as ThresholdConfig;
+
+ const result = getProjectThresholds('project-a', thresholds);
+
+ expect(result).toEqual({ lines: 80, statements: 80, functions: 75, branches: 70 });
+ expect(core.info).toHaveBeenCalledWith('Using global thresholds for project project-a');
+ });
+ });
+});
diff --git a/tools/scripts/run-many/threshold-handler.ts b/tools/scripts/run-many/threshold-handler.ts
new file mode 100644
index 00000000..96c8de5e
--- /dev/null
+++ b/tools/scripts/run-many/threshold-handler.ts
@@ -0,0 +1,80 @@
+import * as core from '@actions/core';
+
+export interface CoverageThreshold {
+ lines?: number;
+ statements?: number;
+ functions?: number;
+ branches?: number;
+}
+
+export interface ThresholdConfig {
+ global: CoverageThreshold;
+ projects: Record;
+}
+
+/**
+ * Parses the COVERAGE_THRESHOLDS environment variable
+ *
+ * Configuration format:
+ * {
+ * "global": { "lines": 80, "statements": 80, "functions": 75, "branches": 70 },
+ * "projects": {
+ * "project-a": { "lines": 90 }, // Override global thresholds
+ * "project-b": null, // Explicitly skip this project
+ * "project-c": {} // Skip this project (empty thresholds)
+ * }
+ * }
+ *
+ * Projects are skipped when:
+ * - Explicitly set to null in the projects configuration
+ * - Have empty threshold objects ({})
+ * - No thresholds defined anywhere (no global, no project-specific)
+ */
+export function getCoverageThresholds(): ThresholdConfig {
+ if (!process.env.COVERAGE_THRESHOLDS) {
+ core.info('No coverage thresholds defined, using empty configuration');
+ return { global: {}, projects: {} };
+ }
+
+ try {
+ const thresholdConfig = JSON.parse(process.env.COVERAGE_THRESHOLDS);
+ core.info(`Successfully parsed coverage thresholds`);
+
+ // Validate structure
+ if (!thresholdConfig.global) {
+ core.warning('No global thresholds defined in configuration');
+ }
+
+ return thresholdConfig;
+ } catch (error) {
+ core.error(`Error parsing COVERAGE_THRESHOLDS: ${error.message}`);
+ return { global: {}, projects: {} };
+ }
+}
+
+/**
+ * Gets thresholds for a specific project
+ */
+export function getProjectThresholds(project: string, thresholds: ThresholdConfig): CoverageThreshold | null {
+ // If project explicitly set to null, return null to skip
+ if (thresholds.projects && thresholds.projects[project] === null) {
+ core.info(`Project ${project} is set to null in config, skipping coverage evaluation`);
+ return null;
+ }
+
+ // If project has specific thresholds, use those
+ if (thresholds.projects && thresholds.projects[project]) {
+ core.info(`Using specific thresholds for project ${project}`);
+ return thresholds.projects[project];
+ }
+
+ // Otherwise, use global thresholds if available
+ if (thresholds.global) {
+ core.info(`Using global thresholds for project ${project}`);
+ return thresholds.global;
+ }
+
+ // If no thresholds defined, return null
+ core.warning(`No thresholds defined for project ${project} and no global thresholds available`);
+ return null;
+}