From 0a4ef302635e4665ae9881746867dd80ca0d2dc7 Mon Sep 17 00:00:00 2001 From: Jan Martin Date: Thu, 26 Sep 2024 13:13:29 -0700 Subject: [PATCH] feat(@angular-devkit/build-angular): karma-coverage w/ app builder --- packages/angular/build/package.json | 1 + .../build/src/builders/application/options.ts | 9 ++++ .../tools/babel/plugins/add-code-coverage.ts | 43 +++++++++++++++++++ .../build/src/tools/babel/plugins/types.d.ts | 20 +++++++++ .../tools/esbuild/angular/compiler-plugin.ts | 3 ++ .../tools/esbuild/compiler-plugin-options.ts | 2 + .../esbuild/javascript-transformer-worker.ts | 8 +++- .../tools/esbuild/javascript-transformer.ts | 6 ++- .../src/builders/karma/application_builder.ts | 36 ++++++++++++++-- .../tests/behavior/code-coverage_spec.ts | 8 +--- .../options/code-coverage-exclude_spec.ts | 8 +--- .../karma/tests/options/code-coverage_spec.ts | 8 +--- yarn.lock | 1 + 13 files changed, 127 insertions(+), 26 deletions(-) create mode 100644 packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts create mode 100644 packages/angular/build/src/tools/babel/plugins/types.d.ts diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 6261920fd2f4..629cb18f60de 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -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", diff --git a/packages/angular/build/src/builders/application/options.ts b/packages/angular/build/src/builders/application/options.ts index 106b7c45617b..7cf261c47295 100644 --- a/packages/angular/build/src/builders/application/options.ts +++ b/packages/angular/build/src/builders/application/options.ts @@ -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. */ @@ -382,6 +389,7 @@ export async function normalizeOptions( define, partialSSRBuild = false, externalRuntimeStyles, + instrumentForCoverage, } = options; // Return all the normalized options @@ -444,6 +452,7 @@ export async function normalizeOptions( define, partialSSRBuild: usePartialSsrBuild || partialSSRBuild, externalRuntimeStyles, + instrumentForCoverage, }; } diff --git a/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts b/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts new file mode 100644 index 000000000000..efa95870f698 --- /dev/null +++ b/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts @@ -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(); + + 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); + }, + }, + }, + }; +} diff --git a/packages/angular/build/src/tools/babel/plugins/types.d.ts b/packages/angular/build/src/tools/babel/plugins/types.d.ts new file mode 100644 index 000000000000..4ff052dcb136 --- /dev/null +++ b/packages/angular/build/src/tools/babel/plugins/types.d.ts @@ -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): void; + exit(path: import('@babel/core').NodePath): void; + } + + export function programVisitor( + types: typeof import('@babel/core').types, + filePath?: string, + options?: { inputSourceMap?: object | null }, + ): Visitor; +} diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 8d2dd2837530..558a19e4c4b6 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -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 @@ -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 diff --git a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts index 4361d7f0cca7..37aa318dd36c 100644 --- a/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts +++ b/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts @@ -38,6 +38,7 @@ export function createCompilerPluginOptions( postcssConfiguration, publicPath, externalRuntimeStyles, + instrumentForCoverage, } = options; return { @@ -53,6 +54,7 @@ export function createCompilerPluginOptions( loadResultCache: sourceFileCache?.loadResultCache, incremental: !!options.watch, externalRuntimeStyles, + instrumentForCoverage, }, // Component stylesheet options styleOptions: { diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index 44396473f954..7bf29fc2e7a8 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -21,6 +21,7 @@ interface JavaScriptTransformRequest { skipLinker?: boolean; sideEffects?: boolean; jit: boolean; + instrumentForCoverage?: boolean; } const textDecoder = new TextDecoder(); @@ -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); } diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts index f8445ccbef03..2e323bd26f6e 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer.ts @@ -75,6 +75,7 @@ export class JavaScriptTransformer { filename: string, skipLinker?: boolean, sideEffects?: boolean, + instrumentForCoverage?: boolean, ): Promise { const data = await readFile(filename); @@ -105,6 +106,7 @@ export class JavaScriptTransformer { data, skipLinker, sideEffects, + instrumentForCoverage, ...this.#commonOptions, }, { @@ -141,10 +143,11 @@ export class JavaScriptTransformer { data: string, skipLinker: boolean, sideEffects?: boolean, + instrumentForCoverage?: boolean, ): Promise { // 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)); @@ -160,6 +163,7 @@ export class JavaScriptTransformer { data, skipLinker, sideEffects, + instrumentForCoverage, ...this.#commonOptions, }); } diff --git a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts index b80ae54e896b..3661cfc256ef 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/application_builder.ts @@ -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'; @@ -87,9 +88,8 @@ async function getProjectSourceRoot(context: BuilderContext): Promise { async function collectEntrypoints( options: KarmaBuilderOptions, context: BuilderContext, + projectSourceRoot: string, ): Promise<[Set, string[]]> { - const projectSourceRoot = await getProjectSourceRoot(context); - // Glob for files to test. const testFiles = await findTests( options.include ?? [], @@ -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( @@ -152,6 +160,7 @@ async function initializeApplication( styles: true, vendor: true, }, + instrumentForCoverage, styles: options.styles, polyfills, webWorkerTsConfig: options.webWorkerTsConfig, @@ -281,3 +290,24 @@ async function first(generator: AsyncIterable): Promise { throw new Error('Expected generator to emit at least once.'); } + +function createInstrumentationFilter(includedBasePath: string, excludedPaths: Set) { + 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 { + const excluded = new Set(); + + 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; +} diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/code-coverage_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/code-coverage_spec.ts index a19d3005fbdb..835f48724dbe 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/code-coverage_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/tests/behavior/code-coverage_spec.ts @@ -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); }); diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage-exclude_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage-exclude_spec.ts index f04352d0c90a..082275dfd0b6 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage-exclude_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage-exclude_spec.ts @@ -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); }); diff --git a/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage_spec.ts b/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage_spec.ts index 6eba93914f85..109cc30a4b56 100644 --- a/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage_spec.ts +++ b/packages/angular_devkit/build_angular/src/builders/karma/tests/options/code-coverage_spec.ts @@ -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); }); diff --git a/yarn.lock b/yarn.lock index 6f9269f42dff..5b34e95309f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"