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) - : {}), - }; -}