From 633e44bdd63095aef8c59bec6c2b371b218de504 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 13 Nov 2025 11:13:49 +0100 Subject: [PATCH 1/2] test(@angular/build): add failing test --- .../tests/options/setup-files_spec.ts | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts index 5f888ed7ff64..0e3c715e9d24 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -16,7 +16,7 @@ import { } from '../setup'; describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { - xdescribe('Option: "setupFiles"', () => { + describe('Option: "setupFiles"', () => { beforeEach(async () => { setupApplicationTarget(harness); }); @@ -27,19 +27,20 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { setupFiles: ['src/setup.ts'], }); - const { result, error } = await harness.executeOnce({ outputLogsOnFailure: false }); - expect(result).toBeUndefined(); - expect(error?.message).toMatch(`The specified setup file "src/setup.ts" does not exist.`); + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + // TODO: Re-enable once Vite logs are remapped through build system + // expectLog(logs, `The specified setup file "src/setup.ts" does not exist.`); }); it('should include the setup files', async () => { await harness.writeFiles({ - 'src/setup.ts': `console.log('Hello from setup.ts');`, + 'src/setup.ts': `(globalThis as any).setupLoaded = true;`, 'src/app/app.component.spec.ts': ` import { describe, expect, test } from 'vitest' describe('AppComponent', () => { test('should create the app', () => { - expect(true).toBe(true); + expect((globalThis as any).setupLoaded).toBe(true); }); });`, }); @@ -49,9 +50,45 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { setupFiles: ['src/setup.ts'], }); - const { result, logs } = await harness.executeOnce(); + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should allow setup files to configure testing module', async () => { + pending('failing'); + await harness.writeFiles({ + 'src/setup.ts': ` + import { TestBed } from '@angular/core/testing'; + import { SETUP_LOADED_TOKEN } from './setup-loaded-token'; + + TestBed.configureTestingModule({ + providers: [{provide: SETUP_LOADED_TOKEN, useValue: true}], + }); + `, + 'src/setup-loaded-token.ts': ` + import { InjectionToken } from '@angular/core'; + + export const SETUP_LOADED_TOKEN = new InjectionToken('SETUP_LOADED_TOKEN'); + `, + 'src/app/app.component.spec.ts': ` + import { describe, expect, test } from 'vitest'; + import { TestBed } from '@angular/core/testing'; + import { SETUP_LOADED_TOKEN } from '../setup-loaded-token'; + + describe('AppComponent', () => { + test('should create the app', () => { + expect(TestBed.inject(SETUP_LOADED_TOKEN)).toBe(true); + }); + });`, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + setupFiles: ['src/setup.ts'], + }); + + const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); - expectLog(logs, 'Hello from setup.ts'); }); }); }); From 5e427cc73374f7d89da9a35bfbcdf62b7a98d2c9 Mon Sep 17 00:00:00 2001 From: Younes Jaaidi Date: Thu, 13 Nov 2025 16:44:59 +0100 Subject: [PATCH 2/2] fix(@angular/build): fix setup files duplicate modules This includes setup files in the initial build and avoids lazy discovery and thus module duplicates. Module duplicates can break many things such as dependency injection. Closes #31732 --- .../unit-test/runners/vitest/build-options.ts | 15 ++++-- .../unit-test/runners/vitest/executor.ts | 15 +++++- .../src/builders/unit-test/test-discovery.ts | 48 ++++++++++++++++--- .../tests/options/setup-files_spec.ts | 27 ++++++++--- 4 files changed, 86 insertions(+), 19 deletions(-) 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 05aba1d9c261..d60d1dd162aa 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 @@ -11,7 +11,7 @@ import { toPosixPath } from '../../../../utils/path'; import type { ApplicationBuilderInternalOptions } from '../../../application/options'; import { OutputHashing } from '../../../application/schema'; import { NormalizedUnitTestBuilderOptions, injectTestingPolyfills } from '../../options'; -import { findTests, getTestEntrypoints } from '../../test-discovery'; +import { findTests, getSetupEntrypoints, getTestEntrypoints } from '../../test-discovery'; import { RunnerOptions } from '../api'; function createTestBedInitVirtualFile( @@ -88,12 +88,19 @@ export async function getVitestBuildOptions( ); } - const entryPoints = getTestEntrypoints(testFiles, { + const testEntryPoints = getTestEntrypoints(testFiles, { projectSourceRoot, workspaceRoot, removeTestExtension: true, }); - entryPoints.set('init-testbed', 'angular:test-bed-init'); + const setupEntryPoints = getSetupEntrypoints(options.setupFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension: true, + }); + setupEntryPoints.set('init-testbed', 'angular:test-bed-init'); + + const entryPoints = new Map([...testEntryPoints, ...setupEntryPoints]); // The 'vitest' package is always external for testing purposes const externalDependencies = ['vitest']; @@ -138,6 +145,6 @@ export async function getVitestBuildOptions( virtualFiles: { 'angular:test-bed-init': testBedInitContents, }, - testEntryPointMappings: entryPoints, + testEntryPointMappings: testEntryPoints, }; } 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 8d09550b671a..e61e488dd744 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 @@ -26,6 +26,7 @@ import type { TestExecutor } from '../api'; import { setupBrowserConfiguration } from './browser-provider'; import { findVitestBaseConfig } from './configuration'; import { createVitestConfigPlugin, createVitestPlugins } from './plugins'; +import { getSetupEntrypoints } from '../../test-discovery'; export class VitestExecutor implements TestExecutor { private vitest: Vitest | undefined; @@ -127,9 +128,19 @@ export class VitestExecutor implements TestExecutor { } private prepareSetupFiles(): string[] { - const { setupFiles } = this.options; + const { setupFiles, workspaceRoot } = this.options; + + const setupFilesEntrypoints = getSetupEntrypoints(setupFiles, { + projectSourceRoot: this.options.projectSourceRoot, + workspaceRoot, + removeTestExtension: true, + }); + const setupFileNames = Array.from(setupFilesEntrypoints.keys()).map( + (entrypoint) => `${entrypoint}.js`, + ); + // Add setup file entries for TestBed initialization and project polyfills - const testSetupFiles = ['init-testbed.js', ...setupFiles]; + const testSetupFiles = ['init-testbed.js', ...setupFileNames]; // TODO: Provide additional result metadata to avoid needing to extract based on filename if (this.buildResultFiles.has('polyfills.js')) { diff --git a/packages/angular/build/src/builders/unit-test/test-discovery.ts b/packages/angular/build/src/builders/unit-test/test-discovery.ts index 89d38dbbe787..5cb56e95cdf5 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -73,7 +73,7 @@ export async function findTests( return [...resolvedTestFiles]; } -interface TestEntrypointsOptions { +interface EntrypointsOptions { projectSourceRoot: string; workspaceRoot: string; removeTestExtension?: boolean; @@ -89,15 +89,49 @@ interface TestEntrypointsOptions { */ export function getTestEntrypoints( testFiles: string[], - { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, + { projectSourceRoot, workspaceRoot, removeTestExtension }: EntrypointsOptions, +): Map { + return getEntrypoints(testFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix: 'spec', + }); +} + +/** + * @param setupFiles An array of absolute paths to setup files. + * @param options Configuration options for generating entry points. + * @returns A map where keys are the generated unique bundle names and values are the original file paths. + */ +export function getSetupEntrypoints( + setupFiles: string[], + { projectSourceRoot, workspaceRoot, removeTestExtension }: EntrypointsOptions, +): Map { + return getEntrypoints(setupFiles, { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix: 'setup', + }); +} + +function getEntrypoints( + files: string[], + { + projectSourceRoot, + workspaceRoot, + removeTestExtension, + prefix, + }: EntrypointsOptions & { prefix: string }, ): Map { const seen = new Set(); const roots = [projectSourceRoot, workspaceRoot]; return new Map( - Array.from(testFiles, (testFile) => { - const fileName = generateNameFromPath(testFile, roots, !!removeTestExtension); - const baseName = `spec-${fileName}`; + Array.from(files, (setupFile) => { + const fileName = generateNameFromPath(setupFile, roots, !!removeTestExtension); + const baseName = `${prefix}-${fileName}`; let uniqueName = baseName; let suffix = 2; while (seen.has(uniqueName)) { @@ -106,7 +140,7 @@ export function getTestEntrypoints( } seen.add(uniqueName); - return [uniqueName, testFile]; + return [uniqueName, setupFile]; }), ); } @@ -136,7 +170,7 @@ function generateNameFromPath( let endIndex = relativePath.length; if (removeTestExtension) { const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|'); - const match = relativePath.match(new RegExp(`\\.(${infixes})\\.[^.]+$`)); + const match = relativePath.match(new RegExp(`\\.((${infixes})\\.)?[^.]+$`)); if (match?.index) { endIndex = match.index; diff --git a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts index 0e3c715e9d24..ab3f7ae9a8bc 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/setup-files_spec.ts @@ -10,9 +10,8 @@ import { execute } from '../../index'; import { BASE_OPTIONS, describeBuilder, - UNIT_TEST_BUILDER_INFO, setupApplicationTarget, - expectLog, + UNIT_TEST_BUILDER_INFO, } from '../setup'; describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { @@ -45,6 +44,13 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { });`, }); + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('setup.ts'); + return JSON.stringify(tsConfig); + }); + harness.useTarget('test', { ...BASE_OPTIONS, setupFiles: ['src/setup.ts'], @@ -55,14 +61,16 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { }); it('should allow setup files to configure testing module', async () => { - pending('failing'); await harness.writeFiles({ 'src/setup.ts': ` import { TestBed } from '@angular/core/testing'; + import { beforeEach } from 'vitest'; import { SETUP_LOADED_TOKEN } from './setup-loaded-token'; - TestBed.configureTestingModule({ - providers: [{provide: SETUP_LOADED_TOKEN, useValue: true}], + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{provide: SETUP_LOADED_TOKEN, useValue: true}], + }); }); `, 'src/setup-loaded-token.ts': ` @@ -71,7 +79,7 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { export const SETUP_LOADED_TOKEN = new InjectionToken('SETUP_LOADED_TOKEN'); `, 'src/app/app.component.spec.ts': ` - import { describe, expect, test } from 'vitest'; + import { beforeEach, describe, expect, test } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { SETUP_LOADED_TOKEN } from '../setup-loaded-token'; @@ -82,6 +90,13 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { });`, }); + await harness.modifyFile('src/tsconfig.spec.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('setup.ts'); + return JSON.stringify(tsConfig); + }); + harness.useTarget('test', { ...BASE_OPTIONS, setupFiles: ['src/setup.ts'],