Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/angular/build/src/builders/unit-test/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,15 @@ export async function getVitestBuildOptions(
options: NormalizedUnitTestBuilderOptions,
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
): Promise<RunnerOptions> {
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);
Expand Down
228 changes: 158 additions & 70 deletions packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -28,73 +31,94 @@ 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<string, ResultFile>();

// 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<string, string>();
private entryPointToTestFile = new Map<string, string>();

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<BuilderOutput> {
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();
const vitest = this.vitest;

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<string>();
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<void> {
process.off('SIGINT', this.sigintListener);
await this.vitest?.close();
await rm(this.outputPath, { recursive: true, force: true });
}

private async initializeVitest(): Promise<Vitest> {
const { codeCoverage, reporters, workspaceRoot, setupFiles, browsers, debug, watch } =
this.options;
const { outputPath, projectName, latestBuildResult } = this;

let vitestNodeModule;
try {
Expand All @@ -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
Expand All @@ -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,
},
{
Expand All @@ -162,39 +188,111 @@ export class VitestExecutor implements TestExecutor {
plugins: [
{
name: 'angular:project-init',
async configureVitest(context) {
// Type is incorrect. This allows a Promise<void>.
// 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 [];
},
},
],
Expand All @@ -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 {
Expand All @@ -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)
Expand Down
1 change: 0 additions & 1 deletion packages/angular/build/src/builders/unit-test/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@
"items": {
"type": "string"
},
"default": [],
"description": "Globs of files to exclude, relative to the project root."
},
"watch": {
Expand Down
Loading