66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9- import { PathLike , constants , promises as fs } from 'node:fs' ;
10- import { basename , dirname , extname , join , relative } from 'node:path' ;
9+ import { type PathLike , constants , promises as fs } from 'node:fs' ;
10+ import { basename , dirname , extname , isAbsolute , join , relative } from 'node:path' ;
1111import { glob , isDynamicPattern } from 'tinyglobby' ;
1212import { toPosixPath } from '../../utils/path' ;
1313
1414/**
15- * Finds all test files in the project.
15+ * An array of file infix notations that identify a file as a test file.
16+ * For example, `.spec` in `app.component.spec.ts`.
17+ */
18+ const TEST_FILE_INFIXES = [ '.spec' , '.test' ] ;
19+
20+ /**
21+ * Finds all test files in the project. This function implements a special handling
22+ * for static paths (non-globs) to improve developer experience. For example, if a
23+ * user provides a path to a component, this function will find the corresponding
24+ * test file. If a user provides a path to a directory, it will find all test
25+ * files within that directory.
1626 *
1727 * @param include Glob patterns of files to include.
1828 * @param exclude Glob patterns of files to exclude.
@@ -26,26 +36,21 @@ export async function findTests(
2636 workspaceRoot : string ,
2737 projectSourceRoot : string ,
2838) : Promise < string [ ] > {
29- const staticMatches = new Set < string > ( ) ;
39+ const resolvedTestFiles = new Set < string > ( ) ;
3040 const dynamicPatterns : string [ ] = [ ] ;
3141
32- const normalizedExcludes = exclude . map ( ( p ) =>
33- normalizePattern ( p , workspaceRoot , projectSourceRoot ) ,
34- ) ;
42+ const projectRootPrefix = toPosixPath ( relative ( workspaceRoot , projectSourceRoot ) + '/' ) ;
43+ const normalizedExcludes = exclude . map ( ( p ) => normalizePattern ( p , projectRootPrefix ) ) ;
3544
3645 // 1. Separate static and dynamic patterns
3746 for ( const pattern of include ) {
38- const normalized = normalizePattern ( pattern , workspaceRoot , projectSourceRoot ) ;
39- if ( isDynamicPattern ( normalized ) ) {
47+ const normalized = normalizePattern ( pattern , projectRootPrefix ) ;
48+ if ( isDynamicPattern ( pattern ) ) {
4049 dynamicPatterns . push ( normalized ) ;
4150 } else {
42- const result = await handleStaticPattern ( normalized , projectSourceRoot ) ;
43- if ( Array . isArray ( result ) ) {
44- result . forEach ( ( file ) => staticMatches . add ( file ) ) ;
45- } else {
46- // It was a static path that didn't resolve to a spec, treat as dynamic
47- dynamicPatterns . push ( result ) ;
48- }
51+ const { resolved, unresolved } = await resolveStaticPattern ( normalized , projectSourceRoot ) ;
52+ resolved . forEach ( ( file ) => resolvedTestFiles . add ( file ) ) ;
53+ unresolved . forEach ( ( p ) => dynamicPatterns . push ( p ) ) ;
4954 }
5055 }
5156
@@ -59,12 +64,12 @@ export async function findTests(
5964 } ) ;
6065
6166 for ( const match of globMatches ) {
62- staticMatches . add ( match ) ;
67+ resolvedTestFiles . add ( match ) ;
6368 }
6469 }
6570
6671 // 3. Combine and de-duplicate results
67- return [ ...staticMatches ] ;
72+ return [ ...resolvedTestFiles ] ;
6873}
6974
7075interface TestEntrypointsOptions {
@@ -106,11 +111,12 @@ export function getTestEntrypoints(
106111}
107112
108113/**
109- * Generates a unique, dash-delimited name from a file path.
110- * This is used to create a consistent and readable bundle name for a given test file.
114+ * Generates a unique, dash-delimited name from a file path. This is used to
115+ * create a consistent and readable bundle name for a given test file.
116+ *
111117 * @param testFile The absolute path to the test file.
112118 * @param roots An array of root paths to remove from the beginning of the test file path.
113- * @param removeTestExtension Whether to remove the `.spec` or `.test` extension from the result.
119+ * @param removeTestExtension Whether to remove the test file infix and extension from the result.
114120 * @returns A dash-cased name derived from the relative path of the test file.
115121 */
116122function generateNameFromPath (
@@ -128,7 +134,9 @@ function generateNameFromPath(
128134
129135 let endIndex = relativePath . length ;
130136 if ( removeTestExtension ) {
131- const match = relativePath . match ( / \. ( s p e c | t e s t ) \. [ ^ . ] + $ / ) ;
137+ const infixes = TEST_FILE_INFIXES . map ( ( p ) => p . substring ( 1 ) ) . join ( '|' ) ;
138+ const match = relativePath . match ( new RegExp ( `\\.(${ infixes } )\\.[^.]+$` ) ) ;
139+
132140 if ( match ?. index ) {
133141 endIndex = match . index ;
134142 }
@@ -149,25 +157,23 @@ function generateNameFromPath(
149157 return result ;
150158}
151159
152- const removeLeadingSlash = ( pattern : string ) : string => {
153- if ( pattern . charAt ( 0 ) === '/' ) {
154- return pattern . substring ( 1 ) ;
155- }
156-
157- return pattern ;
160+ /** Removes a leading slash from a path. */
161+ const removeLeadingSlash = ( path : string ) : string => {
162+ return path . startsWith ( '/' ) ? path . substring ( 1 ) : path ;
158163} ;
159164
160- const removeRelativeRoot = ( path : string , root : string ) : string => {
161- if ( path . startsWith ( root ) ) {
162- return path . substring ( root . length ) ;
163- }
164-
165- return path ;
165+ /** Removes a prefix from the beginning of a string. */
166+ const removePrefix = ( str : string , prefix : string ) : string => {
167+ return str . startsWith ( prefix ) ? str . substring ( prefix . length ) : str ;
166168} ;
167169
168170/**
169171 * Removes potential root paths from a file path, returning a relative path.
170172 * If no root path matches, it returns the file's basename.
173+ *
174+ * @param path The file path to process.
175+ * @param roots An array of root paths to attempt to remove.
176+ * @returns A relative path.
171177 */
172178function removeRoots ( path : string , roots : string [ ] ) : string {
173179 for ( const root of roots ) {
@@ -180,61 +186,67 @@ function removeRoots(path: string, roots: string[]): string {
180186}
181187
182188/**
183- * Normalizes a glob pattern by converting it to a POSIX path, removing leading slashes,
184- * and making it relative to the project source root.
189+ * Normalizes a glob pattern by converting it to a POSIX path, removing leading
190+ * slashes, and making it relative to the project source root.
185191 *
186192 * @param pattern The glob pattern to normalize.
187- * @param workspaceRoot The absolute path to the workspace root.
188- * @param projectSourceRoot The absolute path to the project's source root.
193+ * @param projectRootPrefix The POSIX-formatted prefix of the project's source root relative to the workspace root.
189194 * @returns A normalized glob pattern.
190195 */
191- function normalizePattern (
192- pattern : string ,
193- workspaceRoot : string ,
194- projectSourceRoot : string ,
195- ) : string {
196- // normalize pattern, glob lib only accepts forward slashes
196+ function normalizePattern ( pattern : string , projectRootPrefix : string ) : string {
197197 let normalizedPattern = toPosixPath ( pattern ) ;
198198 normalizedPattern = removeLeadingSlash ( normalizedPattern ) ;
199199
200- const relativeProjectRoot = toPosixPath ( relative ( workspaceRoot , projectSourceRoot ) + '/' ) ;
200+ // Some IDEs and tools may provide patterns relative to the workspace root.
201+ // To ensure the glob operates correctly within the project's source root,
202+ // we remove the project's relative path from the front of the pattern.
203+ normalizedPattern = removePrefix ( normalizedPattern , projectRootPrefix ) ;
201204
202- // remove relativeProjectRoot to support relative paths from root
203- // such paths are easy to get when running scripts via IDEs
204- return removeRelativeRoot ( normalizedPattern , relativeProjectRoot ) ;
205+ return normalizedPattern ;
205206}
206207
207208/**
208- * Handles static (non-glob) patterns by attempting to resolve them to a directory
209- * of spec files or a corresponding `.spec` file.
209+ * Resolves a static (non-glob) path.
210+ *
211+ * If the path is a directory, it returns a glob pattern to find all test files
212+ * within that directory.
213+ *
214+ * If the path is a file, it attempts to find a corresponding test file by
215+ * checking for files with the same name and a test infix (e.g., `.spec.ts`).
216+ *
217+ * If no corresponding test file is found, the original path is returned as an
218+ * unresolved pattern.
210219 *
211220 * @param pattern The static path pattern.
212221 * @param projectSourceRoot The absolute path to the project's source root.
213- * @returns A promise that resolves to either an array of found spec files, a new glob pattern,
214- * or the original pattern if no special handling was applied.
222+ * @returns A promise that resolves to an object containing resolved spec files and unresolved patterns.
215223 */
216- async function handleStaticPattern (
224+ async function resolveStaticPattern (
217225 pattern : string ,
218226 projectSourceRoot : string ,
219- ) : Promise < string [ ] | string > {
220- const fullPath = join ( projectSourceRoot , pattern ) ;
227+ ) : Promise < { resolved : string [ ] ; unresolved : string [ ] } > {
228+ const fullPath = isAbsolute ( pattern ) ? pattern : join ( projectSourceRoot , pattern ) ;
221229 if ( await isDirectory ( fullPath ) ) {
222- return `${ pattern } /**/*.spec.@(ts|tsx)` ;
230+ const infixes = TEST_FILE_INFIXES . map ( ( p ) => p . substring ( 1 ) ) . join ( '|' ) ;
231+
232+ return { resolved : [ ] , unresolved : [ `${ pattern } /**/*.@(${ infixes } ).@(ts|tsx)` ] } ;
223233 }
224234
225235 const fileExt = extname ( pattern ) ;
226- // Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts`
227- const potentialSpec = join (
228- projectSourceRoot ,
229- dirname ( pattern ) ,
230- `${ basename ( pattern , fileExt ) } .spec${ fileExt } ` ,
231- ) ;
232-
233- if ( await exists ( potentialSpec ) ) {
234- return [ potentialSpec ] ;
236+ const baseName = basename ( pattern , fileExt ) ;
237+
238+ for ( const infix of TEST_FILE_INFIXES ) {
239+ const potentialSpec = join (
240+ projectSourceRoot ,
241+ dirname ( pattern ) ,
242+ `${ baseName } ${ infix } ${ fileExt } ` ,
243+ ) ;
244+ if ( await exists ( potentialSpec ) ) {
245+ return { resolved : [ potentialSpec ] , unresolved : [ ] } ;
246+ }
235247 }
236248
237- return pattern ;
249+ return { resolved : [ ] , unresolved : [ pattern ] } ;
238250}
239251
240252/** Checks if a path exists and is a directory. */
0 commit comments