diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 3ba298f4bf9e..bcf334dc357e 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -44,7 +44,7 @@ export async function normalizeOptions( // Target/configuration specified options buildTarget, include: options.include ?? ['**/*.spec.ts'], - exclude: options.exclude ?? [], + exclude: options.exclude, runnerName: runner, codeCoverage: options.codeCoverage ? { 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 index d0bb6fb9078b..996e6266b1ca 100644 --- 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 @@ -61,8 +61,15 @@ export async function getVitestBuildOptions( options: NormalizedUnitTestBuilderOptions, baseBuildOptions: Partial, ): Promise { - const { workspaceRoot, projectSourceRoot, include, exclude, watch, tsConfig, providersFile } = - options; + const { + workspaceRoot, + projectSourceRoot, + include, + exclude = [], + watch, + tsConfig, + providersFile, + } = options; // Find test files const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot); 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 ecb0dc5e4746..7147bed34390 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,17 +8,20 @@ import type { BuilderOutput } from '@angular-devkit/architect'; import assert from 'node:assert'; -import { randomUUID } from 'node:crypto'; -import { rmSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; 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, type IncrementalResult, ResultKind } from '../../../application/results'; -import { writeTestFiles } from '../../../karma/application_builder'; +import { + type FullResult, + type IncrementalResult, + type ResultFile, + ResultKind, +} from '../../../application/results'; import { NormalizedUnitTestBuilderOptions } from '../../options'; +import { findTests, getTestEntrypoints } from '../../test-discovery'; import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; @@ -28,26 +31,49 @@ 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; + private buildResultFiles = new Map(); - // Graceful shutdown signal handler - // This is needed to remove the temporary output directory on Ctrl+C - private readonly sigintListener = () => { - rmSync(this.outputPath, { recursive: true, force: true }); - }; + // 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(); constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) { this.projectName = projectName; this.options = options; - this.outputPath = toPosixPath(path.join(options.workspaceRoot, generateOutputPath())); - process.on('SIGINT', this.sigintListener); } async *execute(buildResult: FullResult | IncrementalResult): AsyncIterable { - await writeTestFiles(buildResult.files, this.outputPath); + if (buildResult.kind === ResultKind.Full) { + this.buildResultFiles.clear(); + for (const [path, file] of Object.entries(buildResult.files)) { + this.buildResultFiles.set(path, file); + } + } else { + for (const file of buildResult.removed) { + this.buildResultFiles.delete(file.path); + } + for (const [path, file] of Object.entries(buildResult.files)) { + this.buildResultFiles.set(path, file); + } + } - this.latestBuildResult = buildResult; + // The `getTestEntrypoints` function is used here to create the same mapping + // that was used in `build-options.ts` to generate the build entry points. + // This is a deliberate duplication to avoid a larger refactoring of the + // builder's core interfaces to pass the entry points from the build setup + // phase to the execution phase. + if (this.testFileToEntryPoint.size === 0) { + const { include, exclude = [], workspaceRoot, projectSourceRoot } = this.options; + const testFiles = await findTests(include, exclude, workspaceRoot, projectSourceRoot); + const entryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot }); + for (const [entryPoint, testFile] of entryPoints) { + this.testFileToEntryPoint.set(testFile, entryPoint); + this.entryPointToTestFile.set(entryPoint + '.js', testFile); + } + } // Initialize Vitest if not already present. this.vitest ??= await this.initializeVitest(); @@ -55,46 +81,44 @@ export class VitestExecutor implements TestExecutor { let testResults; if (buildResult.kind === ResultKind.Incremental) { - const addedFiles = buildResult.added.map((file) => path.join(this.outputPath, file)); - const modifiedFiles = buildResult.modified.map((file) => path.join(this.outputPath, file)); - - if (addedFiles.length === 0 && modifiedFiles.length === 0) { - yield { success: true }; - - return; + // To rerun tests, Vitest needs the original test file paths, not the output paths. + const modifiedSourceFiles = new Set(); + for (const modifiedFile of buildResult.modified) { + // The `modified` files in the build result are the output paths. + // We need to find the original source file path to pass to Vitest. + const source = this.entryPointToTestFile.get(modifiedFile); + if (source) { + modifiedSourceFiles.add(source); + } } - // If new files are added, use `start` to trigger test discovery. - // Also pass modified files to `start` to ensure they are re-run. - if (addedFiles.length > 0) { - await vitest.start([...addedFiles, ...modifiedFiles]); - } else { - // For modified files only, use the more efficient `rerunTestSpecifications` - const specsToRerun = modifiedFiles.flatMap((file) => vitest.getModuleSpecifications(file)); - - if (specsToRerun.length > 0) { - modifiedFiles.forEach((file) => vitest.invalidateFile(file)); - testResults = await vitest.rerunTestSpecifications(specsToRerun); + const specsToRerun = []; + for (const file of modifiedSourceFiles) { + vitest.invalidateFile(file); + const specs = vitest.getModuleSpecifications(file); + if (specs) { + specsToRerun.push(...specs); } } + + if (specsToRerun.length > 0) { + testResults = await vitest.rerunTestSpecifications(specsToRerun); + } } // Check if all the tests pass to calculate the result - const testModules = testResults?.testModules; + const testModules = testResults?.testModules ?? this.vitest.state.getTestModules(); - yield { success: testModules?.every((testModule) => testModule.ok()) ?? true }; + yield { success: testModules.every((testModule) => testModule.ok()) }; } async [Symbol.asyncDispose](): Promise { - process.off('SIGINT', this.sigintListener); await this.vitest?.close(); - await rm(this.outputPath, { recursive: true, force: true }); } private async initializeVitest(): Promise { const { codeCoverage, reporters, workspaceRoot, setupFiles, browsers, debug, watch } = this.options; - const { outputPath, projectName, latestBuildResult } = this; let vitestNodeModule; try { @@ -120,14 +144,16 @@ export class VitestExecutor implements TestExecutor { throw new Error(browserOptions.errors.join('\n')); } - assert(latestBuildResult, 'buildResult must be available before initializing vitest'); + assert( + this.buildResultFiles.size > 0, + '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); + if (this.buildResultFiles.has('polyfills.js')) { + testSetupFiles.unshift('polyfills.js'); } const debugOptions = debug @@ -145,12 +171,12 @@ export class VitestExecutor implements TestExecutor { // Disable configuration file resolution/loading config: false, root: workspaceRoot, - project: ['base', projectName], + project: ['base', this.projectName], name: 'base', include: [], reporters: reporters ?? ['default'], watch, - coverage: generateCoverageOption(codeCoverage, workspaceRoot, this.outputPath), + coverage: generateCoverageOption(codeCoverage), ...debugOptions, }, { @@ -162,39 +188,111 @@ export class VitestExecutor implements TestExecutor { plugins: [ { name: 'angular:project-init', - async configureVitest(context) { + // 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. const [project] = await context.injectTestProjects({ test: { - name: projectName, - root: outputPath, + 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:html-index', - transformIndexHtml: () => { + name: 'angular:test-in-memory-provider', + enforce: 'pre', + resolveId: (id, importer) => { + if (importer && id.startsWith('.')) { + let fullPath; + let relativePath; + if (this.testFileToEntryPoint.has(importer)) { + fullPath = toPosixPath(path.join(this.options.workspaceRoot, id)); + relativePath = path.normalize(id); + } else { + fullPath = toPosixPath(path.join(path.dirname(importer), id)); + relativePath = path.relative(this.options.workspaceRoot, fullPath); + } + if (this.buildResultFiles.has(toPosixPath(relativePath))) { + return fullPath; + } + } + + if (this.testFileToEntryPoint.has(id)) { + return id; + } + assert( - latestBuildResult, - 'buildResult must be available for HTML index transformation.', + this.buildResultFiles.size > 0, + 'buildResult must be available for resolving.', ); - // Add all global stylesheets - const styleFiles = Object.entries(latestBuildResult.files).filter( - ([file]) => file === 'styles.css', + 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.', ); - return styleFiles.map(([href]) => ({ - tag: 'link', - attrs: { href, rel: 'stylesheet' }, - injectTo: 'head', - })); + // Attempt to load as a source test file. + const entryPoint = this.testFileToEntryPoint.get(id); + let outputPath; + if (entryPoint) { + outputPath = entryPoint + '.js'; + } 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 []; }, }, ], @@ -216,17 +314,8 @@ export class VitestExecutor implements TestExecutor { } } -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 { @@ -237,7 +326,6 @@ function generateCoverageOption( 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/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 79185218dee2..bd0836273091 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -39,7 +39,6 @@ "items": { "type": "string" }, - "default": [], "description": "Globs of files to exclude, relative to the project root." }, "watch": { diff --git a/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts index 036adf8be63f..2c21f7680716 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/exclude_spec.ts @@ -15,7 +15,7 @@ import { } from '../setup'; describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { - xdescribe('Option: "exclude"', () => { + describe('Option: "exclude"', () => { beforeEach(async () => { setupApplicationTarget(harness); }); @@ -59,15 +59,5 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); - - it(`should exclude spec that matches the 'exclude' pattern prefixed with a slash`, async () => { - harness.useTarget('test', { - ...BASE_OPTIONS, - exclude: ['/src/app/error.spec.ts'], - }); - - const { result } = await harness.executeOnce(); - expect(result?.success).toBeTrue(); - }); }); }); diff --git a/packages/angular/build/src/builders/unit-test/tests/setup.ts b/packages/angular/build/src/builders/unit-test/tests/setup.ts index 80e0426ec3e4..5c13d9b660f9 100644 --- a/packages/angular/build/src/builders/unit-test/tests/setup.ts +++ b/packages/angular/build/src/builders/unit-test/tests/setup.ts @@ -95,6 +95,7 @@ export function setupApplicationTarget( buildApplication, { ...APPLICATION_BASE_OPTIONS, + polyfills: ['zone.js', '@angular/localize/init'], ...extraOptions, }, {