From c9844bec4c09d00ddeaac2a18f1f093a081b11d4 Mon Sep 17 00:00:00 2001 From: Aukevanoost Date: Fri, 12 Jun 2026 12:54:54 +0200 Subject: [PATCH] fix: Support for instrumentForCoverage --- .../angular/src/builders/build/builder.ts | 29 ++++++-- .../angular/src/builders/build/schema.d.ts | 2 + .../angular/src/builders/build/schema.json | 11 +++ .../src/utils/coverage-instrumentation.ts | 74 +++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 packages/angular/src/utils/coverage-instrumentation.ts diff --git a/packages/angular/src/builders/build/builder.ts b/packages/angular/src/builders/build/builder.ts index 7b951bc..f4702ea 100644 --- a/packages/angular/src/builders/build/builder.ts +++ b/packages/angular/src/builders/build/builder.ts @@ -42,6 +42,7 @@ import { import { type Plugin, type PluginBuild } from 'esbuild'; import { devHostInstancesPlugin } from '../../plugin/dev-host-instances-plugin.js'; import { createAngularBuildAdapter } from '../../utils/angular-esbuild-adapter.js'; +import { resolveInstrumentationFilter } from '../../utils/coverage-instrumentation.js'; import { getI18nConfig, translateFederationArtifacts } from '../../utils/i18n.js'; import { updateScriptTags } from '../../utils/update-index-html.js'; import { checkForInvalidImports } from './../../utils/check-for-invalid-imports.js'; @@ -69,7 +70,7 @@ process.stderr.write = function ( }; const createInternalAngularBuilder = - (externals: string[]) => + (externals: string[], opts?: { instrumentForCoverage?: (request: string) => boolean }) => ( options: Parameters[0], context: BuilderContext, @@ -90,6 +91,10 @@ const createInternalAngularBuilder = // pre-bundle packages that include native .node binaries. options.externalDependencies = [...(options.externalDependencies ?? []), ...externals]; + if (opts?.instrumentForCoverage) { + options.instrumentForCoverage = opts.instrumentForCoverage; + } + // Todo: share cache with Angular builder: https://github.com/angular/angular-cli/pull/32527 // options.codeBundleCache = nfOptions.federationCache.bundlerCache; return buildApplicationInternal(options, context, extensions); @@ -390,13 +395,18 @@ export async function* runBuilder( ngBuilderOptions.deleteOutputPath = false; + const instrumentForCoverage = await resolveInstrumentationFilter(context, { + instrumentForCoverage: nfBuilderOptions.instrumentForCoverage, + codeCoverageExclude: nfBuilderOptions.codeCoverageExclude, + }); + const appBuilderName = '@angular/build:application'; const builderRun = runViteServer ? serveWithVite( serverOptions as unknown as Parameters[0], appBuilderName, - createInternalAngularBuilder(externals), + createInternalAngularBuilder(externals, { instrumentForCoverage }), context, nfBuilderOptions.skipHtmlTransform ? {} @@ -406,10 +416,17 @@ export async function* runBuilder( middleware, } ) - : buildApplication(ngBuilderOptions, context, { - codePlugins: plugins, - indexHtmlTransformer: transformIndexHtml(nfBuilderOptions), - }); + : buildApplication( + { + ...ngBuilderOptions, + ...(instrumentForCoverage ? { instrumentForCoverage } : {}), + } as typeof ngBuilderOptions, + context, + { + codePlugins: plugins, + indexHtmlTransformer: transformIndexHtml(nfBuilderOptions), + } + ); const rebuildQueue = new RebuildQueue(); diff --git a/packages/angular/src/builders/build/schema.d.ts b/packages/angular/src/builders/build/schema.d.ts index 9828e99..62712ee 100644 --- a/packages/angular/src/builders/build/schema.d.ts +++ b/packages/angular/src/builders/build/schema.d.ts @@ -16,6 +16,8 @@ export interface NfBuilderSchema extends JsonObject { outputPath?: string; projectName?: string; ssr: boolean; + instrumentForCoverage?: boolean; + codeCoverageExclude?: string[]; tsConfig?: string; devServer?: boolean; entryPoints?: string[]; diff --git a/packages/angular/src/builders/build/schema.json b/packages/angular/src/builders/build/schema.json index ba352ff..c9bda25 100644 --- a/packages/angular/src/builders/build/schema.json +++ b/packages/angular/src/builders/build/schema.json @@ -63,6 +63,17 @@ "type": "string", "description": "A specific tsconfig file for the nf remotes and exposed modules." }, + "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": [] + }, "cacheExternalArtifacts": { "type": "boolean", "default": true, diff --git a/packages/angular/src/utils/coverage-instrumentation.ts b/packages/angular/src/utils/coverage-instrumentation.ts new file mode 100644 index 0000000..fc2844e --- /dev/null +++ b/packages/angular/src/utils/coverage-instrumentation.ts @@ -0,0 +1,74 @@ +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; +}