diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index 3c0b9bd67c53..c9d83bc6fd9b 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -243,6 +243,7 @@ export type UnitTestBuilderOptions = { runner?: Runner; runnerConfig?: RunnerConfig; setupFiles?: string[]; + splitting?: boolean; tsConfig?: string; ui?: boolean; watch?: boolean; diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index 7d57716214b1..44f6b6b9208b 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -67,8 +67,17 @@ export async function normalizeOptions( const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); cacheOptions.path = path.join(cacheOptions.path, projectName); - const { runner, browsers, progress, filter, browserViewport, ui, runnerConfig, isolate } = - options; + const { + runner, + browsers, + progress, + filter, + browserViewport, + ui, + runnerConfig, + isolate, + splitting = true, + } = options; if (ui && runner !== Runner.Vitest) { throw new Error('The "ui" option is only available for the "vitest" runner.'); @@ -140,6 +149,7 @@ export async function normalizeOptions( debug: options.debug ?? false, ui: process.env['CI'] ? false : ui, isolate, + splitting, quiet: options.quiet ?? (process.env['CI'] ? false : true), providersFile: options.providersFile && path.join(workspaceRoot, options.providersFile), setupFiles: options.setupFiles 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 c522d51c2272..9c5abb9386bf 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 @@ -260,9 +260,8 @@ export async function getVitestBuildOptions( entryPoints, // Vitest's Node-based module loading emulation (vite-node) is not fully spec compliant and lacks // live ESM bindings across chunk boundaries. This can cause uninitialized exports or break mocking. - // In browser tests, however, the real browser adheres to the ECMAScript spec, so code splitting can - // be safely enabled. - disableCodeSplitting: options.browsers?.length ? false : true, + // Disabling code splitting avoids shared chunks, but increases build and coverage memory/time. + disableCodeSplitting: !options.splitting, // Enable support for vitest browser prebundling. Excludes can be controlled with a runnerConfig // and the `optimizeDeps.exclude` option. externalPackages: true, diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 0c6c819392f3..5e9a62cf495d 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -77,6 +77,11 @@ "type": "boolean", "description": "Enables isolation for test execution. When true, Vitest runs tests in separate threads or processes. This option is only available for the Vitest runner. Defaults to false to align with the Karma/Jasmine experience." }, + "splitting": { + "type": "boolean", + "description": "Enables code splitting for test execution. When enabled, shared code between test files is split into separate chunks. Issues with live ESM bindings in Node.js environments (such as uninitialized exports or mocking failures) can be resolved by disabling splitting. This option is only available for the Vitest runner.", + "default": true + }, "quiet": { "type": "boolean", "description": "Suppresses the verbose build summary and stats table on each rebuild. Defaults to `true` locally and `false` in CI environments." diff --git a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts index 13870ba36eba..afccfc2c06e1 100644 --- a/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts @@ -37,6 +37,7 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { harness.useTarget('test', { ...BASE_OPTIONS, + splitting: false, }); // Keep the default project's spec deterministic; a third spec entry that does not touch diff --git a/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts new file mode 100644 index 000000000000..133c54ca21e9 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/splitting_spec.ts @@ -0,0 +1,132 @@ +/** + * @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, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "splitting"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + }); + + it('should default to true and run tests successfully', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when splitting is true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should run tests successfully when splitting is false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should split shared code into a separate chunk when splitting is true', async () => { + await harness.writeFile( + 'src/app/shared.ts', + `export const SHARED_DATA = 'shared-data-value';`, + ); + + await harness.writeFile( + 'src/app/first.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests first', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + await harness.writeFile( + 'src/app/second.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests second', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: true, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectLog(logs, /chunk-[a-z0-9]+\.js/i); + }); + + it('should not split shared code into a separate chunk when splitting is false', async () => { + await harness.writeFile( + 'src/app/shared.ts', + `export const SHARED_DATA = 'shared-data-value';`, + ); + + await harness.writeFile( + 'src/app/first.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests first', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + await harness.writeFile( + 'src/app/second.spec.ts', + ` + import { SHARED_DATA } from './shared'; + + it('tests second', () => { + expect(SHARED_DATA).toBe('shared-data-value'); + }); + `, + ); + + harness.useTarget('test', { + ...BASE_OPTIONS, + splitting: false, + quiet: false, + }); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expectNoLog(logs, /chunk-[a-z0-9]+\.js/i); + }); + }); +});