Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions packages/angular/src/builders/build/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,7 +70,7 @@ process.stderr.write = function (
};

const createInternalAngularBuilder =
(externals: string[]) =>
(externals: string[], opts?: { instrumentForCoverage?: (request: string) => boolean }) =>
(
options: Parameters<typeof buildApplicationInternal>[0],
context: BuilderContext,
Expand All @@ -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);
Expand Down Expand Up @@ -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<typeof serveWithVite>[0],
appBuilderName,
createInternalAngularBuilder(externals),
createInternalAngularBuilder(externals, { instrumentForCoverage }),
context,
nfBuilderOptions.skipHtmlTransform
? {}
Expand All @@ -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();

Expand Down
2 changes: 2 additions & 0 deletions packages/angular/src/builders/build/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
11 changes: 11 additions & 0 deletions packages/angular/src/builders/build/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions packages/angular/src/utils/coverage-instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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<string>
): (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<string> {
const excluded = new Set<string>();
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 <root>/src.
async function getProjectSourceRoot(context: BuilderContext): Promise<string> {
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;
}
Loading