Skip to content

Commit 542d528

Browse files
committed
fix(@angular/build): allow custom runner configuration file for unit-test
Adds a new `runnerConfig` option to the `unit-test` system to provide more control over the test runner's configuration file. This option is runner-agnostic and enhances flexibility for both Vitest and Karma. The option accepts a boolean or a string path: - `true`: Automatically searches for a default config file (`karma.conf.js` or `vitest.config.ts`). If not found, the runner will use its internal default configuration. - `false` (default): Disables the use of any external config file. - `path/to/config`: Uses the specified configuration file. For Vitest, the loaded configuration is deep-merged with the system's programmatic config. This allows users to add or override advanced options (like `test.coverage`) while preserving the essential in-memory integration. The system's settings take precedence in case of conflicts. Please note that while the file is loaded, the Angular team does not provide direct support for its specific contents or any third-party plugins used within it. Informational messages are now logged to indicate which configuration file is being used, improving transparency. (cherry picked from commit 20079ed)
1 parent b884eb8 commit 542d528

File tree

8 files changed

+129
-11
lines changed

8 files changed

+129
-11
lines changed

goldens/public-api/angular/build/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ export type UnitTestBuilderOptions = {
235235
providersFile?: string;
236236
reporters?: SchemaReporter[];
237237
runner?: Runner;
238+
runnerConfig?: RunnerConfig;
238239
setupFiles?: string[];
239240
tsConfig?: string;
240241
ui?: boolean;

packages/angular/build/src/builders/unit-test/options.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export async function normalizeOptions(
5454
const buildTargetSpecifier = options.buildTarget ?? `::development`;
5555
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');
5656

57-
const { runner, browsers, progress, filter, browserViewport, ui } = options;
57+
const { runner, browsers, progress, filter, browserViewport, ui, runnerConfig } = options;
5858

5959
if (ui && runner !== 'vitest') {
6060
throw new Error('The "ui" option is only available for the "vitest" runner.');
@@ -127,6 +127,8 @@ export async function normalizeOptions(
127127
: [],
128128
dumpVirtualFiles: options.dumpVirtualFiles,
129129
listTests: options.listTests,
130+
runnerConfig:
131+
typeof runnerConfig === 'string' ? path.join(workspaceRoot, runnerConfig) : runnerConfig,
130132
};
131133
}
132134

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
*/
88

99
import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
10+
import fs from 'node:fs/promises';
11+
import path from 'node:path';
1012
import type { ApplicationBuilderInternalOptions } from '../../../application/options';
1113
import type { KarmaBuilderOptions, KarmaBuilderTransformsOptions } from '../../../karma';
1214
import { NormalizedUnitTestBuilderOptions } from '../../options';
@@ -50,7 +52,23 @@ export class KarmaExecutor implements TestExecutor {
5052
await context.getBuilderNameForTarget(unitTestOptions.buildTarget),
5153
)) as unknown as ApplicationBuilderInternalOptions;
5254

55+
let karmaConfig: string | undefined;
56+
if (typeof unitTestOptions.runnerConfig === 'string') {
57+
karmaConfig = unitTestOptions.runnerConfig;
58+
context.logger.info(`Using Karma configuration file: ${karmaConfig}`);
59+
} else if (unitTestOptions.runnerConfig) {
60+
const potentialPath = path.join(unitTestOptions.projectRoot, 'karma.conf.js');
61+
try {
62+
await fs.access(potentialPath);
63+
karmaConfig = potentialPath;
64+
context.logger.info(`Using Karma configuration file: ${karmaConfig}`);
65+
} catch {
66+
context.logger.info('No Karma configuration file found. Using default configuration.');
67+
}
68+
}
69+
5370
const karmaOptions: KarmaBuilderOptions = {
71+
karmaConfig,
5472
tsConfig: unitTestOptions.tsConfig ?? buildTargetOptions.tsConfig,
5573
polyfills: buildTargetOptions.polyfills,
5674
assets: buildTargetOptions.assets,

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export class VitestExecutor implements TestExecutor {
139139
browserViewport,
140140
ui,
141141
} = this.options;
142+
142143
let vitestNodeModule;
143144
try {
144145
vitestNodeModule = await import('vitest/node');
@@ -192,21 +193,22 @@ export class VitestExecutor implements TestExecutor {
192193
'test',
193194
undefined,
194195
{
195-
// Disable configuration file resolution/loading
196-
config: false,
196+
config: this.options.runnerConfig === true ? undefined : this.options.runnerConfig,
197197
root: workspaceRoot,
198198
project: ['base', this.projectName],
199199
name: 'base',
200200
include: [],
201201
testNamePattern: this.options.filter,
202-
reporters: reporters ?? ['default'],
203-
outputFile,
204202
watch,
205203
ui,
206-
coverage: await generateCoverageOption(coverage, this.projectName),
207-
...debugOptions,
208204
},
209205
{
206+
test: {
207+
coverage: await generateCoverageOption(coverage, this.projectName),
208+
outputFile,
209+
...debugOptions,
210+
...(reporters ? { reporters } : {}),
211+
},
210212
server: {
211213
// Disable the actual file watcher. The boolean watch option above should still
212214
// be enabled as it controls other internal behavior related to rerunning tests.

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ const VitestTestRunner: TestRunner = {
4747
const projectName = context.target?.project;
4848
assert(projectName, 'The builder requires a target.');
4949

50+
if (typeof options.runnerConfig === 'string') {
51+
context.logger.info(`Using Vitest configuration file: ${options.runnerConfig}`);
52+
} else if (options.runnerConfig) {
53+
context.logger.info('Automatically searching for and using Vitest configuration file.');
54+
}
55+
5056
return new VitestExecutor(projectName, options, testEntryPointMappings);
5157
},
5258
};

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,11 @@ export function createVitestPlugins(
5151
root: workspaceRoot,
5252
globals: true,
5353
setupFiles: testSetupFiles,
54-
// Use `jsdom` if no browsers are explicitly configured.
55-
// `node` is effectively no "environment" and the default.
56-
environment: browserOptions.browser ? 'node' : 'jsdom',
57-
browser: browserOptions.browser,
5854
include: options.include,
5955
...(options.exclude ? { exclude: options.exclude } : {}),
56+
browser: browserOptions.browser,
57+
// Use `jsdom` if no browsers are explicitly configured.
58+
...(browserOptions.browser ? {} : { environment: 'jsdom' }),
6059
},
6160
plugins: [
6261
{

packages/angular/build/src/builders/unit-test/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
"default": "vitest",
2020
"enum": ["karma", "vitest"]
2121
},
22+
"runnerConfig": {
23+
"type": ["boolean", "string"],
24+
"description": "Specifies the configuration file for the selected test runner. If a string is provided, it will be used as the path to the configuration file. If `true`, the builder will search for a default configuration file (e.g., `vitest.config.ts` or `karma.conf.js`). If `false`, no external configuration file will be used.\\nFor Vitest, this enables advanced options and the use of custom plugins. Please note that while the file is loaded, the Angular team does not provide direct support for its specific contents or any third-party plugins used within it.",
25+
"default": false
26+
},
2227
"browsers": {
2328
"description": "Specifies the browsers to use for test execution. When not specified, tests are run in a Node.js environment using jsdom. For both Vitest and Karma, browser names ending with 'Headless' (e.g., 'ChromeHeadless') will enable headless mode.",
2429
"type": "array",
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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+
const VITEST_CONFIG_CONTENT = `
18+
import { defineConfig } from 'vitest/config';
19+
export default defineConfig({
20+
test: {
21+
reporters: [['junit', { outputFile: './vitest-results.xml' }]],
22+
},
23+
});
24+
`;
25+
26+
describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
27+
describe('Option: "runnerConfig"', () => {
28+
beforeEach(() => {
29+
setupApplicationTarget(harness);
30+
});
31+
32+
describe('Vitest Runner', () => {
33+
it('should use a specified config file path', async () => {
34+
harness.writeFile('custom-vitest.config.ts', VITEST_CONFIG_CONTENT);
35+
harness.useTarget('test', {
36+
...BASE_OPTIONS,
37+
runnerConfig: 'custom-vitest.config.ts',
38+
});
39+
40+
const { result } = await harness.executeOnce();
41+
42+
expect(result?.success).toBeTrue();
43+
harness.expectFile('vitest-results.xml').toExist();
44+
});
45+
46+
it('should search for a config file when `true`', async () => {
47+
harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT);
48+
harness.useTarget('test', {
49+
...BASE_OPTIONS,
50+
runnerConfig: true,
51+
});
52+
53+
const { result } = await harness.executeOnce();
54+
55+
expect(result?.success).toBeTrue();
56+
harness.expectFile('vitest-results.xml').toExist();
57+
});
58+
59+
it('should ignore config file when `false`', async () => {
60+
harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT);
61+
harness.useTarget('test', {
62+
...BASE_OPTIONS,
63+
runnerConfig: false,
64+
});
65+
66+
const { result } = await harness.executeOnce();
67+
68+
expect(result?.success).toBeTrue();
69+
harness.expectFile('vitest-results.xml').toNotExist();
70+
});
71+
72+
it('should ignore config file by default', async () => {
73+
harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT);
74+
harness.useTarget('test', {
75+
...BASE_OPTIONS,
76+
});
77+
78+
const { result } = await harness.executeOnce();
79+
80+
expect(result?.success).toBeTrue();
81+
harness.expectFile('vitest-results.xml').toNotExist();
82+
});
83+
});
84+
});
85+
});

0 commit comments

Comments
 (0)