diff --git a/packages/angular_devkit/build_angular/src/builders/karma/find-tests.ts b/packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts similarity index 55% rename from packages/angular_devkit/build_angular/src/builders/karma/find-tests.ts rename to packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts index 8c3fd18d3acc..256f4506c0c4 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/find-tests.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/find-tests-plugin.ts @@ -6,15 +6,73 @@ * found in the LICENSE file at https://angular.io/license */ +import assert from 'assert'; import { PathLike, constants, promises as fs } from 'fs'; import glob, { hasMagic } from 'glob'; import { basename, dirname, extname, join, relative } from 'path'; import { promisify } from 'util'; +import type { Compilation, Compiler } from 'webpack'; +import { addError } from '../../utils/webpack-diagnostics'; const globPromise = promisify(glob); +/** + * The name of the plugin provided to Webpack when tapping Webpack compiler hooks. + */ +const PLUGIN_NAME = 'angular-find-tests-plugin'; + +export interface FindTestsPluginOptions { + include?: string[]; + workspaceRoot: string; + projectSourceRoot: string; +} + +export class FindTestsPlugin { + private compilation: Compilation | undefined; + + constructor(private options: FindTestsPluginOptions) {} + + apply(compiler: Compiler): void { + const { include = ['**/*.spec.ts'], projectSourceRoot, workspaceRoot } = this.options; + const webpackOptions = compiler.options; + const entry = + typeof webpackOptions.entry === 'function' ? webpackOptions.entry() : webpackOptions.entry; + + let originalImport: string[] | undefined; + + // Add tests files are part of the entry-point. + webpackOptions.entry = async () => { + const specFiles = await findTests(include, workspaceRoot, projectSourceRoot); + + if (!specFiles.length) { + assert(this.compilation, 'Compilation cannot be undefined.'); + addError( + this.compilation, + `Specified patterns: "${include.join(', ')}" did not match any spec files.`, + ); + } + + const entrypoints = await entry; + const entrypoint = entrypoints['main']; + if (!entrypoint.import) { + throw new Error(`Cannot find 'main' entrypoint.`); + } + + originalImport ??= entrypoint.import; + entrypoint.import = [...originalImport, ...specFiles]; + + return entrypoints; + }; + + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => { + this.compilation = compilation; + compilation.contextDependencies.add(projectSourceRoot); + }); + } +} + // go through all patterns and find unique list of files -export async function findTests( +async function findTests( patterns: string[], workspaceRoot: string, projectSourceRoot: string, @@ -37,6 +95,10 @@ async function findMatchingTests( ): Promise { // normalize pattern, glob lib only accepts forward slashes let normalizedPattern = normalizePath(pattern); + if (normalizedPattern.charAt(0) === '/') { + normalizedPattern = normalizedPattern.substring(1); + } + const relativeProjectRoot = normalizePath(relative(workspaceRoot, projectSourceRoot) + '/'); // remove relativeProjectRoot to support relative paths from root @@ -54,12 +116,13 @@ async function findMatchingTests( const fileExt = extname(normalizedPattern); // Replace extension to `.spec.ext`. Example: `src/app/app.component.ts`-> `src/app/app.component.spec.ts` const potentialSpec = join( + projectSourceRoot, dirname(normalizedPattern), `${basename(normalizedPattern, fileExt)}.spec${fileExt}`, ); - if (await exists(join(projectSourceRoot, potentialSpec))) { - return [normalizePath(potentialSpec)]; + if (await exists(potentialSpec)) { + return [potentialSpec]; } } } @@ -68,6 +131,7 @@ async function findMatchingTests( cwd: projectSourceRoot, root: projectSourceRoot, nomount: true, + absolute: true, }); } diff --git a/packages/angular_devkit/build_angular/src/builders/karma/index.ts b/packages/angular_devkit/build_angular/src/builders/karma/index.ts index db58042a8406..d8ca058507d8 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/index.ts @@ -17,9 +17,8 @@ import { purgeStaleBuildCache } from '../../utils/purge-cache'; import { assertCompatibleAngularVersion } from '../../utils/version'; import { generateBrowserWebpackConfigFromContext } from '../../utils/webpack-browser-config'; import { getCommonConfig, getStylesConfig } from '../../webpack/configs'; -import { SingleTestTransformLoader } from '../../webpack/plugins/single-test-transform'; import { Schema as BrowserBuilderOptions, OutputHashing } from '../browser/schema'; -import { findTests } from './find-tests'; +import { FindTestsPlugin } from './find-tests-plugin'; import { Schema as KarmaBuilderOptions } from './schema'; export type KarmaConfigOptions = ConfigOptions & { @@ -62,10 +61,7 @@ async function initialize( const karma = await import('karma'); - return [ - karma, - webpackConfigurationTransformer ? await webpackConfigurationTransformer(config) : config, - ]; + return [karma, (await webpackConfigurationTransformer?.(config)) ?? config]; } /** @@ -110,45 +106,22 @@ export function execute( } } - // prepend special webpack loader that will transform test.ts - if (options.include?.length) { - const projectName = context.target?.project; - if (!projectName) { - throw new Error('The builder requires a target.'); - } + const projectName = context.target?.project; + if (!projectName) { + throw new Error('The builder requires a target.'); + } - const projectMetadata = await context.getProjectMetadata(projectName); - const sourceRoot = (projectMetadata.sourceRoot ?? projectMetadata.root ?? '') as string; - const projectSourceRoot = path.join(context.workspaceRoot, sourceRoot); + const projectMetadata = await context.getProjectMetadata(projectName); + const sourceRoot = (projectMetadata.sourceRoot ?? projectMetadata.root ?? '') as string; - const files = await findTests(options.include, context.workspaceRoot, projectSourceRoot); - // early exit, no reason to start karma - if (!files.length) { - throw new Error( - `Specified patterns: "${options.include.join(', ')}" did not match any spec files.`, - ); - } - - // Get the rules and ensure the Webpack configuration is setup properly - const rules = webpackConfig.module?.rules || []; - if (!webpackConfig.module) { - webpackConfig.module = { rules }; - } else if (!webpackConfig.module.rules) { - webpackConfig.module.rules = rules; - } - - rules.unshift({ - test: path.resolve(context.workspaceRoot, options.main), - use: { - // cannot be a simple path as it differs between environments - loader: SingleTestTransformLoader, - options: { - files, - logger: context.logger, - }, - }, - }); - } + webpackConfig.plugins ??= []; + webpackConfig.plugins.push( + new FindTestsPlugin({ + include: options.include, + workspaceRoot: context.workspaceRoot, + projectSourceRoot: path.join(context.workspaceRoot, sourceRoot), + }), + ); karmaOptions.buildWebpack = { options, diff --git a/packages/angular_devkit/build_angular/src/builders/karma/schema.json b/packages/angular_devkit/build_angular/src/builders/karma/schema.json index 033b4712ca90..b5e44c9dace5 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/schema.json +++ b/packages/angular_devkit/build_angular/src/builders/karma/schema.json @@ -126,6 +126,7 @@ "items": { "type": "string" }, + "default": ["**/*.spec.ts"], "description": "Globs of files to include, relative to workspace or project root. \nThere are 2 special cases:\n - when a path to directory is provided, all spec files ending \".spec.@(ts|tsx)\" will be included\n - when a path to a file is provided, and a matching spec file exists it will be included instead." }, "sourceMap": { diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/options/include_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/options/include_spec.ts index bf0484eb28a6..0d9370b971a4 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/tests/options/include_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/tests/options/include_spec.ts @@ -17,11 +17,15 @@ describeBuilder(execute, KARMA_BUILDER_INFO, (harness) => { include: ['abc.spec.ts', 'def.spec.ts'], }); - const { error } = await harness.executeOnce({ - outputLogsOnException: false, - }); - expect(error?.message).toBe( - 'Specified patterns: "abc.spec.ts, def.spec.ts" did not match any spec files.', + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + 'Specified patterns: "abc.spec.ts, def.spec.ts" did not match any spec files.', + ), + }), ); }); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts index be04758a5bc6..a2ff8387deab 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -352,6 +352,8 @@ export async function getCommonConfig(wco: WebpackConfigOptions): Promise { logger.error(statsErrorsToString(statsJson, { colors: true })); - // Notify potential listeners of the compile error. - emitter.emit('compile_error', { - errors: statsJson.errors?.map((e) => e.message), - }); + if (config.singleRun) { + // Notify potential listeners of the compile error. + emitter.emit('load_error'); + } // Finish Karma run early in case of compilation error. emitter.emit('run_complete', [], { exitCode: 1 }); diff --git a/packages/angular_devkit/build_angular/src/webpack/plugins/single-test-transform.ts b/packages/angular_devkit/build_angular/src/webpack/plugins/single-test-transform.ts deleted file mode 100644 index c3f10ac56dd2..000000000000 --- a/packages/angular_devkit/build_angular/src/webpack/plugins/single-test-transform.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @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.io/license - */ - -import { logging, tags } from '@angular-devkit/core'; -import { extname } from 'path'; - -export interface SingleTestTransformLoaderOptions { - /* list of paths relative to the entry-point */ - files?: string[]; - logger?: logging.Logger; -} - -export const SingleTestTransformLoader = __filename; - -/** - * This loader transforms the default test file to only run tests - * for some specs instead of all specs. - * It works by replacing the known content of the auto-generated test file: - * const context = require.context('./', true, /\.spec\.ts$/); - * context.keys().map(context); - * with: - * const context = { keys: () => ({ map: (_a) => { } }) }; - * context.keys().map(context); - * So that it does nothing. - * Then it adds import statements for each file in the files options - * array to import them directly, and thus run the tests there. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default function loader( - this: import('webpack').LoaderContext, - source: string, -): string { - const { files = [], logger = console } = this.getOptions(); - // signal the user that expected content is not present. - if (!source.includes('require.context(')) { - logger.error(tags.stripIndent`The 'include' option requires that the 'main' file for tests includes the below line: - const context = require.context('./', true, /\.spec\.ts$/); - Arguments passed to require.context are not strict and can be changed.`); - - return source; - } - - const targettedImports = files - .map((path) => `require('./${path.replace('.' + extname(path), '')}');`) - .join('\n'); - - const mockedRequireContext = - 'Object.assign(() => { }, { keys: () => [], resolve: () => undefined });\n'; - source = source.replace(/require\.context\(.*/, mockedRequireContext + targettedImports); - - return source; -} diff --git a/packages/angular_devkit/build_angular/test/hello-world-app/src/test.ts b/packages/angular_devkit/build_angular/test/hello-world-app/src/test.ts index 149a98c3c157..a1358960f3e7 100644 --- a/packages/angular_devkit/build_angular/test/hello-world-app/src/test.ts +++ b/packages/angular_devkit/build_angular/test/hello-world-app/src/test.ts @@ -15,19 +15,8 @@ import { platformBrowserDynamicTesting, } from '@angular/platform-browser-dynamic/testing'; -declare const require: { - context(path: string, deep?: boolean, filter?: RegExp): { - keys(): string[]; - (id: string): T; - }; -}; - // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { errorOnUnknownElements: true, errorOnUnknownProperties: true }); -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().forEach(context); diff --git a/packages/angular_devkit/build_angular/test/hello-world-lib/projects/lib/src/test.ts b/packages/angular_devkit/build_angular/test/hello-world-lib/projects/lib/src/test.ts index a24258af75ba..88ee02586b8d 100644 --- a/packages/angular_devkit/build_angular/test/hello-world-lib/projects/lib/src/test.ts +++ b/packages/angular_devkit/build_angular/test/hello-world-lib/projects/lib/src/test.ts @@ -16,19 +16,8 @@ import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; -declare const require: { - context(path: string, deep?: boolean, filter?: RegExp): { - keys(): string[]; - (id: string): T; - }; -}; - // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { errorOnUnknownElements: true, errorOnUnknownProperties: true }); -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().forEach(context); diff --git a/packages/schematics/angular/application/files/src/test.ts.template b/packages/schematics/angular/application/files/src/test.ts.template index cac5aa3ea352..863549a71308 100644 --- a/packages/schematics/angular/application/files/src/test.ts.template +++ b/packages/schematics/angular/application/files/src/test.ts.template @@ -7,20 +7,8 @@ import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; -declare const require: { - context(path: string, deep?: boolean, filter?: RegExp): { - (id: string): T; - keys(): string[]; - }; -}; - // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { errorOnUnknownElements: true, errorOnUnknownProperties: true }); - -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().forEach(context); diff --git a/packages/schematics/angular/library/files/src/test.ts.template b/packages/schematics/angular/library/files/src/test.ts.template index 29e4db3829fe..406f37d5a7a5 100644 --- a/packages/schematics/angular/library/files/src/test.ts.template +++ b/packages/schematics/angular/library/files/src/test.ts.template @@ -8,20 +8,8 @@ import { platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; -declare const require: { - context(path: string, deep?: boolean, filter?: RegExp): { - (id: string): T; - keys(): string[]; - }; -}; - // First, initialize the Angular testing environment. getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { errorOnUnknownElements: true, errorOnUnknownProperties: true }); - -// Then we find all the tests. -const context = require.context('./', true, /\.spec\.ts$/); -// And load the modules. -context.keys().forEach(context);