Skip to content

Commit c6176f6

Browse files
committed
fix(@angular/build): add upfront dependency validation for unit-test runners
Previously, missing peer dependencies for test runners (e.g., `jsdom`, `karma-coverage`, `playwright`) were only discovered late in the execution process, often leading to cryptic errors. When multiple packages were missing, this resulted in a frustrating cycle of running the command, installing a package, and repeating. This change introduces a comprehensive, upfront dependency validation system: - A `validateDependencies` hook is added to the `TestRunner` interface, allowing each runner to declare its own requirements. - A `DependencyChecker` class now collects all missing dependencies and reports them to the user in a single, clean error message without a stack trace. - Both the Karma and Vitest runners implement this hook to check for all required packages based on the user's configuration (including browser launchers, coverage packages, and the JSDOM environment).
1 parent c0b00d7 commit c6176f6

File tree

8 files changed

+151
-5
lines changed

8 files changed

+151
-5
lines changed

modules/testing/builder/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ts_project(
2020
# Needed at runtime by some builder tests relying on SSR being
2121
# resolvable in the test project.
2222
":node_modules/@angular/ssr",
23+
":node_modules/jsdom",
2324
":node_modules/vitest",
2425
":node_modules/@vitest/coverage-v8",
2526
] + glob(["projects/**/*"]),

modules/testing/builder/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"@angular/ssr": "workspace:*",
66
"@angular-devkit/build-angular": "workspace:*",
77
"@vitest/coverage-v8": "3.2.4",
8+
"jsdom": "27.0.0",
89
"rxjs": "7.8.2",
910
"vitest": "3.2.4"
1011
}

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
import { ResultKind } from '../application/results';
2323
import { normalizeOptions } from './options';
2424
import type { TestRunner } from './runners/api';
25+
import { MissingDependenciesError } from './runners/dependency-checker';
2526
import type { Schema as UnitTestBuilderOptions } from './schema';
2627

