Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/angular/build/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ ts_project(
":node_modules/@babel/core",
":node_modules/@parcel/watcher",
":node_modules/oxc-parser",
":node_modules/vitest",
"//:node_modules/@angular/compiler-cli",
"//:node_modules/@angular/router",
"//:node_modules/@types/jasmine",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { NormalizedUnitTestBuilderOptions } from '../../options';
import type { TestExecutor } from '../api';
import { setupBrowserConfiguration } from './browser-provider';
import { findVitestBaseConfig } from './configuration';
import { createVitestConfigPlugin, createVitestPlugins } from './plugins';
import { createVitestConfigPlugin, createVitestPlugins, rebasePath } from './plugins';

enum DebugLogLevel {
Info = 1,
Expand Down Expand Up @@ -144,6 +144,11 @@ export class VitestExecutor implements TestExecutor {

let testResults;
if (buildResult.kind === ResultKind.Incremental) {
const vitestRoot = vitest.config.root;
const workspaceRoot = this.options.workspaceRoot;
const isAliasedRoot = Boolean(vitestRoot && vitestRoot !== toPosixPath(workspaceRoot));
const activeRoot = isAliasedRoot ? vitestRoot : workspaceRoot;

// 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, ...buildResult.added]) {
Expand All @@ -162,15 +167,14 @@ export class VitestExecutor implements TestExecutor {
`Could not map output file '${modifiedFile}' to a source file. It may not be a test file.`,
);
}
vitest.invalidateFile(
this.normalizePath(path.join(this.options.workspaceRoot, modifiedFile)),
);
vitest.invalidateFile(this.normalizePath(path.join(activeRoot, modifiedFile)));
}

const specsToRerun = [];
for (const file of modifiedSourceFiles) {
vitest.invalidateFile(file);
const specs = vitest.getModuleSpecifications(file);
const targetFile = isAliasedRoot ? rebasePath(file, workspaceRoot, vitestRoot) : file;
vitest.invalidateFile(targetFile);
const specs = vitest.getModuleSpecifications(targetFile);
if (specs) {
specsToRerun.push(...specs);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @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 } from '@angular-devkit/architect';
import path from 'node:path';
import type { Vitest } from 'vitest/node';
import { toPosixPath } from '../../../../utils/path';
import { type IncrementalResult, ResultKind } from '../../../application/results';
import type { NormalizedUnitTestBuilderOptions } from '../../options';
import { VitestExecutor } from './executor';

describe('VitestExecutor', () => {
describe('incremental rebuilds', () => {
const workspaceRoot = path.resolve('/logical-workspace/test-project');
const specFile = toPosixPath(path.join(workspaceRoot, 'src/app/app.spec.ts'));
const incrementalResult: IncrementalResult = {
kind: ResultKind.Incremental,
added: [],
removed: [],
modified: ['spec-src-app-app-spec.js', 'chunk-ABCDEF.js'],
files: [],
};

let originalExitCode: typeof process.exitCode;

beforeEach(() => {
// The executor resets the process exit code when executing.
originalExitCode = process.exitCode;
});

afterEach(() => {
process.exitCode = originalExitCode;
});

/**
* Executes an incremental rebuild with a stand-in Vitest instance that uses the provided root.
* The stand-in only has test specifications for the module ids in `knownModuleIds`.
*/
async function executeIncremental(vitestRoot: string, knownModuleIds: string[]) {
const executor = new VitestExecutor(
'test-project',
{ workspaceRoot } as NormalizedUnitTestBuilderOptions,
new Map([['spec-src-app-app-spec', specFile]]),
{ info: () => {} } as unknown as BuilderContext['logger'],
);

const invalidateFile = jasmine.createSpy('invalidateFile');
const getModuleSpecifications = jasmine
.createSpy('getModuleSpecifications')
.and.callFake((moduleId: string) =>
knownModuleIds.includes(moduleId) ? [{ moduleId }] : [],
);
const rerunTestSpecifications = jasmine
.createSpy('rerunTestSpecifications')
.and.resolveTo({ testModules: [{ ok: () => true }] });

// Provide an already initialized Vitest instance to avoid starting Vitest.
executor['vitest'] = {
config: { root: vitestRoot },
state: { getTestModules: () => [] },
invalidateFile,
getModuleSpecifications,
rerunTestSpecifications,
} as unknown as Vitest;

const outputs = [];
for await (const output of executor.execute(incrementalResult)) {
outputs.push(output);
}

return { outputs, invalidateFile, getModuleSpecifications, rerunTestSpecifications };
}

it('uses workspace root based paths when the Vitest root is not aliased', async () => {
const root = toPosixPath(workspaceRoot);
const { outputs, invalidateFile, getModuleSpecifications, rerunTestSpecifications } =
await executeIncremental(root, [specFile]);

expect(invalidateFile.calls.allArgs()).toEqual([
[`${root}/spec-src-app-app-spec.js`],
[`${root}/chunk-ABCDEF.js`],
[specFile],
]);
expect(getModuleSpecifications.calls.allArgs()).toEqual([[specFile]]);
expect(rerunTestSpecifications).toHaveBeenCalledOnceWith([{ moduleId: specFile }]);
expect(outputs).toEqual([{ success: true }]);
});

it('rebases paths onto the Vitest root when the root is aliased', async () => {
const physicalRoot = toPosixPath(path.resolve('/physical-disk/tmp/test-project'));
const physicalSpecFile = `${physicalRoot}/src/app/app.spec.ts`;
const { outputs, invalidateFile, getModuleSpecifications, rerunTestSpecifications } =
await executeIncremental(physicalRoot, [physicalSpecFile]);

expect(invalidateFile.calls.allArgs()).toEqual([
[`${physicalRoot}/spec-src-app-app-spec.js`],
[`${physicalRoot}/chunk-ABCDEF.js`],
[physicalSpecFile],
]);
expect(getModuleSpecifications.calls.allArgs()).toEqual([[physicalSpecFile]]);
expect(rerunTestSpecifications).toHaveBeenCalledOnceWith([{ moduleId: physicalSpecFile }]);
expect(outputs).toEqual([{ success: true }]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import assert from 'node:assert';
import { readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import path from 'node:path';
import path, { type PlatformPath } from 'node:path';
import type {
BrowserConfigOptions,
InlineConfig,
Expand Down Expand Up @@ -338,6 +338,33 @@ async function loadResultFile(file: ResultFile): Promise<string> {
return readFile(file.source, 'utf-8');
}

/**
* Rebases a file path from `fromRoot` to `toRoot` in POSIX format.
*
* The path is rebased using its location relative to `fromRoot`. This includes a path outside of
* `fromRoot` (reached through `..` segments), which matches how Vitest resolves the root relative
* test `include` entries. Only a path that cannot be expressed relative to `fromRoot`, such as a
* file on a different Windows drive, is left unchanged (as POSIX).
*
* @param filePath The absolute file path to rebase.
* @param fromRoot The root that `filePath` is currently based on.
* @param toRoot The root to rebase `filePath` onto.
* @param pathImpl The path implementation to use. Defaults to that of the host platform.
* @returns The rebased file path in POSIX format.
*/
export function rebasePath(
filePath: string,
fromRoot: string,
toRoot: string,
pathImpl: PlatformPath = path,
): string {
const relative = pathImpl.relative(fromRoot, filePath);
const rebased = pathImpl.isAbsolute(relative) ? filePath : pathImpl.join(toRoot, relative);

// Equivalent to `toPosixPath` but follows `pathImpl` instead of the host platform.
return pathImpl.sep === '\\' ? rebased.replace(/\\/g, '/') : rebased;
}

export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] {
const { buildResultFiles, setupFiles } = pluginOptions;
let { workspaceRoot, testFileToEntryPoint } = pluginOptions;
Expand Down Expand Up @@ -370,24 +397,19 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[]
vitestConfig = context.vitest.config;

if (vitestConfig.root && vitestConfig.root !== toPosixPath(workspaceRoot)) {
const previousRoot = workspaceRoot;
const originalRoot = pluginOptions.workspaceRoot;
workspaceRoot = vitestConfig.root;
testFileToEntryPoint = new Map(
Array.from(testFileToEntryPoint, ([file, entryPoint]) => [
file.startsWith('angular:')
? file
: toPosixPath(path.join(workspaceRoot, path.relative(previousRoot, file))),
Array.from(pluginOptions.testFileToEntryPoint, ([file, entryPoint]) => [
file.startsWith('angular:') ? file : rebasePath(file, originalRoot, workspaceRoot),
entryPoint,
]),
);
setupFileSet = new Set(
setupFiles.map((file) =>
toPosixPath(
path.join(
workspaceRoot,
path.isAbsolute(file) ? path.relative(previousRoot, file) : file,
),
),
path.isAbsolute(file)
? rebasePath(file, originalRoot, workspaceRoot)
: toPosixPath(path.join(workspaceRoot, file)),
),
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,45 @@ import path from 'node:path';
import { BuildOutputFileType } from '../../../../tools/esbuild/bundler-files';
import { toPosixPath } from '../../../../utils/path';
import type { ResultFile } from '../../../application/results';
import { createVitestPlugins } from './plugins';
import { createVitestPlugins, rebasePath } from './plugins';

describe('rebasePath', () => {
it('rebases a path from fromRoot to toRoot in POSIX format', () => {
const fromRoot = path.resolve('/logical-workspace/test-project');
const toRoot = toPosixPath(path.resolve('/physical-disk/tmp/test-project'));
const filePath = path.join(fromRoot, 'src/app/app.spec.ts');

expect(rebasePath(filePath, fromRoot, toRoot)).toBe(`${toRoot}/src/app/app.spec.ts`);
});

it('rebases a path outside of fromRoot using its relative location', () => {
expect(
rebasePath(
'/logical/shared/setup.ts',
'/logical/test-project',
'/physical/test-project',
path.posix,
),
).toBe('/physical/shared/setup.ts');
});

it('rebases a Windows path onto a different drive in POSIX format', () => {
expect(
rebasePath(
'X:\\test-project\\src\\app\\app.spec.ts',
'X:\\test-project',
'D:/tmp/test-project',
path.win32,
),
).toBe('D:/tmp/test-project/src/app/app.spec.ts');
});

it('leaves a path on a different Windows drive than fromRoot unchanged', () => {
expect(
rebasePath('C:\\shared\\setup.ts', 'X:\\test-project', 'D:/tmp/test-project', path.win32),
).toBe('C:/shared/setup.ts');
});
});

describe('createVitestPlugins', () => {
it('resolves and loads setup and spec files when vitestConfig.root differs from workspaceRoot', async () => {
Expand Down Expand Up @@ -41,7 +79,10 @@ describe('createVitestPlugins', () => {
],
]);

const testFileToEntryPoint = new Map<string, string>([[specFile, 'spec-src-app-app-spec']]);
const testFileToEntryPoint = new Map<string, string>([
[specFile, 'spec-src-app-app-spec'],
['angular:compiler-init', 'init-compiler'],
]);

const plugins = createVitestPlugins({
workspaceRoot,
Expand All @@ -56,7 +97,10 @@ describe('createVitestPlugins', () => {
expect(inMemoryPlugin).toBeDefined();

const configureVitest = inMemoryPlugin?.configureVitest as
((context: { vitest: { config: { root: string } } }) => void) | undefined;
| ((context: {
vitest: { config: { root: string; coverage?: { enabled: boolean } } };
}) => void)
| undefined;
configureVitest?.({
vitest: {
config: {
Expand All @@ -73,6 +117,8 @@ describe('createVitestPlugins', () => {
id: string,
) => Promise<{ code: string; map?: unknown } | undefined>;

expect(resolveId('angular:compiler-init', undefined)).toBe('angular:compiler-init');

const expectedSetupId = `${physicalRoot}/init-compiler.js`;
const resolvedSetupId = resolveId(expectedSetupId, undefined);
expect(resolvedSetupId).toBe(expectedSetupId);
Expand All @@ -87,4 +133,82 @@ describe('createVitestPlugins', () => {
const loadedSpec = await load(expectedSpecId);
expect(loadedSpec?.code).toBe('console.log("spec");');
});

it('rebases absolute setupFiles for coverage exclusion and handles repeated configureVitest calls', async () => {
const workspaceRoot = path.resolve('/logical-workspace/test-project');
const firstPhysicalRoot = toPosixPath(path.resolve('/physical-disk/first/test-project'));
const secondPhysicalRoot = toPosixPath(path.resolve('/physical-disk/second/test-project'));
const userSetupFile = toPosixPath(path.join(workspaceRoot, 'src/test-setup.ts'));
const specFile = toPosixPath(path.join(workspaceRoot, 'src/app/app.spec.ts'));

const buildResultFiles = new Map<string, ResultFile>([
[
'setup-src-test-setup.js',
{
origin: 'memory',
path: 'setup-src-test-setup.js',
type: BuildOutputFileType.Root,
contents: new TextEncoder().encode('console.log("user-setup");'),
hash: 'hash-user-setup',
},
],
[
'spec-src-app-app-spec.js',
{
origin: 'memory',
path: 'spec-src-app-app-spec.js',
type: BuildOutputFileType.Root,
contents: new TextEncoder().encode('console.log("spec");'),
hash: 'hash-spec',
},
],
]);

const testFileToEntryPoint = new Map<string, string>([
[userSetupFile, 'setup-src-test-setup'],
[specFile, 'spec-src-app-app-spec'],
]);

const plugins = createVitestPlugins({
workspaceRoot,
projectSourceRoot: path.join(workspaceRoot, 'src'),
projectName: 'test-project',
buildResultFiles,
testFileToEntryPoint,
setupFiles: [userSetupFile],
});

const inMemoryPlugin = plugins.find((p) => p.name === 'angular:test-in-memory-provider');
const configureVitest = inMemoryPlugin?.configureVitest as
| ((context: {
vitest: { config: { root: string; coverage?: { enabled: boolean } } };
}) => void)
| undefined;
const load = inMemoryPlugin?.load as (
id: string,
) => Promise<{ code: string; map?: unknown } | undefined>;

configureVitest?.({
vitest: {
config: {
root: firstPhysicalRoot,
coverage: { enabled: true },
},
},
});
configureVitest?.({
vitest: {
config: {
root: secondPhysicalRoot,
coverage: { enabled: true },
},
},
});

const loadedSetup = await load(`${secondPhysicalRoot}/src/test-setup.ts`);
expect(loadedSetup?.code).toBe('console.log("user-setup");');

const loadedSpec = await load(`${secondPhysicalRoot}/src/app/app.spec.ts`);
expect(loadedSpec?.code).toBe('import "./spec-src-app-app-spec.js";');
});
});
Loading