Skip to content

Commit 1f1b21d

Browse files
clydinalan-agius4
authored andcommitted
fix(@angular/build): support merging coverage thresholds with Vitest runnerConfig
The Vitest unit test builder now correctly merges coverage thresholds and watermarks from a user's Vitest configuration with CLI-provided options. Previously, providing any CLI thresholds would completely replace the configuration's thresholds. Now, partial CLI thresholds are merged, allowing users to override specific metrics while keeping others from their config. This change also ensures that the builder correctly reports failure when Vitest coverage thresholds are not met by monitoring `process.exitCode`.
1 parent 8ad7000 commit 1f1b21d

File tree

3 files changed

+169
-3
lines changed

3 files changed

+169
-3
lines changed

packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export class VitestExecutor implements TestExecutor {
8585

8686
updateExternalMetadata(buildResult, this.externalMetadata, undefined, true);
8787

88+
// Reset the exit code to allow for a clean state.
89+
// This is necessary because Vitest may set the exit code on failure, which can
90+
// affect subsequent runs in watch mode or when running multiple builders.
91+
process.exitCode = 0;
92+
8893
// Initialize Vitest if not already present.
8994
this.vitest ??= await this.initializeVitest();
9095
const vitest = this.vitest;
@@ -122,7 +127,17 @@ export class VitestExecutor implements TestExecutor {
122127
// Check if all the tests pass to calculate the result
123128
const testModules = testResults?.testModules ?? this.vitest.state.getTestModules();
124129

125-
yield { success: testModules.every((testModule) => testModule.ok()) };
130+
let success = testModules.every((testModule) => testModule.ok());
131+
// Vitest does not return a failure result when coverage thresholds are not met.
132+
// Instead, it sets the process exit code to 1.
133+
// We check this exit code to determine if the test run should be considered a failure.
134+
if (success && process.exitCode === 1) {
135+
success = false;
136+
// Reset the exit code to prevent it from carrying over to subsequent runs/builds
137+
process.exitCode = 0;
138+
}
139+
140+
yield { success };
126141
}
127142

128143
async [Symbol.asyncDispose](): Promise<void> {

packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,16 @@ async function generateCoverageOption(
370370
...(optionsCoverage.include
371371
? { include: ['spec-*.js', 'chunk-*.js', ...optionsCoverage.include] }
372372
: {}),
373-
thresholds: optionsCoverage.thresholds,
374-
watermarks: optionsCoverage.watermarks,
373+
// The 'in' operator is used here because 'configCoverage' is a union type and
374+
// not all coverage providers support thresholds or watermarks.
375+
thresholds: mergeCoverageObjects(
376+
configCoverage && 'thresholds' in configCoverage ? configCoverage.thresholds : undefined,
377+
optionsCoverage.thresholds,
378+
),
379+
watermarks: mergeCoverageObjects(
380+
configCoverage && 'watermarks' in configCoverage ? configCoverage.watermarks : undefined,
381+
optionsCoverage.watermarks,
382+
),
375383
// Special handling for `exclude`/`reporters` due to an undefined value causing upstream failures
376384
...(optionsCoverage.exclude
377385
? {
@@ -388,3 +396,26 @@ async function generateCoverageOption(
388396
: {}),
389397
};
390398
}
399+
400+
/**
401+
* Merges coverage related objects while ignoring any `undefined` values.
402+
* This ensures that Angular CLI options correctly override Vitest configuration
403+
* only when explicitly provided.
404+
*/
405+
function mergeCoverageObjects<T extends object>(
406+
configValue: T | undefined,
407+
optionsValue: T | undefined,
408+
): T | undefined {
409+
if (optionsValue === undefined) {
410+
return configValue;
411+
}
412+
413+
const result: Record<string, unknown> = { ...(configValue ?? {}) };
414+
for (const [key, value] of Object.entries(optionsValue)) {
415+
if (value !== undefined) {
416+
result[key] = value;
417+
}
418+
}
419+
420+
return Object.keys(result).length > 0 ? (result as T) : undefined;
421+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { execute } from '../../index';
10+
import {
11+
BASE_OPTIONS,
12+
describeBuilder,
13+
UNIT_TEST_BUILDER_INFO,
14+
setupApplicationTarget,
15+
} from '../setup';
16+
17+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
18+
describe('Option: "runnerConfig" Coverage Merging', () => {
19+
beforeEach(() => {
20+
setupApplicationTarget(harness);
21+
});
22+
23+
describe('Vitest Runner', () => {
24+
it('should preserve thresholds from Vitest config when not overridden by CLI', async () => {
25+
harness.writeFile(
26+
'vitest-base.config.ts',
27+
`
28+
import { defineConfig } from 'vitest/config';
29+
export default defineConfig({
30+
test: {
31+
coverage: {
32+
thresholds: {
33+
branches: 100
34+
}
35+
}
36+
}
37+
});
38+
`,
39+
);
40+
41+
harness.useTarget('test', {
42+
...BASE_OPTIONS,
43+
runnerConfig: true,
44+
coverage: true,
45+
});
46+
47+
const { result } = await harness.executeOnce();
48+
49+
// Should fail because branches are not 100%
50+
expect(result?.success).toBeFalse();
51+
});
52+
53+
it('should override Vitest config thresholds with CLI thresholds', async () => {
54+
harness.writeFile(
55+
'vitest-base.config.ts',
56+
`
57+
import { defineConfig } from 'vitest/config';
58+
export default defineConfig({
59+
test: {
60+
coverage: {
61+
thresholds: {
62+
branches: 100
63+
}
64+
}
65+
}
66+
});
67+
`,
68+
);
69+
70+
harness.useTarget('test', {
71+
...BASE_OPTIONS,
72+
runnerConfig: true,
73+
coverage: true,
74+
coverageThresholds: {
75+
branches: 0,
76+
},
77+
});
78+
79+
const { result } = await harness.executeOnce();
80+
81+
// Should pass because CLI overrides threshold to 0
82+
expect(result?.success).toBeTrue();
83+
});
84+
85+
it('should merge partial CLI thresholds with Vitest config thresholds', async () => {
86+
harness.writeFile(
87+
'vitest-base.config.ts',
88+
`
89+
import { defineConfig } from 'vitest/config';
90+
export default defineConfig({
91+
test: {
92+
coverage: {
93+
thresholds: {
94+
statements: 100,
95+
branches: 100
96+
}
97+
}
98+
}
99+
});
100+
`,
101+
);
102+
103+
harness.useTarget('test', {
104+
...BASE_OPTIONS,
105+
runnerConfig: true,
106+
coverage: true,
107+
coverageThresholds: {
108+
statements: 0,
109+
// branches is undefined here, should remain 100 from config
110+
},
111+
});
112+
113+
const { result } = await harness.executeOnce();
114+
115+
// Should still fail because branches threshold (100) is not met
116+
expect(result?.success).toBeFalse();
117+
});
118+
});
119+
});
120+
});

0 commit comments

Comments
 (0)