From 03078f188bf6fb799204d6bd6f794934ab841c10 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:33:57 -0400 Subject: [PATCH] refactor(@angular/build): use standalone Instrumenter API for code coverage This refactors the code coverage instrumentation in the esbuild pipeline to bypass Babel and use the standalone `Instrumenter` API from `istanbul-lib-instrument` directly. The instrumenter is only used by the Karma/Jasmine testing implementation. Since first-party code coverage instrumentation and third-party Angular linking are almost always mutually exclusive for any given file, we do not benefit from composing them into a single Babel pass. Using the high-level standalone API simplifies the transformer worker and allows us to completely remove the custom `add-code-coverage.ts` Babel plugin. --- .../tools/babel/plugins/add-code-coverage.ts | 45 ------ .../build/src/tools/babel/plugins/types.d.ts | 16 +-- .../esbuild/javascript-transformer-worker.ts | 133 ++++++++++-------- 3 files changed, 82 insertions(+), 112 deletions(-) delete mode 100644 packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts 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 deleted file mode 100644 index e549db97f8bb..000000000000 --- a/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * @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, PluginObject, PluginPass, types } from '@babel/core'; -import type { Visitor } 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 ( - programVisitor: typeof import('istanbul-lib-instrument').programVisitor, -): PluginObject { - const visitors = new WeakMap(); - - return { - visitor: { - Program: { - enter(path: NodePath, state: PluginPass) { - 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: NodePath) { - 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 index 4ff052dcb136..aa1f40580491 100644 --- a/packages/angular/build/src/tools/babel/plugins/types.d.ts +++ b/packages/angular/build/src/tools/babel/plugins/types.d.ts @@ -7,14 +7,14 @@ */ declare module 'istanbul-lib-instrument' { - export interface Visitor { - enter(path: import('@babel/core').NodePath): void; - exit(path: import('@babel/core').NodePath): void; + export interface Instrumenter { + instrumentSync(code: string, filename: string, inputSourceMap?: object): string; + lastSourceMap(): object | undefined; } - export function programVisitor( - types: typeof import('@babel/core').types, - filePath?: string, - options?: { inputSourceMap?: object | null }, - ): Visitor; + export function createInstrumenter(options?: { + produceSourceMap?: boolean; + esModules?: boolean; + coverageVariable?: string; + }): Instrumenter; } 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 de722d9c2243..a19c1ac282d0 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -10,7 +10,7 @@ import { type PluginItem, transformAsync } from '@babel/core'; import { createRequire } from 'node:module'; import Piscina from 'piscina'; import { useBabelLinker } from '../../utils/environment-options.js'; -import { removeSourceMappingURL } from '../../utils/source-map'; +import { loadInputSourceMap, removeSourceMappingURL } from '../../utils/source-map'; interface JavaScriptTransformRequest { filename: string; @@ -35,6 +35,51 @@ const textEncoder = new TextEncoder(); */ const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare'; +async function instrumentCoverage( + filename: string, + data: string, + useInputSourcemap: boolean, +): Promise { + try { + let resolvedPath = 'istanbul-lib-instrument'; + try { + const requireFn = createRequire(filename); + resolvedPath = requireFn.resolve('istanbul-lib-instrument'); + } catch { + // Fallback to pool worker import traversal + } + + const { createInstrumenter } = (await import( + resolvedPath + )) as typeof import('istanbul-lib-instrument'); + const instrumenter = createInstrumenter({ + produceSourceMap: useInputSourcemap, + esModules: true, + }); + + const inputSourceMap = useInputSourcemap ? loadInputSourceMap(filename, data) : undefined; + const instrumentedCode = instrumenter.instrumentSync( + data, + filename, + inputSourceMap as Parameters[2], + ); + const lastMap = instrumenter.lastSourceMap(); + + if (useInputSourcemap && lastMap) { + const inlineMap = Buffer.from(JSON.stringify(lastMap)).toString('base64'); + + return instrumentedCode + `\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`; + } + + return removeSourceMappingURL(instrumentedCode); + } catch (error) { + throw new Error( + `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`, + { cause: error }, + ); + } +} + export default async function transformJavaScript( request: JavaScriptTransformRequest, ): Promise { @@ -62,58 +107,43 @@ async function transformJavaScriptImpl( options.sourcemap && (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); - const babelPlugins: PluginItem[] = []; + let code = data; if (options.instrumentForCoverage) { - try { - let resolvedPath = 'istanbul-lib-instrument'; - try { - const requireFn = createRequire(filename); - resolvedPath = requireFn.resolve('istanbul-lib-instrument'); - } catch { - // Fallback to pool worker import traversal - } - - const istanbul = await import(resolvedPath); - const programVisitor = istanbul.programVisitor ?? istanbul.default?.programVisitor; - - if (!programVisitor) { - throw new Error('programVisitor is not available in istanbul-lib-instrument.'); - } - - const { default: coveragePluginFactory } = - await import('../babel/plugins/add-code-coverage.js'); - babelPlugins.push(coveragePluginFactory(programVisitor) as unknown as PluginItem); - } catch (error) { - throw new Error( - `The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`, - { cause: error }, - ); - } + code = await instrumentCoverage(filename, code, useInputSourcemap); } - let code = data; - if (shouldLink) { if (useBabelLinker) { const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel'); const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli'); - babelPlugins.push( - createEs2015LinkerPlugin({ - fileSystem: { - exists: () => false, - readFile: () => '', - resolve: (...paths: string[]) => paths.join('/'), - dirname: (path: string) => path.split('/').slice(0, -1).join('/'), - relative: (_from: string, to: string) => to, - } as never, - logger: new ConsoleLogger(LogLevel.info), - linkerJitMode: options.jit, - // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. - sourceMapping: false, - }) as PluginItem, - ); + const result = await transformAsync(code, { + filename, + inputSourceMap: (useInputSourcemap ? undefined : false) as undefined, + sourceMaps: useInputSourcemap ? 'inline' : false, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [ + createEs2015LinkerPlugin({ + fileSystem: { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, + } as never, + logger: new ConsoleLogger(LogLevel.info), + linkerJitMode: options.jit, + // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. + sourceMapping: false, + }) as PluginItem, + ], + }); + + code = result?.code ?? code; } else { oxcLinkerModule ??= await import('../angular/linker/oxc-linker.js'); const result = oxcLinkerModule.linkWithOxc(filename, code, { @@ -130,21 +160,6 @@ async function transformJavaScriptImpl( } } - // If Babel is needed for code coverage or babel linker fallback, run it - if (babelPlugins.length > 0) { - const result = await transformAsync(code, { - filename, - inputSourceMap: (useInputSourcemap ? undefined : false) as undefined, - sourceMaps: useInputSourcemap ? 'inline' : false, - compact: false, - configFile: false, - babelrc: false, - browserslistConfigFile: false, - plugins: babelPlugins, - }); - code = result?.code ?? code; - } - // Run advanced optimizations using our fast oxc-transform if (options.advancedOptimizations) { const { transform } = await import('../babel/plugins/oxc-transform.js');