From 885c783829d54fff0d9234f09ea768795e296e7a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:16:02 +0000 Subject: [PATCH] fix(@angular/build): link partial Ivy dependencies in Vitest runner --- .../unit-test/runners/vitest/executor.ts | 49 +++++++--- .../unit-test/runners/vitest/plugins.ts | 43 ++++++++- .../tests/behavior/vitest-linker_spec.ts | 89 +++++++++++++++++++ 3 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/vitest-linker_spec.ts 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 55003b4fc82a..31c7a8254125 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 @@ -14,6 +14,7 @@ import type * as Vite from 'vite' with { 'resolution-mode': 'import', }; import type { Vitest } from 'vitest/node'; +import { JavaScriptTransformer } from '../../../../tools/javascript-transformer/javascript-transformer'; import { DevServerExternalResultMetadata, updateExternalMetadata, @@ -25,6 +26,11 @@ import { type ResultFile, ResultKind, } from '../../../application/results'; +import { + getSupportedBrowsers, + isZonelessApp, + transformSupportedBrowsersToTargets, +} from '../../../dev-server/internal'; import { NormalizedUnitTestBuilderOptions } from '../../options'; import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; @@ -57,6 +63,12 @@ export class VitestExecutor implements TestExecutor { // Example: `Map<'/path/to/src/app.spec.ts', 'spec-src-app-spec'>` private readonly testFileToEntryPoint = new Map(); private readonly entryPointToTestFile = new Map(); + private readonly prebundleTransformer = new JavaScriptTransformer({ + sourcemap: true, + jit: true, + thirdPartySourcemaps: false, + maxConcurrency: 1, + }); constructor( projectName: string, @@ -193,7 +205,7 @@ export class VitestExecutor implements TestExecutor { // Vitest does not return a failure result when coverage thresholds are not met. // Instead, it sets the process exit code to 1. // We check this exit code to determine if the test run should be considered a failure. - if (success && process.exitCode === 1) { + if (success && this.options.coverage?.enabled && process.exitCode === 1) { success = false; finalResultReason = 'Test run failed due to unmet coverage thresholds.'; // Reset the exit code to prevent it from carrying over to subsequent runs/builds @@ -218,17 +230,20 @@ export class VitestExecutor implements TestExecutor { const timeoutMs = 10_000; try { - await Promise.race([ - this.vitest.close(), - setTimeout(timeoutMs, undefined, { signal: controller.signal, ref: false }) - .then(() => { - this.logger.warn( - `Vitest instance failed to close cleanly within ${timeoutMs}ms. Continuing teardown...`, - ); - }) - .catch(() => { - // Suppress AbortError triggered by controller.abort() when close() resolves first - }), + await Promise.all([ + this.prebundleTransformer.close(), + Promise.race([ + this.vitest.close(), + setTimeout(timeoutMs, undefined, { signal: controller.signal, ref: false }) + .then(() => { + this.logger.warn( + `Vitest instance failed to close cleanly within ${timeoutMs}ms. Continuing teardown...`, + ); + }) + .catch(() => { + // Suppress AbortError triggered by controller.abort() when close() resolves first + }), + ]), ]); } catch (error: unknown) { assertIsError(error); @@ -334,6 +349,7 @@ export class VitestExecutor implements TestExecutor { buildResultFiles: this.buildResultFiles, testFileToEntryPoint: this.testFileToEntryPoint, setupFiles: testSetupFiles, + prebundleTransformer: this.prebundleTransformer, }); const debugOptions = debug @@ -379,6 +395,13 @@ export class VitestExecutor implements TestExecutor { }); this.debugLog(DebugLogLevel.Verbose, 'Included test files (after filtering):', include); + const supportedBrowsers = getSupportedBrowsers(projectRoot, this.logger); + const target = transformSupportedBrowsersToTargets(supportedBrowsers); + if (!isZonelessApp(this.options.polyfills)) { + // Rolldown doesn't have an option to support Zone.js/async-await, so we need to support es2016. + target.push('es2016'); + } + const vitestConfig = { config: externalConfigPath, root: workspaceRoot, @@ -412,6 +435,8 @@ export class VitestExecutor implements TestExecutor { watch, isolate: this.options.isolate, preserveSymlinks: this.options.preserveSymlinks, + prebundleTransformer: this.prebundleTransformer, + target, }), ], }; 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 6be4c6e8ebe3..f301589873b4 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 @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import { needsLinking } from '@angular/compiler-cli/linker'; import assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import { platform } from 'node:os'; @@ -18,7 +19,9 @@ import type { Vite, VitestPluginContext, } from 'vitest/node'; +import type { JavaScriptTransformer } from '../../../../tools/javascript-transformer/javascript-transformer'; import { createBuildAssetsMiddleware } from '../../../../tools/vite/middlewares/assets-middleware'; +import { getDepOptimizationConfig } from '../../../../tools/vite/utils'; import { toPosixPath } from '../../../../utils/path'; import { createProjectResolver } from '../../../../utils/resolve-project'; import type { ResultFile } from '../../../application/results'; @@ -37,6 +40,7 @@ interface PluginOptions { projectName: string; buildResultFiles: ReadonlyMap; testFileToEntryPoint: ReadonlyMap; + prebundleTransformer: JavaScriptTransformer; setupFiles: readonly string[]; } @@ -55,6 +59,8 @@ interface VitestConfigPluginOptions { watch: boolean; isolate: boolean | undefined; preserveSymlinks?: boolean; + prebundleTransformer: JavaScriptTransformer; + target: string[]; } async function findTestEnvironment( @@ -253,11 +259,21 @@ export async function createVitestConfigPlugin( // Default to `false` to align with the Karma/Jasmine experience. isolate: false, sequence: { setupFiles: 'list' }, + server: { + deps: { + inline: options.optimizeDepsInclude.filter((dep) => !dep.startsWith('@angular/')), + }, + }, }, - optimizeDeps: { - noDiscovery: true, + optimizeDeps: getDepOptimizationConfig({ + target: options.target, + disabled: false, + exclude: [], include: options.optimizeDepsInclude, - }, + prebundleTransformer: options.prebundleTransformer, + thirdPartySourcemaps: false, + define: undefined, + }), resolve: { mainFields: ['es2020', 'module', 'main'], conditions: ['es2015', 'es2020', 'module', ...(browser ? ['browser'] : [])], @@ -314,7 +330,13 @@ async function loadResultFile(file: ResultFile): Promise { } export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] { - const { workspaceRoot, buildResultFiles, testFileToEntryPoint, setupFiles } = pluginOptions; + const { + workspaceRoot, + buildResultFiles, + testFileToEntryPoint, + setupFiles, + prebundleTransformer, + } = pluginOptions; const isWindows = platform() === 'win32'; const setupFileSet = new Set( setupFiles.map((file) => @@ -324,6 +346,19 @@ export function createVitestPlugins(pluginOptions: PluginOptions): Vite.Plugin[] let vitestConfig: ResolvedConfig; return [ + { + name: 'angular:linker', + enforce: 'pre', + async transform(code: string, id: string) { + if (!needsLinking(id, code)) { + return null; + } + + const transformedData = await prebundleTransformer.transformData(id, code); + + return Buffer.from(transformedData).toString('utf-8'); + }, + }, { name: 'angular:test-in-memory-provider', enforce: 'pre', diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-linker_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-linker_spec.ts new file mode 100644 index 000000000000..d3e13388bdf8 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-linker_spec.ts @@ -0,0 +1,89 @@ +/** + * @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 { execute } from '../../index'; +import { + BASE_OPTIONS, + describeBuilder, + UNIT_TEST_BUILDER_INFO, + setupApplicationTarget, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Behavior: "Vitest Angular Linker"', () => { + it('should link partially-compiled packages with cross-class references', async () => { + setupApplicationTarget(harness); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + await harness.writeFile( + 'node_modules/test-lib/package.json', + JSON.stringify({ + name: 'test-lib', + version: '1.0.0', + type: 'module', + main: './fesm2022/test-lib.mjs', + types: './index.d.ts', + exports: { + '.': { + types: './index.d.ts', + default: './fesm2022/test-lib.mjs', + }, + }, + }), + ); + + await harness.writeFile( + 'node_modules/test-lib/index.d.ts', + ` + export declare class CascadeSelectSub {} + export declare class CascadeSelect {} + `, + ); + + await harness.writeFile( + 'node_modules/test-lib/fesm2022/test-lib.mjs', + ` + import * as i0 from "@angular/core"; + export class CascadeSelectSub { + static ɵfac = i0.ɵɵngDeclareFactory({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: CascadeSelectSub, + deps: [{ token: CascadeSelect }], + target: i0.ɵɵFactoryTarget.Component + }); + } + export class CascadeSelect {} + `, + ); + + await harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { CascadeSelectSub, CascadeSelect } from 'test-lib'; + + describe('CascadeSelect test', () => { + it('loads without ReferenceError', () => { + expect(CascadeSelectSub).toBeDefined(); + expect(CascadeSelect).toBeDefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + }); + }); +});