From 4d3926d212f930fe46ea24e0db050c02b7c84267 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 13 Nov 2026 15:26:04 -0500 Subject: [PATCH] fix(@angular/build): rebase Vitest incremental paths when workspace root is aliased When `vitestConfig.root` differs from `workspaceRoot` (such as with symlinks or Windows `subst` drives), `VitestExecutor` still invalidated and queried test specifications during incremental watch-mode rebuilds using the un-rebased `workspaceRoot` paths. Because Vitest keys its module graph and test specifications against `vitest.config.root`, incremental re-runs could fail to invalidate cached modules or locate the affected test specifications. To address this, extract a `rebasePath` helper in `plugins.ts` that converts paths between roots while leaving paths that cannot be expressed relative to the original root (such as files on a different Windows drive) unchanged instead of concatenating them. `VitestExecutor` now uses this helper during incremental rebuilds to rebase modified output and source file paths onto `vitest.config.root` when the root is aliased. Additionally, `configureVitest` now rebases from the original plugin options rather than mutating from the previous root so repeated invocations remain idempotent. `plugins_spec.ts` is expanded to cover `rebasePath`, `angular:` virtual entries, absolute setup files under coverage, and repeated `configureVitest` calls, and a new `executor_spec.ts` covers the incremental rebuild paths. --- packages/angular/build/BUILD.bazel | 1 + .../unit-test/runners/vitest/executor.ts | 16 ++- .../unit-test/runners/vitest/executor_spec.ts | 110 +++++++++++++++ .../unit-test/runners/vitest/plugins.ts | 46 +++++-- .../unit-test/runners/vitest/plugins_spec.ts | 130 +++++++++++++++++- 5 files changed, 282 insertions(+), 21 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/runners/vitest/executor_spec.ts diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel index d0ab55750e98..ac02fb717399 100644 --- a/packages/angular/build/BUILD.bazel +++ b/packages/angular/build/BUILD.bazel @@ -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", 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 6af8b0a13111..12a7a1bedef1 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 @@ -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, @@ -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(); for (const modifiedFile of [...buildResult.modified, ...buildResult.added]) { @@ -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); } diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor_spec.ts new file mode 100644 index 000000000000..8ff3ed6fa486 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor_spec.ts @@ -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 }]); + }); + }); +}); 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 index f430c3094b10..43863ca73028 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins.ts @@ -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, @@ -338,6 +338,33 @@ async function loadResultFile(file: ResultFile): Promise { 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; @@ -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)), ), ); } diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins_spec.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins_spec.ts index b097dfb48481..637862bcbf1a 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/plugins_spec.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/plugins_spec.ts @@ -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 () => { @@ -41,7 +79,10 @@ describe('createVitestPlugins', () => { ], ]); - const testFileToEntryPoint = new Map([[specFile, 'spec-src-app-app-spec']]); + const testFileToEntryPoint = new Map([ + [specFile, 'spec-src-app-app-spec'], + ['angular:compiler-init', 'init-compiler'], + ]); const plugins = createVitestPlugins({ workspaceRoot, @@ -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: { @@ -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); @@ -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([ + [ + '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([ + [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";'); + }); });