Skip to content

Commit 75f0a2b

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

File tree

6 files changed

+214
-5
lines changed

6 files changed

+214
-5
lines changed
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 { logging } from '@angular-devkit/core';
9+
import { getOptions } from 'loader-utils';
10+
import { loader } from 'webpack';
11+
12+
export interface SingleTestTransformLoaderOptions {
13+
files: string[];
14+
logger: logging.Logger;
15+
}
16+
17+
export default function loader(this: loader.LoaderContext, source: string) {
18+
const options = getOptions(this) as SingleTestTransformLoaderOptions;
19+
const start = 'import \'';
20+
const end = '\';';
21+
const testCode = start + options.files
22+
.map(path => `./${path.replace('.ts', '')}`)
23+
.join(`${end}\n${start}`) + end;
24+
25+
// TODO: maybe a documented 'marker/comment' inside test.ts would be nicer?
26+
let mockedRequireContext = '{ keys: () => ({ map: (_a) => { } }) };';
27+
mockedRequireContext += process.platform === 'win32' ? '\r\n' : '\n';
28+
source = source.replace(/require\.context\(.*/, mockedRequireContext + testCode);
29+
30+
return source;
31+
}
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 { Path, getSystemPath, join } from '@angular-devkit/core';
9+
import * as glob from 'glob';
10+
11+
export function findSpecs(input: string, workspaceRoot: Path, sourceRoot: string): string[] {
12+
13+
input = input.replace(/\\/g, '/'); // normalize input
14+
15+
// remove source root to support relative paths from root
16+
// such paths are easy to get when running script via IDEs
17+
if (sourceRoot && input.startsWith(sourceRoot + '/')) {
18+
input = input.substr(sourceRoot.length + 1); // +1 to include slash
19+
}
20+
21+
if (input.endsWith('.ts') && !input.endsWith('.spec.ts')) {
22+
input = input.substr(0, input.length - 2) + 'spec.ts';
23+
} else if (!input.endsWith('.spec.ts') && input.indexOf('*') === -1) {
24+
input += '**/*.spec.ts';
25+
}
26+
const files = glob.sync(input, {
27+
cwd: getSystemPath(join(workspaceRoot, sourceRoot)),
28+
});
29+
30+
return files;
31+
}

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
9+
import { experimental } from '@angular-devkit/core';
910
import { resolve } from 'path';
1011
import { Observable, from } from 'rxjs';
1112
import { switchMap } from 'rxjs/operators';
@@ -17,6 +18,10 @@ import {
1718
getTestConfig,
1819
getWorkerConfig,
1920
} from '../angular-cli-files/models/webpack-configs';
21+
import {
22+
SingleTestTransformLoaderOptions,
23+
} from '../angular-cli-files/plugins/single-test-transform';
24+
import { findSpecs } from '../angular-cli-files/utilities/find-specs';
2025
import { Schema as BrowserBuilderOptions } from '../browser/schema';
2126
import { ExecutionTransformer } from '../transforms';
2227
import { Version } from '../utils/version';
@@ -33,9 +38,13 @@ async function initialize(
3338
options: KarmaBuilderOptions,
3439
context: BuilderContext,
3540
webpackConfigurationTransformer?: ExecutionTransformer<webpack.Configuration>,
41+
): Promise<[
42+
experimental.workspace.Workspace,
43+
string,
3644
// tslint:disable-next-line:no-implicit-dependencies
37-
): Promise<[typeof import ('karma'), webpack.Configuration]> {
38-
const { config } = await generateBrowserWebpackConfigFromContext(
45+
typeof import ('karma'), webpack.Configuration
46+
]> {
47+
const { config, workspace, projectName } = await generateBrowserWebpackConfigFromContext(
3948
// only two properties are missing:
4049
// * `outputPath` which is fixed for tests
4150
// * `budgets` which might be incorrect due to extra dev libs
@@ -54,6 +63,8 @@ async function initialize(
5463
const karma = await import('karma');
5564

5665
return [
66+
workspace,
67+
projectName,
5768
karma,
5869
webpackConfigurationTransformer ? await webpackConfigurationTransformer(config[0]) : config[0],
5970
];
@@ -72,7 +83,12 @@ export function execute(
7283
Version.assertCompatibleAngularVersion(context.workspaceRoot);
7384

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

7894
if (options.watch !== undefined) {
@@ -95,6 +111,33 @@ export function execute(
95111
}
96112
}
97113

114+
// prepend special webpack loader that will transform test.ts
115+
if (webpackConfig && webpackConfig.module && options.spec) {
116+
const sourceRoot = workspace.getProject(projectName).sourceRoot || '';
117+
const files = findSpecs(options.spec, workspace.root, sourceRoot);
118+
// early exit, no reason to start karma
119+
if (!files.length) {
120+
subscriber.error(`Specified pattern: "${options.spec}" did not match any spec files`);
121+
122+
return;
123+
}
124+
// local unit test requires .ts extension
125+
// without one specs will fail with unclear error
126+
// release build does not need any extension
127+
const ext = __filename.endsWith('.ts') ? '.ts' : '';
128+
129+
webpackConfig.module.rules.unshift({
130+
test: /test\.ts$/,
131+
use: {
132+
loader: resolve(__dirname, '../angular-cli-files/plugins/single-test-transform' + ext),
133+
options: {
134+
files,
135+
logger: context.logger,
136+
} as SingleTestTransformLoaderOptions,
137+
},
138+
});
139+
}
140+
98141
// Assign additional karmaConfig options to the local ngapp config
99142
karmaOptions.configFile = resolve(context.workspaceRoot, options.karmaConfig);
100143

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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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 { createArchitect, host, karmaTargetSpec } from '../utils';
10+
11+
describe('Karma Builder', () => {
12+
let architect: Architect;
13+
14+
beforeEach(async () => {
15+
await host.initialize().toPromise();
16+
architect = (await createArchitect(host.root())).architect;
17+
});
18+
19+
afterEach(() => host.restore().toPromise());
20+
21+
it('should fail when spec does not match any files', async () => {
22+
const overrides = {
23+
spec: 'abc.spec.ts',
24+
};
25+
const run = await architect.scheduleTarget(karmaTargetSpec, overrides);
26+
27+
await expectAsync(run.result)
28+
.toBeRejectedWith(`Specified pattern: "abc.spec.ts" did not match any spec files`);
29+
30+
await run.stop();
31+
});
32+
33+
describe('selected tests', () => {
34+
beforeEach(() => {
35+
host.writeMultipleFiles({
36+
'src/app/services/test.service.spec.ts': `
37+
describe('TestService', () => {
38+
it('should succeed', () => {
39+
expect(true).toBe(true);
40+
});
41+
});`,
42+
'src/app/failing.service.spec.ts': `
43+
describe('FailingService', () => {
44+
it('should be ignored', () => {
45+
expect(true).toBe(false);
46+
});
47+
});`,
48+
'src/app/property.pipe.spec.ts': `
49+
describe('PropertyPipe', () => {
50+
it('should succeed', () => {
51+
expect(true).toBe(true);
52+
});
53+
});`,
54+
});
55+
});
56+
[
57+
{
58+
test: 'relative path from workspace to spec',
59+
input: 'src/app/app.component.spec.ts',
60+
},
61+
{
62+
test: 'relative path from project root to spec',
63+
input: 'app/services/test.service.spec.ts',
64+
},
65+
{
66+
test: 'relative path from workspace to spec',
67+
input: 'src/app/services',
68+
},
69+
{
70+
test: 'relative path from project root to spec',
71+
input: 'app/services',
72+
},
73+
{
74+
test: 'glob with spec suffix',
75+
input: '**/*.pipe.spec.ts',
76+
},
77+
{
78+
test: 'glob to typescript file',
79+
input: '**/app.component.ts',
80+
},
81+
].forEach((options) => {
82+
83+
it(`should work with ${options.test}`, async () => {
84+
const overrides = {
85+
spec: options.input,
86+
};
87+
const run = await architect.scheduleTarget(karmaTargetSpec, overrides);
88+
89+
await expectAsync(run.result).toBeResolvedTo(jasmine.objectContaining({ success: true }));
90+
91+
await run.stop();
92+
}, 30000);
93+
});
94+
});
95+
96+
});

0 commit comments

Comments
 (0)