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
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
172 changes: 109 additions & 63 deletions packages/angular_devkit/build_angular/src/karma/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,14 +18,19 @@ 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';
import { generateBrowserWebpackConfigFromContext } from '../utils/webpack-browser-config';
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;
};
Expand All @@ -34,12 +40,12 @@ async function initialize(
context: BuilderContext,
webpackConfigurationTransformer?: ExecutionTransformer<webpack.Configuration>,
// 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),
Expand All @@ -54,6 +60,7 @@ async function initialize(
const karma = await import('karma');

return [
workspace,
karma,
webpackConfigurationTransformer ? await webpackConfigurationTransformer(config[0]) : config[0],
];
Expand All @@ -63,71 +70,110 @@ export function execute(
options: KarmaBuilderOptions,
context: BuilderContext,
transforms: {
webpackConfiguration?: ExecutionTransformer<webpack.Configuration>,
webpackConfiguration?: ExecutionTransformer<webpack.Configuration>;
// The karma options transform cannot be async without a refactor of the builder implementation
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions,
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
} = {},
): Observable<BuilderOutput> {
// Check Angular version.
assertCompatibleAngularVersion(context.workspaceRoot, context.logger);

return from(initialize(options, context, transforms.webpackConfiguration)).pipe(
switchMap(([karma, webpackConfig]) => new Observable<BuilderOutput>(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<string[]>((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<void>;

// 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<void> };
if (typeof karmaServerWithStop.stop === 'function') {
return karmaStart.then(() => karmaServerWithStop.stop());
}
};
})),
switchMap(
([workspace, karma, webpackConfig]) =>
new Observable<BuilderOutput>(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<string[]>((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<void>;

// 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<void> };
if (typeof karmaServerWithStop.stop === 'function') {
return karmaStart.then(() => karmaServerWithStop.stop());
}
};
}),
),
defaultIfEmpty({ success: false }),
);
}
Expand Down
7 changes: 7 additions & 0 deletions packages/angular_devkit/build_angular/src/karma/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading