From 7e654f505f57c2fad8be0755855c8fb602a00db6 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:51:43 -0500 Subject: [PATCH] feat(@angular/build): support runtime Zone.js detection in Vitest unit test runner This commit improves how the Vitest unit test runner handles Zone.js and its testing polyfills. Previously, the inclusion of provideZoneChangeDetection and zone.js/testing was determined solely by a build-time check for zone.js in the polyfills array. Now, the runner supports three strategies for zone.js/testing inclusion: - none: If zone.js is not installed in the project. - static: If zone.js is explicitly included in the polyfills build option. - dynamic: If zone.js is installed but not explicitly in polyfills. This uses a runtime check and dynamic import to load testing support if Zone is present. Additionally, TestBed initialization now dynamically provides ZoneChangeDetection based on the runtime presence of Zone.js, better supporting zoneless applications and implicit Zone.js loading scenarios. --- .../unit-test/runners/vitest/build-options.ts | 45 ++++++++++-- .../tests/behavior/vitest-zone-init_spec.ts | 71 +++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts index 3aa7e2c8947e..73e99ccc0ef0 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/build-options.ts @@ -6,11 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ +import { createRequire } from 'node:module'; import path from 'node:path'; import { toPosixPath } from '../../../../utils/path'; import type { ApplicationBuilderInternalOptions } from '../../../application/options'; import { OutputHashing } from '../../../application/schema'; -import { NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; +import { NormalizedUnitTestBuilderOptions } from '../../options'; import { findTests, getTestEntrypoints } from '../../test-discovery'; import { RunnerOptions } from '../api'; @@ -18,9 +19,8 @@ function createTestBedInitVirtualFile( providersFile: string | undefined, projectSourceRoot: string, teardown: boolean, - polyfills: string[] = [], + zoneTestingStrategy: 'none' | 'static' | 'dynamic', ): string { - const usesZoneJS = polyfills.includes('zone.js'); let providersImport = 'const providers = [];'; if (providersFile) { const relativePath = path.relative(projectSourceRoot, providersFile); @@ -31,12 +31,25 @@ function createTestBedInitVirtualFile( return ` // Initialize the Angular testing environment - import { NgModule${usesZoneJS ? ', provideZoneChangeDetection' : ''} } from '@angular/core'; + import { NgModule, provideZoneChangeDetection } from '@angular/core'; import { getTestBed, ɵgetCleanupHook as getCleanupHook } from '@angular/core/testing'; import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; import { afterEach, beforeEach } from 'vitest'; ${providersImport} + ${ + zoneTestingStrategy === 'static' + ? `import 'zone.js/testing';` + : zoneTestingStrategy === 'dynamic' + ? ` + if (typeof Zone !== 'undefined') { + // 'zone.js/testing' is used to initialize the ZoneJS testing environment. + // It must be imported dynamically to avoid a static dependency on 'zone.js'. + await import('zone.js/testing'); + }` + : '' + } + // The beforeEach and afterEach hooks are registered outside the globalThis guard. // This ensures that the hooks are always applied, even in non-isolated browser environments. // Same as https://github.com/angular/angular/blob/05a03d3f975771bb59c7eefd37c01fa127ee2229/packages/core/testing/srcs/test_hooks.ts#L21-L29 @@ -52,7 +65,10 @@ function createTestBedInitVirtualFile( // The guard condition above ensures that the setup is only performed once. @NgModule({ - providers: [${usesZoneJS ? 'provideZoneChangeDetection(), ' : ''}...providers], + providers: [ + ...(typeof Zone !== 'undefined' ? [provideZoneChangeDetection()] : []), + ...providers, + ], }) class TestModule {} @@ -145,13 +161,28 @@ export async function getVitestBuildOptions( externalDependencies, }; - buildOptions.polyfills = injectTestingPolyfills(buildOptions.polyfills); + // Inject the zone.js testing polyfill if Zone.js is installed. + let zoneTestingStrategy: 'none' | 'static' | 'dynamic' = 'none'; + let isZoneJsInstalled = false; + try { + const projectRequire = createRequire(path.join(projectSourceRoot, 'package.json')); + projectRequire.resolve('zone.js'); + isZoneJsInstalled = true; + } catch {} + + if (isZoneJsInstalled) { + if (buildOptions.polyfills?.includes('zone.js')) { + zoneTestingStrategy = 'static'; + } else { + zoneTestingStrategy = 'dynamic'; + } + } const testBedInitContents = createTestBedInitVirtualFile( providersFile, projectSourceRoot, !options.debug, - buildOptions.polyfills, + zoneTestingStrategy, ); const mockPatchContents = ` diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts new file mode 100644 index 000000000000..812dba7fa70d --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-zone-init_spec.ts @@ -0,0 +1,71 @@ +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 Zone initialization"', () => { + // Zone.js does not current provide fakAsync support for Vitest + xit('should load Zone and Zone testing support by default', async () => { + setupApplicationTarget(harness); // Defaults include zone.js + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + import { fakeAsync, tick } from '@angular/core/testing'; + + describe('Zone Test', () => { + it('should have Zone defined', () => { + expect((globalThis as any).Zone).toBeDefined(); + }); + + it('should support fakeAsync', fakeAsync(() => { + let val = false; + setTimeout(() => { val = true; }, 100); + tick(100); + expect(val).toBeTrue(); + })); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should NOT load Zone when zoneless (no zone.js in polyfills)', async () => { + // Setup application target WITHOUT zone.js in polyfills + setupApplicationTarget(harness, { + polyfills: [], + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + harness.writeFile( + 'src/app/app.component.spec.ts', + ` + import { describe, it, expect } from 'vitest'; + + describe('Zoneless Test', () => { + it('should NOT have Zone defined', () => { + expect((globalThis as any).Zone).toBeUndefined(); + }); + }); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + }); +});