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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {};
}
Expand Down
149 changes: 14 additions & 135 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,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';
Expand All @@ -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<InlineConfig['coverage'], undefined>;
type VitestPlugins = Awaited<ReturnType<typeof VitestPlugin>>;

export class VitestExecutor implements TestExecutor {
private vitest: Vitest | undefined;
private readonly projectName: string;
private readonly options: NormalizedUnitTestBuilderOptions;
private buildResultFiles = new Map<string, ResultFile>();
private readonly buildResultFiles = new Map<string, ResultFile>();

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

constructor(projectName: string, options: NormalizedUnitTestBuilderOptions) {
this.projectName = projectName;
Expand Down Expand Up @@ -135,134 +134,6 @@ export class VitestExecutor implements TestExecutor {
return testSetupFiles;
}

private createVitestPlugins(
testSetupFiles: string[],
browserOptions: Awaited<ReturnType<typeof setupBrowserConfiguration>>,
): VitestPlugins {
const { workspaceRoot } = this.options;

return [
{
name: 'angular:project-init',
// 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.
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<Vitest> {
const { codeCoverage, reporters, workspaceRoot, browsers, debug, watch } = this.options;

Expand Down Expand Up @@ -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
? {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof VitestPlugin>>;

interface PluginOptions {
workspaceRoot: string;
projectSourceRoot: string;
projectName: string;
include?: string[];
exclude?: string[];
buildResultFiles: ReadonlyMap<string, ResultFile>;
testFileToEntryPoint: ReadonlyMap<string, string>;
}

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<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.
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 [];
},
},
],
});
},
},
];
}