From f840519a272f68296727bab07ae4e4b458daa4a8 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:45:50 -0400 Subject: [PATCH 1/3] feat(@angular/build): add --list-tests flag to unit-test builder Debugging test file discovery patterns (`include` and `exclude`) can be difficult. When glob patterns are misconfigured, the builder may not find the intended test files, and the only feedback is a "No tests found" message after a potentially long build. This commit introduces a new `--list-tests` flag to the `unit-test` builder. When this flag is used, the builder will discover all test files according to the project's configuration, print the list of files to the console, and then exit without initiating a build or running the tests. This provides immediate feedback for developers to verify their test discovery configuration. As part of this change, the test discovery logic was centralized from the Karma builder into the `unit-test` builder's directory, fulfilling a TODO and improving the overall code structure. --- goldens/public-api/angular/build/index.api.md | 1 + .../build/src/builders/karma/find-tests.ts | 157 +----------------- .../build/src/builders/unit-test/builder.ts | 19 +++ .../build/src/builders/unit-test/options.ts | 1 + .../build/src/builders/unit-test/schema.json | 5 + .../src/builders/unit-test/test-discovery.ts | 155 ++++++++++++++++- .../tests/options/list-tests_spec.ts | 89 ++++++++++ 7 files changed, 271 insertions(+), 156 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index 88bb421dcf9f..c4e03409ba2b 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -225,6 +225,7 @@ export type UnitTestBuilderOptions = { exclude?: string[]; filter?: string; include?: string[]; + listTests?: boolean; outputFile?: string; progress?: boolean; providersFile?: string; diff --git a/packages/angular/build/src/builders/karma/find-tests.ts b/packages/angular/build/src/builders/karma/find-tests.ts index a84373964c8e..62bcd563d455 100644 --- a/packages/angular/build/src/builders/karma/find-tests.ts +++ b/packages/angular/build/src/builders/karma/find-tests.ts @@ -6,157 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import { PathLike, constants, promises as fs } from 'node:fs'; -import { basename, dirname, extname, join, relative } from 'node:path'; -import { glob, isDynamicPattern } from 'tinyglobby'; -import { toPosixPath } from '../../utils/path'; - -/* Go through all patterns and find unique list of files */ -export async function findTests( - include: string[], - exclude: string[], - workspaceRoot: string, - projectSourceRoot: string, -): Promise { - const matchingTestsPromises = include.map((pattern) => - findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot), - ); - const files = await Promise.all(matchingTestsPromises); - - // Unique file names - return [...new Set(files.flat())]; -} - -interface TestEntrypointsOptions { - projectSourceRoot: string; - workspaceRoot: string; - removeTestExtension?: boolean; -} - -/** Generate unique bundle names for a set of test files. */ -export function getTestEntrypoints( - testFiles: string[], - { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, -): Map { - const seen = new Set(); - - return new Map( - Array.from(testFiles, (testFile) => { - const relativePath = removeRoots(testFile, [projectSourceRoot, workspaceRoot]) - // Strip leading dots and path separators. - .replace(/^[./\\]+/, '') - // Replace any path separators with dashes. - .replace(/[/\\]/g, '-'); - - let fileName = basename(relativePath, extname(relativePath)); - if (removeTestExtension) { - fileName = fileName.replace(/\.(spec|test)$/, ''); - } - - const baseName = `spec-${fileName}`; - let uniqueName = baseName; - let suffix = 2; - while (seen.has(uniqueName)) { - uniqueName = `${baseName}-${suffix}`.replace(/([^\w](?:spec|test))-([\d]+)$/, '-$2$1'); - ++suffix; - } - seen.add(uniqueName); - - return [uniqueName, testFile]; - }), - ); -} - -const removeLeadingSlash = (pattern: string): string => { - if (pattern.charAt(0) === '/') { - return pattern.substring(1); - } - - return pattern; -}; - -const removeRelativeRoot = (path: string, root: string): string => { - if (path.startsWith(root)) { - return path.substring(root.length); - } - - return path; -}; - -function removeRoots(path: string, roots: string[]): string { - for (const root of roots) { - if (path.startsWith(root)) { - return path.substring(root.length); - } - } - - return basename(path); -} - -async function findMatchingTests( - pattern: string, - ignore: string[], - workspaceRoot: string, - projectSourceRoot: string, -): Promise { - // normalize pattern, glob lib only accepts forward slashes - let normalizedPattern = toPosixPath(pattern); - normalizedPattern = removeLeadingSlash(normalizedPattern); - - const relativeProjectRoot = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/'); - - // remove relativeProjectRoot to support relative paths from root - // such paths are easy to get when running scripts via IDEs - normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot); - - // special logic when pattern does not look like a glob - if (!isDynamicPattern(normalizedPattern)) { - if (await isDirectory(join(projectSourceRoot, normalizedPattern))) { - normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`; - } else { - // see if matching spec file exists - const fileExt = extname(normalizedPattern); - // Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts` - const potentialSpec = join( - projectSourceRoot, - dirname(normalizedPattern), - `${basename(normalizedPattern, fileExt)}.spec${fileExt}`, - ); - - if (await exists(potentialSpec)) { - return [potentialSpec]; - } - } - } - - // normalize the patterns in the ignore list - const normalizedIgnorePatternList = ignore.map((pattern: string) => - removeRelativeRoot(removeLeadingSlash(toPosixPath(pattern)), relativeProjectRoot), - ); - - return glob(normalizedPattern, { - cwd: projectSourceRoot, - absolute: true, - ignore: ['**/node_modules/**', ...normalizedIgnorePatternList], - }); -} - -async function isDirectory(path: PathLike): Promise { - try { - const stats = await fs.stat(path); - - return stats.isDirectory(); - } catch { - return false; - } -} - -async function exists(path: PathLike): Promise { - try { - await fs.access(path, constants.F_OK); - - return true; - } catch { - return false; - } -} +// This file is a compatibility layer that re-exports the test discovery logic from its new location. +// This is necessary to avoid breaking the Karma builder, which still depends on this file. +export { findTests, getTestEntrypoints } from '../unit-test/test-discovery'; diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index 1201218ecf2c..39872ade8dda 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -27,6 +27,7 @@ import { normalizeOptions } from './options'; import type { TestRunner } from './runners/api'; import { MissingDependenciesError } from './runners/dependency-checker'; import type { Schema as UnitTestBuilderOptions } from './schema'; +import { findTests } from './test-discovery'; export type { UnitTestBuilderOptions }; @@ -200,6 +201,24 @@ export async function* execute( return; } + if (normalizedOptions.listTests) { + const testFiles = await findTests( + normalizedOptions.include, + normalizedOptions.exclude ?? [], + normalizedOptions.workspaceRoot, + normalizedOptions.projectSourceRoot, + ); + + context.logger.info('Discovered test files:'); + for (const file of testFiles) { + context.logger.info(` ${path.relative(normalizedOptions.workspaceRoot, file)}`); + } + + yield { success: true }; + + return; + } + if (runner.isStandalone) { try { await using executor = await runner.createExecutor(context, normalizedOptions, undefined); diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index c39616d6b5e1..544ddd9f9d9c 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -75,6 +75,7 @@ export async function normalizeOptions( ? options.setupFiles.map((setupFile) => path.join(workspaceRoot, setupFile)) : [], dumpVirtualFiles: options.dumpVirtualFiles, + listTests: options.listTests, }; } diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index a2c15d8189ae..4253605f3e00 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -148,6 +148,11 @@ "type": "boolean", "description": "Shows build progress information in the console. Defaults to the `progress` setting of the specified `buildTarget`." }, + "listTests": { + "type": "boolean", + "description": "Lists all discovered test files and exits the process without building or executing the tests.", + "default": false + }, "dumpVirtualFiles": { "type": "boolean", "description": "Dumps build output files to the `.angular/cache` directory for debugging purposes.", 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 10de232b6d37..6c32cb21af0f 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -6,5 +6,156 @@ * found in the LICENSE file at https://angular.dev/license */ -// TODO: This should eventually contain the implementations for these -export { findTests, getTestEntrypoints } from '../karma/find-tests'; +import { PathLike, constants, promises as fs } from 'node:fs'; +import { basename, dirname, extname, join, relative } from 'node:path'; +import { glob, isDynamicPattern } from 'tinyglobby'; +import { toPosixPath } from '../../utils/path'; + +/* Go through all patterns and find unique list of files */ +export async function findTests( + include: string[], + exclude: string[], + workspaceRoot: string, + projectSourceRoot: string, +): Promise { + const matchingTestsPromises = include.map((pattern) => + findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot), + ); + const files = await Promise.all(matchingTestsPromises); + + // Unique file names + return [...new Set(files.flat())]; +} + +interface TestEntrypointsOptions { + projectSourceRoot: string; + workspaceRoot: string; + removeTestExtension?: boolean; +} + +/** Generate unique bundle names for a set of test files. */ +export function getTestEntrypoints( + testFiles: string[], + { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, +): Map { + const seen = new Set(); + + return new Map( + Array.from(testFiles, (testFile) => { + const relativePath = removeRoots(testFile, [projectSourceRoot, workspaceRoot]) + // Strip leading dots and path separators. + .replace(/^[./\\]+/, '') + // Replace any path separators with dashes. + .replace(/[/\\]/g, '-'); + let fileName = basename(relativePath, extname(relativePath)); + if (removeTestExtension) { + fileName = fileName.replace(/\.(spec|test)$/, ''); + } + + const baseName = `spec-${fileName}`; + let uniqueName = baseName; + let suffix = 2; + while (seen.has(uniqueName)) { + uniqueName = `${baseName}-${suffix}`.replace(/([^\w](?:spec|test))-([\d]+)$/, '-$2$1'); + ++suffix; + } + seen.add(uniqueName); + + return [uniqueName, testFile]; + }), + ); +} + +const removeLeadingSlash = (pattern: string): string => { + if (pattern.charAt(0) === '/') { + return pattern.substring(1); + } + + return pattern; +}; + +const removeRelativeRoot = (path: string, root: string): string => { + if (path.startsWith(root)) { + return path.substring(root.length); + } + + return path; +}; + +function removeRoots(path: string, roots: string[]): string { + for (const root of roots) { + if (path.startsWith(root)) { + return path.substring(root.length); + } + } + + return basename(path); +} + +async function findMatchingTests( + pattern: string, + ignore: string[], + workspaceRoot: string, + projectSourceRoot: string, +): Promise { + // normalize pattern, glob lib only accepts forward slashes + let normalizedPattern = toPosixPath(pattern); + normalizedPattern = removeLeadingSlash(normalizedPattern); + + const relativeProjectRoot = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/'); + + // remove relativeProjectRoot to support relative paths from root + // such paths are easy to get when running scripts via IDEs + normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot); + + // special logic when pattern does not look like a glob + if (!isDynamicPattern(normalizedPattern)) { + if (await isDirectory(join(projectSourceRoot, normalizedPattern))) { + normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`; + } else { + // see if matching spec file exists + const fileExt = extname(normalizedPattern); + // Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts` + const potentialSpec = join( + projectSourceRoot, + dirname(normalizedPattern), + `${basename(normalizedPattern, fileExt)}.spec${fileExt}`, + ); + + if (await exists(potentialSpec)) { + return [potentialSpec]; + } + } + } + + // normalize the patterns in the ignore list + const normalizedIgnorePatternList = ignore.map((pattern: string) => + removeRelativeRoot(removeLeadingSlash(toPosixPath(pattern)), relativeProjectRoot), + ); + + return glob(normalizedPattern, { + cwd: projectSourceRoot, + absolute: true, + ignore: ['**/node_modules/**', ...normalizedIgnorePatternList], + }); +} + +async function isDirectory(path: PathLike): Promise { + try { + const stats = await fs.stat(path); + + return stats.isDirectory(); + } catch { + return false; + } +} + +async function exists(path: PathLike): Promise { + try { + await fs.access(path, constants.F_OK); + + return true; + } catch { + return false; + } +} diff --git a/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts new file mode 100644 index 000000000000..ade399d1936f --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/tests/options/list-tests_spec.ts @@ -0,0 +1,89 @@ +/** + * @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, +} from '../setup'; + +describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { + describe('Option: "listTests"', () => { + beforeEach(async () => { + setupApplicationTarget(harness); + + await harness.writeFiles({ + 'src/app/app.component.spec.ts': + 'describe("AppComponent", () => { it("should...", () => {}); });', + 'src/app/other.spec.ts': 'describe("Other", () => { it("should...", () => {}); });', + 'src/app/ignored.ts': 'export const a = 1;', + }); + }); + + it('should list all discovered tests and exit when true', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + listTests: true, + }); + + const { result, logs } = await harness.executeOnce(); + + // Should succeed and exit without running tests + expect(result?.success).toBe(true); + + // Should log the discovered test files + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/Discovered test files:/) }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/.*app\.component\.spec\.ts/), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/.*other\.spec\.ts/) }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/.*ignore\.ts/) }), + ); + + // Should NOT log output from the test runner (since it shouldn't run) + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Application bundle generation complete/), + }), + ); + }); + + it('should not list tests and should run them as normal when false', async () => { + harness.useTarget('test', { + ...BASE_OPTIONS, + listTests: false, + }); + + const { result, logs } = await harness.executeOnce(); + + // Should succeed because the tests pass + expect(result?.success).toBe(true); + + // Should NOT log the discovered test files + expect(logs).not.toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/Discovered test files:/) }), + ); + + // Should log output from the test runner + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/Application bundle generation complete/), + }), + ); + }); + }); +}); From 77d9b8fd1e40260a46922cf7edd1a6b0e8efdf47 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:26:49 -0400 Subject: [PATCH 2/3] refactor(@angular/build): consolidate glob calls and clarify test discovery logic The test discovery logic previously executed a separate `glob` operation for each pattern in the `include` array. In projects with multiple test locations, this resulted in redundant and inefficient file system scans. This commit refactors the `findTests` function to perform only a single, consolidated `glob` call for all dynamic patterns, significantly improving performance. Additionally, the logic for handling static (non-glob) paths has been extracted into a dedicated function, and the entire module has been documented with JSDoc comments to improve readability and maintainability. --- .../src/builders/unit-test/test-discovery.ts | 136 +++++++++++++----- 1 file changed, 99 insertions(+), 37 deletions(-) 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 6c32cb21af0f..9bd8a94e1329 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -11,20 +11,59 @@ import { basename, dirname, extname, join, relative } from 'node:path'; import { glob, isDynamicPattern } from 'tinyglobby'; import { toPosixPath } from '../../utils/path'; -/* Go through all patterns and find unique list of files */ +/** + * Finds all test files in the project. + * + * @param include Glob patterns of files to include. + * @param exclude Glob patterns of files to exclude. + * @param workspaceRoot The absolute path to the workspace root. + * @param projectSourceRoot The absolute path to the project's source root. + * @returns A unique set of absolute paths to all test files. + */ export async function findTests( include: string[], exclude: string[], workspaceRoot: string, projectSourceRoot: string, ): Promise { - const matchingTestsPromises = include.map((pattern) => - findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot), + const staticMatches = new Set(); + const dynamicPatterns: string[] = []; + + const normalizedExcludes = exclude.map((p) => + normalizePattern(p, workspaceRoot, projectSourceRoot), ); - const files = await Promise.all(matchingTestsPromises); - // Unique file names - return [...new Set(files.flat())]; + // 1. Separate static and dynamic patterns + for (const pattern of include) { + const normalized = normalizePattern(pattern, workspaceRoot, projectSourceRoot); + if (isDynamicPattern(normalized)) { + dynamicPatterns.push(normalized); + } else { + const result = await handleStaticPattern(normalized, projectSourceRoot); + if (Array.isArray(result)) { + result.forEach((file) => staticMatches.add(file)); + } else { + // It was a static path that didn't resolve to a spec, treat as dynamic + dynamicPatterns.push(result); + } + } + } + + // 2. Execute a single glob for all dynamic patterns + if (dynamicPatterns.length > 0) { + const globMatches = await glob(dynamicPatterns, { + cwd: projectSourceRoot, + absolute: true, + ignore: ['**/node_modules/**', ...normalizedExcludes], + }); + + for (const match of globMatches) { + staticMatches.add(match); + } + } + + // 3. Combine and de-duplicate results + return [...staticMatches]; } interface TestEntrypointsOptions { @@ -33,7 +72,14 @@ interface TestEntrypointsOptions { removeTestExtension?: boolean; } -/** Generate unique bundle names for a set of test files. */ +/** + * Generates unique, dash-delimited bundle names for a set of test files. + * This is used to create distinct output files for each test. + * + * @param testFiles An array of absolute paths to test 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 getTestEntrypoints( testFiles: string[], { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, @@ -82,6 +128,10 @@ const removeRelativeRoot = (path: string, root: string): string => { return path; }; +/** + * Removes potential root paths from a file path, returning a relative path. + * If no root path matches, it returns the file's basename. + */ function removeRoots(path: string, roots: string[]): string { for (const root of roots) { if (path.startsWith(root)) { @@ -92,12 +142,20 @@ function removeRoots(path: string, roots: string[]): string { return basename(path); } -async function findMatchingTests( +/** + * Normalizes a glob pattern by converting it to a POSIX path, removing leading slashes, + * and making it relative to the project source root. + * + * @param pattern The glob pattern to normalize. + * @param workspaceRoot The absolute path to the workspace root. + * @param projectSourceRoot The absolute path to the project's source root. + * @returns A normalized glob pattern. + */ +function normalizePattern( pattern: string, - ignore: string[], workspaceRoot: string, projectSourceRoot: string, -): Promise { +): string { // normalize pattern, glob lib only accepts forward slashes let normalizedPattern = toPosixPath(pattern); normalizedPattern = removeLeadingSlash(normalizedPattern); @@ -106,40 +164,43 @@ async function findMatchingTests( // remove relativeProjectRoot to support relative paths from root // such paths are easy to get when running scripts via IDEs - normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot); + return removeRelativeRoot(normalizedPattern, relativeProjectRoot); +} - // special logic when pattern does not look like a glob - if (!isDynamicPattern(normalizedPattern)) { - if (await isDirectory(join(projectSourceRoot, normalizedPattern))) { - normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`; - } else { - // see if matching spec file exists - const fileExt = extname(normalizedPattern); - // Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts` - const potentialSpec = join( - projectSourceRoot, - dirname(normalizedPattern), - `${basename(normalizedPattern, fileExt)}.spec${fileExt}`, - ); - - if (await exists(potentialSpec)) { - return [potentialSpec]; - } - } +/** + * Handles static (non-glob) patterns by attempting to resolve them to a directory + * of spec files or a corresponding `.spec` file. + * + * @param pattern The static path pattern. + * @param projectSourceRoot The absolute path to the project's source root. + * @returns A promise that resolves to either an array of found spec files, a new glob pattern, + * or the original pattern if no special handling was applied. + */ +async function handleStaticPattern( + pattern: string, + projectSourceRoot: string, +): Promise { + const fullPath = join(projectSourceRoot, pattern); + if (await isDirectory(fullPath)) { + return `${pattern}/**/*.spec.@(ts|tsx)`; } - // normalize the patterns in the ignore list - const normalizedIgnorePatternList = ignore.map((pattern: string) => - removeRelativeRoot(removeLeadingSlash(toPosixPath(pattern)), relativeProjectRoot), + const fileExt = extname(pattern); + // Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts` + const potentialSpec = join( + projectSourceRoot, + dirname(pattern), + `${basename(pattern, fileExt)}.spec${fileExt}`, ); - return glob(normalizedPattern, { - cwd: projectSourceRoot, - absolute: true, - ignore: ['**/node_modules/**', ...normalizedIgnorePatternList], - }); + if (await exists(potentialSpec)) { + return [potentialSpec]; + } + + return pattern; } +/** Checks if a path exists and is a directory. */ async function isDirectory(path: PathLike): Promise { try { const stats = await fs.stat(path); @@ -150,6 +211,7 @@ async function isDirectory(path: PathLike): Promise { } } +/** Checks if a path exists on the file system. */ async function exists(path: PathLike): Promise { try { await fs.access(path, constants.F_OK); From 707b64c9dc2a1ea0d47403a6056928f04d072320 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:49:37 -0400 Subject: [PATCH 3/3] refactor(@angular/build): optimize test entrypoint name generation The `getTestEntrypoints` function previously used a series of chained string replacement operations to generate a bundle name from a test file's path. For each file, this created multiple intermediate strings that were immediately discarded. This commit refactors the name generation logic into a single-pass function. The new implementation iterates over the file's relative path once to construct the final dash-cased name, significantly reducing the number of temporary string allocations and the resulting garbage collector pressure. This provides a performance improvement, particularly in large projects with thousands of test files. --- .../src/builders/unit-test/test-discovery.ts | 56 +++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) 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 9bd8a94e1329..597472be7224 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -85,19 +85,11 @@ export function getTestEntrypoints( { projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions, ): Map { const seen = new Set(); + const roots = [projectSourceRoot, workspaceRoot]; return new Map( Array.from(testFiles, (testFile) => { - const relativePath = removeRoots(testFile, [projectSourceRoot, workspaceRoot]) - // Strip leading dots and path separators. - .replace(/^[./\\]+/, '') - // Replace any path separators with dashes. - .replace(/[/\\]/g, '-'); - let fileName = basename(relativePath, extname(relativePath)); - if (removeTestExtension) { - fileName = fileName.replace(/\.(spec|test)$/, ''); - } - + const fileName = generateNameFromPath(testFile, roots, !!removeTestExtension); const baseName = `spec-${fileName}`; let uniqueName = baseName; let suffix = 2; @@ -112,6 +104,50 @@ export function getTestEntrypoints( ); } +/** + * Generates a unique, dash-delimited name from a file path. + * This is used to create a consistent and readable bundle name for a given test file. + * @param testFile The absolute path to the test file. + * @param roots An array of root paths to remove from the beginning of the test file path. + * @param removeTestExtension Whether to remove the `.spec` or `.test` extension from the result. + * @returns A dash-cased name derived from the relative path of the test file. + */ +function generateNameFromPath( + testFile: string, + roots: string[], + removeTestExtension: boolean, +): string { + const relativePath = removeRoots(testFile, roots); + + let startIndex = 0; + // Skip leading dots and slashes + while (startIndex < relativePath.length && /^[./\\]$/.test(relativePath[startIndex])) { + startIndex++; + } + + let endIndex = relativePath.length; + if (removeTestExtension) { + const match = relativePath.match(/\.(spec|test)\.[^.]+$/); + if (match?.index) { + endIndex = match.index; + } + } else { + const extIndex = relativePath.lastIndexOf('.'); + if (extIndex > startIndex) { + endIndex = extIndex; + } + } + + // Build the final string in a single pass + let result = ''; + for (let i = startIndex; i < endIndex; i++) { + const char = relativePath[i]; + result += char === '/' || char === '\\' ? '-' : char; + } + + return result; +} + const removeLeadingSlash = (pattern: string): string => { if (pattern.charAt(0) === '/') { return pattern.substring(1);