Skip to content

Commit 84ffdf0

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

File tree

6 files changed

+283
-6
lines changed

6 files changed

+283
-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 => `import './${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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 * as glob from 'glob';
9+
10+
export function findSpecs(input: string, sourceRoot: string, cwd: string): string[] {
11+
12+
input = input.replace(/\\/g, '/'); // normalize input
13+
14+
// remove source root to support relative paths from root
15+
// such paths are easy to get when running script via IDEs
16+
if (sourceRoot && input.startsWith(sourceRoot + '/')) {
17+
input = input.substr(sourceRoot.length + 1); // +1 to include slash
18+
}
19+
20+
if (input.endsWith('.ts') && !input.endsWith('.spec.ts')) {
21+
input = input.substr(0, input.length - 2) + 'spec.ts';
22+
} else if (!input.endsWith('.spec.ts') && input.indexOf('*') === -1) {
23+
input += '**/*.spec.ts';
24+
}
25+
26+
const files = glob.sync(input, {
27+
cwd,
28+
});
29+
30+
return files;
31+
}

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

0 commit comments

Comments
 (0)