From 547101a50bd3a4e9a72aa36141d7c1edfeda8974 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Fri, 10 Oct 2025 17:27:31 -0400 Subject: [PATCH] feat(@angular/build): support `.test.ts` files by default in unit test builder The unit test discovery logic previously had hardcoded conventions for `.spec.ts` files. This made it inflexible for projects that use other common patterns, such as `.test.ts`. This change introduces support for `.test.ts` files by default and refactors the discovery logic to be more flexible and maintainable. Key changes: - The `unit-test` builder schema now includes both `**/*.spec.ts` and `**/*.test.ts` in its default `include` globs. - The internal test discovery logic in `test-discovery.ts` is refactored to use a configurable array of test file infixes (`.spec`, `.test`). - This allows the smart-handling of static paths (e.g., `ng test --include src/app/app.component.ts`) to correctly resolve the corresponding test file for both conventions. - JSDoc comments and variable names have been updated to improve clarity and reflect the new, more flexible approach. --- .../build/src/builders/unit-test/schema.json | 4 +- .../src/builders/unit-test/test-discovery.ts | 144 ++++++++++-------- 2 files changed, 80 insertions(+), 68 deletions(-) diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 5c6820204071..668cc8639a09 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -36,8 +36,8 @@ "items": { "type": "string" }, - "default": ["**/*.spec.ts"], - "description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all `.spec.ts` files within) and file paths (includes the corresponding `.spec` file if one exists)." + "default": ["**/*.spec.ts", "**/*.test.ts"], + "description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)." }, "exclude": { "type": "array", 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 9dbf98bbc5a8..a7022924c622 100644 --- a/packages/angular/build/src/builders/unit-test/test-discovery.ts +++ b/packages/angular/build/src/builders/unit-test/test-discovery.ts @@ -6,13 +6,23 @@ * 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 { type PathLike, constants, promises as fs } from 'node:fs'; +import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path'; import { glob, isDynamicPattern } from 'tinyglobby'; import { toPosixPath } from '../../utils/path'; /** - * Finds all test files in the project. + * An array of file infix notations that identify a file as a test file. + * For example, `.spec` in `app.component.spec.ts`. + */ +const TEST_FILE_INFIXES = ['.spec', '.test']; + +/** + * Finds all test files in the project. This function implements a special handling + * for static paths (non-globs) to improve developer experience. For example, if a + * user provides a path to a component, this function will find the corresponding + * test file. If a user provides a path to a directory, it will find all test + * files within that directory. * * @param include Glob patterns of files to include. * @param exclude Glob patterns of files to exclude. @@ -26,26 +36,21 @@ export async function findTests( workspaceRoot: string, projectSourceRoot: string, ): Promise { - const staticMatches = new Set(); + const resolvedTestFiles = new Set(); const dynamicPatterns: string[] = []; - const normalizedExcludes = exclude.map((p) => - normalizePattern(p, workspaceRoot, projectSourceRoot), - ); + const projectRootPrefix = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/'); + const normalizedExcludes = exclude.map((p) => normalizePattern(p, projectRootPrefix)); // 1. Separate static and dynamic patterns for (const pattern of include) { - const normalized = normalizePattern(pattern, workspaceRoot, projectSourceRoot); - if (isDynamicPattern(normalized)) { + const normalized = normalizePattern(pattern, projectRootPrefix); + if (isDynamicPattern(pattern)) { 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); - } + const { resolved, unresolved } = await resolveStaticPattern(normalized, projectSourceRoot); + resolved.forEach((file) => resolvedTestFiles.add(file)); + unresolved.forEach((p) => dynamicPatterns.push(p)); } } @@ -59,12 +64,12 @@ export async function findTests( }); for (const match of globMatches) { - staticMatches.add(match); + resolvedTestFiles.add(match); } } // 3. Combine and de-duplicate results - return [...staticMatches]; + return [...resolvedTestFiles]; } interface TestEntrypointsOptions { @@ -106,11 +111,12 @@ 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. + * 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. + * @param removeTestExtension Whether to remove the test file infix and extension from the result. * @returns A dash-cased name derived from the relative path of the test file. */ function generateNameFromPath( @@ -128,7 +134,9 @@ function generateNameFromPath( let endIndex = relativePath.length; if (removeTestExtension) { - const match = relativePath.match(/\.(spec|test)\.[^.]+$/); + const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|'); + const match = relativePath.match(new RegExp(`\\.(${infixes})\\.[^.]+$`)); + if (match?.index) { endIndex = match.index; } @@ -149,25 +157,23 @@ function generateNameFromPath( return result; } -const removeLeadingSlash = (pattern: string): string => { - if (pattern.charAt(0) === '/') { - return pattern.substring(1); - } - - return pattern; +/** Removes a leading slash from a path. */ +const removeLeadingSlash = (path: string): string => { + return path.startsWith('/') ? path.substring(1) : path; }; -const removeRelativeRoot = (path: string, root: string): string => { - if (path.startsWith(root)) { - return path.substring(root.length); - } - - return path; +/** Removes a prefix from the beginning of a string. */ +const removePrefix = (str: string, prefix: string): string => { + return str.startsWith(prefix) ? str.substring(prefix.length) : str; }; /** * Removes potential root paths from a file path, returning a relative path. * If no root path matches, it returns the file's basename. + * + * @param path The file path to process. + * @param roots An array of root paths to attempt to remove. + * @returns A relative path. */ function removeRoots(path: string, roots: string[]): string { for (const root of roots) { @@ -180,61 +186,67 @@ function removeRoots(path: string, roots: string[]): string { } /** - * Normalizes a glob pattern by converting it to a POSIX path, removing leading slashes, - * and making it relative to the project source root. + * 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. + * @param projectRootPrefix The POSIX-formatted prefix of the project's source root relative to the workspace root. * @returns A normalized glob pattern. */ -function normalizePattern( - pattern: string, - workspaceRoot: string, - projectSourceRoot: string, -): string { - // normalize pattern, glob lib only accepts forward slashes +function normalizePattern(pattern: string, projectRootPrefix: string): string { let normalizedPattern = toPosixPath(pattern); normalizedPattern = removeLeadingSlash(normalizedPattern); - const relativeProjectRoot = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/'); + // Some IDEs and tools may provide patterns relative to the workspace root. + // To ensure the glob operates correctly within the project's source root, + // we remove the project's relative path from the front of the pattern. + normalizedPattern = removePrefix(normalizedPattern, projectRootPrefix); - // remove relativeProjectRoot to support relative paths from root - // such paths are easy to get when running scripts via IDEs - return removeRelativeRoot(normalizedPattern, relativeProjectRoot); + return normalizedPattern; } /** - * Handles static (non-glob) patterns by attempting to resolve them to a directory - * of spec files or a corresponding `.spec` file. + * Resolves a static (non-glob) path. + * + * If the path is a directory, it returns a glob pattern to find all test files + * within that directory. + * + * If the path is a file, it attempts to find a corresponding test file by + * checking for files with the same name and a test infix (e.g., `.spec.ts`). + * + * If no corresponding test file is found, the original path is returned as an + * unresolved pattern. * * @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. + * @returns A promise that resolves to an object containing resolved spec files and unresolved patterns. */ -async function handleStaticPattern( +async function resolveStaticPattern( pattern: string, projectSourceRoot: string, -): Promise { - const fullPath = join(projectSourceRoot, pattern); +): Promise<{ resolved: string[]; unresolved: string[] }> { + const fullPath = isAbsolute(pattern) ? pattern : join(projectSourceRoot, pattern); if (await isDirectory(fullPath)) { - return `${pattern}/**/*.spec.@(ts|tsx)`; + const infixes = TEST_FILE_INFIXES.map((p) => p.substring(1)).join('|'); + + return { resolved: [], unresolved: [`${pattern}/**/*.@(${infixes}).@(ts|tsx)`] }; } 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}`, - ); - - if (await exists(potentialSpec)) { - return [potentialSpec]; + const baseName = basename(pattern, fileExt); + + for (const infix of TEST_FILE_INFIXES) { + const potentialSpec = join( + projectSourceRoot, + dirname(pattern), + `${baseName}${infix}${fileExt}`, + ); + if (await exists(potentialSpec)) { + return { resolved: [potentialSpec], unresolved: [] }; + } } - return pattern; + return { resolved: [], unresolved: [pattern] }; } /** Checks if a path exists and is a directory. */