Skip to content

Commit af2b1b5

Browse files
FDIMvikerman
authored andcommitted
feat(@angular-devkit/build-angular): option to build and test only specified spec files
1 parent 4151300 commit af2b1b5

File tree

5 files changed

+379
-63
lines changed

5 files changed

+379
-63
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { logging } from '@angular-devkit/core';
9+
import { getOptions } from 'loader-utils';
10+
import { extname, join } from 'path';
11+
import { loader } from 'webpack';
12+
13+
export interface SingleTestTransformLoaderOptions {
14+
files: string[]; // list of paths relative to main
15+
logger: logging.Logger;
16+
}
17+
18+
export const SingleTestTransformLoader = require.resolve(join(__dirname, 'single-test-transform'));
19+
20+
/**
21+
* This loader transforms the default test file to only run tests
22+
* for some specs instead of all specs.
23+
* It works by replacing the known content of the auto-generated test file:
24+
* const context = require.context('./', true, /\.spec\.ts$/);
25+
* context.keys().map(context);
26+
* with:
27+
* const context = { keys: () => ({ map: (_a) => { } }) };
28+
* context.keys().map(context);
29+
* So that it does nothing.
30+
* Then it adds import statements for each file in the files options
31+
* array to import them directly, and thus run the tests there.
32+
*/
33+
export default function loader(this: loader.LoaderContext, source: string) {
34+
const options = getOptions(this) as SingleTestTransformLoaderOptions;
35+
const lineSeparator = process.platform === 'win32' ? '\r\n' : '\n';
36+
37+
const targettedImports = options.files
38+
.map(path => `require('./${path.replace('.' + extname(path), '')}');`)
39+
.join(lineSeparator);
40+
41+
// TODO: maybe a documented 'marker/comment' inside test.ts would be nicer?
42+
const regex = /require\.context\(.*/;
43+
44+
// signal the user that expected content is not present
45+
if (!regex.test(source)) {
46+
const message = [
47+
`The 'include' option requires that the 'main' file for tests include the line below:`,
48+
`const context = require.context('./', true, /\.spec\.ts$/);`,
49+
`Arguments passed to require.context are not strict and can be changed`,
50+
];
51+
options.logger.error(message.join(lineSeparator));
52+
}
53+
54+
const mockedRequireContext = '{ keys: () => ({ map: (_a) => { } }) };' + lineSeparator;
55+
source = source.replace(regex, mockedRequireContext + targettedImports);
56+
57+
return source;
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { existsSync } from 'fs';
9+
import * as glob from 'glob';
10+
import { basename, dirname, extname, join } from 'path';
11+
import { isDirectory } from './is-directory';
12+
13+
// go through all patterns and find unique list of files
14+
export function findTests(patterns: string[], cwd: string, workspaceRoot: string): string[] {
15+
return patterns.reduce(
16+
(files, pattern) => {
17+
const relativePathToMain = cwd.replace(workspaceRoot, '').substr(1); // remove leading slash
18+
const tests = findMatchingTests(pattern, cwd, relativePathToMain);
19+
tests.forEach(file => {
20+
if (!files.includes(file)) {
21+
files.push(file);
22+
}
23+
});
24+
25+
return files;
26+
},
27+
[] as string[],
28+
);
29+
}
30+
31+
function findMatchingTests(pattern: string, cwd: string, relativePathToMain: string): string[] {
32+
// normalize pattern, glob lib only accepts forward slashes
33+
pattern = pattern.replace(/\\/g, '/');
34+
relativePathToMain = relativePathToMain.replace(/\\/g, '/');
35+
36+
// remove relativePathToMain to support relative paths from root
37+
// such paths are easy to get when running scripts via IDEs
38+
if (pattern.startsWith(relativePathToMain + '/')) {
39+
pattern = pattern.substr(relativePathToMain.length + 1); // +1 to include slash
40+
}
41+
42+
// special logic when pattern does not look like a glob
43+
if (!glob.hasMagic(pattern)) {
44+
if (isDirectory(join(cwd, pattern))) {
45+
pattern = `${pattern}/**/*.spec.@(ts|tsx)`;
46+
} else {
47+
// see if matching spec file exists
48+
const extension = extname(pattern);
49+
const matchingSpec = `${basename(pattern, extension)}.spec${extension}`;
50+
51+
if (existsSync(join(cwd, dirname(pattern), matchingSpec))) {
52+
pattern = join(dirname(pattern), matchingSpec).replace(/\\/g, '/');
53+
}
54+
}
55+
}
56+
57+
const files = glob.sync(pattern, {
58+
cwd,
59+
});
60+
61+
return files;
62+
}

packages/angular_devkit/build_angular/src/karma/index.ts

+109-63
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
9-
import { resolve } from 'path';
9+
import { experimental, getSystemPath, join } from '@angular-devkit/core';
10+
import { dirname, resolve } from 'path';
1011
import { Observable, from } from 'rxjs';
1112
import { defaultIfEmpty, switchMap } from 'rxjs/operators';
1213
import * as webpack from 'webpack';
@@ -17,14 +18,19 @@ import {
1718
getTestConfig,
1819
getWorkerConfig,
1920
} from '../angular-cli-files/models/webpack-configs';
21+
import {
22+
SingleTestTransformLoader,
23+
SingleTestTransformLoaderOptions,
24+
} from '../angular-cli-files/plugins/single-test-transform';
25+
import { findTests } from '../angular-cli-files/utilities/find-tests';
2026
import { Schema as BrowserBuilderOptions } from '../browser/schema';
2127
import { ExecutionTransformer } from '../transforms';
2228
import { assertCompatibleAngularVersion } from '../utils/version';
2329
import { generateBrowserWebpackConfigFromContext } from '../utils/webpack-browser-config';
2430
import { Schema as KarmaBuilderOptions } from './schema';
2531

2632
// tslint:disable-next-line:no-implicit-dependencies
27-
export type KarmaConfigOptions = import ('karma').ConfigOptions & {
33+
export type KarmaConfigOptions = import('karma').ConfigOptions & {
2834
buildWebpack?: unknown;
2935
configFile?: string;
3036
};
@@ -34,12 +40,12 @@ async function initialize(
3440
context: BuilderContext,
3541
webpackConfigurationTransformer?: ExecutionTransformer<webpack.Configuration>,
3642
// tslint:disable-next-line:no-implicit-dependencies
37-
): Promise<[typeof import ('karma'), webpack.Configuration]> {
38-
const { config } = await generateBrowserWebpackConfigFromContext(
43+
): Promise<[experimental.workspace.Workspace, typeof import('karma'), webpack.Configuration]> {
44+
const { config, workspace } = await generateBrowserWebpackConfigFromContext(
3945
// only two properties are missing:
4046
// * `outputPath` which is fixed for tests
4147
// * `budgets` which might be incorrect due to extra dev libs
42-
{ ...options as unknown as BrowserBuilderOptions, outputPath: '', budgets: undefined },
48+
{ ...((options as unknown) as BrowserBuilderOptions), outputPath: '', budgets: undefined },
4349
context,
4450
wco => [
4551
getCommonConfig(wco),
@@ -54,6 +60,7 @@ async function initialize(
5460
const karma = await import('karma');
5561

5662
return [
63+
workspace,
5764
karma,
5865
webpackConfigurationTransformer ? await webpackConfigurationTransformer(config[0]) : config[0],
5966
];
@@ -63,71 +70,110 @@ export function execute(
6370
options: KarmaBuilderOptions,
6471
context: BuilderContext,
6572
transforms: {
66-
webpackConfiguration?: ExecutionTransformer<webpack.Configuration>,
73+
webpackConfiguration?: ExecutionTransformer<webpack.Configuration>;
6774
// The karma options transform cannot be async without a refactor of the builder implementation
68-
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions,
75+
karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions;
6976
} = {},
7077
): Observable<BuilderOutput> {
7178
// Check Angular version.
7279
assertCompatibleAngularVersion(context.workspaceRoot, context.logger);
7380

7481
return from(initialize(options, context, transforms.webpackConfiguration)).pipe(
75-
switchMap(([karma, webpackConfig]) => new Observable<BuilderOutput>(subscriber => {
76-
const karmaOptions: KarmaConfigOptions = {};
77-
78-
if (options.watch !== undefined) {
79-
karmaOptions.singleRun = !options.watch;
80-
}
81-
82-
// Convert browsers from a string to an array
83-
if (options.browsers) {
84-
karmaOptions.browsers = options.browsers.split(',');
85-
}
86-
87-
if (options.reporters) {
88-
// Split along commas to make it more natural, and remove empty strings.
89-
const reporters = options.reporters
90-
.reduce<string[]>((acc, curr) => acc.concat(curr.split(',')), [])
91-
.filter(x => !!x);
92-
93-
if (reporters.length > 0) {
94-
karmaOptions.reporters = reporters;
95-
}
96-
}
97-
98-
// Assign additional karmaConfig options to the local ngapp config
99-
karmaOptions.configFile = resolve(context.workspaceRoot, options.karmaConfig);
100-
101-
karmaOptions.buildWebpack = {
102-
options,
103-
webpackConfig,
104-
// Pass onto Karma to emit BuildEvents.
105-
successCb: () => subscriber.next({ success: true }),
106-
failureCb: () => subscriber.next({ success: false }),
107-
// Workaround for https://github.com/karma-runner/karma/issues/3154
108-
// When this workaround is removed, user projects need to be updated to use a Karma
109-
// version that has a fix for this issue.
110-
toJSON: () => { },
111-
logger: context.logger,
112-
};
113-
114-
// Complete the observable once the Karma server returns.
115-
const karmaServer = new karma.Server(
116-
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
117-
() => subscriber.complete());
118-
// karma typings incorrectly define start's return value as void
119-
// tslint:disable-next-line:no-use-of-empty-return-value
120-
const karmaStart = karmaServer.start() as unknown as Promise<void>;
121-
122-
// Cleanup, signal Karma to exit.
123-
return () => {
124-
// Karma only has the `stop` method start with 3.1.1, so we must defensively check.
125-
const karmaServerWithStop = karmaServer as unknown as { stop: () => Promise<void> };
126-
if (typeof karmaServerWithStop.stop === 'function') {
127-
return karmaStart.then(() => karmaServerWithStop.stop());
128-
}
129-
};
130-
})),
82+
switchMap(
83+
([workspace, karma, webpackConfig]) =>
84+
new Observable<BuilderOutput>(subscriber => {
85+
const karmaOptions: KarmaConfigOptions = {};
86+
87+
if (options.watch !== undefined) {
88+
karmaOptions.singleRun = !options.watch;
89+
}
90+
91+
// Convert browsers from a string to an array
92+
if (options.browsers) {
93+
karmaOptions.browsers = options.browsers.split(',');
94+
}
95+
96+
if (options.reporters) {
97+
// Split along commas to make it more natural, and remove empty strings.
98+
const reporters = options.reporters
99+
.reduce<string[]>((acc, curr) => acc.concat(curr.split(',')), [])
100+
.filter(x => !!x);
101+
102+
if (reporters.length > 0) {
103+
karmaOptions.reporters = reporters;
104+
}
105+
}
106+
107+
// prepend special webpack loader that will transform test.ts
108+
if (
109+
webpackConfig &&
110+
webpackConfig.module &&
111+
options.include &&
112+
options.include.length > 0
113+
) {
114+
const mainFilePath = getSystemPath(join(workspace.root, options.main));
115+
const files = findTests(
116+
options.include,
117+
dirname(mainFilePath),
118+
getSystemPath(workspace.root),
119+
);
120+
// early exit, no reason to start karma
121+
if (!files.length) {
122+
subscriber.error(
123+
`Specified patterns: "${options.include.join(', ')}" did not match any spec files`,
124+
);
125+
126+
return;
127+
}
128+
129+
webpackConfig.module.rules.unshift({
130+
test: path => path === mainFilePath,
131+
use: {
132+
// cannot be a simple path as it differs between environments
133+
loader: SingleTestTransformLoader,
134+
options: {
135+
files,
136+
logger: context.logger,
137+
} as SingleTestTransformLoaderOptions,
138+
},
139+
});
140+
}
141+
142+
// Assign additional karmaConfig options to the local ngapp config
143+
karmaOptions.configFile = resolve(context.workspaceRoot, options.karmaConfig);
144+
145+
karmaOptions.buildWebpack = {
146+
options,
147+
webpackConfig,
148+
// Pass onto Karma to emit BuildEvents.
149+
successCb: () => subscriber.next({ success: true }),
150+
failureCb: () => subscriber.next({ success: false }),
151+
// Workaround for https://github.com/karma-runner/karma/issues/3154
152+
// When this workaround is removed, user projects need to be updated to use a Karma
153+
// version that has a fix for this issue.
154+
toJSON: () => {},
155+
logger: context.logger,
156+
};
157+
158+
// Complete the observable once the Karma server returns.
159+
const karmaServer = new karma.Server(
160+
transforms.karmaOptions ? transforms.karmaOptions(karmaOptions) : karmaOptions,
161+
() => subscriber.complete(),
162+
);
163+
// karma typings incorrectly define start's return value as void
164+
// tslint:disable-next-line:no-use-of-empty-return-value
165+
const karmaStart = (karmaServer.start() as unknown) as Promise<void>;
166+
167+
// Cleanup, signal Karma to exit.
168+
return () => {
169+
// Karma only has the `stop` method start with 3.1.1, so we must defensively check.
170+
const karmaServerWithStop = (karmaServer as unknown) as { stop: () => Promise<void> };
171+
if (typeof karmaServerWithStop.stop === 'function') {
172+
return karmaStart.then(() => karmaServerWithStop.stop());
173+
}
174+
};
175+
}),
176+
),
131177
defaultIfEmpty({ success: false }),
132178
);
133179
}

packages/angular_devkit/build_angular/src/karma/schema.json

+7
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@
6363
"type": "string",
6464
"description": "Defines the build environment."
6565
},
66+
"include": {
67+
"type": "array",
68+
"items": {
69+
"type": "string"
70+
},
71+
"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"
72+
},
6673
"sourceMap": {
6774
"description": "Output sourcemaps.",
6875
"default": true,

0 commit comments

Comments
 (0)