diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts index 16913d50b3f7..dbf5725e14fa 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts @@ -8,6 +8,11 @@ import { createRequire } from 'node:module'; +export interface BrowserConfiguration { + browser?: import('vitest/node').BrowserConfigOptions; + errors?: string[]; +} + function findBrowserProvider( projectResolver: NodeJS.RequireResolve, ): import('vitest/node').BrowserBuiltinProvider | undefined { @@ -38,7 +43,7 @@ export function setupBrowserConfiguration( browsers: string[] | undefined, debug: boolean, projectSourceRoot: string, -): { browser?: import('vitest/node').BrowserConfigOptions; errors?: string[] } { +): BrowserConfiguration { if (browsers === undefined) { return {}; } 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 9aae7b6a9fa8..b97c451f08a0 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 @@ -8,9 +8,8 @@ import type { BuilderOutput } from '@angular-devkit/architect'; import assert from 'node:assert'; -import { readFile } from 'node:fs/promises'; import path from 'node:path'; -import type { InlineConfig, Vitest, VitestPlugin } from 'vitest/node'; +import type { InlineConfig, Vitest } from 'vitest/node'; import { assertIsError } from '../../../../utils/error'; import { loadEsmModule } from '../../../../utils/load-esm'; import { toPosixPath } from '../../../../utils/path'; @@ -24,22 +23,22 @@ import { NormalizedUnitTestBuilderOptions } from '../../options'; import { findTests, getTestEntrypoints } from '../../test-discovery'; import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; +import { createVitestPlugins } from './plugins'; type VitestCoverageOption = Exclude; -type VitestPlugins = Awaited>; export class VitestExecutor implements TestExecutor { private vitest: Vitest | undefined; private readonly projectName: string; private readonly options: NormalizedUnitTestBuilderOptions; - private buildResultFiles = new Map(); + private readonly buildResultFiles = new Map(); // This is a reverse map of the entry points created in `build-options.ts`. // It is used by the in-memory provider plugin to map the requested test file // path back to its bundled output path. // Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>` - private testFileToEntryPoint = new Map(); - private entryPointToTestFile = new Map(); + private readonly testFileToEntryPoint = new Map(); + private readonly entryPointToTestFile = new Map(); constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) { this.projectName = projectName; @@ -135,134 +134,6 @@ export class VitestExecutor implements TestExecutor { return testSetupFiles; } - private createVitestPlugins( - testSetupFiles: string[], - browserOptions: Awaited>, - ): VitestPlugins { - const { workspaceRoot } = this.options; - - return [ - { - name: 'angular:project-init', - // Type is incorrect. This allows a Promise. - // eslint-disable-next-line @typescript-eslint/no-misused-promises - configureVitest: async (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. - await context.injectTestProjects({ - test: { - name: this.projectName, - 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: this.options.include, - ...(this.options.exclude ? { exclude: this.options.exclude } : {}), - }, - plugins: [ - { - name: 'angular:test-in-memory-provider', - enforce: 'pre', - resolveId: (id, importer) => { - if (importer && (id[0] === '.' || id[0] === '/')) { - let fullPath; - if (this.testFileToEntryPoint.has(importer)) { - fullPath = toPosixPath(path.join(this.options.workspaceRoot, id)); - } else { - fullPath = toPosixPath(path.join(path.dirname(importer), id)); - } - - const relativePath = path.relative(this.options.workspaceRoot, fullPath); - if (this.buildResultFiles.has(toPosixPath(relativePath))) { - return fullPath; - } - } - - if (this.testFileToEntryPoint.has(id)) { - return id; - } - - assert( - this.buildResultFiles.size > 0, - 'buildResult must be available for resolving.', - ); - const relativePath = path.relative(this.options.workspaceRoot, id); - if (this.buildResultFiles.has(toPosixPath(relativePath))) { - return id; - } - }, - load: async (id) => { - assert( - this.buildResultFiles.size > 0, - 'buildResult must be available for in-memory loading.', - ); - - // Attempt to load as a source test file. - const entryPoint = this.testFileToEntryPoint.get(id); - let outputPath; - if (entryPoint) { - outputPath = entryPoint + '.js'; - - // To support coverage exclusion of the actual test file, the virtual - // test entry point only references the built and bundled intermediate file. - return { - code: `import "./${outputPath}";`, - }; - } else { - // Attempt to load as a built artifact. - const relativePath = path.relative(this.options.workspaceRoot, id); - outputPath = toPosixPath(relativePath); - } - - const outputFile = this.buildResultFiles.get(outputPath); - if (outputFile) { - const sourceMapPath = outputPath + '.map'; - const sourceMapFile = this.buildResultFiles.get(sourceMapPath); - const code = - outputFile.origin === 'memory' - ? Buffer.from(outputFile.contents).toString('utf-8') - : await readFile(outputFile.inputPath, 'utf-8'); - const map = sourceMapFile - ? sourceMapFile.origin === 'memory' - ? Buffer.from(sourceMapFile.contents).toString('utf-8') - : await readFile(sourceMapFile.inputPath, 'utf-8') - : undefined; - - return { - code, - map: map ? JSON.parse(map) : undefined, - }; - } - }, - }, - { - name: 'angular:html-index', - transformIndexHtml: () => { - // Add all global stylesheets - if (this.buildResultFiles.has('styles.css')) { - return [ - { - tag: 'link', - attrs: { href: 'styles.css', rel: 'stylesheet' }, - injectTo: 'head', - }, - ]; - } - - return []; - }, - }, - ], - }); - }, - }, - ]; - } - private async initializeVitest(): Promise { const { codeCoverage, reporters, workspaceRoot, browsers, debug, watch } = this.options; @@ -296,7 +167,15 @@ export class VitestExecutor implements TestExecutor { ); const testSetupFiles = this.prepareSetupFiles(); - const plugins = this.createVitestPlugins(testSetupFiles, browserOptions); + const plugins = createVitestPlugins(this.options, testSetupFiles, browserOptions, { + workspaceRoot, + projectSourceRoot: this.options.projectSourceRoot, + projectName: this.projectName, + include: this.options.include, + exclude: this.options.exclude, + buildResultFiles: this.buildResultFiles, + testFileToEntryPoint: this.testFileToEntryPoint, + }); const debugOptions = debug ? { 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 new file mode 100644 index 000000000000..166a40ded6b3 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -0,0 +1,155 @@ +/** + * @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 assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { VitestPlugin } from 'vitest/node'; +import { toPosixPath } from '../../../../utils/path'; +import type { ResultFile } from '../../../application/results'; +import type { NormalizedUnitTestBuilderOptions } from '../../options'; +import type { BrowserConfiguration } from './browser-provider'; + +type VitestPlugins = Awaited>; + +interface PluginOptions { + workspaceRoot: string; + projectSourceRoot: string; + projectName: string; + include?: string[]; + exclude?: string[]; + buildResultFiles: ReadonlyMap; + testFileToEntryPoint: ReadonlyMap; +} + +export function createVitestPlugins( + options: NormalizedUnitTestBuilderOptions, + testSetupFiles: string[], + browserOptions: BrowserConfiguration, + pluginOptions: PluginOptions, +): VitestPlugins { + const { workspaceRoot, projectName, buildResultFiles, testFileToEntryPoint } = pluginOptions; + + return [ + { + name: 'angular:project-init', + // Type is incorrect. This allows a Promise. + // eslint-disable-next-line @typescript-eslint/no-misused-promises + configureVitest: async (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. + await context.injectTestProjects({ + test: { + name: projectName, + 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 } : {}), + }, + plugins: [ + { + name: 'angular:test-in-memory-provider', + enforce: 'pre', + resolveId: (id, importer) => { + if (importer && (id[0] === '.' || id[0] === '/')) { + let fullPath; + if (testFileToEntryPoint.has(importer)) { + fullPath = toPosixPath(path.join(workspaceRoot, id)); + } else { + fullPath = toPosixPath(path.join(path.dirname(importer), id)); + } + + const relativePath = path.relative(workspaceRoot, fullPath); + if (buildResultFiles.has(toPosixPath(relativePath))) { + return fullPath; + } + } + + if (testFileToEntryPoint.has(id)) { + return id; + } + + assert(buildResultFiles.size > 0, 'buildResult must be available for resolving.'); + const relativePath = path.relative(workspaceRoot, id); + if (buildResultFiles.has(toPosixPath(relativePath))) { + return id; + } + }, + load: async (id) => { + assert( + buildResultFiles.size > 0, + 'buildResult must be available for in-memory loading.', + ); + + // Attempt to load as a source test file. + const entryPoint = testFileToEntryPoint.get(id); + let outputPath; + if (entryPoint) { + outputPath = entryPoint + '.js'; + + // To support coverage exclusion of the actual test file, the virtual + // test entry point only references the built and bundled intermediate file. + return { + code: `import "./${outputPath}";`, + }; + } else { + // Attempt to load as a built artifact. + const relativePath = path.relative(workspaceRoot, id); + outputPath = toPosixPath(relativePath); + } + + const outputFile = buildResultFiles.get(outputPath); + if (outputFile) { + const sourceMapPath = outputPath + '.map'; + const sourceMapFile = buildResultFiles.get(sourceMapPath); + const code = + outputFile.origin === 'memory' + ? Buffer.from(outputFile.contents).toString('utf-8') + : await readFile(outputFile.inputPath, 'utf-8'); + const map = sourceMapFile + ? sourceMapFile.origin === 'memory' + ? Buffer.from(sourceMapFile.contents).toString('utf-8') + : await readFile(sourceMapFile.inputPath, 'utf-8') + : undefined; + + return { + code, + map: map ? JSON.parse(map) : undefined, + }; + } + }, + }, + { + name: 'angular:html-index', + transformIndexHtml: () => { + // Add all global stylesheets + if (buildResultFiles.has('styles.css')) { + return [ + { + tag: 'link', + attrs: { href: 'styles.css', rel: 'stylesheet' }, + injectTo: 'head', + }, + ]; + } + + return []; + }, + }, + ], + }); + }, + }, + ]; +}