From dfd390098e112f7d78f4a52eb1927721a186dcf5 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:43:11 -0400 Subject: [PATCH] refactor(@angular/build): introduce internal generic test runner API in unit-test builder Refactors the unit-test builder to introduce a generic `TestRunner` API. This change decouples the main builder logic from the specifics of individual test runners like Karma and Vitest. The new API consists of two main parts: - `TestRunner`: A declarative definition of a test runner, including its name and hooks for creating an executor and getting build options. - `TestExecutor`: A stateful object that manages a test execution session. The Karma and Vitest runners have been refactored to implement this new API. The Karma runner is now marked as `isStandalone: true` to indicate that it manages its own build process. The Vitest runner now uses the `getBuildOptions` hook to provide its build configuration to the main builder. This refactoring makes the unit-test builder more extensible, allowing for new test runners to be added more easily in the future. It also improves the separation of concerns within the builder, making the code easier to understand and maintain. --- .../build/src/builders/unit-test/builder.ts | 118 +++++- .../src/builders/unit-test/runners/api.ts | 60 +++ .../unit-test/runners/karma/executor.ts | 76 ++++ .../builders/unit-test/runners/karma/index.ts | 23 +- .../unit-test/runners/karma/runner.ts | 67 --- .../unit-test/runners/vitest/build-options.ts | 102 +++++ .../unit-test/runners/vitest/executor.ts | 284 +++++++++++++ .../unit-test/runners/vitest/index.ts | 25 +- .../unit-test/runners/vitest/runner.ts | 389 ------------------ 9 files changed, 671 insertions(+), 473 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/runners/api.ts create mode 100644 packages/angular/build/src/builders/unit-test/runners/karma/executor.ts delete mode 100644 packages/angular/build/src/builders/unit-test/runners/karma/runner.ts create mode 100644 packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts create mode 100644 packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts delete mode 100644 packages/angular/build/src/builders/unit-test/runners/vitest/runner.ts diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index 48cb7c36c198..ce6dddab4815 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -7,10 +7,17 @@ */ import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; -import type { ApplicationBuilderExtensions } from '../application/options'; +import assert from 'node:assert'; +import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin'; +import { assertIsError } from '../../utils/error'; +import { buildApplicationInternal } from '../application'; +import type { + ApplicationBuilderExtensions, + ApplicationBuilderInternalOptions, +} from '../application/options'; +import { ResultKind } from '../application/results'; import { normalizeOptions } from './options'; -import { useKarmaRunner } from './runners/karma'; -import { runVitest } from './runners/vitest'; +import type { TestRunner } from './runners/api'; import type { Schema as UnitTestBuilderOptions } from './schema'; export type { UnitTestBuilderOptions }; @@ -36,17 +43,98 @@ export async function* execute( ); const normalizedOptions = await normalizeOptions(context, projectName, options); - const { runnerName } = normalizedOptions; - - switch (runnerName) { - case 'karma': - yield* await useKarmaRunner(context, normalizedOptions); - break; - case 'vitest': - yield* runVitest(normalizedOptions, context, extensions); - break; - default: - context.logger.error('Unknown test runner: ' + runnerName); - break; + const { runnerName, projectSourceRoot } = normalizedOptions; + + // Dynamically load the requested runner + let runner: TestRunner; + try { + const { default: runnerModule } = await import(`./runners/${runnerName}/index`); + runner = runnerModule; + } catch (e) { + assertIsError(e); + if (e.code !== 'ERR_MODULE_NOT_FOUND') { + throw e; + } + context.logger.error(`Unknown test runner "${runnerName}".`); + + return; + } + + // Create the stateful executor once + await using executor = await runner.createExecutor(context, normalizedOptions); + + if (runner.isStandalone) { + yield* executor.execute({ + kind: ResultKind.Full, + files: {}, + }); + + return; + } + + // Get base build options from the buildTarget + const buildTargetOptions = (await context.validateOptions( + await context.getTargetOptions(normalizedOptions.buildTarget), + await context.getBuilderNameForTarget(normalizedOptions.buildTarget), + )) as unknown as ApplicationBuilderInternalOptions; + + // Get runner-specific build options from the hook + const { buildOptions: runnerBuildOptions, virtualFiles } = await runner.getBuildOptions( + normalizedOptions, + buildTargetOptions, + ); + + if (virtualFiles) { + extensions ??= {}; + extensions.codePlugins ??= []; + for (const [namespace, contents] of Object.entries(virtualFiles)) { + extensions.codePlugins.push( + createVirtualModulePlugin({ + namespace, + loadContent: () => { + return { + contents, + loader: 'js', + resolveDir: projectSourceRoot, + }; + }, + }), + ); + } + } + + const { watch, tsConfig } = normalizedOptions; + + // Prepare and run the application build + const applicationBuildOptions = { + // Base options + ...buildTargetOptions, + watch, + tsConfig, + // Runner specific + ...runnerBuildOptions, + } satisfies ApplicationBuilderInternalOptions; + + for await (const buildResult of buildApplicationInternal( + applicationBuildOptions, + context, + extensions, + )) { + if (buildResult.kind === ResultKind.Failure) { + yield { success: false }; + continue; + } else if ( + buildResult.kind !== ResultKind.Full && + buildResult.kind !== ResultKind.Incremental + ) { + assert.fail( + 'A full and/or incremental build result is required from the application builder.', + ); + } + + assert(buildResult.files, 'Builder did not provide result files.'); + + // Pass the build artifacts to the executor + yield* executor.execute(buildResult); } } diff --git a/packages/angular/build/src/builders/unit-test/runners/api.ts b/packages/angular/build/src/builders/unit-test/runners/api.ts new file mode 100644 index 000000000000..60d30ecf0cc3 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/api.ts @@ -0,0 +1,60 @@ +/** + * @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 type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { ApplicationBuilderInternalOptions } from '../../application/options'; +import type { FullResult, IncrementalResult } from '../../application/results'; +import type { NormalizedUnitTestBuilderOptions } from '../options'; + +export interface RunnerOptions { + buildOptions: Partial; + virtualFiles?: Record; +} + +/** + * Represents a stateful test execution session. + * An instance of this is created for each `ng test` command. + */ +export interface TestExecutor { + /** + * Executes tests using the artifacts from a specific build. + * This method can be called multiple times in watch mode. + * + * @param buildResult The output from the application builder. + * @returns An async iterable builder output stream. + */ + execute(buildResult: FullResult | IncrementalResult): AsyncIterable; + + [Symbol.asyncDispose](): Promise; +} + +/** + * Represents the metadata and hooks for a specific test runner. + */ +export interface TestRunner { + readonly name: string; + readonly isStandalone?: boolean; + + getBuildOptions( + options: NormalizedUnitTestBuilderOptions, + baseBuildOptions: Partial, + ): RunnerOptions | Promise; + + /** + * Creates a stateful executor for a test session. + * This is called once at the start of the `ng test` command. + * + * @param context The Architect builder context. + * @param options The normalized unit test options. + * @returns A TestExecutor instance that will handle the test runs. + */ + createExecutor( + context: BuilderContext, + options: NormalizedUnitTestBuilderOptions, + ): Promise; +} 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 new file mode 100644 index 000000000000..7f5e6dbb5010 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts @@ -0,0 +1,76 @@ +/** + * @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 type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { ApplicationBuilderInternalOptions } from '../../../application/options'; +import type { KarmaBuilderOptions } from '../../../karma'; +import { NormalizedUnitTestBuilderOptions } from '../../options'; +import type { TestExecutor } from '../api'; + +export class KarmaExecutor implements TestExecutor { + constructor( + private context: BuilderContext, + private options: NormalizedUnitTestBuilderOptions, + ) {} + + async *execute(): AsyncIterable { + const { context, options: unitTestOptions } = this; + + if (unitTestOptions.debug) { + context.logger.warn( + 'The "karma" test runner does not support the "debug" option. The option will be ignored.', + ); + } + + if (unitTestOptions.setupFiles.length) { + context.logger.warn( + 'The "karma" test runner does not support the "setupFiles" option. The option will be ignored.', + ); + } + + const buildTargetOptions = (await context.validateOptions( + await context.getTargetOptions(unitTestOptions.buildTarget), + await context.getBuilderNameForTarget(unitTestOptions.buildTarget), + )) as unknown as ApplicationBuilderInternalOptions; + + const karmaOptions: KarmaBuilderOptions = { + tsConfig: unitTestOptions.tsConfig, + polyfills: buildTargetOptions.polyfills, + assets: buildTargetOptions.assets, + scripts: buildTargetOptions.scripts, + styles: buildTargetOptions.styles, + inlineStyleLanguage: buildTargetOptions.inlineStyleLanguage, + stylePreprocessorOptions: buildTargetOptions.stylePreprocessorOptions, + externalDependencies: buildTargetOptions.externalDependencies, + loader: buildTargetOptions.loader, + define: buildTargetOptions.define, + include: unitTestOptions.include, + exclude: unitTestOptions.exclude, + sourceMap: buildTargetOptions.sourceMap, + progress: buildTargetOptions.progress, + watch: unitTestOptions.watch, + poll: buildTargetOptions.poll, + preserveSymlinks: buildTargetOptions.preserveSymlinks, + browsers: unitTestOptions.browsers?.join(','), + codeCoverage: !!unitTestOptions.codeCoverage, + codeCoverageExclude: unitTestOptions.codeCoverage?.exclude, + fileReplacements: buildTargetOptions.fileReplacements, + reporters: unitTestOptions.reporters, + webWorkerTsConfig: buildTargetOptions.webWorkerTsConfig, + aot: buildTargetOptions.aot, + }; + + const { execute } = await import('../../../karma'); + + yield* execute(karmaOptions, context); + } + + async [Symbol.asyncDispose](): Promise { + // The Karma builder handles its own teardown + } +} diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/index.ts b/packages/angular/build/src/builders/unit-test/runners/karma/index.ts index b0e4c2d65570..410fab0c3d44 100644 --- a/packages/angular/build/src/builders/unit-test/runners/karma/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/karma/index.ts @@ -6,4 +6,25 @@ * found in the LICENSE file at https://angular.dev/license */ -export { useKarmaRunner } from './runner'; +import type { TestRunner } from '../api'; +import { KarmaExecutor } from './executor'; + +/** + * A declarative definition of the Karma test runner. + */ +const KarmaTestRunner: TestRunner = { + name: 'karma', + isStandalone: true, + + getBuildOptions() { + return { + buildOptions: {}, + }; + }, + + async createExecutor(context, options) { + return new KarmaExecutor(context, options); + }, +}; + +export default KarmaTestRunner; diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/runner.ts b/packages/angular/build/src/builders/unit-test/runners/karma/runner.ts deleted file mode 100644 index bb8809a76ce4..000000000000 --- a/packages/angular/build/src/builders/unit-test/runners/karma/runner.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @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 type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; -import type { ApplicationBuilderInternalOptions } from '../../../application/options'; -import type { KarmaBuilderOptions } from '../../../karma'; -import { type NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; - -export async function useKarmaRunner( - context: BuilderContext, - unitTestOptions: NormalizedUnitTestBuilderOptions, -): Promise> { - if (unitTestOptions.debug) { - context.logger.warn( - 'The "karma" test runner does not support the "debug" option. The option will be ignored.', - ); - } - - if (unitTestOptions.setupFiles.length) { - context.logger.warn( - 'The "karma" test runner does not support the "setupFiles" option. The option will be ignored.', - ); - } - - const buildTargetOptions = (await context.validateOptions( - await context.getTargetOptions(unitTestOptions.buildTarget), - await context.getBuilderNameForTarget(unitTestOptions.buildTarget), - )) as unknown as ApplicationBuilderInternalOptions; - - buildTargetOptions.polyfills = injectTestingPolyfills(buildTargetOptions.polyfills); - - const options: KarmaBuilderOptions = { - tsConfig: unitTestOptions.tsConfig, - polyfills: buildTargetOptions.polyfills, - assets: buildTargetOptions.assets, - scripts: buildTargetOptions.scripts, - styles: buildTargetOptions.styles, - inlineStyleLanguage: buildTargetOptions.inlineStyleLanguage, - stylePreprocessorOptions: buildTargetOptions.stylePreprocessorOptions, - externalDependencies: buildTargetOptions.externalDependencies, - loader: buildTargetOptions.loader, - define: buildTargetOptions.define, - include: unitTestOptions.include, - exclude: unitTestOptions.exclude, - sourceMap: buildTargetOptions.sourceMap, - progress: buildTargetOptions.progress, - watch: unitTestOptions.watch, - poll: buildTargetOptions.poll, - preserveSymlinks: buildTargetOptions.preserveSymlinks, - browsers: unitTestOptions.browsers?.join(','), - codeCoverage: !!unitTestOptions.codeCoverage, - codeCoverageExclude: unitTestOptions.codeCoverage?.exclude, - fileReplacements: buildTargetOptions.fileReplacements, - reporters: unitTestOptions.reporters, - webWorkerTsConfig: buildTargetOptions.webWorkerTsConfig, - aot: buildTargetOptions.aot, - }; - - const { execute } = await import('../../../karma'); - - return execute(options, context); -} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts new file mode 100644 index 000000000000..1d08bb4c156f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -0,0 +1,102 @@ +/** + * @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 path from 'node:path'; +import { toPosixPath } from '../../../../utils/path'; +import type { ApplicationBuilderInternalOptions } from '../../../application/options'; +import { OutputHashing } from '../../../application/schema'; +import { NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; +import { findTests, getTestEntrypoints } from '../../test-discovery'; +import { RunnerOptions } from '../api'; + +function createTestBedInitVirtualFile( + providersFile: string | undefined, + projectSourceRoot: string, +): string { + let providersImport = 'const providers = [];'; + if (providersFile) { + const relativePath = path.relative(projectSourceRoot, providersFile); + const { dir, name } = path.parse(relativePath); + const importPath = toPosixPath(path.join(dir, name)); + providersImport = `import providers from './${importPath}';`; + } + + return ` + // Initialize the Angular testing environment + import { NgModule } from '@angular/core'; + import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing'; + import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; + ${providersImport} + // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29 + beforeEach(getCleanupHook(false)); + afterEach(getCleanupHook(true)); + @NgModule({ + providers, + }) + export class TestModule {} + getTestBed().initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting(), { + errorOnUnknownElements: true, + errorOnUnknownProperties: true, + }); + `; +} + +export async function getVitestBuildOptions( + options: NormalizedUnitTestBuilderOptions, + baseBuildOptions: Partial, +): Promise { + const { workspaceRoot, projectSourceRoot, include, exclude, watch, tsConfig, providersFile } = + options; + + // Find test files + const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot); + if (testFiles.length === 0) { + throw new Error( + 'No tests found matching the following patterns:\n' + + `- Included: ${include.join(', ')}\n` + + (exclude.length ? `- Excluded: ${exclude.join(', ')}\n` : '') + + `\nPlease check the 'test' target configuration in your project's 'angular.json' file.`, + ); + } + + const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot }); + entryPoints.set('init-testbed', 'angular:test-bed-init'); + + const buildOptions: Partial = { + ...baseBuildOptions, + watch, + incrementalResults: watch, + index: false, + browser: undefined, + server: undefined, + outputMode: undefined, + localize: false, + budgets: [], + serviceWorker: false, + appShell: false, + ssr: false, + prerender: false, + sourceMap: { scripts: true, vendor: false, styles: false }, + outputHashing: OutputHashing.None, + optimization: false, + tsConfig, + entryPoints, + externalDependencies: ['vitest', '@vitest/browser/context'], + }; + + buildOptions.polyfills = injectTestingPolyfills(buildOptions.polyfills); + + const testBedInitContents = createTestBedInitVirtualFile(providersFile, projectSourceRoot); + + return { + buildOptions, + virtualFiles: { + 'angular:test-bed-init': testBedInitContents, + }, + }; +} 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 new file mode 100644 index 000000000000..54a25b2a83fb --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -0,0 +1,284 @@ +/** + * @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 type { BuilderOutput } from '@angular-devkit/architect'; +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import type { InlineConfig, Vitest } from 'vitest/node'; +import { assertIsError } from '../../../../utils/error'; +import { loadEsmModule } from '../../../../utils/load-esm'; +import { toPosixPath } from '../../../../utils/path'; +import type { FullResult, IncrementalResult } from '../../../application/results'; +import { writeTestFiles } from '../../../karma/application_builder'; +import { NormalizedUnitTestBuilderOptions } from '../../options'; +import type { TestExecutor } from '../api'; + +type VitestCoverageOption = Exclude; + +export class VitestExecutor implements TestExecutor { + private vitest: Vitest | undefined; + private readonly projectName: string; + private readonly options: NormalizedUnitTestBuilderOptions; + private readonly outputPath: string; + private latestBuildResult: FullResult | IncrementalResult | undefined; + + constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) { + this.projectName = projectName; + this.options = options; + this.outputPath = toPosixPath(path.join(options.workspaceRoot, generateOutputPath())); + } + + async *execute(buildResult: FullResult | IncrementalResult): AsyncIterable { + await writeTestFiles(buildResult.files, this.outputPath); + + this.latestBuildResult = buildResult; + this.vitest ??= await this.initializeVitest(); + + // Check if all the tests pass to calculate the result + const testModules = this.vitest.state.getTestModules(); + + yield { success: testModules.every((testModule) => testModule.ok()) }; + } + + async [Symbol.asyncDispose](): Promise { + await this.vitest?.close(); + } + + private async initializeVitest(): Promise { + const { codeCoverage, reporters, watch, workspaceRoot, setupFiles, browsers, debug } = + this.options; + const { outputPath, projectName, latestBuildResult } = this; + + let vitestNodeModule; + try { + vitestNodeModule = await loadEsmModule('vitest/node'); + } catch (error: unknown) { + assertIsError(error); + if (error.code !== 'ERR_MODULE_NOT_FOUND') { + throw error; + } + throw new Error( + 'The `vitest` package was not found. Please install the package and rerun the test command.', + ); + } + const { startVitest } = vitestNodeModule; + + // Setup vitest browser options if configured + const browserOptions = setupBrowserConfiguration( + browsers, + debug, + this.options.projectSourceRoot, + ); + if (browserOptions.errors?.length) { + throw new Error(browserOptions.errors.join('\n')); + } + + assert(latestBuildResult, 'buildResult must be available before initializing vitest'); + // Add setup file entries for TestBed initialization and project polyfills + const testSetupFiles = ['init-testbed.js', ...setupFiles]; + + // TODO: Provide additional result metadata to avoid needing to extract based on filename + const polyfillsFile = Object.keys(latestBuildResult.files).find((f) => f === 'polyfills.js'); + if (polyfillsFile) { + testSetupFiles.unshift(polyfillsFile); + } + + const debugOptions = debug + ? { + inspectBrk: true, + isolate: false, + fileParallelism: false, + } + : {}; + + return startVitest( + 'test', + undefined /* cliFilters */, + { + // Disable configuration file resolution/loading + config: false, + root: workspaceRoot, + project: ['base', projectName], + name: 'base', + include: [], + reporters: reporters ?? ['default'], + watch, + coverage: generateCoverageOption(codeCoverage, workspaceRoot, this.outputPath), + ...debugOptions, + }, + { + plugins: [ + { + name: 'angular:project-init', + async configureVitest(context) { + // Create a subproject that can be configured with plugins for browser mode. + // Plugins defined directly in the vite overrides will not be present in the + // browser specific Vite instance. + const [project] = await context.injectTestProjects({ + test: { + name: projectName, + root: outputPath, + 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, + }, + plugins: [ + { + name: 'angular:html-index', + transformIndexHtml: () => { + assert( + latestBuildResult, + 'buildResult must be available for HTML index transformation.', + ); + // Add all global stylesheets + const styleFiles = Object.entries(latestBuildResult.files).filter( + ([file]) => file === 'styles.css', + ); + + return styleFiles.map(([href]) => ({ + tag: 'link', + attrs: { href, rel: 'stylesheet' }, + injectTo: 'head', + })); + }, + }, + ], + }); + + // Adjust coverage excludes to not include the otherwise automatically inserted included unit tests. + // Vite does this as a convenience but is problematic for the bundling strategy employed by the + // builder's test setup. To workaround this, the excludes are adjusted here to only automatically + // exclude the TypeScript source test files. + project.config.coverage.exclude = [ + ...(codeCoverage?.exclude ?? []), + '**/*.{test,spec}.?(c|m)ts', + ]; + }, + }, + ], + }, + ); + } +} + +function findBrowserProvider( + projectResolver: NodeJS.RequireResolve, +): import('vitest/node').BrowserBuiltinProvider | undefined { + // One of these must be installed in the project to use browser testing + const vitestBuiltinProviders = ['playwright', 'webdriverio'] as const; + + for (const providerName of vitestBuiltinProviders) { + try { + projectResolver(providerName); + + return providerName; + } catch {} + } + + return undefined; +} + +function normalizeBrowserName(browserName: string): string { + // Normalize browser names to match Vitest's expectations for headless but also supports karma's names + // e.g., 'ChromeHeadless' -> 'chrome', 'FirefoxHeadless' -> 'firefox' + // and 'Chrome' -> 'chrome', 'Firefox' -> 'firefox'. + const normalized = browserName.toLowerCase(); + + return normalized.replace(/headless$/, ''); +} + +function setupBrowserConfiguration( + browsers: string[] | undefined, + debug: boolean, + projectSourceRoot: string, +): { browser?: import('vitest/node').BrowserConfigOptions; errors?: string[] } { + if (browsers === undefined) { + return {}; + } + + const projectResolver = createRequire(projectSourceRoot + '/').resolve; + let errors: string[] | undefined; + + try { + projectResolver('@vitest/browser'); + } catch { + errors ??= []; + errors.push( + 'The "browsers" option requires the "@vitest/browser" package to be installed within the project.' + + ' Please install this package and rerun the test command.', + ); + } + + const provider = findBrowserProvider(projectResolver); + if (!provider) { + errors ??= []; + errors.push( + 'The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' + + ' Please install one of these packages and rerun the test command.', + ); + } + + // Vitest current requires the playwright browser provider to use the inspect-brk option used by "debug" + if (debug && provider !== 'playwright') { + errors ??= []; + errors.push( + 'Debugging browser mode tests currently requires the use of "playwright".' + + ' Please install this package and rerun the test command.', + ); + } + + if (errors) { + return { errors }; + } + + const browser = { + enabled: true, + provider, + headless: browsers.some((name) => name.toLowerCase().includes('headless')), + + instances: browsers.map((browserName) => ({ + browser: normalizeBrowserName(browserName), + })), + }; + + return { browser }; +} + +function generateOutputPath(): string { + const datePrefix = new Date().toISOString().replaceAll(/[-:.]/g, ''); + const uuidSuffix = randomUUID().slice(0, 8); + + return path.join('dist', 'test-out', `${datePrefix}-${uuidSuffix}`); +} + +function generateCoverageOption( + codeCoverage: NormalizedUnitTestBuilderOptions['codeCoverage'], + workspaceRoot: string, + outputPath: string, +): VitestCoverageOption { + if (!codeCoverage) { + return { + enabled: false, + }; + } + + return { + enabled: true, + excludeAfterRemap: true, + include: [`${toPosixPath(path.relative(workspaceRoot, outputPath))}/**`], + // Special handling for `reporter` due to an undefined value causing upstream failures + ...(codeCoverage.reporters + ? ({ reporter: codeCoverage.reporters } satisfies VitestCoverageOption) + : {}), + }; +} 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 d7672239688a..b257bb984250 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 @@ -6,4 +6,27 @@ * found in the LICENSE file at https://angular.dev/license */ -export { run as runVitest } from './runner'; +import assert from 'node:assert'; +import type { TestRunner } from '../api'; +import { getVitestBuildOptions } from './build-options'; +import { VitestExecutor } from './executor'; + +/** + * A declarative definition of the Vitest test runner. + */ +const VitestTestRunner: TestRunner = { + name: 'vitest', + + getBuildOptions(options, baseBuildOptions) { + return getVitestBuildOptions(options, baseBuildOptions); + }, + + async createExecutor(context, options) { + const projectName = context.target?.project; + assert(projectName, 'The builder requires a target.'); + + return new VitestExecutor(projectName, options); + }, +}; + +export default VitestTestRunner; diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/runner.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/runner.ts deleted file mode 100644 index fae09ccce13b..000000000000 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/runner.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * @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 type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; -import assert from 'node:assert'; -import { randomUUID } from 'node:crypto'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import type { InlineConfig, Vitest } from 'vitest'; -import { createVirtualModulePlugin } from '../../../../tools/esbuild/virtual-module-plugin'; -import { assertIsError } from '../../../../utils/error'; -import { loadEsmModule } from '../../../../utils/load-esm'; -import { toPosixPath } from '../../../../utils/path'; -import { buildApplicationInternal } from '../../../application'; -import type { - ApplicationBuilderExtensions, - ApplicationBuilderInternalOptions, -} from '../../../application/options'; -import { ResultKind } from '../../../application/results'; -import { OutputHashing } from '../../../application/schema'; -import { writeTestFiles } from '../../../karma/application_builder'; -import { NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; -import { findTests, getTestEntrypoints } from '../../test-discovery'; - -type VitestCoverageOption = Exclude; - -// eslint-disable-next-line max-lines-per-function -export async function* run( - normalizedOptions: NormalizedUnitTestBuilderOptions, - context: BuilderContext, - extensions?: ApplicationBuilderExtensions, -): AsyncIterable { - const { - codeCoverage, - projectSourceRoot, - reporters, - watch, - workspaceRoot, - setupFiles, - browsers, - debug, - buildTarget, - include, - exclude, - } = normalizedOptions; - const projectName = context.target?.project; - assert(projectName, 'The builder requires a target.'); - - // Find test files - const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot); - if (testFiles.length === 0) { - context.logger.error('No tests found.'); - - return { success: false }; - } - - const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot }); - entryPoints.set('init-testbed', 'angular:test-bed-init'); - - let vitestNodeModule; - try { - vitestNodeModule = await loadEsmModule('vitest/node'); - } catch (error: unknown) { - assertIsError(error); - if (error.code !== 'ERR_MODULE_NOT_FOUND') { - throw error; - } - - context.logger.error( - 'The `vitest` package was not found. Please install the package and rerun the test command.', - ); - - return; - } - const { startVitest } = vitestNodeModule; - - // Setup test file build options based on application build target options - const buildTargetOptions = (await context.validateOptions( - await context.getTargetOptions(buildTarget), - await context.getBuilderNameForTarget(buildTarget), - )) as unknown as ApplicationBuilderInternalOptions; - - buildTargetOptions.polyfills = injectTestingPolyfills(buildTargetOptions.polyfills); - - const outputPath = toPosixPath(path.join(workspaceRoot, generateOutputPath())); - const buildOptions: ApplicationBuilderInternalOptions = { - ...buildTargetOptions, - watch, - incrementalResults: watch, - outputPath, - index: false, - browser: undefined, - server: undefined, - outputMode: undefined, - localize: false, - budgets: [], - serviceWorker: false, - appShell: false, - ssr: false, - prerender: false, - sourceMap: { scripts: true, vendor: false, styles: false }, - outputHashing: OutputHashing.None, - optimization: false, - tsConfig: normalizedOptions.tsConfig, - entryPoints, - externalDependencies: [ - 'vitest', - '@vitest/browser/context', - ...(buildTargetOptions.externalDependencies ?? []), - ], - }; - extensions ??= {}; - extensions.codePlugins ??= []; - const virtualTestBedInit = createVirtualModulePlugin({ - namespace: 'angular:test-bed-init', - loadContent: async () => { - const contents: string[] = [ - // Initialize the Angular testing environment - `import { NgModule } from '@angular/core';`, - `import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing';`, - `import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';`, - '', - normalizedOptions.providersFile - ? `import providers from './${toPosixPath( - path - .relative(projectSourceRoot, normalizedOptions.providersFile) - .replace(/.[mc]?ts$/, ''), - )}'` - : 'const providers = [];', - '', - // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/src/test_hooks.ts#L21-L29 - `beforeEach(getCleanupHook(false));`, - `afterEach(getCleanupHook(true));`, - '', - `@NgModule({ - providers, - })`, - `export class TestModule {}`, - '', - `getTestBed().initTestEnvironment([BrowserTestingModule, TestModule], platformBrowserTesting(), { - errorOnUnknownElements: true, - errorOnUnknownProperties: true, - });`, - ]; - - return { - contents: contents.join('\n'), - loader: 'js', - resolveDir: projectSourceRoot, - }; - }, - }); - extensions.codePlugins.unshift(virtualTestBedInit); - - let instance: Vitest | undefined; - - // Setup vitest browser options if configured - const browserOptions = setupBrowserConfiguration(browsers, debug, projectSourceRoot); - if (browserOptions.errors?.length) { - browserOptions.errors.forEach((error) => context.logger.error(error)); - - return { success: false }; - } - - // Add setup file entries for TestBed initialization and project polyfills - const testSetupFiles = ['init-testbed.js', ...setupFiles]; - if (buildTargetOptions?.polyfills?.length) { - // Placed first as polyfills may be required by the Testbed initialization - // or other project provided setup files (e.g., zone.js, ECMAScript polyfills). - testSetupFiles.unshift('polyfills.js'); - } - const debugOptions = debug - ? { - inspectBrk: true, - isolate: false, - fileParallelism: false, - } - : {}; - - try { - for await (const result of buildApplicationInternal(buildOptions, context, extensions)) { - if (result.kind === ResultKind.Failure) { - continue; - } else if (result.kind !== ResultKind.Full && result.kind !== ResultKind.Incremental) { - assert.fail( - 'A full and/or incremental build result is required from the application builder.', - ); - } - assert(result.files, 'Builder did not provide result files.'); - - await writeTestFiles(result.files, outputPath); - - instance ??= await startVitest( - 'test', - undefined /* cliFilters */, - { - // Disable configuration file resolution/loading - config: false, - root: workspaceRoot, - project: ['base', projectName], - name: 'base', - include: [], - reporters: reporters ?? ['default'], - watch, - coverage: generateCoverageOption(codeCoverage, workspaceRoot, outputPath), - ...debugOptions, - }, - { - plugins: [ - { - name: 'angular:project-init', - async configureVitest(context) { - // Create a subproject that can be configured with plugins for browser mode. - // Plugins defined directly in the vite overrides will not be present in the - // browser specific Vite instance. - const [project] = await context.injectTestProjects({ - test: { - name: projectName, - root: outputPath, - 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, - }, - plugins: [ - { - name: 'angular:html-index', - transformIndexHtml() { - // Add all global stylesheets - return ( - Object.entries(result.files) - // TODO: Expand this to all configured global stylesheets - .filter(([file]) => file === 'styles.css') - .map(([styleUrl]) => ({ - tag: 'link', - attrs: { - 'href': styleUrl, - 'rel': 'stylesheet', - }, - injectTo: 'head', - })) - ); - }, - }, - ], - }); - - // Adjust coverage excludes to not include the otherwise automatically inserted included unit tests. - // Vite does this as a convenience but is problematic for the bundling strategy employed by the - // builder's test setup. To workaround this, the excludes are adjusted here to only automatically - // exclude the TypeScript source test files. - project.config.coverage.exclude = [ - ...(codeCoverage?.exclude ?? []), - '**/*.{test,spec}.?(c|m)ts', - ]; - }, - }, - ], - }, - ); - - // Check if all the tests pass to calculate the result - const testModules = instance.state.getTestModules(); - - yield { success: testModules.every((testModule) => testModule.ok()) }; - } - } finally { - if (watch) { - // Vitest will automatically close if not using watch mode - await instance?.close(); - } - } -} - -function findBrowserProvider( - projectResolver: NodeJS.RequireResolve, -): import('vitest/node').BrowserBuiltinProvider | undefined { - // One of these must be installed in the project to use browser testing - const vitestBuiltinProviders = ['playwright', 'webdriverio'] as const; - - for (const providerName of vitestBuiltinProviders) { - try { - projectResolver(providerName); - - return providerName; - } catch {} - } -} - -function normalizeBrowserName(browserName: string): string { - // Normalize browser names to match Vitest's expectations for headless but also supports karma's names - // e.g., 'ChromeHeadless' -> 'chrome', 'FirefoxHeadless' -> 'firefox' - // and 'Chrome' -> 'chrome', 'Firefox' -> 'firefox'. - const normalized = browserName.toLowerCase(); - - return normalized.replace(/headless$/, ''); -} - -function setupBrowserConfiguration( - browsers: string[] | undefined, - debug: boolean, - projectSourceRoot: string, -): { browser?: import('vitest/node').BrowserConfigOptions; errors?: string[] } { - if (browsers === undefined) { - return {}; - } - - const projectResolver = createRequire(projectSourceRoot + '/').resolve; - let errors: string[] | undefined; - - try { - projectResolver('@vitest/browser'); - } catch { - errors ??= []; - errors.push( - 'The "browsers" option requires the "@vitest/browser" package to be installed within the project.' + - ' Please install this package and rerun the test command.', - ); - } - - const provider = findBrowserProvider(projectResolver); - if (!provider) { - errors ??= []; - errors.push( - 'The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' + - ' Please install one of these packages and rerun the test command.', - ); - } - - // Vitest current requires the playwright browser provider to use the inspect-brk option used by "debug" - if (debug && provider !== 'playwright') { - errors ??= []; - errors.push( - 'Debugging browser mode tests currently requires the use of "playwright".' + - ' Please install this package and rerun the test command.', - ); - } - - if (errors) { - return { errors }; - } - - const browser = { - enabled: true, - provider, - headless: browsers.some((name) => name.toLowerCase().includes('headless')), - - instances: browsers.map((browserName) => ({ - browser: normalizeBrowserName(browserName), - })), - }; - - return { browser }; -} - -function generateOutputPath(): string { - const datePrefix = new Date().toISOString().replaceAll(/[-:.]/g, ''); - const uuidSuffix = randomUUID().slice(0, 8); - - return path.join('dist', 'test-out', `${datePrefix}-${uuidSuffix}`); -} -function generateCoverageOption( - codeCoverage: NormalizedUnitTestBuilderOptions['codeCoverage'], - workspaceRoot: string, - outputPath: string, -): VitestCoverageOption { - if (!codeCoverage) { - return { - enabled: false, - }; - } - - return { - enabled: true, - excludeAfterRemap: true, - include: [`${toPosixPath(path.relative(workspaceRoot, outputPath))}/**`], - // Special handling for `reporter` due to an undefined value causing upstream failures - ...(codeCoverage.reporters - ? ({ reporter: codeCoverage.reporters } satisfies VitestCoverageOption) - : {}), - }; -}