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); + }); + }); +});