Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/angular/build/src/builders/unit-test/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 78 additions & 66 deletions packages/angular/build/src/builders/unit-test/test-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -26,26 +36,21 @@ export async function findTests(
workspaceRoot: string,
projectSourceRoot: string,
): Promise<string[]> {
const staticMatches = new Set<string>();
const resolvedTestFiles = new Set<string>();
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));
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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<string[] | string> {
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. */
Expand Down