diff --git a/modules/testing/builder/BUILD.bazel b/modules/testing/builder/BUILD.bazel index 4fa8b7ee723b..45c39358c29c 100644 --- a/modules/testing/builder/BUILD.bazel +++ b/modules/testing/builder/BUILD.bazel @@ -21,6 +21,7 @@ ts_project( # resolvable in the test project. ":node_modules/@angular/ssr", ":node_modules/browser-sync", + ":node_modules/istanbul-lib-instrument", ":node_modules/jsdom", ":node_modules/ng-packagr", ":node_modules/vitest", diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json index 77d9ac942495..5ba604719d42 100644 --- a/modules/testing/builder/package.json +++ b/modules/testing/builder/package.json @@ -6,6 +6,7 @@ "@angular/ssr": "workspace:*", "@vitest/coverage-v8": "4.1.2", "browser-sync": "3.0.4", + "istanbul-lib-instrument": "6.0.3", "jsdom": "29.0.1", "ng-packagr": "22.0.0-next.1", "rxjs": "7.8.2", diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json index 68f134b63cf8..1541f7bd63cb 100644 --- a/packages/angular/build/package.json +++ b/packages/angular/build/package.json @@ -29,7 +29,6 @@ "browserslist": "^4.26.0", "esbuild": "0.27.3", "https-proxy-agent": "8.0.0", - "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", "listr2": "10.2.1", "magic-string": "0.30.21", @@ -51,6 +50,7 @@ "devDependencies": { "@angular-devkit/core": "workspace:*", "@angular/ssr": "workspace:*", + "istanbul-lib-instrument": "6.0.3", "jsdom": "29.0.1", "less": "4.6.4", "ng-packagr": "22.0.0-next.1", @@ -67,6 +67,7 @@ "@angular/platform-server": "0.0.0-ANGULAR-FW-PEER-DEP", "@angular/service-worker": "0.0.0-ANGULAR-FW-PEER-DEP", "@angular/ssr": "^0.0.0-PLACEHOLDER", + "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "0.0.0-NG-PACKAGR-PEER-DEP", @@ -95,6 +96,9 @@ "@angular/ssr": { "optional": true }, + "istanbul-lib-instrument": { + "optional": true + }, "karma": { "optional": true }, 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 index efa95870f698..7917c412b15d 100644 --- a/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts +++ b/packages/angular/build/src/tools/babel/plugins/add-code-coverage.ts @@ -7,7 +7,7 @@ */ import { NodePath, PluginObj, types } from '@babel/core'; -import { Visitor, programVisitor } from 'istanbul-lib-instrument'; +import type { Visitor } from 'istanbul-lib-instrument'; import assert from 'node:assert'; /** @@ -15,7 +15,9 @@ import assert from 'node:assert'; * * @returns A babel plugin object instance. */ -export default function (): PluginObj { +export default function ( + programVisitor: typeof import('istanbul-lib-instrument').programVisitor, +): PluginObj { const visitors = new WeakMap(); return { 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 7d86009b773f..dfb7cfb2087f 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -8,6 +8,7 @@ import { type PluginItem, transformAsync } from '@babel/core'; import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import Piscina from 'piscina'; @@ -66,8 +67,31 @@ async function transformWithBabel( const plugins: PluginItem[] = []; if (options.instrumentForCoverage) { - const { default: coveragePlugin } = await import('../babel/plugins/add-code-coverage.js'); - plugins.push(coveragePlugin); + 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)); + } 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 }, + ); + } } if (shouldLink) { diff --git a/packages/schematics/angular/migrations/add-istanbul-instrumenter/migration.ts b/packages/schematics/angular/migrations/add-istanbul-instrumenter/migration.ts new file mode 100644 index 000000000000..d3ccb16facd3 --- /dev/null +++ b/packages/schematics/angular/migrations/add-istanbul-instrumenter/migration.ts @@ -0,0 +1,52 @@ +/** + * @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 { Rule } from '@angular-devkit/schematics'; +import { DependencyType, ExistingBehavior, addDependency } from '../../utility/dependency'; +import { latestVersions } from '../../utility/latest-versions'; +import { allTargetOptions, getWorkspace } from '../../utility/workspace'; +import { Builders } from '../../utility/workspace-models'; + +export default function (): Rule { + return async (tree) => { + const workspace = await getWorkspace(tree); + let needInstrumenter = false; + + for (const [, project] of workspace.projects) { + for (const [, target] of project.targets) { + if (target.builder === Builders.Karma || target.builder === Builders.BuildKarma) { + needInstrumenter = true; + break; + } + + if (target.builder === Builders.BuildUnitTest) { + for (const [, options] of allTargetOptions(target)) { + if (options['runner'] === 'karma') { + needInstrumenter = true; + break; + } + } + } + + if (needInstrumenter) { + break; + } + } + if (needInstrumenter) { + break; + } + } + + if (needInstrumenter) { + return addDependency('istanbul-lib-instrument', latestVersions['istanbul-lib-instrument'], { + type: DependencyType.Dev, + existing: ExistingBehavior.Skip, + }); + } + }; +} diff --git a/packages/schematics/angular/migrations/add-istanbul-instrumenter/migration_spec.ts b/packages/schematics/angular/migrations/add-istanbul-instrumenter/migration_spec.ts new file mode 100644 index 000000000000..e9d855b29b9f --- /dev/null +++ b/packages/schematics/angular/migrations/add-istanbul-instrumenter/migration_spec.ts @@ -0,0 +1,103 @@ +/** + * @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 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; + +describe('Migration to add istanbul-lib-instrument', () => { + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + tree.create( + '/package.json', + JSON.stringify({ + devDependencies: {}, + }), + ); + }); + + function createWorkspace(builder: string, options?: any, configurations?: any) { + tree.create( + '/angular.json', + JSON.stringify({ + version: 1, + projects: { + app: { + root: '', + targets: { + test: { + builder, + options, + configurations, + }, + }, + }, + }, + }), + ); + } + + async function expectDependency(defined: boolean) { + const newTree = await schematicRunner.runSchematic('add-istanbul-instrumenter', {}, tree); + const { devDependencies } = newTree.readJson('/package.json') as any; + if (defined) { + expect(devDependencies['istanbul-lib-instrument']).toBeDefined(); + } else { + expect(devDependencies['istanbul-lib-instrument']).toBeUndefined(); + } + } + + it('should add istanbul-lib-instrument for @angular-devkit/build-angular:karma', async () => { + createWorkspace('@angular-devkit/build-angular:karma'); + + await expectDependency(true); + }); + + it('should add istanbul-lib-instrument for @angular/build:karma', async () => { + createWorkspace('@angular/build:karma'); + + await expectDependency(true); + }); + + it('should add istanbul-lib-instrument for @angular/build:unit-test with runner: karma', async () => { + createWorkspace('@angular/build:unit-test', { runner: 'karma' }); + + await expectDependency(true); + }); + + it('should add istanbul-lib-instrument if runner: karma is in configuration', async () => { + createWorkspace('@angular/build:unit-test', undefined, { ci: { runner: 'karma' } }); + + await expectDependency(true); + }); + + it('should NOT add istanbul-lib-instrument for @angular/build:unit-test with runner: vitest', async () => { + createWorkspace('@angular/build:unit-test', { runner: 'vitest' }); + + await expectDependency(false); + }); + + it('should NOT add istanbul-lib-instrument for @angular/build:unit-test with no runner specified (default vitest)', async () => { + createWorkspace('@angular/build:unit-test', {}); + + await expectDependency(false); + }); + + it('should NOT add istanbul-lib-instrument if no karma builder is used', async () => { + createWorkspace('@angular-devkit/build-angular:browser'); + + await expectDependency(false); + }); +}); diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index 3745792eb6cc..209f8519ef6f 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -1,6 +1,11 @@ { "encapsulation": false, "schematics": { + "add-istanbul-instrumenter": { + "version": "22.0.0", + "factory": "./add-istanbul-instrumenter/migration", + "description": "Add istanbul-lib-instrument to devDependencies if Karma unit testing is used." + }, "use-application-builder": { "version": "22.0.0", "factory": "./use-application-builder/migration", diff --git a/packages/schematics/angular/utility/dependencies.ts b/packages/schematics/angular/utility/dependencies.ts index b90ba8796975..0bd61807c390 100644 --- a/packages/schematics/angular/utility/dependencies.ts +++ b/packages/schematics/angular/utility/dependencies.ts @@ -97,6 +97,7 @@ export function addTestRunnerDependencies( 'karma-jasmine-html-reporter', 'jasmine-core', '@types/jasmine', + 'istanbul-lib-instrument', ]; return dependencies.map((name) => diff --git a/packages/schematics/angular/utility/latest-versions/package.json b/packages/schematics/angular/utility/latest-versions/package.json index a696fc09273c..c1b0dd2cd519 100644 --- a/packages/schematics/angular/utility/latest-versions/package.json +++ b/packages/schematics/angular/utility/latest-versions/package.json @@ -8,6 +8,7 @@ "@types/node": "^20.17.19", "browser-sync": "^3.0.0", "express": "^5.1.0", + "istanbul-lib-instrument": "^6.0.3", "jasmine-core": "~6.1.0", "jasmine-spec-reporter": "~7.0.0", "karma-chrome-launcher": "~3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcffea3de988..0df0d73ff002 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -319,6 +319,9 @@ importers: browser-sync: specifier: 3.0.4 version: 3.0.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) + istanbul-lib-instrument: + specifier: 6.0.3 + version: 6.0.3 jsdom: specifier: 29.0.1 version: 29.0.1 @@ -367,9 +370,6 @@ importers: https-proxy-agent: specifier: 8.0.0 version: 8.0.0 - istanbul-lib-instrument: - specifier: 6.0.3 - version: 6.0.3 jsonc-parser: specifier: 3.3.1 version: 3.3.1 @@ -419,6 +419,9 @@ importers: '@angular/ssr': specifier: workspace:* version: link:../ssr + istanbul-lib-instrument: + specifier: 6.0.3 + version: 6.0.3 jsdom: specifier: 29.0.1 version: 29.0.1