Skip to content

Commit 7d5d0cf

Browse files
committed
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.
1 parent eaedbd8 commit 7d5d0cf

3 files changed

Lines changed: 72 additions & 85 deletions

File tree

packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts

Lines changed: 0 additions & 45 deletions
This file was deleted.

packages/angular/build/src/tools/babel/plugins/types.d.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
*/
88

99
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;
10+
export interface Instrumenter {
11+
instrumentSync(code: string, filename: string, inputSourceMap?: object): string;
12+
lastSourceMap(): object | undefined;
1313
}
1414

15-
export function programVisitor(
16-
types: typeof import('@babel/core').types,
17-
filePath?: string,
18-
options?: { inputSourceMap?: object | null },
19-
): Visitor;
15+
export function createInstrumenter(options?: {
16+
produceSourceMap?: boolean;
17+
esModules?: boolean;
18+
coverageVariable?: string;
19+
}): Instrumenter;
2020
}

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

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,61 @@ const textEncoder = new TextEncoder();
3636
*/
3737
const LINKER_DECLARATION_PREFIX = 'ɵɵngDeclare';
3838

39+
function extractInlineSourceMap(code: string): object | undefined {
40+
const match = code.match(
41+
/\/\/# sourceMappingURL=data:application\/json;(?:charset=utf-8;)?base64,(.*)$/m,
42+
);
43+
if (!match) {
44+
return undefined;
45+
}
46+
try {
47+
return JSON.parse(Buffer.from(match[1], 'base64').toString()) as object;
48+
} catch {
49+
return undefined;
50+
}
51+
}
52+
53+
async function instrumentCoverage(
54+
filename: string,
55+
data: string,
56+
useInputSourcemap: boolean,
57+
): Promise<string> {
58+
try {
59+
let resolvedPath = 'istanbul-lib-instrument';
60+
try {
61+
const requireFn = createRequire(filename);
62+
resolvedPath = requireFn.resolve('istanbul-lib-instrument');
63+
} catch {
64+
// Fallback to pool worker import traversal
65+
}
66+
67+
const { createInstrumenter } = (await import(
68+
resolvedPath
69+
)) as typeof import('istanbul-lib-instrument');
70+
const instrumenter = createInstrumenter({
71+
produceSourceMap: useInputSourcemap,
72+
esModules: true,
73+
});
74+
75+
const inputSourceMap = useInputSourcemap ? extractInlineSourceMap(data) : undefined;
76+
const instrumentedCode = instrumenter.instrumentSync(data, filename, inputSourceMap);
77+
const lastMap = instrumenter.lastSourceMap();
78+
79+
if (useInputSourcemap && lastMap) {
80+
const inlineMap = Buffer.from(JSON.stringify(lastMap)).toString('base64');
81+
82+
return instrumentedCode + `\n//# sourceMappingURL=data:application/json;base64,${inlineMap}`;
83+
}
84+
85+
return removeSourceMappingURL(instrumentedCode);
86+
} catch (error) {
87+
throw new Error(
88+
`The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`,
89+
{ cause: error },
90+
);
91+
}
92+
}
93+
3994
export default async function transformJavaScript(
4095
request: JavaScriptTransformRequest,
4196
): Promise<unknown> {
@@ -59,41 +114,18 @@ async function transformWithBabel(
59114
data: string,
60115
options: Omit<JavaScriptTransformRequest, 'filename' | 'data'>,
61116
): Promise<string> {
62-
const shouldLink = !options.skipLinker && (await requiresLinking(filename, data));
63117
const useInputSourcemap =
64118
options.sourcemap &&
65119
(!!options.thirdPartySourcemaps || !/[\\/]node_modules[\\/]/.test(filename));
66120

67-
const plugins: PluginItem[] = [];
68-
121+
let code = data;
69122
if (options.instrumentForCoverage) {
70-
try {
71-
let resolvedPath = 'istanbul-lib-instrument';
72-
try {
73-
const requireFn = createRequire(filename);
74-
resolvedPath = requireFn.resolve('istanbul-lib-instrument');
75-
} catch {
76-
// Fallback to pool worker import traversal
77-
}
78-
79-
const istanbul = await import(resolvedPath);
80-
const programVisitor = istanbul.programVisitor ?? istanbul.default?.programVisitor;
81-
82-
if (!programVisitor) {
83-
throw new Error('programVisitor is not available in istanbul-lib-instrument.');
84-
}
85-
86-
const { default: coveragePluginFactory } =
87-
await import('../babel/plugins/add-code-coverage.js');
88-
plugins.push(coveragePluginFactory(programVisitor) as unknown as PluginItem);
89-
} catch (error) {
90-
throw new Error(
91-
`The 'istanbul-lib-instrument' package is required for code coverage but was not found. Please install the package.`,
92-
{ cause: error },
93-
);
94-
}
123+
code = await instrumentCoverage(filename, code, useInputSourcemap);
95124
}
96125

126+
const shouldLink = !options.skipLinker && (await requiresLinking(filename, code));
127+
const plugins: PluginItem[] = [];
128+
97129
if (shouldLink) {
98130
// Lazy load the linker plugin only when linking is required
99131
const linkerPlugin = await createLinkerPlugin(options);
@@ -116,13 +148,13 @@ async function transformWithBabel(
116148
);
117149
}
118150

119-
// If no additional transformations are needed, return the data directly
151+
// If no additional transformations are needed, return the code directly
120152
if (plugins.length === 0) {
121153
// Strip sourcemaps if they should not be used
122-
return useInputSourcemap ? data : removeSourceMappingURL(data);
154+
return useInputSourcemap ? code : removeSourceMappingURL(code);
123155
}
124156

125-
const result = await transformAsync(data, {
157+
const result = await transformAsync(code, {
126158
filename,
127159
inputSourceMap: (useInputSourcemap ? undefined : false) as undefined,
128160
sourceMaps: useInputSourcemap ? 'inline' : false,
@@ -133,7 +165,7 @@ async function transformWithBabel(
133165
plugins,
134166
});
135167

136-
const outputCode = result?.code ?? data;
168+
const outputCode = result?.code ?? code;
137169

138170
// Strip sourcemaps if they should not be used.
139171
// Babel will keep the original comments even if sourcemaps are disabled.

0 commit comments

Comments
 (0)