Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): karma-coverage w/ app builder
Browse files Browse the repository at this point in the history
  • Loading branch information
jkrems committed Sep 27, 2024
1 parent 422e847 commit 0a4ef30
Show file tree
Hide file tree
Showing 13 changed files with 127 additions and 26 deletions.
1 change: 1 addition & 0 deletions packages/angular/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"esbuild": "0.24.0",
"fast-glob": "3.3.2",
"https-proxy-agent": "7.0.5",
"istanbul-lib-instrument": "6.0.3",
"listr2": "8.2.4",
"lmdb": "3.1.3",
"magic-string": "0.30.11",
Expand Down
9 changes: 9 additions & 0 deletions packages/angular/build/src/builders/application/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ interface InternalOptions {
* styles.
*/
externalRuntimeStyles?: boolean;

/**
* Enables instrumentation to collect code coverage data for specific files.
*
* Used exclusively for tests and shouldn't be used for other kinds of builds.
*/
instrumentForCoverage?: (filename: string) => boolean;
}

/** Full set of options for `application` builder. */
Expand Down Expand Up @@ -382,6 +389,7 @@ export async function normalizeOptions(
define,
partialSSRBuild = false,
externalRuntimeStyles,
instrumentForCoverage,
} = options;

// Return all the normalized options
Expand Down Expand Up @@ -444,6 +452,7 @@ export async function normalizeOptions(
define,
partialSSRBuild: usePartialSsrBuild || partialSSRBuild,
externalRuntimeStyles,
instrumentForCoverage,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import { NodePath, PluginObj, types } from '@babel/core';
import { Visitor, programVisitor } from 'istanbul-lib-instrument';
import assert from 'node:assert';

/**
* A babel plugin factory function for adding istanbul instrumentation.
*
* @returns A babel plugin object instance.
*/
export default function (): PluginObj {
const visitors = new WeakMap<NodePath, Visitor>();

return {
visitor: {
Program: {
enter(path, state) {
const visitor = programVisitor(types, state.filename, {
// Babel returns a Converter object from the `convert-source-map` package
inputSourceMap: (state.file.inputMap as undefined | { toObject(): object })?.toObject(),
});
visitors.set(path, visitor);

visitor.enter(path);
},
exit(path) {
const visitor = visitors.get(path);
assert(visitor, 'Instrumentation visitor should always be present for program path.');

visitor.exit(path);
visitors.delete(path);
},
},
},
};
}
20 changes: 20 additions & 0 deletions packages/angular/build/src/tools/babel/plugins/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

declare module 'istanbul-lib-instrument' {
export interface Visitor {
enter(path: import('@babel/core').NodePath<types.Program>): void;
exit(path: import('@babel/core').NodePath<types.Program>): void;
}

export function programVisitor(
types: typeof import('@babel/core').types,
filePath?: string,
options?: { inputSourceMap?: object | null },
): Visitor;
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export interface CompilerPluginOptions {
loadResultCache?: LoadResultCache;
incremental: boolean;
externalRuntimeStyles?: boolean;
instrumentForCoverage?: (request: string) => boolean;
}

// eslint-disable-next-line max-lines-per-function
Expand Down Expand Up @@ -441,11 +442,13 @@ export function createCompilerPlugin(
// A string indicates untransformed output from the TS/NG compiler.
// This step is unneeded when using esbuild transpilation.
const sideEffects = await hasSideEffects(request);
const instrumentForCoverage = pluginOptions.instrumentForCoverage?.(request);
contents = await javascriptTransformer.transformData(
request,
contents,
true /* skipLinker */,
sideEffects,
instrumentForCoverage,
);

// Store as the returned Uint8Array to allow caching the fully transformed code
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function createCompilerPluginOptions(
postcssConfiguration,
publicPath,
externalRuntimeStyles,
instrumentForCoverage,
} = options;

return {
Expand All @@ -53,6 +54,7 @@ export function createCompilerPluginOptions(
loadResultCache: sourceFileCache?.loadResultCache,
incremental: !!options.watch,
externalRuntimeStyles,
instrumentForCoverage,
},
// Component stylesheet options
styleOptions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface JavaScriptTransformRequest {
skipLinker?: boolean;
sideEffects?: boolean;
jit: boolean;
instrumentForCoverage?: boolean;
}

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

// Lazy load the linker plugin only when linking is required
if (options.instrumentForCoverage) {
const { default: coveragePlugin } = await import('../babel/plugins/add-code-coverage.js');
plugins.push(coveragePlugin);
}

if (shouldLink) {
// Lazy load the linker plugin only when linking is required
const linkerPlugin = await createLinkerPlugin(options);
plugins.push(linkerPlugin);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export class JavaScriptTransformer {
filename: string,
skipLinker?: boolean,
sideEffects?: boolean,
instrumentForCoverage?: boolean,
): Promise<Uint8Array> {
const data = await readFile(filename);

Expand Down Expand Up @@ -105,6 +106,7 @@ export class JavaScriptTransformer {
data,
skipLinker,
sideEffects,
instrumentForCoverage,
...this.#commonOptions,
},
{
Expand Down Expand Up @@ -141,10 +143,11 @@ export class JavaScriptTransformer {
data: string,
skipLinker: boolean,
sideEffects?: boolean,
instrumentForCoverage?: boolean,
): Promise<Uint8Array> {
// Perform a quick test to determine if the data needs any transformations.
// This allows directly returning the data without the worker communication overhead.
if (skipLinker && !this.#commonOptions.advancedOptimizations) {
if (skipLinker && !this.#commonOptions.advancedOptimizations && !instrumentForCoverage) {
const keepSourcemap =
this.#commonOptions.sourcemap &&
(!!this.#commonOptions.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
Expand All @@ -160,6 +163,7 @@ export class JavaScriptTransformer {
data,
skipLinker,
sideEffects,
instrumentForCoverage,
...this.#commonOptions,
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '@angular/build/private';
import { BuilderContext, BuilderOutput } from '@angular-devkit/architect';
import { randomUUID } from 'crypto';
import glob from 'fast-glob';
import * as fs from 'fs/promises';
import type { Config, ConfigOptions, InlinePluginDef } from 'karma';
import * as path from 'path';
Expand Down Expand Up @@ -87,9 +88,8 @@ async function getProjectSourceRoot(context: BuilderContext): Promise<string> {
async function collectEntrypoints(
options: KarmaBuilderOptions,
context: BuilderContext,
projectSourceRoot: string,
): Promise<[Set<string>, string[]]> {
const projectSourceRoot = await getProjectSourceRoot(context);

// Glob for files to test.
const testFiles = await findTests(
options.include ?? [],
Expand Down Expand Up @@ -127,15 +127,23 @@ async function initializeApplication(
}

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

const [karma, [entryPoints, polyfills]] = await Promise.all([
import('karma'),
collectEntrypoints(options, context),
collectEntrypoints(options, context, projectSourceRoot),
fs.rm(testDir, { recursive: true, force: true }),
]);

const outputPath = testDir;

const instrumentForCoverage = options.codeCoverage
? createInstrumentationFilter(
projectSourceRoot,
getInstrumentationExcludedPaths(context.workspaceRoot, options.codeCoverageExclude ?? []),
)
: undefined;

// Build tests with `application` builder, using test files as entry points.
const buildOutput = await first(
buildApplicationInternal(
Expand All @@ -152,6 +160,7 @@ async function initializeApplication(
styles: true,
vendor: true,
},
instrumentForCoverage,
styles: options.styles,
polyfills,
webWorkerTsConfig: options.webWorkerTsConfig,
Expand Down Expand Up @@ -281,3 +290,24 @@ async function first<T>(generator: AsyncIterable<T>): Promise<T> {

throw new Error('Expected generator to emit at least once.');
}

function createInstrumentationFilter(includedBasePath: string, excludedPaths: Set<string>) {
return (request: string): boolean => {
return (
!excludedPaths.has(request) &&
!/\.(e2e|spec)\.tsx?$|[\\/]node_modules[\\/]/.test(request) &&
request.startsWith(includedBasePath)
);
};
}

function getInstrumentationExcludedPaths(root: string, excludedPaths: string[]): Set<string> {
const excluded = new Set<string>();

for (const excludeGlob of excludedPaths) {
const excludePath = excludeGlob[0] === '/' ? excludeGlob.slice(1) : excludeGlob;
glob.sync(excludePath, { cwd: root }).forEach((p) => excluded.add(path.join(root, p)));
}

return excluded;
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,8 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup

const coveragePath = 'coverage/lcov.info';

describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
describe('Behavior: "codeCoverage"', () => {
if (isApplicationBuilder) {
beforeEach(() => {
pending('Code coverage not implemented yet for application builder');
});
}

beforeEach(async () => {
await setupTarget(harness);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,8 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup

const coveragePath = 'coverage/lcov.info';

describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
describe('Option: "codeCoverageExclude"', () => {
if (isApplicationBuilder) {
beforeEach(() => {
pending('Code coverage not implemented yet for application builder');
});
}

beforeEach(async () => {
await setupTarget(harness);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,8 @@ import { BASE_OPTIONS, KARMA_BUILDER_INFO, describeKarmaBuilder } from '../setup

const coveragePath = 'coverage/lcov.info';

describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget, isApplicationBuilder) => {
describeKarmaBuilder(execute, KARMA_BUILDER_INFO, (harness, setupTarget) => {
describe('Option: "codeCoverage"', () => {
if (isApplicationBuilder) {
beforeEach(() => {
pending('Code coverage not implemented yet for application builder');
});
}

beforeEach(async () => {
await setupTarget(harness);
});
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ __metadata:
esbuild: "npm:0.24.0"
fast-glob: "npm:3.3.2"
https-proxy-agent: "npm:7.0.5"
istanbul-lib-instrument: "npm:6.0.3"
listr2: "npm:8.2.4"
lmdb: "npm:3.1.3"
magic-string: "npm:0.30.11"
Expand Down

0 comments on commit 0a4ef30

Please sign in to comment.