2728
export type { UnitTestBuilderOptions };
@@ -166,11 +167,16 @@ export async function* execute(
166167
try {
167168
normalizedOptions = await normalizeOptions(context, projectName, options);
168169
runner = await loadTestRunner(normalizedOptions.runnerName);
170+
await runner.validateDependencies?.(normalizedOptions);
169171
} catch (e) {
170172
assertIsError(e);
171-
context.logger.error(
172-
`An exception occurred during initialization of the test runner:\n${e.stack ?? e.message}`,
173-
);
173+
if (e instanceof MissingDependenciesError) {
174+
context.logger.error(e.message);
175+
} else {
176+
context.logger.error(
177+
`An exception occurred during initialization of the test runner:\n${e.stack ?? e.message}`,
178+
);
179+
}
174180
yield { success: false };
175181

176182
return;

packages/angular/build/src/builders/unit-test/runners/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export interface TestRunner {
5858
readonly name: string;
5959
readonly isStandalone?: boolean;
6060

61+
validateDependencies?(options: NormalizedUnitTestBuilderOptions): void | Promise<void>;
62+
6163
getBuildOptions(
6264
options: NormalizedUnitTestBuilderOptions,
6365
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { createRequire } from 'node:module';
10+
11+
/**
12+
* A custom error class to represent missing dependency errors.
13+
* This is used to avoid printing a stack trace for this expected error.
14+
*/
15+
export class MissingDependenciesError extends Error {
16+
constructor(message: string) {
17+
super(message);
18+
this.name = 'MissingDependenciesError';
19+
}
20+
}
21+
22+
type Resolver = (packageName: string) => string;
23+
24+
export class DependencyChecker {
25+
private readonly resolver: Resolver;
26+
private readonly missingDependencies = new Set<string>();
27+
28+
constructor(projectSourceRoot: string) {
29+
this.resolver = createRequire(projectSourceRoot + '/').resolve;
30+
}
31+
32+
/**
33+
* Checks if a package is installed.
34+
* @param packageName The name of the package to check.
35+
* @returns True if the package is found, false otherwise.
36+
*/
37+
private isInstalled(packageName: string): boolean {
38+
try {
39+
this.resolver(packageName);
40+
41+
return true;
42+
} catch {
43+
return false;
44+
}
45+
}
46+
47+
/**
48+
* Verifies that a package is installed and adds it to a list of missing
49+
* dependencies if it is not.
50+
* @param packageName The name of the package to check.
51+
*/
52+
check(packageName: string): void {
53+
if (!this.isInstalled(packageName)) {
54+
this.missingDependencies.add(packageName);
55+
}
56+
}
57+
58+
/**
59+
* Verifies that at least one of a list of packages is installed. If none are
60+
* installed, a custom error message is added to the list of errors.
61+
* @param packageNames An array of package names to check.
62+
* @param customErrorMessage The error message to use if none of the packages are found.
63+
*/
64+
checkAny(packageNames: string[], customErrorMessage: string): void {
65+
if (packageNames.every((name) => !this.isInstalled(name))) {
66+
// This is a custom error, so we add it directly.
67+
// Using a Set avoids duplicate custom messages.
68+
this.missingDependencies.add(customErrorMessage);
69+
}
70+
}
71+
72+
/**
73+
* Throws a `MissingDependenciesError` if any dependencies were found to be missing.
74+
* The error message is a formatted list of all missing packages.
75+
*/
76+
report(): void {
77+
if (this.missingDependencies.size === 0) {
78+
return;
79+
}
80+
81+
let message = 'The following packages are required but were not found:\n';
82+
for (const name of this.missingDependencies) {
83+
message += ` - ${name}\n`;
84+
}
85+
message += 'Please install the missing packages and rerun the test command.';
86+
87+
throw new MissingDependenciesError(message);
88+
}
89+
}

packages/angular/build/src/builders/unit-test/runners/karma/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import type { TestRunner } from '../api';
10+
import { DependencyChecker } from '../dependency-checker';
1011
import { KarmaExecutor } from './executor';
1112

1213
/**
@@ -16,6 +17,26 @@ const KarmaTestRunner: TestRunner = {
1617
name: 'karma',
1718
isStandalone: true,
1819

20+
validateDependencies(options) {
21+
const checker = new DependencyChecker(options.projectSourceRoot);
22+
checker.check('karma');
23+
checker.check('karma-jasmine');
24+
25+
// Check for browser launchers
26+
if (options.browsers?.length) {
27+
for (const browser of options.browsers) {
28+
const launcherName = `karma-${browser.toLowerCase().split('headless')[0]}-launcher`;
29+
checker.check(launcherName);
30+
}
31+
}
32+
33+
if (options.codeCoverage) {
34+
checker.check('karma-coverage');
35+
}
36+
37+
checker.report();
38+
},
39+
1940
getBuildOptions() {
2041
return {
2142
buildOptions: {},

packages/angular/build/src/builders/unit-test/runners/vitest/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import assert from 'node:assert';
1010
import type { TestRunner } from '../api';
11+
import { DependencyChecker } from '../dependency-checker';
1112
import { getVitestBuildOptions } from './build-options';
1213
import { VitestExecutor } from './executor';
1314

@@ -17,6 +18,28 @@ import { VitestExecutor } from './executor';
1718
const VitestTestRunner: TestRunner = {
1819
name: 'vitest',
1920

21+
validateDependencies(options) {
22+
const checker = new DependencyChecker(options.projectSourceRoot);
23+
checker.check('vitest');
24+
25+
if (options.browsers?.length) {
26+
checker.check('@vitest/browser');
27+
checker.checkAny(
28+
['playwright', 'webdriverio'],
29+
'The "browsers" option requires either "playwright" or "webdriverio" to be installed.',
30+
);
31+
} else {
32+
// JSDOM is used when no browsers are specified
33+
checker.check('jsdom');
34+
}
35+
36+
if (options.codeCoverage) {
37+
checker.check('@vitest/coverage-v8');
38+
}
39+
40+
checker.report();
41+
},
42+
2043
getBuildOptions(options, baseBuildOptions) {
2144
return getVitestBuildOptions(options, baseBuildOptions);
2245
},

pnpm-lock.yaml

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)