From b3cb76788e4b81cfe93459122380a4bf677d2d27 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 15 Sep 2025 20:23:28 -0400 Subject: [PATCH] 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). --- modules/testing/builder/BUILD.bazel | 1 + modules/testing/builder/package.json | 1 + .../build/src/builders/unit-test/builder.ts | 12 ++- .../src/builders/unit-test/runners/api.ts | 2 + .../unit-test/runners/dependency-checker.ts | 89 +++++++++++++++++++ .../builders/unit-test/runners/karma/index.ts | 21 +++++ .../unit-test/runners/vitest/index.ts | 23 +++++ pnpm-lock.yaml | 7 +- 8 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts diff --git a/modules/testing/builder/BUILD.bazel b/modules/testing/builder/BUILD.bazel index 55966f1591fb..9b88a714cd05 100644 --- a/modules/testing/builder/BUILD.bazel +++ b/modules/testing/builder/BUILD.bazel @@ -20,6 +20,7 @@ ts_project( # Needed at runtime by some builder tests relying on SSR being # resolvable in the test project. ":node_modules/@angular/ssr", + ":node_modules/jsdom", ":node_modules/vitest", ":node_modules/@vitest/coverage-v8", ] + glob(["projects/**/*"]), diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json index 4e5f938f2818..5dcce55ac11d 100644 --- a/modules/testing/builder/package.json +++ b/modules/testing/builder/package.json @@ -5,6 +5,7 @@ "@angular/ssr": "workspace:*", "@angular-devkit/build-angular": "workspace:*", "@vitest/coverage-v8": "3.2.4", + "jsdom": "27.0.0", "rxjs": "7.8.2", "vitest": "3.2.4" } diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index 175441ea7df2..9624fd2cc048 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -22,6 +22,7 @@ import type { import { ResultKind } from '../application/results'; import { normalizeOptions } from './options'; import type { TestRunner } from './runners/api'; +import { MissingDependenciesError } from './runners/dependency-checker'; import type { Schema as UnitTestBuilderOptions } from './schema'; export type { UnitTestBuilderOptions }; @@ -166,11 +167,16 @@ export async function* execute( try { normalizedOptions = await normalizeOptions(context, projectName, options); runner = await loadTestRunner(normalizedOptions.runnerName); + await runner.validateDependencies?.(normalizedOptions); } catch (e) { assertIsError(e); - context.logger.error( - `An exception occurred during initialization of the test runner:\n${e.stack ?? e.message}`, - ); + if (e instanceof MissingDependenciesError) { + context.logger.error(e.message); + } else { + context.logger.error( + `An exception occurred during initialization of the test runner:\n${e.stack ?? e.message}`, + ); + } yield { success: false }; return; diff --git a/packages/angular/build/src/builders/unit-test/runners/api.ts b/packages/angular/build/src/builders/unit-test/runners/api.ts index dd88c4435469..43f65ef68adc 100644 --- a/packages/angular/build/src/builders/unit-test/runners/api.ts +++ b/packages/angular/build/src/builders/unit-test/runners/api.ts @@ -58,6 +58,8 @@ export interface TestRunner { readonly name: string; readonly isStandalone?: boolean; + validateDependencies?(options: NormalizedUnitTestBuilderOptions): void | Promise; + getBuildOptions( options: NormalizedUnitTestBuilderOptions, baseBuildOptions: Partial, diff --git a/packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts b/packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts new file mode 100644 index 000000000000..3072bb038464 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/dependency-checker.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createRequire } from 'node:module'; + +/** + * A custom error class to represent missing dependency errors. + * This is used to avoid printing a stack trace for this expected error. + */ +export class MissingDependenciesError extends Error { + constructor(message: string) { + super(message); + this.name = 'MissingDependenciesError'; + } +} + +type Resolver = (packageName: string) => string; + +export class DependencyChecker { + private readonly resolver: Resolver; + private readonly missingDependencies = new Set(); + + constructor(projectSourceRoot: string) { + this.resolver = createRequire(projectSourceRoot + '/').resolve; + } + + /** + * Checks if a package is installed. + * @param packageName The name of the package to check. + * @returns True if the package is found, false otherwise. + */ + private isInstalled(packageName: string): boolean { + try { + this.resolver(packageName); + + return true; + } catch { + return false; + } + } + + /** + * Verifies that a package is installed and adds it to a list of missing + * dependencies if it is not. + * @param packageName The name of the package to check. + */ + check(packageName: string): void { + if (!this.isInstalled(packageName)) { + this.missingDependencies.add(packageName); + } + } + + /** + * Verifies that at least one of a list of packages is installed. If none are + * installed, a custom error message is added to the list of errors. + * @param packageNames An array of package names to check. + * @param customErrorMessage The error message to use if none of the packages are found. + */ + checkAny(packageNames: string[], customErrorMessage: string): void { + if (packageNames.every((name) => !this.isInstalled(name))) { + // This is a custom error, so we add it directly. + // Using a Set avoids duplicate custom messages. + this.missingDependencies.add(customErrorMessage); + } + } + + /** + * Throws a `MissingDependenciesError` if any dependencies were found to be missing. + * The error message is a formatted list of all missing packages. + */ + report(): void { + if (this.missingDependencies.size === 0) { + return; + } + + let message = 'The following packages are required but were not found:\n'; + for (const name of this.missingDependencies) { + message += ` - ${name}\n`; + } + message += 'Please install the missing packages and rerun the test command.'; + + throw new MissingDependenciesError(message); + } +} diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/index.ts b/packages/angular/build/src/builders/unit-test/runners/karma/index.ts index 410fab0c3d44..4ff3c24db6d1 100644 --- a/packages/angular/build/src/builders/unit-test/runners/karma/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/karma/index.ts @@ -7,6 +7,7 @@ */ import type { TestRunner } from '../api'; +import { DependencyChecker } from '../dependency-checker'; import { KarmaExecutor } from './executor'; /** @@ -16,6 +17,26 @@ const KarmaTestRunner: TestRunner = { name: 'karma', isStandalone: true, + validateDependencies(options) { + const checker = new DependencyChecker(options.projectSourceRoot); + checker.check('karma'); + checker.check('karma-jasmine'); + + // Check for browser launchers + if (options.browsers?.length) { + for (const browser of options.browsers) { + const launcherName = `karma-${browser.toLowerCase().split('headless')[0]}-launcher`; + checker.check(launcherName); + } + } + + if (options.codeCoverage) { + checker.check('karma-coverage'); + } + + checker.report(); + }, + getBuildOptions() { return { buildOptions: {}, diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts index 8b44a1a397a8..3e371dd7ebc3 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/index.ts @@ -8,6 +8,7 @@ import assert from 'node:assert'; import type { TestRunner } from '../api'; +import { DependencyChecker } from '../dependency-checker'; import { getVitestBuildOptions } from './build-options'; import { VitestExecutor } from './executor'; @@ -17,6 +18,28 @@ import { VitestExecutor } from './executor'; const VitestTestRunner: TestRunner = { name: 'vitest', + validateDependencies(options) { + const checker = new DependencyChecker(options.projectSourceRoot); + checker.check('vitest'); + + if (options.browsers?.length) { + checker.check('@vitest/browser'); + checker.checkAny( + ['playwright', 'webdriverio'], + 'The "browsers" option requires either "playwright" or "webdriverio" to be installed.', + ); + } else { + // JSDOM is used when no browsers are specified + checker.check('jsdom'); + } + + if (options.codeCoverage) { + checker.check('@vitest/coverage-v8'); + } + + checker.report(); + }, + getBuildOptions(options, baseBuildOptions) { return getVitestBuildOptions(options, baseBuildOptions); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fc6178a21f4..e98d14fe2748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -339,7 +339,10 @@ importers: version: link:../../../packages/angular/ssr '@vitest/coverage-v8': specifier: 3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.3.3)(jiti@2.5.1)(jsdom@27.0.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(less@4.4.1)(sass@1.92.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) + version: 3.2.4(vitest@3.2.4(@types/node@24.3.3)(jiti@2.5.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@6.0.5))(less@4.4.1)(sass@1.92.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1)) + jsdom: + specifier: 27.0.0 + version: 27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@6.0.5) rxjs: specifier: 7.8.2 version: 7.8.2 @@ -12813,7 +12816,7 @@ snapshots: dependencies: vite: 7.1.5(@types/node@24.3.3)(jiti@2.5.1)(less@4.4.1)(sass@1.92.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.3)(jiti@2.5.1)(jsdom@27.0.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(less@4.4.1)(sass@1.92.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.3)(jiti@2.5.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@6.0.5))(less@4.4.1)(sass@1.92.1)(terser@5.44.0)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2