Skip to content

refactor(@angular/build): improve error handling for unit-test builder setup #30945

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 19, 2025
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 @@ -223,6 +223,7 @@ export type UnitTestBuilderOptions = {
debug?: boolean;
exclude?: string[];
include?: string[];
progress?: boolean;
providersFile?: string;
reporters?: string[];
runner: Runner;
Expand Down
194 changes: 126 additions & 68 deletions packages/angular/build/src/builders/unit-test/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
* found in the LICENSE file at https://angular.dev/license
*/

import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import {
type BuilderContext,
type BuilderOutput,
targetStringFromTarget,
} from '@angular-devkit/architect';
import assert from 'node:assert';
import { createVirtualModulePlugin } from '../../tools/esbuild/virtual-module-plugin';
import { assertIsError } from '../../utils/error';
Expand All @@ -22,6 +26,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema';

export type { UnitTestBuilderOptions };

async function loadTestRunner(runnerName: string): Promise<TestRunner> {
// Harden against directory traversal
if (!/^[a-zA-Z0-9-]+$/.test(runnerName)) {
throw new Error(
`Invalid runner name "${runnerName}". Runner names can only contain alphanumeric characters and hyphens.`,
);
}

let runnerModule;
try {
runnerModule = await import(`./runners/${runnerName}/index`);
} catch (e) {
assertIsError(e);
if (e.code === 'ERR_MODULE_NOT_FOUND') {
throw new Error(`Unknown test runner "${runnerName}".`);
}
throw new Error(
`Failed to load the '${runnerName}' test runner. The package may be corrupted or improperly installed.\n` +
`Error: ${e.message}`,
);
}

const runner = runnerModule.default;
if (
!runner ||
typeof runner.getBuildOptions !== 'function' ||
typeof runner.createExecutor !== 'function'
) {
throw new Error(
`The loaded test runner '${runnerName}' does not appear to be a valid TestRunner implementation.`,
);
}

return runner;
}

function prepareBuildExtensions(
virtualFiles: Record<string, string> | undefined,
projectSourceRoot: string,
extensions?: ApplicationBuilderExtensions,
): ApplicationBuilderExtensions | undefined {
if (!virtualFiles) {
return extensions;
}

extensions ??= {};
extensions.codePlugins ??= [];
for (const [namespace, contents] of Object.entries(virtualFiles)) {
extensions.codePlugins.push(
createVirtualModulePlugin({
namespace,
loadContent: () => {
return {
contents,
loader: 'js',
resolveDir: projectSourceRoot,
};
},
}),
);
}

return extensions;
}

async function* runBuildAndTest(
executor: import('./runners/api').TestExecutor,
applicationBuildOptions: ApplicationBuilderInternalOptions,
context: BuilderContext,
extensions: ApplicationBuilderExtensions | undefined,
): AsyncIterable<BuilderOutput> {
for await (const buildResult of buildApplicationInternal(
applicationBuildOptions,
context,
extensions,
)) {
if (buildResult.kind === ResultKind.Failure) {
yield { success: false };
continue;
} else if (
buildResult.kind !== ResultKind.Full &&
buildResult.kind !== ResultKind.Incremental
) {
assert.fail(
'A full and/or incremental build result is required from the application builder.',
);
}

assert(buildResult.files, 'Builder did not provide result files.');

// Pass the build artifacts to the executor
yield* executor.execute(buildResult);
}
}

/**
* @experimental Direct usage of this function is considered experimental.
*/
Expand All @@ -43,24 +142,8 @@ export async function* execute(
);

const normalizedOptions = await normalizeOptions(context, projectName, options);
const { runnerName, projectSourceRoot } = normalizedOptions;

// Dynamically load the requested runner
let runner: TestRunner;
try {
const { default: runnerModule } = await import(`./runners/${runnerName}/index`);
runner = runnerModule;
} catch (e) {
assertIsError(e);
if (e.code !== 'ERR_MODULE_NOT_FOUND') {
throw e;
}
context.logger.error(`Unknown test runner "${runnerName}".`);
const runner = await loadTestRunner(normalizedOptions.runnerName);

return;
}

// Create the stateful executor once
await using executor = await runner.createExecutor(context, normalizedOptions);

if (runner.isStandalone) {
Expand All @@ -73,68 +156,43 @@ export async function* execute(
}

// Get base build options from the buildTarget
const buildTargetOptions = (await context.validateOptions(
await context.getTargetOptions(normalizedOptions.buildTarget),
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
)) as unknown as ApplicationBuilderInternalOptions;
let buildTargetOptions: ApplicationBuilderInternalOptions;
try {
buildTargetOptions = (await context.validateOptions(
await context.getTargetOptions(normalizedOptions.buildTarget),
await context.getBuilderNameForTarget(normalizedOptions.buildTarget),
)) as unknown as ApplicationBuilderInternalOptions;
} catch (e) {
assertIsError(e);
context.logger.error(
`Could not load build target options for "${targetStringFromTarget(normalizedOptions.buildTarget)}".\n` +
`Please check your 'angular.json' configuration.\n` +
`Error: ${e.message}`,
);

return;
}

// Get runner-specific build options from the hook
const { buildOptions: runnerBuildOptions, virtualFiles } = await runner.getBuildOptions(
normalizedOptions,
buildTargetOptions,
);

if (virtualFiles) {
extensions ??= {};
extensions.codePlugins ??= [];
for (const [namespace, contents] of Object.entries(virtualFiles)) {
extensions.codePlugins.push(
createVirtualModulePlugin({
namespace,
loadContent: () => {
return {
contents,
loader: 'js',
resolveDir: projectSourceRoot,
};
},
}),
);
}
}

const { watch, tsConfig } = normalizedOptions;
const finalExtensions = prepareBuildExtensions(
virtualFiles,
normalizedOptions.projectSourceRoot,
extensions,
);

// Prepare and run the application build
const applicationBuildOptions = {
// Base options
...buildTargetOptions,
watch,
tsConfig,
// Runner specific
...runnerBuildOptions,
watch: normalizedOptions.watch,
tsConfig: normalizedOptions.tsConfig,
progress: normalizedOptions.buildProgress ?? buildTargetOptions.progress,
} satisfies ApplicationBuilderInternalOptions;

for await (const buildResult of buildApplicationInternal(
applicationBuildOptions,
context,
extensions,
)) {
if (buildResult.kind === ResultKind.Failure) {
yield { success: false };
continue;
} else if (
buildResult.kind !== ResultKind.Full &&
buildResult.kind !== ResultKind.Incremental
) {
assert.fail(
'A full and/or incremental build result is required from the application builder.',
);
}

assert(buildResult.files, 'Builder did not provide result files.');

// Pass the build artifacts to the executor
yield* executor.execute(buildResult);
}
yield* runBuildAndTest(executor, applicationBuildOptions, context, finalExtensions);
}
3 changes: 2 additions & 1 deletion packages/angular/build/src/builders/unit-test/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function normalizeOptions(
const buildTargetSpecifier = options.buildTarget ?? `::development`;
const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build');

const { tsConfig, runner, reporters, browsers } = options;
const { tsConfig, runner, reporters, browsers, progress } = options;

return {
// Project/workspace information
Expand All @@ -57,6 +57,7 @@ export async function normalizeOptions(
}
: undefined,
tsConfig,
buildProgress: progress,
reporters,
browsers,
watch: options.watch ?? isTTY(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class KarmaExecutor implements TestExecutor {
include: unitTestOptions.include,
exclude: unitTestOptions.exclude,
sourceMap: buildTargetOptions.sourceMap,
progress: buildTargetOptions.progress,
progress: unitTestOptions.buildProgress ?? buildTargetOptions.progress,
watch: unitTestOptions.watch,
poll: buildTargetOptions.poll,
preserveSymlinks: buildTargetOptions.preserveSymlinks,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* @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';

function findBrowserProvider(
projectResolver: NodeJS.RequireResolve,
): import('vitest/node').BrowserBuiltinProvider | undefined {
// One of these must be installed in the project to use browser testing
const vitestBuiltinProviders = ['playwright', 'webdriverio'] as const;

for (const providerName of vitestBuiltinProviders) {
try {
projectResolver(providerName);

return providerName;
} catch {}
}

return undefined;
}

function normalizeBrowserName(browserName: string): string {
// Normalize browser names to match Vitest's expectations for headless but also supports karma's names
// e.g., 'ChromeHeadless' -> 'chrome', 'FirefoxHeadless' -> 'firefox'
// and 'Chrome' -> 'chrome', 'Firefox' -> 'firefox'.
const normalized = browserName.toLowerCase();

return normalized.replace(/headless$/, '');
}

export function setupBrowserConfiguration(
browsers: string[] | undefined,
debug: boolean,
projectSourceRoot: string,
): { browser?: import('vitest/node').BrowserConfigOptions; errors?: string[] } {
if (browsers === undefined) {
return {};
}

const projectResolver = createRequire(projectSourceRoot + '/').resolve;
let errors: string[] | undefined;

try {
projectResolver('@vitest/browser');
} catch {
errors ??= [];
errors.push(
'The "browsers" option requires the "@vitest/browser" package to be installed within the project.' +
' Please install this package and rerun the test command.',
);
}

const provider = findBrowserProvider(projectResolver);
if (!provider) {
errors ??= [];
errors.push(
'The "browsers" option requires either "playwright" or "webdriverio" to be installed within the project.' +
' Please install one of these packages and rerun the test command.',
);
}

// Vitest current requires the playwright browser provider to use the inspect-brk option used by "debug"
if (debug && provider !== 'playwright') {
errors ??= [];
errors.push(
'Debugging browser mode tests currently requires the use of "playwright".' +
' Please install this package and rerun the test command.',
);
}

if (errors) {
return { errors };
}

const browser = {
enabled: true,
provider,
headless: browsers.some((name) => name.toLowerCase().includes('headless')),

instances: browsers.map((browserName) => ({
browser: normalizeBrowserName(browserName),
})),
};

return { browser };
}
Loading
Loading