diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index bce60e18850d..939d7e26dc5e 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -235,6 +235,7 @@ export type UnitTestBuilderOptions = { providersFile?: string; reporters?: SchemaReporter[]; runner?: Runner; + runnerConfig?: RunnerConfig; setupFiles?: string[]; tsConfig?: string; ui?: boolean; diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 47db08e52d17..ef7132198451 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -54,7 +54,7 @@ export async function normalizeOptions( const buildTargetSpecifier = options.buildTarget ?? `::development`; const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); - const { runner, browsers, progress, filter, browserViewport, ui } = options; + const { runner, browsers, progress, filter, browserViewport, ui, runnerConfig } = options; if (ui && runner !== 'vitest') { throw new Error('The "ui" option is only available for the "vitest" runner.'); @@ -127,6 +127,8 @@ export async function normalizeOptions( : [], dumpVirtualFiles: options.dumpVirtualFiles, listTests: options.listTests, + runnerConfig: + typeof runnerConfig === 'string' ? path.join(workspaceRoot, runnerConfig) : runnerConfig, }; } diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts index c81f22d0b7d1..9d1627df000b 100644 --- a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts @@ -7,6 +7,8 @@ */ import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import type { ApplicationBuilderInternalOptions } from '../../../application/options'; import type { KarmaBuilderOptions, KarmaBuilderTransformsOptions } from '../../../karma'; import { NormalizedUnitTestBuilderOptions } from '../../options'; @@ -50,7 +52,23 @@ export class KarmaExecutor implements TestExecutor { await context.getBuilderNameForTarget(unitTestOptions.buildTarget), )) as unknown as ApplicationBuilderInternalOptions; + let karmaConfig: string | undefined; + if (typeof unitTestOptions.runnerConfig === 'string') { + karmaConfig = unitTestOptions.runnerConfig; + context.logger.info(`Using Karma configuration file: ${karmaConfig}`); + } else if (unitTestOptions.runnerConfig) { + const potentialPath = path.join(unitTestOptions.projectRoot, 'karma.conf.js'); + try { + await fs.access(potentialPath); + karmaConfig = potentialPath; + context.logger.info(`Using Karma configuration file: ${karmaConfig}`); + } catch { + context.logger.info('No Karma configuration file found. Using default configuration.'); + } + } + const karmaOptions: KarmaBuilderOptions = { + karmaConfig, tsConfig: unitTestOptions.tsConfig ?? buildTargetOptions.tsConfig, polyfills: buildTargetOptions.polyfills, assets: buildTargetOptions.assets, diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index 520e8c461201..00a9bb71c18b 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -139,6 +139,7 @@ export class VitestExecutor implements TestExecutor { browserViewport, ui, } = this.options; + let vitestNodeModule; try { vitestNodeModule = await import('vitest/node'); @@ -192,21 +193,22 @@ export class VitestExecutor implements TestExecutor { 'test', undefined, { - // Disable configuration file resolution/loading - config: false, + config: this.options.runnerConfig === true ? undefined : this.options.runnerConfig, root: workspaceRoot, project: ['base', this.projectName], name: 'base', include: [], testNamePattern: this.options.filter, - reporters: reporters ?? ['default'], - outputFile, watch, ui, - coverage: await generateCoverageOption(coverage, this.projectName), - ...debugOptions, }, { + test: { + coverage: await generateCoverageOption(coverage, this.projectName), + outputFile, + ...debugOptions, + ...(reporters ? { reporters } : {}), + }, server: { // Disable the actual file watcher. The boolean watch option above should still // be enabled as it controls other internal behavior related to rerunning tests. diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts index 2b76c5fd7456..4c8e8ad7043b 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts @@ -47,6 +47,12 @@ const VitestTestRunner: TestRunner = { const projectName = context.target?.project; assert(projectName, 'The builder requires a target.'); + if (typeof options.runnerConfig === 'string') { + context.logger.info(`Using Vitest configuration file: ${options.runnerConfig}`); + } else if (options.runnerConfig) { + context.logger.info('Automatically searching for and using Vitest configuration file.'); + } + return new VitestExecutor(projectName, options, testEntryPointMappings); }, }; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts index df7d2e06449f..fa51ae996d2f 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -51,12 +51,11 @@ export function createVitestPlugins( root: workspaceRoot, globals: true, setupFiles: testSetupFiles, - // Use `jsdom` if no browsers are explicitly configured. - // `node` is effectively no "environment" and the default. - environment: browserOptions.browser ? 'node' : 'jsdom', - browser: browserOptions.browser, include: options.include, ...(options.exclude ? { exclude: options.exclude } : {}), + browser: browserOptions.browser, + // Use `jsdom` if no browsers are explicitly configured. + ...(browserOptions.browser ? {} : { environment: 'jsdom' }), }, plugins: [ { diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 7ab96760a20a..ed766c1774a2 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -19,6 +19,11 @@ "default": "vitest", "enum": ["karma", "vitest"] }, + "runnerConfig": { + "type": ["boolean", "string"], + "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.", + "default": false + }, "browsers": { "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.", "type": "array", diff --git a/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts new file mode 100644 index 000000000000..54e4d9b21d13 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/runner-config_spec.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +const VITEST_CONFIG_CONTENT = ` +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { + reporters: [['junit', { outputFile: './vitest-results.xml' }]], + }, +}); +`; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "runnerConfig"', () => { + beforeEach(() => { + setupApplicationTarget(harness); + }); + + describe('Vitest Runner', () => { + it('should use a specified config file path', async () => { + harness.writeFile('custom-vitest.config.ts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: 'custom-vitest.config.ts', + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should search for a config file when `true`', async () => { + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toExist(); + }); + + it('should ignore config file when `false`', async () => { + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + runnerConfig: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + + it('should ignore config file by default', async () => { + harness.writeFile('vitest.config.ts', VITEST_CONFIG_CONTENT); + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + harness.expectFile('vitest-results.xml').toNotExist(); + }); + }); + }); +});