Skip to content

Commit 998a5eb

Browse files
committed
feat(@angular-devkit/build-angular): option to build and test only specified spec files
1 parent 5df02a3 commit 998a5eb

File tree

6 files changed

+301
-6
lines changed

6 files changed

+301
-6
lines changed
Lines changed: 58 additions & 0 deletions
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[];
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 'spec' 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+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
export function findSpecs(pattern: string, sourceRoot: string, cwd: string): string[] {
14+
15+
// normalize pattern, glob lib only accepts forward slashes
16+
pattern = pattern.replace(/\\/g, '/');
17+
18+
// remove source root to support relative paths from root
19+
// such paths are easy to get when running script via IDEs
20+
if (sourceRoot && pattern.startsWith(sourceRoot + '/')) {
21+
pattern = pattern.substr(sourceRoot.length + 1); // +1 to include slash
22+
}
23+
24+
// special logic when pattern does not look like a glob
25+
if (!glob.hasMagic(pattern)) {
26+
if (isDirectory(join(cwd, pattern))) {
27+
pattern = `${pattern}/**/*.spec.@(ts|tsx)`;
28+
} else {
29+
// see if matching spec file exists
30+
const extension = extname(pattern);
31+
const matchingSpec = `${basename(pattern, extension)}.spec${extension}`;
32+
33+
if (existsSync(join(cwd, dirname(pattern), matchingSpec))) {
34+
pattern = join(dirname(pattern), matchingSpec).replace(/\\/g, '/');
35+
}
36+
}
37+
}
38+
39+
const files = glob.sync(pattern, {
40+
cwd,
41+
});
42+
43+
return files;
44+
}

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

