Skip to content

Commit 00426e3

Browse files
committed
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.
1 parent 1ad7816 commit 00426e3

File tree

7 files changed

+271
-156
lines changed

7 files changed

+271
-156
lines changed

goldens/public-api/angular/build/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export type UnitTestBuilderOptions = {
225225
exclude?: string[];
226226
filter?: string;
227227
include?: string[];
228+
listTests?: boolean;
228229
outputFile?: string;
229230
progress?: boolean;
230231
providersFile?: string;

packages/angular/build/src/builders/karma/find-tests.ts

Lines changed: 3 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -6,157 +6,6 @@
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';
11-
import { glob, isDynamicPattern } from 'tinyglobby';
12-
import { toPosixPath } from '../../utils/path';
13-
14-
/* Go through all patterns and find unique list of files */
15-
export async function findTests(
16-
include: string[],
17-
exclude: string[],
18-
workspaceRoot: string,
19-
projectSourceRoot: string,
20-
): Promise<string[]> {
21-
const matchingTestsPromises = include.map((pattern) =>
22-
findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot),
23-
);
24-
const files = await Promise.all(matchingTestsPromises);
25-
26-
// Unique file names
27-
return [...new Set(files.flat())];
28-
}
29-
30-
interface TestEntrypointsOptions {
31-
projectSourceRoot: string;
32-
workspaceRoot: string;
33-
removeTestExtension?: boolean;
34-
}
35-
36-
/** Generate unique bundle names for a set of test files. */
37-
export function getTestEntrypoints(
38-
testFiles: string[],
39-
{ projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions,
40-
): Map<string, string> {
41-
const seen = new Set<string>();
42-
43-
return new Map(
44-
Array.from(testFiles, (testFile) => {
45-
const relativePath = removeRoots(testFile, [projectSourceRoot, workspaceRoot])
46-
// Strip leading dots and path separators.
47-
.replace(/^[./\\]+/, '')
48-
// Replace any path separators with dashes.
49-
.replace(/[/\\]/g, '-');
50-
51-
let fileName = basename(relativePath, extname(relativePath));
52-
if (removeTestExtension) {
53-
fileName = fileName.replace(/\.(spec|test)$/, '');
54-
}
55-
56-
const baseName = `spec-${fileName}`;
57-
let uniqueName = baseName;
58-
let suffix = 2;
59-
while (seen.has(uniqueName)) {
60-
uniqueName = `${baseName}-${suffix}`.replace(/([^\w](?:spec|test))-([\d]+)$/, '-$2$1');
61-
++suffix;
62-
}
63-
seen.add(uniqueName);
64-
65-
return [uniqueName, testFile];
66-
}),
67-
);
68-
}
69-
70-
const removeLeadingSlash = (pattern: string): string => {
71-
if (pattern.charAt(0) === '/') {
72-
return pattern.substring(1);
73-
}
74-
75-
return pattern;
76-
};
77-
78-
const removeRelativeRoot = (path: string, root: string): string => {
79-
if (path.startsWith(root)) {
80-
return path.substring(root.length);
81-
}
82-
83-
return path;
84-
};
85-
86-
function removeRoots(path: string, roots: string[]): string {
87-
for (const root of roots) {
88-
if (path.startsWith(root)) {
89-
return path.substring(root.length);
90-
}
91-
}
92-
93-
return basename(path);
94-
}
95-
96-
async function findMatchingTests(
97-
pattern: string,
98-
ignore: string[],
99-
workspaceRoot: string,
100-
projectSourceRoot: string,
101-
): Promise<string[]> {
102-
// normalize pattern, glob lib only accepts forward slashes
103-
let normalizedPattern = toPosixPath(pattern);
104-
normalizedPattern = removeLeadingSlash(normalizedPattern);
105-
106-
const relativeProjectRoot = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/');
107-
108-
// remove relativeProjectRoot to support relative paths from root
109-
// such paths are easy to get when running scripts via IDEs
110-
normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot);
111-
112-
// special logic when pattern does not look like a glob
113-
if (!isDynamicPattern(normalizedPattern)) {
114-
if (await isDirectory(join(projectSourceRoot, normalizedPattern))) {
115-
normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`;
116-
} else {
117-
// see if matching spec file exists
118-
const fileExt = extname(normalizedPattern);
119-
// Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts`
120-
const potentialSpec = join(
121-
projectSourceRoot,
122-
dirname(normalizedPattern),
123-
`${basename(normalizedPattern, fileExt)}.spec${fileExt}`,
124-
);
125-
126-
if (await exists(potentialSpec)) {
127-
return [potentialSpec];
128-
}
129-
}
130-
}
131-
132-
// normalize the patterns in the ignore list
133-
const normalizedIgnorePatternList = ignore.map((pattern: string) =>
134-
removeRelativeRoot(removeLeadingSlash(toPosixPath(pattern)), relativeProjectRoot),
135-
);
136-
137-
return glob(normalizedPattern, {
138-
cwd: projectSourceRoot,
139-
absolute: true,
140-
ignore: ['**/node_modules/**', ...normalizedIgnorePatternList],
141-
});
142-
}
143-
144-
async function isDirectory(path: PathLike): Promise<boolean> {
145-
try {
146-
const stats = await fs.stat(path);
147-
148-
return stats.isDirectory();
149-
} catch {
150-
return false;
151-
}
152-
}
153-
154-
async function exists(path: PathLike): Promise<boolean> {
155-
try {
156-
await fs.access(path, constants.F_OK);
157-
158-
return true;
159-
} catch {
160-
return false;
161-
}
162-
}
9+
// This file is a compatibility layer that re-exports the test discovery logic from its new location.
10+
// This is necessary to avoid breaking the Karma builder, which still depends on this file.
11+
export { findTests, getTestEntrypoints } from '../unit-test/test-discovery';

