From 7d5d0cf81836417e321f4fae10d4f2b312315c49 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 | 96 ++++++++++++------- 3 files changed, 72 insertions(+), 85 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 f2e5c0e64886..23b70ef9ea49 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -36,6 +36,61 @@ const textEncoder = new TextEncoder(); */ const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare'; +function extractInlineSourceMap(code: string): object | undefined { + const match = code.match( + /\/\/# sourceMappingURL=data:application\/json;(?:charset=utf-8;)?base64,(.*)$/m, + ); + if (!match) { + return undefined; + } + try { + return JSON.parse(Buffer.from(match[1], 'base64').toString()) as object; + } catch { + return undefined; + } +} + +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 ? extractInlineSourceMap(data) : undefined; + const instrumentedCode = instrumenter.instrumentSync(data, filename, inputSourceMap); + 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 { @@ -59,41 +114,18 @@ async function transformWithBabel( data: string, options: Omit, ): Promise { - const shouldLink = !options.skipLinker && (await requiresLinking(filename, data)); const useInputSourcemap = options.sourcemap && (!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename)); - const plugins: 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'); - plugins.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); } + const shouldLink = !options.skipLinker && (await requiresLinking(filename, code)); + const plugins: PluginItem[] = []; + if (shouldLink) { // Lazy load the linker plugin only when linking is required const linkerPlugin = await createLinkerPlugin(options); @@ -116,13 +148,13 @@ async function transformWithBabel( ); } - // If no additional transformations are needed, return the data directly + // If no additional transformations are needed, return the code directly if (plugins.length === 0) { // Strip sourcemaps if they should not be used - return useInputSourcemap ? data : removeSourceMappingURL(data); + return useInputSourcemap ? code : removeSourceMappingURL(code); } - const result = await transformAsync(data, { + const result = await transformAsync(code, { filename, inputSourceMap: (useInputSourcemap ? undefined : false) as undefined, sourceMaps: useInputSourcemap ? 'inline' : false, @@ -133,7 +165,7 @@ async function transformWithBabel( plugins, }); - const outputCode = result?.code ?? data; + const outputCode = result?.code ?? code; // Strip sourcemaps if they should not be used. // Babel will keep the original comments even if sourcemaps are disabled.