Lines changed: 46 additions & 4 deletions
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 { switchMap } from 'rxjs/operators';
1213
import * as webpack from 'webpack';
@@ -17,6 +18,11 @@ 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 { findSpecs } from '../angular-cli-files/utilities/find-specs';
2026
import { Schema as BrowserBuilderOptions } from '../browser/schema';
2127
import { ExecutionTransformer } from '../transforms';
2228
import { Version } from '../utils/version';
@@ -33,9 +39,13 @@ async function initialize(
3339
options: KarmaBuilderOptions,
3440
context: BuilderContext,
3541
webpackConfigurationTransformer?: ExecutionTransformer<webpack.Configuration>,
42+
): Promise<[
43+
experimental.workspace.Workspace,
44+
string,
3645
// tslint:disable-next-line:no-implicit-dependencies
37-
): Promise<[typeof import ('karma'), webpack.Configuration]> {
38-
const { config } = await generateBrowserWebpackConfigFromContext(
46+
typeof import ('karma'), webpack.Configuration
47+
]> {
48+
const { config, workspace, projectName } = await generateBrowserWebpackConfigFromContext(
3949
// only two properties are missing:
4050
// * `outputPath` which is fixed for tests
4151
// * `budgets` which might be incorrect due to extra dev libs
@@ -54,6 +64,8 @@ async function initialize(
5464
const karma = await import('karma');
5565

5666
return [
67+
workspace,
68+
projectName,
5769
karma,
5870
webpackConfigurationTransformer ? await webpackConfigurationTransformer(config[0]) : config[0],
5971
];
@@ -72,7 +84,12 @@ export function execute(
7284
Version.assertCompatibleAngularVersion(context.workspaceRoot);
7385

7486
return from(initialize(options, context, transforms.webpackConfiguration)).pipe(
75-
switchMap(([karma, webpackConfig]) => new Observable<BuilderOutput>(subscriber => {
87+
switchMap(([
88+
workspace,
89+
projectName,
90+
karma,
91+
webpackConfig,
92+
]) => new Observable<BuilderOutput>(subscriber => {
7693
const karmaOptions: KarmaConfigOptions = {};
7794

7895
if (options.watch !== undefined) {
@@ -95,6 +112,31 @@ export function execute(
95112
}
96113
}
97114

115+
// prepend special webpack loader that will transform test.ts
116+
if (webpackConfig && webpackConfig.module && options.spec) {
117+
const sourceRoot = workspace.getProject(projectName).sourceRoot || '';
118+
const mainFilePath = getSystemPath(join(workspace.root, options.main));
119+
const files = findSpecs(options.spec, sourceRoot, dirname(mainFilePath));
120+
// early exit, no reason to start karma
121+
if (!files.length) {
122+
subscriber.error(`Specified pattern: "${options.spec}" did not match any spec files`);
123+
124+
return;
125+
}
126+
127+
webpackConfig.module.rules.unshift({
128+
test: (path) => path === mainFilePath,
129+
use: {
130+
// cannot be a simple path as it differs between environments
131+
loader: SingleTestTransformLoader,
132+
options: {
133+
files,
134+
logger: context.logger,
135+
} as SingleTestTransformLoaderOptions,
136+
},
137+
});
138+
}
139+
98140
// Assign additional karmaConfig options to the local ngapp config
99141
karmaOptions.configFile = resolve(context.workspaceRoot, options.karmaConfig);
100142

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
"type": "string",
6464
"description": "Defines the build environment."
6565
},
66+
"spec": {
67+
"type": "string",
68+
"description": "Glob of files to include, relative to workspace or project root."
69+
},
6670
"sourceMap": {
6771
"description": "Output sourcemaps.",
6872
"default": true,

packages/angular_devkit/build_angular/src/utils/webpack-browser-config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,11 @@ export async function generateBrowserWebpackConfigFromContext(
169169
context: BuilderContext,
170170
webpackPartialGenerator: (wco: BrowserWebpackConfigOptions) => webpack.Configuration[],
171171
host: virtualFs.Host<fs.Stats> = new NodeJsSyncHost(),
172-
): Promise<{ workspace: experimental.workspace.Workspace, config: webpack.Configuration[] }> {
172+
): Promise<{
173+
workspace: experimental.workspace.Workspace,
174+
projectName: string,
175+
config: webpack.Configuration[],
176+
}> {
173177
const registry = new schema.CoreSchemaRegistry();
174178
registry.addPostTransform(schema.transforms.addUndefinedDefaults);
175179

@@ -195,5 +199,5 @@ export async function generateBrowserWebpackConfigFromContext(
195199
context.logger,
196200
);
197201

198-
return { workspace, config };
202+
return { workspace, projectName, config };
199203
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 { Architect } from '@angular-devkit/architect';
9+
import { logging } from '@angular-devkit/core';
10+
import { createArchitect, host, karmaTargetSpec } from '../utils';
11+
12+
describe('Karma Builder', () => {
13+
let architect: Architect;
14+
15+
beforeEach(async () => {
16+
await host.initialize().toPromise();
17+
architect = (await createArchitect(host.root())).architect;
18+
});
19+
20+
afterEach(() => host.restore().toPromise());
21+
22+
describe('with spec option', () => {
23+
24+
it('should fail when spec does not match any files', async () => {
25+
const overrides = {
26+
spec: 'abc.spec.ts',
27+
};
28+
const run = await architect.scheduleTarget(karmaTargetSpec, overrides);
29+
30+
await expectAsync(run.result)
31+
.toBeRejectedWith(`Specified pattern: "abc.spec.ts" did not match any spec files`);
32+
33+
await run.stop();
34+
});
35+
36+
it('should fail when main test file does not include require.context usage', async () => {
37+
38+
let lastErrorLogEntry: logging.LogEntry | undefined;
39+
const logger = new logging.Logger('test');
40+
logger.subscribe(m => {
41+
if (m.level === 'error') {
42+
lastErrorLogEntry = m;
43+
}
44+
});
45+
46+
const mockedRequireContext = '{ keys: () => ({ map: (_a) => { } }) };';
47+
const regex = /require\.context\(.*/;
48+
host.replaceInFile('src/test.ts', regex, mockedRequireContext);
49+
50+
const overrides = {
51+
spec: '**/*.spec.ts',
52+
};
53+
54+
const run = await architect.scheduleTarget(karmaTargetSpec, overrides, {
55+
logger,
56+
});
57+
58+
await expectAsync(run.result).toBeResolved();
59+
60+
expect(lastErrorLogEntry && lastErrorLogEntry.message)
61+
.toContain('const context = require.context');
62+
expect(lastErrorLogEntry && lastErrorLogEntry.message)
63+
// tslint:disable-next-line:max-line-length
64+
.toContain('The \'spec\' option requires that the \'main\' file for tests include the line below:');
65+
66+
await run.stop();
67+
});
68+
69+
beforeEach(() => {
70+
host.writeMultipleFiles({
71+
'src/app/services/test.service.spec.ts': `
72+
describe('TestService', () => {
73+
it('should succeed', () => {
74+
expect(true).toBe(true);
75+
});
76+
});`,
77+
'src/app/failing.service.spec.ts': `
78+
describe('FailingService', () => {
79+
it('should be ignored', () => {
80+
expect(true).toBe(false);
81+
});
82+
});`,
83+
'src/app/property.pipe.spec.ts': `
84+
describe('PropertyPipe', () => {
85+
it('should succeed', () => {
86+
expect(true).toBe(true);
87+
});
88+
});`,
89+
});
90+
});
91+
[
92+
{
93+
test: 'relative path from workspace to spec',
94+
input: 'src/app/app.component.spec.ts',
95+
},
96+
{
97+
test: 'relative path from workspace to file',
98+
input: 'src/app/app.component.ts',
99+
},
100+
{
101+
test: 'relative path from project root to spec',
102+
input: 'app/services/test.service.spec.ts',
103+
},
104+
{
105+
test: 'relative path from project root to file',
106+
input: 'app/services/test.service.ts',
107+
},
108+
{
109+
test: 'relative path from workspace to directory',
110+
input: 'src/app/services',
111+
},
112+
{
113+
test: 'relative path from project root to directory',
114+
input: 'app/services',
115+
},
116+
{
117+
test: 'glob with spec suffix',
118+
input: '**/*.pipe.spec.ts',
119+
},
120+
].forEach((options) => {
121+
122+
it(`should work with ${options.test}`, async () => {
123+
const overrides = {
124+
spec: options.input,
125+
};
126+
const logger = new logging.Logger('test');
127+
logger.subscribe((m) => {
128+
if (m.level === 'error') {
129+
fail(m);
130+
}
131+
});
132+
const run = await architect.scheduleTarget(karmaTargetSpec, overrides, {
133+
logger,
134+
});
135+
136+
await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true }));
137+
138+
await run.stop();
139+
}, 30000);
140+
});
141+
});
142+
143+
});

0 commit comments

Comments
 (0)