Skip to content
Merged
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
6 changes: 2 additions & 4 deletions packages/angular/build/src/builders/unit-test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export async function* execute(
buildOptions: runnerBuildOptions,
virtualFiles,
testEntryPointMappings,
} = await runner.getBuildOptions(normalizedOptions, buildTargetOptions));
} = await runner.getBuildOptions(normalizedOptions, buildTargetOptions, context.logger));
} catch (e) {
assertIsError(e);
context.logger.error(
Expand Down Expand Up @@ -323,9 +323,7 @@ export async function* execute(
const applicationBuildOptions = {
...buildTargetOptions,
...runnerBuildOptions,
...(normalizedOptions.polyfills !== undefined
? { polyfills: normalizedOptions.polyfills }
: {}),
polyfills: runnerBuildOptions.polyfills ?? normalizedOptions.polyfills,
watch: normalizedOptions.watch,
progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress,
quiet: normalizedOptions.quiet,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface TestRunner {
getBuildOptions(
options: NormalizedUnitTestBuilderOptions,
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
logger: BuilderContext['logger'],
): RunnerOptions | Promise<RunnerOptions>;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@
* Provides Vitest-specific build options and virtual file contents for Angular unit testing.
*/

import type { BuilderContext } from '@angular-devkit/architect';
import path from 'node:path';
import { toPosixPath } from '../../../../utils/path';
import { createProjectResolver } from '../../../../utils/resolve-project';
import type { ApplicationBuilderInternalOptions } from '../../../application/options';
import { OutputHashing } from '../../../application/schema';
import { NormalizedUnitTestBuilderOptions } from '../../options';
import { type NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options';
import { findTests, getTestEntrypoints } from '../../test-discovery';
import { RunnerOptions } from '../api';

Expand All @@ -26,14 +27,12 @@ import { RunnerOptions } from '../api';
* @param providersFile Optional path to a file that exports default providers.
* @param projectSourceRoot The root directory of the project source.
* @param teardown Whether to configure TestBed to destroy after each test.
* @param zoneTestingStrategy How zone.js should be loaded during initialization.
* @returns The string content of the virtual initialization file.
*/
function createTestBedInitVirtualFile(
providersFile: string | undefined,
projectSourceRoot: string,
teardown: boolean,
zoneTestingStrategy: 'none' | 'static' | 'dynamic' | 'dynamic-zone',
hasLocalize: boolean,
): string {
let providersImport = 'const providers = [];';
Expand All @@ -44,21 +43,6 @@ function createTestBedInitVirtualFile(
providersImport = `import providers from './${importPath}';`;
}

let zoneTestingSnippet = '';
if (zoneTestingStrategy === 'static') {
zoneTestingSnippet = `import 'zone.js/testing';`;
} else if (zoneTestingStrategy === 'dynamic') {
zoneTestingSnippet = `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');
}`;
} else if (zoneTestingStrategy === 'dynamic-zone') {
zoneTestingSnippet = `
await import('zone.js');
await import('zone.js/testing');`;
}

// The DynamicDOMTestComponentRenderer is used to avoid stale document references
// when running Vitest in non-isolated mode with JSDOM. It looks up the
// document dynamically on every operation instead of caching it.
Expand All @@ -72,8 +56,6 @@ function createTestBedInitVirtualFile(
import { afterEach, beforeEach } from 'vitest';
${providersImport}

${zoneTestingSnippet}

// 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
Expand Down Expand Up @@ -150,37 +132,37 @@ function adjustOutputHashing(hashing?: OutputHashing): OutputHashing {
}

/**
* Resolves the Zone.js testing strategy by inspecting polyfills and resolving zone.js package.
* Injects Zone.js and Zone.js testing polyfills into the build options based on the
* project configuration and `polyfills` option.
*
* @param buildOptions The partial application builder options.
* @param polyfills The configured polyfills from the test or build target.
* @param projectSourceRoot The root directory of the project source.
* @returns The resolved zone testing strategy ('none', 'static', 'dynamic', 'dynamic-zone').
* @param logger The logger instance for reporting deprecation warnings.
* @returns An array of polyfill specifiers to use for testing.
*/
function getZoneTestingStrategy(
buildOptions: Partial<ApplicationBuilderInternalOptions>,
function injectZoneJsTestingPolyfills(
polyfills: string[] | undefined,
projectSourceRoot: string,
): 'none' | 'static' | 'dynamic' | 'dynamic-zone' {
if (buildOptions.polyfills?.includes('zone.js/testing')) {
return 'none';
}

if (buildOptions.polyfills?.includes('zone.js')) {
return 'static';
logger: BuilderContext['logger'],
): string[] {
if (polyfills) {
return injectTestingPolyfills(polyfills);
}

// If polyfills is undefined (e.g. library build target), attempt to load zone.js if installed.
try {
const projectResolve = createProjectResolver(projectSourceRoot);
projectResolve('zone.js');

// If polyfills is undefined (e.g. library build target), load zone.js dynamically.
// If polyfills is defined but doesn't include zone.js (e.g. zoneless application), do NOT load zone.js.
if (buildOptions.polyfills === undefined) {
return 'dynamic-zone';
}
logger.warn(
'Zone.js polyfills are being automatically injected because "zone.js" was detected in the project dependencies. ' +
'This behavior is deprecated. If your project is zoneless, set the "polyfills" option to an empty array ("[]") in the ' +
'test configuration. Otherwise, explicitly add "zone.js" to the "polyfills" option.',
);

return 'none';
return ['zone.js', 'zone.js/testing'];
} catch {
return 'none';
return [];
}
}

Expand All @@ -192,16 +174,19 @@ function getZoneTestingStrategy(
*
* @param options The normalized unit test builder options.
* @param baseBuildOptions The base build config to derive testing config from.
* @param logger The logger instance for reporting deprecation warnings.
* @returns An async RunnerOptions configuration.
*/
export async function getVitestBuildOptions(
options: NormalizedUnitTestBuilderOptions,
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
logger: BuilderContext['logger'],
): Promise<RunnerOptions> {
const {
workspaceRoot,
projectSourceRoot,
include,
polyfills,
exclude = [],
watch,
providersFile,
Expand Down Expand Up @@ -256,7 +241,11 @@ export async function getVitestBuildOptions(

const buildOptions: Partial<ApplicationBuilderInternalOptions> = {
...baseBuildOptions,
...(options.polyfills !== undefined ? { polyfills: options.polyfills } : {}),
polyfills: injectZoneJsTestingPolyfills(
polyfills ?? baseBuildOptions.polyfills,
projectSourceRoot,
logger,
),
watch,
incrementalResults: watch,
index: false,
Expand Down Expand Up @@ -285,9 +274,6 @@ export async function getVitestBuildOptions(
externalDependencies,
};

// Inject the zone.js testing polyfill if Zone.js is installed.
const zoneTestingStrategy = getZoneTestingStrategy(buildOptions, projectSourceRoot);

let hasLocalize = false;
try {
const projectResolve = createProjectResolver(projectSourceRoot);
Expand All @@ -299,7 +285,6 @@ export async function getVitestBuildOptions(
providersFile,
projectSourceRoot,
!options.debug,
zoneTestingStrategy,
hasLocalize,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import assert from 'node:assert';
import type { TestRunner } from '../api';
import { DependencyChecker } from '../dependency-checker';
import { normalizeBrowserName } from './browser-provider';
import { getVitestBuildOptions } from './build-options';
import { VitestExecutor } from './executor';

Expand Down Expand Up @@ -60,8 +59,8 @@ const VitestTestRunner: TestRunner = {
checker.report();
},

getBuildOptions(options, baseBuildOptions) {
return getVitestBuildOptions(options, baseBuildOptions);
getBuildOptions(options, baseBuildOptions, logger) {
return getVitestBuildOptions(options, baseBuildOptions, logger);
},

async createExecutor(context, options, testEntryPointMappings) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
describeBuilder,
UNIT_TEST_BUILDER_INFO,
setupApplicationTarget,
expectLog,
expectNoLog,
} from '../setup';

describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
Expand Down Expand Up @@ -68,7 +70,61 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
expect(result?.success).toBe(true);
});

it('should load Zone and Zone testing support when testing a library and zone.js is installed', async () => {
it('should NOT load Zone when test polyfills is empty even if zone.js is in build polyfills', async () => {
setupApplicationTarget(harness, {
polyfills: ['zone.js'],
});

harness.useTarget('test', {
...BASE_OPTIONS,
polyfills: [],
});

harness.writeFile(
'src/app/app.component.spec.ts',
`
import { describe, it, expect } from 'vitest';

describe('Zoneless Override Test', () => {
it('should NOT have Zone defined', () => {
expect((globalThis as any).Zone).toBeUndefined();
});
});
`,
);

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
});

it('should load Zone when test polyfills includes zone.js even if build polyfills is empty', async () => {
setupApplicationTarget(harness, {
polyfills: [],
});

harness.useTarget('test', {
...BASE_OPTIONS,
polyfills: ['zone.js'],
});

harness.writeFile(
'src/app/app.component.spec.ts',
`
import { describe, it, expect } from 'vitest';

describe('Zone Forced Test', () => {
it('should have Zone defined', () => {
expect((globalThis as any).Zone).toBeDefined();
});
});
`,
);

const { result } = await harness.executeOnce();
expect(result?.success).toBeTrue();
});

it('should load Zone and emit a deprecation warning when testing a library and zone.js is installed', async () => {
harness.withBuilderTarget(
'build',
async () => ({ success: true }),
Expand Down Expand Up @@ -107,8 +163,54 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => {
`,
);

const { result } = await harness.executeOnce();
const { result, logs } = await harness.executeOnce();
expect(result?.success).toBeTrue();
expectLog(logs, /Zone\.js polyfills are being automatically injected/);
});

it('should NOT load Zone and not emit warning when testing a library with polyfills: []', async () => {
harness.withBuilderTarget(
'build',
async () => ({ success: true }),
{
project: 'ng-package.json',
},
{
builderName: '@angular/build:ng-packagr',
},
);

await harness.writeFile(
'ng-package.json',
JSON.stringify({
lib: {
entryFile: 'src/public-api.ts',
},
}),
);

harness.useTarget('test', {
...BASE_OPTIONS,
polyfills: [],
include: ['src/app.component.spec.ts'],
});

await harness.writeFile(
'src/app.component.spec.ts',
`
import { describe, it, expect } from 'vitest';

describe('Library Zoneless Test', () => {
it('should NOT have Zone defined', () => {
expect((globalThis as any).Zone).toBeUndefined();
});
});
`,
);

const { result, logs } = await harness.executeOnce();
expect(result?.success).toBeTrue();
expectNoLog(logs, /Zone\.js polyfills are being automatically injected/);
});
});
});
Loading