Skip to content
Merged
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
1 change: 1 addition & 0 deletions goldens/public-api/angular/build/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export type UnitTestBuilderOptions = {
exclude?: string[];
filter?: string;
include?: string[];
listTests?: boolean;
outputFile?: string;
progress?: boolean;
providersFile?: string;
Expand Down
157 changes: 3 additions & 154 deletions packages/angular/build/src/builders/karma/find-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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<string, string> {
const seen = new Set<string>();

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<string[]> {
// 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<boolean> {
try {
const stats = await fs.stat(path);

return stats.isDirectory();
} catch {
return false;
}
}

async function exists(path: PathLike): Promise<boolean> {
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';
19 changes: 19 additions & 0 deletions packages/angular/build/src/builders/unit-test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/angular/build/src/builders/unit-test/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export async function normalizeOptions(
? options.setupFiles.map((setupFile) => path.join(workspaceRoot, setupFile))
: [],
dumpVirtualFiles: options.dumpVirtualFiles,
listTests: options.listTests,
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/angular/build/src/builders/unit-test/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading