Skip to content

Commit 0a4ef30

Browse files
committed
feat(@angular-devkit/build-angular): karma-coverage w/ app builder
1 parent 422e847 commit 0a4ef30

File tree

13 files changed

+127
-26
lines changed

13 files changed

+127
-26
lines changed

packages/angular/build/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"esbuild": "0.24.0",
3232
"fast-glob": "3.3.2",
3333
"https-proxy-agent": "7.0.5",
34+
"istanbul-lib-instrument": "6.0.3",
3435
"listr2": "8.2.4",
3536
"lmdb": "3.1.3",
3637
"magic-string": "0.30.11",

packages/angular/build/src/builders/application/options.ts

+9
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ interface InternalOptions {
9696
* styles.
9797
*/
9898
externalRuntimeStyles?: boolean;
99+
100+
/**
101+
* Enables instrumentation to collect code coverage data for specific files.
102+
*
103+
* Used exclusively for tests and shouldn't be used for other kinds of builds.
104+
*/
105+
instrumentForCoverage?: (filename: string) => boolean;
99106
}
100107

101108
/** Full set of options for `application` builder. */
@@ -382,6 +389,7 @@ export async function normalizeOptions(
382389
define,
383390
partialSSRBuild = false,
384391
externalRuntimeStyles,
392+
instrumentForCoverage,
385393
} = options;
386394

387395
// Return all the normalized options
@@ -444,6 +452,7 @@ export async function normalizeOptions(
444452
define,
445453
partialSSRBuild: usePartialSsrBuild || partialSSRBuild,
446454
externalRuntimeStyles,
455+
instrumentForCoverage,
447456
};
448457
}
449458

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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.dev/license
7+
*/
8+
9+
import { NodePath, PluginObj, types } from '@babel/core';
10+
import { Visitor, programVisitor } from 'istanbul-lib-instrument';
11+
import assert from 'node:assert';
12+
13+
/**
14+
* A babel plugin factory function for adding istanbul instrumentation.
15+
*
16+
* @returns A babel plugin object instance.
17+
*/
18+
export default function (): PluginObj {
19+
const visitors = new WeakMap<NodePath, Visitor>();
20+
21+
return {
22+
visitor: {
23+
Program: {
24+
enter(path, state) {
25+
const visitor = programVisitor(types, state.filename, {
26+
// Babel returns a Converter object from the `convert-source-map` package
27+
inputSourceMap: (state.file.inputMap as undefined | { toObject(): object })?.toObject(),
28+
});
29+
visitors.set(path, visitor);
30+
31+
visitor.enter(path);
32+
},
33+
exit(path) {
34+
const visitor = visitors.get(path);
35+
assert(visitor, 'Instrumentation visitor should always be present for program path.');
36+
37+
visitor.exit(path);
38+
visitors.delete(path);
39+
},
40+
},
41+
},
42+
};
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC 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.dev/license
7+
*/
8+
9+
declare module 'istanbul-lib-instrument' {
10+
export interface Visitor {
11+
enter(path: import('@babel/core').NodePath<types.Program>): void;
12+
exit(path: import('@babel/core').NodePath<types.Program>): void;
13+
}
14+
15+
export function programVisitor(
16+
types: typeof import('@babel/core').types,
17+
filePath?: string,
18+
options?: { inputSourceMap?: object | null },
19+
): Visitor;
20+
}

packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts

+3
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface CompilerPluginOptions {
5050
loadResultCache?: LoadResultCache;
5151
incremental: boolean;
5252
externalRuntimeStyles?: boolean;
53+
instrumentForCoverage?: (request: string) => boolean;
5354
}
5455

5556
// eslint-disable-next-line max-lines-per-function
@@ -441,11 +442,13 @@ export function createCompilerPlugin(
441442
// A string indicates untransformed output from the TS/NG compiler.
442443
// This step is unneeded when using esbuild transpilation.
443444
const sideEffects = await hasSideEffects(request);
445+
const instrumentForCoverage = pluginOptions.instrumentForCoverage?.(request);
444446
contents = await javascriptTransformer.transformData(
445447
request,
446448
contents,
447449
true /* skipLinker */,
448450
sideEffects,
451+
instrumentForCoverage,
449452
);
450453

451454
// Store as the returned Uint8Array to allow caching the fully transformed code

packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts

+2
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function createCompilerPluginOptions(
3838
postcssConfiguration,
3939
publicPath,
4040
externalRuntimeStyles,
41+
instrumentForCoverage,
4142
} = options;
4243

4344
return {
@@ -53,6 +54,7 @@ export function createCompilerPluginOptions(
5354
loadResultCache: sourceFileCache?.loadResultCache,
5455
incremental: !!options.watch,
5556
externalRuntimeStyles,
57+
instrumentForCoverage,
5658
},
5759
// Component stylesheet options
5860
styleOptions: {

packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface JavaScriptTransformRequest {
2121
skipLinker?: boolean;
2222
sideEffects?: boolean;
2323
jit: boolean;
24+
instrumentForCoverage?: boolean;
2425
}
2526

2627
const textDecoder = new TextDecoder();
@@ -64,8 +65,13 @@ async function transformWithBabel(
6465
const { default: importAttributePlugin } = await import('@babel/plugin-syntax-import-attributes');
6566
const plugins: PluginItem[] = [importAttributePlugin];
6667

67-
// Lazy load the linker plugin only when linking is required
68+
if (options.instrumentForCoverage) {
69+
const { default: coveragePlugin } = await import('../babel/plugins/add-code-coverage.js');
70+
plugins.push(coveragePlugin);
71+
}
72+
6873
if (shouldLink) {
74+
// Lazy load the linker plugin only when linking is required
6975
const linkerPlugin = await createLinkerPlugin(options);
7076
plugins.push(linkerPlugin);
7177
}

packages/angular/build/src/tools/esbuild/javascript-transformer.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export class JavaScriptTransformer {
7575
filename: string,
7676
skipLinker?: boolean,
7777
sideEffects?: boolean,
78+
instrumentForCoverage?: boolean,
7879
): Promise<Uint8Array> {
7980
const data = await readFile(filename);
8081

@@ -105,6 +106,7 @@ export class JavaScriptTransformer {
105106
data,
106107
skipLinker,
107108
sideEffects,
109+
instrumentForCoverage,
108110
...this.#commonOptions,
109111
},
110112
{
@@ -141,10 +143,11 @@ export class JavaScriptTransformer {
141143
data: string,
142144
skipLinker: boolean,
143145
sideEffects?: boolean,
146+
instrumentForCoverage?: boolean,
144147
): Promise<Uint8Array> {
145148
// Perform a quick test to determine if the data needs any transformations.
146149
// This allows directly returning the data without the worker communication overhead.
147-
if (skipLinker && !this.#commonOptions.advancedOptimizations) {
150+
if (skipLinker && !this.#commonOptions.advancedOptimizations && !instrumentForCoverage) {
148151
const keepSourcemap =
149152
this.#commonOptions.sourcemap &&
150153
(!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
@@ -160,6 +163,7 @@ export class JavaScriptTransformer {
160163
data,
161164
skipLinker,
162165
sideEffects,
166+
instrumentForCoverage,
163167
...this.#commonOptions,
164168
});
165169
}

packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts

+33-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@angular/build/private';
1616
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
1717
import { randomUUID } from 'crypto';
18+
import glob from 'fast-glob';
1819
import * as fs from 'fs/promises';
1920
import type { Config, ConfigOptions, InlinePluginDef } from 'karma';
2021
import * as path from 'path';
@@ -87,9 +88,8 @@ async function getProjectSourceRoot(context: BuilderContext): Promise<string> {
8788
async function collectEntrypoints(
8889
options: KarmaBuilderOptions,
8990
context: BuilderContext,
91+
projectSourceRoot: string,
9092
): Promise<[Set<string>, string[]]> {
91-
const projectSourceRoot = await getProjectSourceRoot(context);
92-
9393
// Glob for files to test.
9494
const testFiles = await findTests(
9595
options.include ?? [],
@@ -127,15 +127,23 @@ async function initializeApplication(
127127
}
128128

129129
const testDir = path.join(context.workspaceRoot, 'dist/test-out', randomUUID());
130+
const projectSourceRoot = await getProjectSourceRoot(context);
130131

131132
const [karma, [entryPoints, polyfills]] = await Promise.all([
132133
import('karma'),
133-
collectEntrypoints(options, context),
134+
collectEntrypoints(options, context, projectSourceRoot),
134135
fs.rm(testDir, { recursive: true, force: true }),
135136
]);
136137

137138
const outputPath = testDir;
138139

140+
const instrumentForCoverage = options.codeCoverage
141+
? createInstrumentationFilter(
142+
projectSourceRoot,
143+
getInstrumentationExcludedPaths(context.workspaceRoot, options.codeCoverageExclude ?? []),
144+
)
145+
: undefined;
146+
139147
// Build tests with `application` builder, using test files as entry points.
140148
const buildOutput = await first(
141149
buildApplicationInternal(
@@ -152,6 +160,7 @@ async function initializeApplication(
152160
styles: true,
153161
vendor: true,
154162
},
163+
instrumentForCoverage,
155164
styles: options.styles,
156165
polyfills,
157166
webWorkerTsConfig: options.webWorkerTsConfig,
@@ -281,3 +290,24 @@ async function first<T>(generator: AsyncIterable<T>): Promise<T> {
281290

282291
throw new Error('Expected generator to emit at least once.');
283292
}
293+
294+
function createInstrumentationFilter(includedBasePath: string, excludedPaths: Set<string>) {
295+
return (request: string): boolean => {
296+
return (
297+
!excludedPaths.has(request) &&
298+
!/\.(e2e|spec)\.tsx?$|[\\/]node_modules[\\/]/.test(request) &&
299+
request.startsWith(includedBasePath)
300+
);
301+
};
302+
}
303+
304+
function getInstrumentationExcludedPaths(root: string, excludedPaths: string[]): Set<string> {
305+
const excluded = new Set<string>();
306+
307+
for (const excludeGlob of excludedPaths) {
308+
const excludePath = excludeGlob[0] === '/' ? excludeGlob.slice(1) : excludeGlob;
309+
glob.sync(excludePath, { cwd: root }).forEach((p) => excluded.add(path.join(root, p)));
310+
}
311+
312+
return excluded;
313+
}

packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/code-coverage_spec.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,8 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup
2121

2222
const coveragePath = 'coverage/lcov.info';
2323

24-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
24+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
2525
describe('Behavior: "codeCoverage"', () => {
26-
if (isApplicationBuilder) {
27-
beforeEach(() => {
28-
pending('Code coverage not implemented yet for application builder');
29-
});
30-
}
31-
3226
beforeEach(async () => {
3327
await setupTarget(harness);
3428
});

packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage-exclude_spec.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,8 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup
1818

1919
const coveragePath = 'coverage/lcov.info';
2020

21-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
21+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
2222
describe('Option: "codeCoverageExclude"', () => {
23-
if (isApplicationBuilder) {
24-
beforeEach(() => {
25-
pending('Code coverage not implemented yet for application builder');
26-
});
27-
}
28-
2923
beforeEach(async () => {
3024
await setupTarget(harness);
3125
});

packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage_spec.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,8 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup
1919

2020
const coveragePath = 'coverage/lcov.info';
2121

22-
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
22+
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
2323
describe('Option: "codeCoverage"', () => {
24-
if (isApplicationBuilder) {
25-
beforeEach(() => {
26-
pending('Code coverage not implemented yet for application builder');
27-
});
28-
}
29-
3024
beforeEach(async () => {
3125
await setupTarget(harness);
3226
});

yarn.lock

+1
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ __metadata:
394394
esbuild: "npm:0.24.0"
395395
fast-glob: "npm:3.3.2"
396396
https-proxy-agent: "npm:7.0.5"
397+
istanbul-lib-instrument: "npm:6.0.3"
397398
listr2: "npm:8.2.4"
398399
lmdb: "npm:3.1.3"
399400
magic-string: "npm:0.30.11"

0 commit comments

Comments
 (0)