diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md index 19869c39699c..e88df0d8f87c 100644 --- a/goldens/public-api/angular/build/index.api.md +++ b/goldens/public-api/angular/build/index.api.md @@ -223,6 +223,7 @@ export type UnitTestBuilderOptions = { debug?: boolean; exclude?: string[]; include?: string[]; + progress?: boolean; providersFile?: string; reporters?: string[]; runner: Runner; diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts index ce6dddab4815..31b46f61064e 100644 --- a/packages/angular/build/src/builders/unit-test/builder.ts +++ b/packages/angular/build/src/builders/unit-test/builder.ts @@ -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'; @@ -22,6 +26,101 @@ import type { Schema as UnitTestBuilderOptions } from './schema'; export type { UnitTestBuilderOptions }; +async function loadTestRunner(runnerName: string): Promise { + // 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 | 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 { + 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. */ @@ -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) { @@ -73,10 +156,22 @@ 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( @@ -84,57 +179,20 @@ export async function* execute( 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); } diff --git a/packages/angular/build/src/builders/unit-test/options.ts b/packages/angular/build/src/builders/unit-test/options.ts index c1d4b8a308a1..3ba298f4bf9e 100644 --- a/packages/angular/build/src/builders/unit-test/options.ts +++ b/packages/angular/build/src/builders/unit-test/options.ts @@ -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 @@ -57,6 +57,7 @@ export async function normalizeOptions( } : undefined, tsConfig, + buildProgress: progress, reporters, browsers, watch: options.watch ?? isTTY(), diff --git a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts index 7f5e6dbb5010..934e4da994f2 100644 --- a/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/karma/executor.ts @@ -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, diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts new file mode 100644 index 000000000000..16913d50b3f7 --- /dev/null +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/browser-provider.ts @@ -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 }; +} diff --git a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts index 54a25b2a83fb..4f23c960f1ae 100644 --- a/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts +++ b/packages/angular/build/src/builders/unit-test/runners/vitest/executor.ts @@ -19,6 +19,7 @@ import type { FullResult, IncrementalResult } from '../../../application/results import { writeTestFiles } from '../../../karma/application_builder'; import { NormalizedUnitTestBuilderOptions } from '../../options'; import type { TestExecutor } from '../api'; +import { setupBrowserConfiguration } from './browser-provider'; type VitestCoverageOption = Exclude; @@ -171,89 +172,6 @@ export class VitestExecutor implements TestExecutor { } } -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$/, ''); -} - -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 }; -} - function generateOutputPath(): string { const datePrefix = new Date().toISOString().replaceAll(/[-:.]/g, ''); const uuidSuffix = randomUUID().slice(0, 8); diff --git a/packages/angular/build/src/builders/unit-test/schema.json b/packages/angular/build/src/builders/unit-test/schema.json index 8628bb9725e9..79185218dee2 100644 --- a/packages/angular/build/src/builders/unit-test/schema.json +++ b/packages/angular/build/src/builders/unit-test/schema.json @@ -106,6 +106,10 @@ "type": "string" }, "description": "A list of global setup and configuration files that are included before the test files. The application's polyfills are always included before these files. The Angular Testbed is also initialized prior to the execution of these files." + }, + "progress": { + "type": "boolean", + "description": "Log progress to the console while building. Defaults to the build target's progress value." } }, "additionalProperties": false,