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 modules/testing/builder/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"]),
Expand Down
1 change: 1 addition & 0 deletions modules/testing/builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
12 changes: 9 additions & 3 deletions packages/angular/build/src/builders/unit-test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/angular/build/src/builders/unit-test/runners/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface TestRunner {
readonly name: string;
readonly isStandalone?: boolean;

validateDependencies?(options: NormalizedUnitTestBuilderOptions): void | Promise<void>;

getBuildOptions(
options: NormalizedUnitTestBuilderOptions,
baseBuildOptions: Partial<ApplicationBuilderInternalOptions>,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>();

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { TestRunner } from '../api';
import { DependencyChecker } from '../dependency-checker';
import { KarmaExecutor } from './executor';

/**
Expand All @@ -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: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
},
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.