From 5e2b71aa311f4bd5a70af64968534fc80d33bb4e Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Tue, 23 Jun 2026 06:10:23 +0200 Subject: [PATCH] feat: Added support for instrumentForCoverage --- .../src/builders/build/builder.ts | 51 ++++++++---- .../src/builders/build/schema.d.ts | 2 + .../src/builders/build/schema.json | 11 +++ .../src/utils/coverage-instrumentation.ts | 83 +++++++++++++++++++ 4 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 libs/native-federation/src/utils/coverage-instrumentation.ts diff --git a/libs/native-federation/src/builders/build/builder.ts b/libs/native-federation/src/builders/build/builder.ts index f24eef7f..a02c8692 100644 --- a/libs/native-federation/src/builders/build/builder.ts +++ b/libs/native-federation/src/builders/build/builder.ts @@ -32,6 +32,7 @@ import { createAngularBuildAdapter, setMemResultHandler, } from '../../utils/angular-esbuild-adapter'; +import { resolveInstrumentationFilter } from '../../utils/coverage-instrumentation'; import { JsonObject } from '@angular-devkit/core'; import { existsSync, mkdirSync, rmSync } from 'fs'; @@ -78,17 +79,24 @@ process.stderr.write = function ( return originalWrite(chunk, encodingOrCallback as BufferEncoding, callback); }; -function _buildApplication(options, context, pluginsOrExtensions) { - let extensions; - if (pluginsOrExtensions && Array.isArray(pluginsOrExtensions)) { - extensions = { - codePlugins: pluginsOrExtensions, - }; - } else { - extensions = pluginsOrExtensions; - } - return buildApplicationInternal(options, context, extensions); -} +const createInternalAngularBuilder = + (opts?: { instrumentForCoverage?: (request: string) => boolean }) => + (options, context, pluginsOrExtensions) => { + let extensions; + if (pluginsOrExtensions && Array.isArray(pluginsOrExtensions)) { + extensions = { + codePlugins: pluginsOrExtensions, + }; + } else { + extensions = pluginsOrExtensions; + } + + if (opts?.instrumentForCoverage) { + options.instrumentForCoverage = opts.instrumentForCoverage; + } + + return buildApplicationInternal(options, context, extensions); + }; export async function* runBuilder( nfOptions: NfBuilderSchema, @@ -373,13 +381,18 @@ export async function* runBuilder( options.deleteOutputPath = false; + const instrumentForCoverage = await resolveInstrumentationFilter(context, { + instrumentForCoverage: nfOptions.instrumentForCoverage, + codeCoverageExclude: nfOptions.codeCoverageExclude, + }); + const appBuilderName = '@angular/build:application'; const builderRun = runServer ? serveWithVite( serverOptions, appBuilderName, - _buildApplication, + createInternalAngularBuilder({ instrumentForCoverage }), context, nfOptions.skipHtmlTransform ? {} @@ -389,10 +402,16 @@ export async function* runBuilder( middleware, }, ) - : buildApplication(options, context, { - codePlugins: plugins as any, - indexHtmlTransformer: transformIndexHtml(nfOptions), - }); + : buildApplication( + instrumentForCoverage + ? ({ ...options, instrumentForCoverage } as unknown as typeof options) + : options, + context, + { + codePlugins: plugins as any, + indexHtmlTransformer: transformIndexHtml(nfOptions), + }, + ); const rebuildQueue = new RebuildQueue(); diff --git a/libs/native-federation/src/builders/build/schema.d.ts b/libs/native-federation/src/builders/build/schema.d.ts index 8250f5a5..394a61b0 100644 --- a/libs/native-federation/src/builders/build/schema.d.ts +++ b/libs/native-federation/src/builders/build/schema.d.ts @@ -16,6 +16,8 @@ export interface NfBuilderSchema extends JsonObject { baseHref?: string; outputPath?: string; ssr: boolean; + instrumentForCoverage?: boolean; + codeCoverageExclude?: string[]; devServer?: boolean; cacheExternalArtifacts?: boolean; } // eslint-disable-line diff --git a/libs/native-federation/src/builders/build/schema.json b/libs/native-federation/src/builders/build/schema.json index 772d1f0a..294ba045 100644 --- a/libs/native-federation/src/builders/build/schema.json +++ b/libs/native-federation/src/builders/build/schema.json @@ -61,6 +61,17 @@ "description": "uses federation for ssr in ApplicationBuilder too", "default": false }, + "instrumentForCoverage": { + "type": "boolean", + "description": "Enables Istanbul instrumentation of the served/built bundles to collect code coverage data for E2E tests (e.g. Cypress). Uses the same instrumentation filter as 'ng test --code-coverage'.", + "default": false + }, + "codeCoverageExclude": { + "type": "array", + "items": { "type": "string" }, + "description": "Globs (relative to the workspace root) of files to exclude from coverage instrumentation. Only applies when instrumentForCoverage is enabled.", + "default": [] + }, "devServer": { "type": "boolean", "description": "can be used to disable the dev server when dev=true" diff --git a/libs/native-federation/src/utils/coverage-instrumentation.ts b/libs/native-federation/src/utils/coverage-instrumentation.ts new file mode 100644 index 00000000..1c44d01f --- /dev/null +++ b/libs/native-federation/src/utils/coverage-instrumentation.ts @@ -0,0 +1,83 @@ +import { globSync } from 'node:fs'; +import * as path from 'node:path'; + +import { type BuilderContext } from '@angular-devkit/architect'; + +// Mirrors @angular/build's src/builders/karma/coverage.ts. Copied rather than +// imported: those helpers aren't re-exported from @angular/build/private and sit +// on an unstable internal path. + +export function createInstrumentationFilter( + includedBasePath: string, + excludedPaths: Set, +): (request: string) => boolean { + return (request: string): boolean => + !excludedPaths.has(request) && + !/\.(e2e|spec)\.tsx?$|[\\/]node_modules[\\/]|[\\/]\.angular[\\/]/.test( + request, + ) && + request.startsWith(includedBasePath); +} + +export function getInstrumentationExcludedPaths( + root: string, + excludedPaths: string[], +): Set { + const excluded = new Set(); + for (const excludeGlob of excludedPaths) { + const excludePath = + excludeGlob[0] === '/' ? excludeGlob.slice(1) : excludeGlob; + for (const p of globSync(excludePath, { cwd: root })) { + excluded.add(path.join(root, p)); + } + } + return excluded; +} + +export async function resolveInstrumentationFilter( + context: BuilderContext, + options: { instrumentForCoverage?: boolean; codeCoverageExclude?: string[] }, +): Promise<((request: string) => boolean) | undefined> { + if (!options.instrumentForCoverage) { + return undefined; + } + + const workspaceRoot = context.workspaceRoot; + + return createInstrumentationFilter( + await getProjectSourceRoot(context), + getInstrumentationExcludedPaths( + workspaceRoot, + options.codeCoverageExclude ?? [], + ), + ); +} + +// Mirrors @angular/build's getProjectSourceRoot: without a target, fall back to +// the workspace root; sourceRoot defaults to /src. +async function getProjectSourceRoot(context: BuilderContext): Promise { + const projectName = context.target?.project; + if (!projectName) { + return context.workspaceRoot; + } + + const projectMetadata = await context.getProjectMetadata(projectName); + const projectRoot = path.join( + context.workspaceRoot, + (projectMetadata['root'] as string) ?? '', + ); + const rawSourceRoot = projectMetadata['sourceRoot'] as string | undefined; + return normalizeDirectoryPath( + rawSourceRoot === undefined + ? path.join(projectRoot, 'src') + : path.join(context.workspaceRoot, rawSourceRoot), + ); +} + +function normalizeDirectoryPath(directoryPath: string): string { + const last = directoryPath.at(-1); + if (last === '/' || last === '\\') { + return directoryPath.slice(0, -1); + } + return directoryPath; +}