packages/angular/build/src/builders/unit-test/builder.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { normalizeOptions } from './options';
2727
import type { TestRunner } from './runners/api';
2828
import { MissingDependenciesError } from './runners/dependency-checker';
2929
import type { Schema as UnitTestBuilderOptions } from './schema';
30+
import { findTests } from './test-discovery';
3031

3132
export type { UnitTestBuilderOptions };
3233

@@ -200,6 +201,24 @@ export async function* execute(
200201
return;
201202
}
202203

204+
if (normalizedOptions.listTests) {
205+
const testFiles = await findTests(
206+
normalizedOptions.include,
207+
normalizedOptions.exclude ?? [],
208+
normalizedOptions.workspaceRoot,
209+
normalizedOptions.projectSourceRoot,
210+
);
211+
212+
context.logger.info('Discovered test files:');
213+
for (const file of testFiles) {
214+
context.logger.info(` ${path.relative(normalizedOptions.workspaceRoot, file)}`);
215+
}
216+
217+
yield { success: true };
218+
219+
return;
220+
}
221+
203222
if (runner.isStandalone) {
204223
try {
205224
await using executor = await runner.createExecutor(context, normalizedOptions, undefined);

packages/angular/build/src/builders/unit-test/options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export async function normalizeOptions(
7575
? options.setupFiles.map((setupFile) => path.join(workspaceRoot, setupFile))
7676
: [],
7777
dumpVirtualFiles: options.dumpVirtualFiles,
78+
listTests: options.listTests,
7879
};
7980
}
8081

packages/angular/build/src/builders/unit-test/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,11 @@
148148
"type": "boolean",
149149
"description": "Shows build progress information in the console. Defaults to the `progress` setting of the specified `buildTarget`."
150150
},
151+
"listTests": {
152+
"type": "boolean",
153+
"description": "Lists all discovered test files and exits the process without building or executing the tests.",
154+
"default": false
155+
},
151156
"dumpVirtualFiles": {
152157
"type": "boolean",
153158
"description": "Dumps build output files to the `.angular/cache` directory for debugging purposes.",

packages/angular/build/src/builders/unit-test/test-discovery.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,156 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
// TODO: This should eventually contain the implementations for these
10-
export { findTests, getTestEntrypoints } from '../karma/find-tests';
9+
import { PathLike, constants, promises as fs } from 'node:fs';
10+
import { basename, dirname, extname, join, relative } from 'node:path';
11+
import { glob, isDynamicPattern } from 'tinyglobby';
12+
import { toPosixPath } from '../../utils/path';
13+
14+
/* Go through all patterns and find unique list of files */
15+
export async function findTests(
16+
include: string[],
17+
exclude: string[],
18+
workspaceRoot: string,
19+
projectSourceRoot: string,
20+
): Promise<string[]> {
21+
const matchingTestsPromises = include.map((pattern) =>
22+
findMatchingTests(pattern, exclude, workspaceRoot, projectSourceRoot),
23+
);
24+
const files = await Promise.all(matchingTestsPromises);
25+
26+
// Unique file names
27+
return [...new Set(files.flat())];
28+
}
29+
30+
interface TestEntrypointsOptions {
31+
projectSourceRoot: string;
32+
workspaceRoot: string;
33+
removeTestExtension?: boolean;
34+
}
35+
36+
/** Generate unique bundle names for a set of test files. */
37+
export function getTestEntrypoints(
38+
testFiles: string[],
39+
{ projectSourceRoot, workspaceRoot, removeTestExtension }: TestEntrypointsOptions,
40+
): Map<string, string> {
41+
const seen = new Set<string>();
42+
43+
return new Map(
44+
Array.from(testFiles, (testFile) => {
45+
const relativePath = removeRoots(testFile, [projectSourceRoot, workspaceRoot])
46+
// Strip leading dots and path separators.
47+
.replace(/^[./\\]+/, '')
48+
// Replace any path separators with dashes.
49+
.replace(/[/\\]/g, '-');
50+
let fileName = basename(relativePath, extname(relativePath));
51+
if (removeTestExtension) {
52+
fileName = fileName.replace(/\.(spec|test)$/, '');
53+
}
54+
55+
const baseName = `spec-${fileName}`;
56+
let uniqueName = baseName;
57+
let suffix = 2;
58+
while (seen.has(uniqueName)) {
59+
uniqueName = `${baseName}-${suffix}`.replace(/([^\w](?:spec|test))-([\d]+)$/, '-$2$1');
60+
++suffix;
61+
}
62+
seen.add(uniqueName);
63+
64+
return [uniqueName, testFile];
65+
}),
66+
);
67+
}
68+
69+
const removeLeadingSlash = (pattern: string): string => {
70+
if (pattern.charAt(0) === '/') {
71+
return pattern.substring(1);
72+
}
73+
74+
return pattern;
75+
};
76+
77+
const removeRelativeRoot = (path: string, root: string): string => {
78+
if (path.startsWith(root)) {
79+
return path.substring(root.length);
80+
}
81+
82+
return path;
83+
};
84+
85+
function removeRoots(path: string, roots: string[]): string {
86+
for (const root of roots) {
87+
if (path.startsWith(root)) {
88+
return path.substring(root.length);
89+
}
90+
}
91+
92+
return basename(path);
93+
}
94+
95+
async function findMatchingTests(
96+
pattern: string,
97+
ignore: string[],
98+
workspaceRoot: string,
99+
projectSourceRoot: string,
100+
): Promise<string[]> {
101+
// normalize pattern, glob lib only accepts forward slashes
102+
let normalizedPattern = toPosixPath(pattern);
103+
normalizedPattern = removeLeadingSlash(normalizedPattern);
104+
105+
const relativeProjectRoot = toPosixPath(relative(workspaceRoot, projectSourceRoot) + '/');
106+
107+
// remove relativeProjectRoot to support relative paths from root
108+
// such paths are easy to get when running scripts via IDEs
109+
normalizedPattern = removeRelativeRoot(normalizedPattern, relativeProjectRoot);
110+
111+
// special logic when pattern does not look like a glob
112+
if (!isDynamicPattern(normalizedPattern)) {
113+
if (await isDirectory(join(projectSourceRoot, normalizedPattern))) {
114+
normalizedPattern = `${normalizedPattern}/**/*.spec.@(ts|tsx)`;
115+
} else {
116+
// see if matching spec file exists
117+
const fileExt = extname(normalizedPattern);
118+
// Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts`
119+
const potentialSpec = join(
120+
projectSourceRoot,
121+
dirname(normalizedPattern),
122+
`${basename(normalizedPattern, fileExt)}.spec${fileExt}`,
123+
);
124+
125+
if (await exists(potentialSpec)) {
126+
return [potentialSpec];
127+
}
128+
}
129+
}
130+
131+
// normalize the patterns in the ignore list
132+
const normalizedIgnorePatternList = ignore.map((pattern: string) =>
133+
removeRelativeRoot(removeLeadingSlash(toPosixPath(pattern)), relativeProjectRoot),
134+
);
135+
136+
return glob(normalizedPattern, {
137+
cwd: projectSourceRoot,
138+
absolute: true,
139+
ignore: ['**/node_modules/**', ...normalizedIgnorePatternList],
140+
});
141+
}
142+
143+
async function isDirectory(path: PathLike): Promise<boolean> {
144+
try {
145+
const stats = await fs.stat(path);
146+
147+
return stats.isDirectory();
148+
} catch {
149+
return false;
150+
}
151+
}
152+
153+
async function exists(path: PathLike): Promise<boolean> {
154+
try {
155+
await fs.access(path, constants.F_OK);
156+
157+
return true;
158+
} catch {
159+
return false;
160+
}
161+
}

0 commit comments

Comments
 (0)