diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index dcac17d97d1e..39e75ef7ca4d 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -64,8 +64,10 @@ interface InternalOptions { * If given a relative path, it is resolved relative to the current workspace and will generate an output at the same relative location * in the output directory. If given an absolute path, the output will be generated in the root of the output directory with the same base * name. + * + * If provided a Map, the key is the name of the output bundle and the value is the entry point file. */ - entryPoints?: Set; + entryPoints?: Set | Map; /** File extension to use for the generated output files. */ outExtension?: 'js' | 'mjs'; @@ -519,7 +521,7 @@ async function getTailwindConfig( function normalizeEntryPoints( workspaceRoot: string, browser: string | undefined, - entryPoints: Set = new Set(), + entryPoints: Set | Map = new Set(), ): Record { if (browser === '') { throw new Error('`browser` option cannot be an empty string.'); @@ -538,6 +540,16 @@ function normalizeEntryPoints( if (browser) { // Use `browser` alone. return { 'main': path.join(workspaceRoot, browser) }; + } else if (entryPoints instanceof Map) { + return Object.fromEntries( + Array.from(entryPoints.entries(), ([name, entryPoint]) => { + // Get the full file path to a relative entry point input. Leave bare specifiers alone so they are resolved as modules. + const isRelativePath = entryPoint.startsWith('.'); + const entryPointPath = isRelativePath ? path.join(workspaceRoot, entryPoint) : entryPoint; + + return [name, entryPointPath]; + }), + ); } else { // Use `entryPoints` alone. const entryPointPaths: Record = {}; diff --git a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts index 187ca8f5468e..dce39e699eaf 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts @@ -260,7 +260,7 @@ async function collectEntrypoints( options: KarmaBuilderOptions, context: BuilderContext, projectSourceRoot: string, -): Promise> { +): Promise> { // Glob for files to test. const testFiles = await findTests( options.include ?? [], @@ -269,7 +269,28 @@ async function collectEntrypoints( projectSourceRoot, ); - return new Set(testFiles); + const seen = new Set(); + + return new Map( + Array.from(testFiles, (testFile) => { + const relativePath = path + .relative( + testFile.startsWith(projectSourceRoot) ? projectSourceRoot : context.workspaceRoot, + testFile, + ) + .replace(/^[./]+/, '_') + .replace(/\//g, '-'); + let uniqueName = `spec-${path.basename(relativePath, path.extname(relativePath))}`; + let suffix = 2; + while (seen.has(uniqueName)) { + uniqueName = `${relativePath}-${suffix}`; + ++suffix; + } + seen.add(uniqueName); + + return [uniqueName, testFile]; + }), + ); } async function initializeApplication( @@ -298,12 +319,11 @@ async function initializeApplication( fs.rm(outputPath, { recursive: true, force: true }), ]); - let mainName = 'init_test_bed'; + const mainName = 'test_main'; if (options.main) { - entryPoints.add(options.main); - mainName = path.basename(options.main, path.extname(options.main)); + entryPoints.set(mainName, options.main); } else { - entryPoints.add('@angular-devkit/build-angular/src/builders/karma/init_test_bed.js'); + entryPoints.set(mainName, '@angular-devkit/build-angular/src/builders/karma/init_test_bed.js'); } const instrumentForCoverage = options.codeCoverage @@ -358,6 +378,8 @@ async function initializeApplication( { pattern: `${outputPath}/${mainName}.js`, type: 'module', watched: false }, // Serve all source maps. { pattern: `${outputPath}/*.map`, included: false, watched: false }, + // These are the test entrypoints. + { pattern: `${outputPath}/spec-*.js`, type: 'module', watched: false }, ); if (hasChunkOrWorkerFiles(buildOutput.files)) { @@ -371,10 +393,6 @@ async function initializeApplication( }, ); } - karmaOptions.files.push( - // Serve remaining JS on page load, these are the test entrypoints. - { pattern: `${outputPath}/*.js`, type: 'module', watched: false }, - ); if (options.styles?.length) { // Serve CSS outputs on page load, these are the global styles. diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/specs_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/specs_spec.ts new file mode 100644 index 000000000000..773f113bec83 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/specs_spec.ts @@ -0,0 +1,44 @@ +/** + * @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, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup'; + +describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApp) => { + describe('Behavior: "Specs"', () => { + beforeEach(async () => { + await setupTarget(harness); + }); + + it('supports multiple spec files with same basename', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + }); + + const collidingBasename = 'collision.spec.ts'; + + // src/app/app.component.spec.ts conflicts with this one: + await harness.writeFiles({ + [`src/app/a/${collidingBasename}`]: `/** Success! */`, + [`src/app/b/${collidingBasename}`]: `/** Success! */`, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + if (isApp) { + const bundleLog = logs.find((log) => + log.message.includes('Application bundle generation complete.'), + ); + expect(bundleLog?.message).toContain('spec-app-a-collision.spec.js'); + expect(bundleLog?.message).toContain('spec-app-b-collision.spec.js'); + } + }); + }); +});