diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/single-test-transform.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/single-test-transform.ts new file mode 100644 index 000000000000..ea8b09fdc038 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/plugins/single-test-transform.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright Google Inc. 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 } from '@angular-devkit/core'; +import { getOptions } from 'loader-utils'; +import { extname, join } from 'path'; +import { loader } from 'webpack'; + +export interface SingleTestTransformLoaderOptions { + files: string[]; // list of paths relative to main + logger: logging.Logger; +} + +export const SingleTestTransformLoader = require.resolve(join(__dirname, 'single-test-transform')); + +/** + * 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. + */ +export default function loader(this: loader.LoaderContext, source: string) { + const options = getOptions(this) as SingleTestTransformLoaderOptions; + const lineSeparator = process.platform === 'win32' ? '\r\n' : '\n'; + + const targettedImports = options.files + .map(path => `require('./${path.replace('.' + extname(path), '')}');`) + .join(lineSeparator); + + // TODO: maybe a documented 'marker/comment' inside test.ts would be nicer? + const regex = /require\.context\(.*/; + + // signal the user that expected content is not present + if (!regex.test(source)) { + const message = [ + `The 'include' option requires that the 'main' file for tests include the line below:`, + `const context = require.context('./', true, /\.spec\.ts$/);`, + `Arguments passed to require.context are not strict and can be changed`, + ]; + options.logger.error(message.join(lineSeparator)); + } + + const mockedRequireContext = '{ keys: () => ({ map: (_a) => { } }) };' + lineSeparator; + source = source.replace(regex, mockedRequireContext + targettedImports); + + return source; +} diff --git a/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/find-tests.ts b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/find-tests.ts new file mode 100644 index 000000000000..72ec0019f103 --- /dev/null +++ b/packages/angular_devkit/build_angular/src/angular-cli-files/utilities/find-tests.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google Inc. 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 { existsSync } from 'fs'; +import * as glob from 'glob'; +import { basename, dirname, extname, join } from 'path'; +import { isDirectory } from './is-directory'; + +// go through all patterns and find unique list of files +export function findTests(patterns: string[], cwd: string, workspaceRoot: string): string[] { + return patterns.reduce( + (files, pattern) => { + const relativePathToMain = cwd.replace(workspaceRoot, '').substr(1); // remove leading slash + const tests = findMatchingTests(pattern, cwd, relativePathToMain); + tests.forEach(file => { + if (!files.includes(file)) { + files.push(file); + } + }); + + return files; + }, + [] as string[], + ); +} + +function findMatchingTests(pattern: string, cwd: string, relativePathToMain: string): string[] { + // normalize pattern, glob lib only accepts forward slashes + pattern = pattern.replace(/\\/g, '/'); + relativePathToMain = relativePathToMain.replace(/\\/g, '/'); + + // remove relativePathToMain to support relative paths from root + // such paths are easy to get when running scripts via IDEs + if (pattern.startsWith(relativePathToMain + '/')) { + pattern = pattern.substr(relativePathToMain.length + 1); // +1 to include slash + } + + // special logic when pattern does not look like a glob + if (!glob.hasMagic(pattern)) { + if (isDirectory(join(cwd, pattern))) { + pattern = `${pattern}/**/*.spec.@(ts|tsx)`; + } else { + // see if matching spec file exists + const extension = extname(pattern); + const matchingSpec = `${basename(pattern, extension)}.spec${extension}`; + + if (existsSync(join(cwd, dirname(pattern), matchingSpec))) { + pattern = join(dirname(pattern), matchingSpec).replace(/\\/g, '/'); + } + } + } + + const files = glob.sync(pattern, { + cwd, + }); + + return files; +} diff --git a/packages/angular_devkit/build_angular/src/karma/index.ts b/packages/angular_devkit/build_angular/src/karma/index.ts index 134dee009d08..c95c400d6216 100644 --- a/packages/angular_devkit/build_angular/src/karma/index.ts +++ b/packages/angular_devkit/build_angular/src/karma/index.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; -import { resolve } from 'path'; +import { experimental, getSystemPath, join } from '@angular-devkit/core'; +import { dirname, resolve } from 'path'; import { Observable, from } from 'rxjs'; import { defaultIfEmpty, switchMap } from 'rxjs/operators'; import * as webpack from 'webpack'; @@ -17,6 +18,11 @@ import { getTestConfig, getWorkerConfig, } from '../angular-cli-files/models/webpack-configs'; +import { + SingleTestTransformLoader, + SingleTestTransformLoaderOptions, +} from '../angular-cli-files/plugins/single-test-transform'; +import { findTests } from '../angular-cli-files/utilities/find-tests'; import { Schema as BrowserBuilderOptions } from '../browser/schema'; import { ExecutionTransformer } from '../transforms'; import { assertCompatibleAngularVersion } from '../utils/version'; @@ -24,7 +30,7 @@ import { generateBrowserWebpackConfigFromContext } from '../utils/webpack-browse import { Schema as KarmaBuilderOptions } from './schema'; // tslint:disable-next-line:no-implicit-dependencies -export type KarmaConfigOptions = import ('karma').ConfigOptions & { +export type KarmaConfigOptions = import('karma').ConfigOptions & { buildWebpack?: unknown; configFile?: string; }; @@ -34,12 +40,12 @@ async function initialize( context: BuilderContext, webpackConfigurationTransformer?: ExecutionTransformer, // tslint:disable-next-line:no-implicit-dependencies -): Promise<[typeof import ('karma'), webpack.Configuration]> { - const { config } = await generateBrowserWebpackConfigFromContext( +): Promise<[experimental.workspace.Workspace, typeof import('karma'), webpack.Configuration]> { + const { config, workspace } = await generateBrowserWebpackConfigFromContext( // only two properties are missing: // * `outputPath` which is fixed for tests // * `budgets` which might be incorrect due to extra dev libs - { ...options as unknown as BrowserBuilderOptions, outputPath: '', budgets: undefined }, + { ...((options as unknown) as BrowserBuilderOptions), outputPath: '', budgets: undefined }, context, wco => [ getCommonConfig(wco), @@ -54,6 +60,7 @@ async function initialize( const karma = await import('karma'); return [ + workspace, karma, webpackConfigurationTransformer ? await webpackConfigurationTransformer(config[0]) : config[0], ]; @@ -63,71 +70,110 @@ export function execute( options: KarmaBuilderOptions, context: BuilderContext, transforms: { - webpackConfiguration?: ExecutionTransformer, + webpackConfiguration?: ExecutionTransformer; // The karma options transform cannot be async without a refactor of the builder implementation - karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions, + karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions; } = {}, ): Observable { // Check Angular version. assertCompatibleAngularVersion(context.workspaceRoot, context.logger); return from(initialize(options, context, transforms.webpackConfiguration)).pipe( - switchMap(([karma, webpackConfig]) => new Observable(subscriber => { - const karmaOptions: KarmaConfigOptions = {}; - - if (options.watch !== undefined) { - karmaOptions.singleRun = !options.watch; - } - - // Convert browsers from a string to an array - if (options.browsers) { - karmaOptions.browsers = options.browsers.split(','); - } - - if (options.reporters) { - // Split along commas to make it more natural, and remove empty strings. - const reporters = options.reporters - .reduce((acc, curr) => acc.concat(curr.split(',')), []) - .filter(x => !!x); - - if (reporters.length > 0) { - karmaOptions.reporters = reporters; - } - } - - // Assign additional karmaConfig options to the local ngapp config - karmaOptions.configFile = resolve(context.workspaceRoot, options.karmaConfig); - - karmaOptions.buildWebpack = { - options, - webpackConfig, - // Pass onto Karma to emit BuildEvents. - successCb: () => subscriber.next({ success: true }), - failureCb: () => subscriber.next({ success: false }), - // Workaround for https://github.com/karma-runner/karma/issues/3154 - // When this workaround is removed, user projects need to be updated to use a Karma - // version that has a fix for this issue. - toJSON: () => { }, - logger: context.logger, - }; - - // Complete the observable once the Karma server returns. - const karmaServer = new karma.Server( - transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions, - () => subscriber.complete()); - // karma typings incorrectly define start's return value as void - // tslint:disable-next-line:no-use-of-empty-return-value - const karmaStart = karmaServer.start() as unknown as Promise; - - // Cleanup, signal Karma to exit. - return () => { - // Karma only has the `stop` method start with 3.1.1, so we must defensively check. - const karmaServerWithStop = karmaServer as unknown as { stop: () => Promise }; - if (typeof karmaServerWithStop.stop === 'function') { - return karmaStart.then(() => karmaServerWithStop.stop()); - } - }; - })), + switchMap( + ([workspace, karma, webpackConfig]) => + new Observable(subscriber => { + const karmaOptions: KarmaConfigOptions = {}; + + if (options.watch !== undefined) { + karmaOptions.singleRun = !options.watch; + } + + // Convert browsers from a string to an array + if (options.browsers) { + karmaOptions.browsers = options.browsers.split(','); + } + + if (options.reporters) { + // Split along commas to make it more natural, and remove empty strings. + const reporters = options.reporters + .reduce((acc, curr) => acc.concat(curr.split(',')), []) + .filter(x => !!x); + + if (reporters.length > 0) { + karmaOptions.reporters = reporters; + } + } + + // prepend special webpack loader that will transform test.ts + if ( + webpackConfig && + webpackConfig.module && + options.include && + options.include.length > 0 + ) { + const mainFilePath = getSystemPath(join(workspace.root, options.main)); + const files = findTests( + options.include, + dirname(mainFilePath), + getSystemPath(workspace.root), + ); + // early exit, no reason to start karma + if (!files.length) { + subscriber.error( + `Specified patterns: "${options.include.join(', ')}" did not match any spec files`, + ); + + return; + } + + webpackConfig.module.rules.unshift({ + test: path => path === mainFilePath, + use: { + // cannot be a simple path as it differs between environments + loader: SingleTestTransformLoader, + options: { + files, + logger: context.logger, + } as SingleTestTransformLoaderOptions, + }, + }); + } + + // Assign additional karmaConfig options to the local ngapp config + karmaOptions.configFile = resolve(context.workspaceRoot, options.karmaConfig); + + karmaOptions.buildWebpack = { + options, + webpackConfig, + // Pass onto Karma to emit BuildEvents. + successCb: () => subscriber.next({ success: true }), + failureCb: () => subscriber.next({ success: false }), + // Workaround for https://github.com/karma-runner/karma/issues/3154 + // When this workaround is removed, user projects need to be updated to use a Karma + // version that has a fix for this issue. + toJSON: () => {}, + logger: context.logger, + }; + + // Complete the observable once the Karma server returns. + const karmaServer = new karma.Server( + transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions, + () => subscriber.complete(), + ); + // karma typings incorrectly define start's return value as void + // tslint:disable-next-line:no-use-of-empty-return-value + const karmaStart = (karmaServer.start() as unknown) as Promise; + + // Cleanup, signal Karma to exit. + return () => { + // Karma only has the `stop` method start with 3.1.1, so we must defensively check. + const karmaServerWithStop = (karmaServer as unknown) as { stop: () => Promise }; + if (typeof karmaServerWithStop.stop === 'function') { + return karmaStart.then(() => karmaServerWithStop.stop()); + } + }; + }), + ), defaultIfEmpty({ success: false }), ); } diff --git a/packages/angular_devkit/build_angular/src/karma/schema.json b/packages/angular_devkit/build_angular/src/karma/schema.json index 1f70c9976313..f5b8b64fddab 100644 --- a/packages/angular_devkit/build_angular/src/karma/schema.json +++ b/packages/angular_devkit/build_angular/src/karma/schema.json @@ -63,6 +63,13 @@ "type": "string", "description": "Defines the build environment." }, + "include": { + "type": "array", + "items": { + "type": "string" + }, + "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": { "description": "Output sourcemaps.", "default": true, diff --git a/packages/angular_devkit/build_angular/test/karma/selected_spec_large.ts b/packages/angular_devkit/build_angular/test/karma/selected_spec_large.ts new file mode 100644 index 000000000000..b42a586fe04d --- /dev/null +++ b/packages/angular_devkit/build_angular/test/karma/selected_spec_large.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google Inc. 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 { Architect } from '@angular-devkit/architect'; +import { logging } from '@angular-devkit/core'; +import { createArchitect, host, karmaTargetSpec } from '../utils'; + +describe('Karma Builder', () => { + let architect: Architect; + + beforeEach(async () => { + await host.initialize().toPromise(); + architect = (await createArchitect(host.root())).architect; + }); + + afterEach(() => host.restore().toPromise()); + + describe('with include option', () => { + it('should fail when include does not match any files', async () => { + const overrides = { + include: ['abc.spec.ts', 'def.spec.ts'], + }; + const run = await architect.scheduleTarget(karmaTargetSpec, overrides); + + await expectAsync(run.result).toBeRejectedWith( + `Specified patterns: "abc.spec.ts, def.spec.ts" did not match any spec files`, + ); + + await run.stop(); + }); + + it('should fail when main test file does not include require.context usage', async () => { + let lastErrorLogEntry: logging.LogEntry | undefined; + const logger = new logging.Logger('test'); + logger.subscribe(m => { + if (m.level === 'error') { + lastErrorLogEntry = m; + } + }); + + const mockedRequireContext = '{ keys: () => ({ map: (_a) => { } }) };'; + const regex = /require\.context\(.*/; + host.replaceInFile('src/test.ts', regex, mockedRequireContext); + + const overrides = { + include: ['**/*.spec.ts'], + }; + + const run = await architect.scheduleTarget(karmaTargetSpec, overrides, { + logger, + }); + + await expectAsync(run.result).toBeResolved(); + + expect(lastErrorLogEntry && lastErrorLogEntry.message).toContain( + 'const context = require.context', + ); + expect(lastErrorLogEntry && lastErrorLogEntry.message) + // tslint:disable-next-line:max-line-length + .toContain( + "The 'include' option requires that the 'main' file for tests include the line below:", + ); + + await run.stop(); + }); + + beforeEach(() => { + host.writeMultipleFiles({ + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + 'src/app/failing.service.spec.ts': ` + describe('FailingService', () => { + it('should be ignored', () => { + expect(true).toBe(false); + }); + });`, + 'src/app/property.pipe.spec.ts': ` + describe('PropertyPipe', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + }); + }); + [ + { + test: 'relative path from workspace to spec', + input: ['src/app/app.component.spec.ts'], + }, + { + test: 'relative path from workspace to file', + input: ['src/app/app.component.ts'], + }, + { + test: 'relative path from project root to spec', + input: ['app/services/test.service.spec.ts'], + }, + { + test: 'relative path from project root to file', + input: ['app/services/test.service.ts'], + }, + { + test: 'relative path from workspace to directory', + input: ['src/app/services'], + }, + { + test: 'relative path from project root to directory', + input: ['app/services'], + }, + { + test: 'glob with spec suffix', + input: ['**/*.pipe.spec.ts', '**/*.pipe.spec.ts', '**/*test.service.spec.ts'], + }, + ].forEach(options => { + it(`should work with ${options.test}`, async () => { + const overrides = { + include: options.input, + }; + const logger = new logging.Logger('test'); + logger.subscribe(m => { + if (m.level === 'error') { + fail(m); + } + }); + const run = await architect.scheduleTarget(karmaTargetSpec, overrides, { + logger, + }); + + await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true })); + + await run.stop(); + }, 30000); + }); + }); +});