diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01b1ee5c38a4..503b2567d3fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,30 @@
+
+
+# 20.0.1 (2025-06-04)
+
+### @angular/cli
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------ |
+| [0883248cb](https://github.com/angular/angular-cli/commit/0883248cbdebcad09393349a0a5d9487b2a452ae) | fix | improve Node.js version check and error messages |
+
+### @schematics/angular
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ---------------------------------------------------------------------- |
+| [525ddcbd2](https://github.com/angular/angular-cli/commit/525ddcbd290525e4dac2547c352cf6c774d728a2) | fix | only overwrite JSON file if actually changed |
+| [83c820e5a](https://github.com/angular/angular-cli/commit/83c820e5ab55d01662417a51e4cc8d094e409fc6) | fix | remove karma config devkit package usages during application migration |
+| [87266b38a](https://github.com/angular/angular-cli/commit/87266b38a09ce783ac6d18f532ebe1f8ae5954c0) | fix | skip zone.js dependency for zoneless applications |
+
+### @angular/build
+
+| Commit | Type | Description |
+| --------------------------------------------------------------------------------------------------- | ---- | ----------------------------------------------------- |
+| [e5efdc577](https://github.com/angular/angular-cli/commit/e5efdc577be913870b29173345b8194b87420474) | fix | also disable outputMode in vitest unit-tests |
+| [5814393db](https://github.com/angular/angular-cli/commit/5814393dbb2f9227ce10f1df77a8deee06c7d1c5) | fix | resolve junit karma reporter output to workspace root |
+
+
+
# 20.0.0 (2025-05-28)
diff --git a/package.json b/package.json
index af8ea1d9c5fe..d21b5262a7a8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@angular/devkit-repo",
- "version": "20.0.0",
+ "version": "20.0.1",
"private": true,
"description": "Software Development Kit for Angular",
"keywords": [
diff --git a/packages/angular/build/src/builders/karma/application_builder.ts b/packages/angular/build/src/builders/karma/application_builder.ts
index fc92ed59dbc0..55a10b37c54e 100644
--- a/packages/angular/build/src/builders/karma/application_builder.ts
+++ b/packages/angular/build/src/builders/karma/application_builder.ts
@@ -577,6 +577,35 @@ async function initializeApplication(
parsedKarmaConfig.reporters ??= [];
parsedKarmaConfig.reporters.push(AngularPolyfillsPlugin.NAME);
+ // Adjust karma junit reporter outDir location to maintain previous (devkit) behavior
+ // The base path for the reporter was previously the workspace root.
+ // To keep the files in the same location, the reporter's output directory is adjusted
+ // to be relative to the workspace root when using junit.
+ if (parsedKarmaConfig.reporters?.some((reporter) => reporter === 'junit')) {
+ if ('junitReporter' in parsedKarmaConfig) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const junitReporterOptions = (parsedKarmaConfig as any)['junitReporter'] as {
+ outputDir?: unknown;
+ };
+ if (junitReporterOptions.outputDir == undefined) {
+ junitReporterOptions.outputDir = context.workspaceRoot;
+ } else if (
+ typeof junitReporterOptions.outputDir === 'string' &&
+ !path.isAbsolute(junitReporterOptions.outputDir)
+ ) {
+ junitReporterOptions.outputDir = path.join(
+ context.workspaceRoot,
+ junitReporterOptions.outputDir,
+ );
+ }
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (parsedKarmaConfig as any)['junitReporter'] = {
+ outputDir: context.workspaceRoot,
+ };
+ }
+ }
+
// When using code-coverage, auto-add karma-coverage.
// This was done as part of the karma plugin for webpack.
if (
diff --git a/packages/angular/build/src/builders/unit-test/builder.ts b/packages/angular/build/src/builders/unit-test/builder.ts
index 1fb1aa06a1b2..84b41f1a280a 100644
--- a/packages/angular/build/src/builders/unit-test/builder.ts
+++ b/packages/angular/build/src/builders/unit-test/builder.ts
@@ -122,6 +122,7 @@ export async function* execute(
index: false,
browser: undefined,
server: undefined,
+ outputMode: undefined,
localize: false,
budgets: [],
serviceWorker: false,
diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js
index 392578c684cb..e0f5eb36a2ef 100755
--- a/packages/angular/cli/bin/ng.js
+++ b/packages/angular/cli/bin/ng.js
@@ -31,7 +31,6 @@ try {
}
const rawCommandName = process.argv[2];
-
if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completion') {
// Skip Node.js supported checks when running ng completion.
// A warning at this stage could cause a broken source action (`source <(ng completion script)`) when in the shell init script.
@@ -43,8 +42,9 @@ if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completi
// This node version check ensures that extremely old versions of node are not used.
// These may not support ES2015 features such as const/let/async/await/etc.
// These would then crash with a hard to diagnose error message.
-var version = process.versions.node.split('.').map((part) => Number(part));
-if (version[0] % 2 === 1) {
+const [major, minor] = process.versions.node.split('.', 2).map((part) => Number(part));
+
+if (major % 2 === 1) {
// Allow new odd numbered releases with a warning (currently v17+)
console.warn(
'Node.js version ' +
@@ -55,13 +55,13 @@ if (version[0] % 2 === 1) {
);
require('./bootstrap');
-} else if (version[0] < 20 || (version[0] === 20 && version[1] < 11)) {
- // Error and exit if less than 20.11
+} else if (major < 20 || (major === 20 && minor < 19) || (major === 22 && minor < 12)) {
+ // Error and exit if less than 20.19 or 22.12
console.error(
'Node.js version ' +
process.version +
' detected.\n' +
- 'The Angular CLI requires a minimum Node.js version of v20.11.\n\n' +
+ 'The Angular CLI requires a minimum Node.js version of v20.19 or v22.12.\n\n' +
'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n',
);
diff --git a/packages/schematics/angular/application/index.ts b/packages/schematics/angular/application/index.ts
index 825f98c04158..14c0688cf334 100644
--- a/packages/schematics/angular/application/index.ts
+++ b/packages/schematics/angular/application/index.ts
@@ -149,6 +149,14 @@ function addDependenciesToPackageJson(options: ApplicationOptions) {
},
].forEach((dependency) => addPackageJsonDependency(host, dependency));
+ if (!options.zoneless) {
+ addPackageJsonDependency(host, {
+ type: NodeDependencyType.Default,
+ name: 'zone.js',
+ version: latestVersions['zone.js'],
+ });
+ }
+
if (!options.skipInstall) {
context.addTask(new NodePackageInstallTask());
}
diff --git a/packages/schematics/angular/application/index_spec.ts b/packages/schematics/angular/application/index_spec.ts
index 458f91d6eef0..60700c9f45ff 100644
--- a/packages/schematics/angular/application/index_spec.ts
+++ b/packages/schematics/angular/application/index_spec.ts
@@ -268,6 +268,48 @@ describe('Application Schematic', () => {
expect(pkg.devDependencies['typescript']).toEqual(latestVersions['typescript']);
});
+ it('should include zone.js if "zoneless" option is false', async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'application',
+ {
+ ...defaultOptions,
+ zoneless: false,
+ },
+ workspaceTree,
+ );
+
+ const pkg = JSON.parse(tree.readContent('/package.json'));
+ expect(pkg.dependencies['zone.js']).toEqual(latestVersions['zone.js']);
+ });
+
+ it('should include zone.js if "zoneless" option is not present', async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'application',
+ {
+ ...defaultOptions,
+ zoneless: undefined,
+ },
+ workspaceTree,
+ );
+
+ const pkg = JSON.parse(tree.readContent('/package.json'));
+ expect(pkg.dependencies['zone.js']).toEqual(latestVersions['zone.js']);
+ });
+
+ it('should not include zone.js if "zoneless" option is true', async () => {
+ const tree = await schematicRunner.runSchematic(
+ 'application',
+ {
+ ...defaultOptions,
+ zoneless: true,
+ },
+ workspaceTree,
+ );
+
+ const pkg = JSON.parse(tree.readContent('/package.json'));
+ expect(pkg.dependencies['zone.js']).toBeUndefined();
+ });
+
it(`should not override existing users dependencies`, async () => {
const oldPackageJson = workspaceTree.readContent('package.json');
workspaceTree.overwrite(
diff --git a/packages/schematics/angular/library/index.ts b/packages/schematics/angular/library/index.ts
index b409d986dd57..d96cc8b505a8 100644
--- a/packages/schematics/angular/library/index.ts
+++ b/packages/schematics/angular/library/index.ts
@@ -22,7 +22,11 @@ import {
} from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import { join } from 'node:path/posix';
-import { NodeDependencyType, addPackageJsonDependency } from '../utility/dependencies';
+import {
+ NodeDependencyType,
+ addPackageJsonDependency,
+ getPackageJsonDependency,
+} from '../utility/dependencies';
import { JSONFile } from '../utility/json-file';
import { latestVersions } from '../utility/latest-versions';
import { relativePathToWorkspaceRoot } from '../utility/paths';
@@ -96,6 +100,7 @@ function addLibToWorkspaceFile(
options: LibraryOptions,
projectRoot: string,
projectName: string,
+ hasZoneDependency: boolean,
): Rule {
return updateWorkspace((workspace) => {
workspace.projects.add({
@@ -121,7 +126,7 @@ function addLibToWorkspaceFile(
builder: Builders.BuildKarma,
options: {
tsConfig: `${projectRoot}/tsconfig.spec.json`,
- polyfills: ['zone.js', 'zone.js/testing'],
+ polyfills: hasZoneDependency ? ['zone.js', 'zone.js/testing'] : undefined,
},
},
},
@@ -172,9 +177,11 @@ export default function (options: LibraryOptions): Rule {
move(libDir),
]);
+ const hasZoneDependency = getPackageJsonDependency(host, 'zone.js') !== null;
+
return chain([
mergeWith(templateSource),
- addLibToWorkspaceFile(options, libDir, packageName),
+ addLibToWorkspaceFile(options, libDir, packageName, hasZoneDependency),
options.skipPackageJson ? noop() : addDependenciesToPackageJson(),
options.skipTsConfig ? noop() : updateTsConfig(packageName, './' + distRoot),
options.skipTsConfig
diff --git a/packages/schematics/angular/library/index_spec.ts b/packages/schematics/angular/library/index_spec.ts
index 1d5503d70b38..97e517767f1f 100644
--- a/packages/schematics/angular/library/index_spec.ts
+++ b/packages/schematics/angular/library/index_spec.ts
@@ -195,6 +195,13 @@ describe('Library Schematic', () => {
expect(workspace.projects.foo.prefix).toEqual('pre');
});
+ it(`should not add zone.js to test polyfills when no zone.js dependency`, async () => {
+ const tree = await schematicRunner.runSchematic('library', defaultOptions, workspaceTree);
+
+ const workspace = getJsonFileContent(tree, '/angular.json');
+ expect(workspace.projects.foo.architect.test.options.polyfills).toBeUndefined();
+ });
+
it('should handle a pascalCasedName', async () => {
const options = { ...defaultOptions, name: 'pascalCasedName' };
const tree = await schematicRunner.runSchematic('library', options, workspaceTree);
diff --git a/packages/schematics/angular/migrations/use-application-builder/migration.ts b/packages/schematics/angular/migrations/use-application-builder/migration.ts
index 396ba48430d2..6a59c212fa21 100644
--- a/packages/schematics/angular/migrations/use-application-builder/migration.ts
+++ b/packages/schematics/angular/migrations/use-application-builder/migration.ts
@@ -232,6 +232,8 @@ function updateProjects(tree: Tree, context: SchematicContext) {
// Use @angular/build directly if there is no devkit package usage
if (!hasAngularDevkitUsage) {
+ const karmaConfigFiles = new Set();
+
for (const [, target] of allWorkspaceTargets(workspace)) {
switch (target.builder) {
case Builders.Application:
@@ -245,9 +247,15 @@ function updateProjects(tree: Tree, context: SchematicContext) {
break;
case Builders.Karma:
target.builder = '@angular/build:karma';
- // Remove "builderMode" option since the builder will always use "application"
for (const [, karmaOptions] of allTargetOptions(target)) {
+ // Remove "builderMode" option since the builder will always use "application"
delete karmaOptions['builderMode'];
+
+ // Collect custom karma configurations for @angular-devkit/build-angular plugin removal
+ const karmaConfig = karmaOptions['karmaConfig'];
+ if (karmaConfig && typeof karmaConfig === 'string') {
+ karmaConfigFiles.add(karmaConfig);
+ }
}
break;
case Builders.NgPackagr:
@@ -292,6 +300,34 @@ function updateProjects(tree: Tree, context: SchematicContext) {
}),
);
}
+
+ for (const karmaConfigFile of karmaConfigFiles) {
+ if (!tree.exists(karmaConfigFile)) {
+ continue;
+ }
+
+ try {
+ const originalKarmaConfigText = tree.readText(karmaConfigFile);
+ const updatedKarmaConfigText = originalKarmaConfigText
+ .replaceAll(`require('@angular-devkit/build-angular/plugins/karma'),`, '')
+ .replaceAll(`require('@angular-devkit/build-angular/plugins/karma')`, '');
+
+ if (updatedKarmaConfigText.includes('@angular-devkit/build-angular/plugins')) {
+ throw new Error(
+ 'Migration does not support found usage of "@angular-devkit/build-angular".',
+ );
+ } else {
+ tree.overwrite(karmaConfigFile, updatedKarmaConfigText);
+ }
+ } catch (error) {
+ const reason = error instanceof Error ? `Reason: ${error.message}` : '';
+ context.logger.warn(
+ `Unable to update custom karma configuration file ("${karmaConfigFile}"). ` +
+ reason +
+ '\nReferences to the "@angular-devkit/build-angular" package within the file may need to be removed manually.',
+ );
+ }
+ }
}
return chain(rules);
diff --git a/packages/schematics/angular/migrations/use-application-builder/migration_spec.ts b/packages/schematics/angular/migrations/use-application-builder/migration_spec.ts
index 3adef7d419eb..a8d50193958e 100644
--- a/packages/schematics/angular/migrations/use-application-builder/migration_spec.ts
+++ b/packages/schematics/angular/migrations/use-application-builder/migration_spec.ts
@@ -130,6 +130,103 @@ describe(`Migration to use the application builder`, () => {
expect(builderMode).toBeUndefined();
});
+ it(`should update file for 'karmaConfig' karma option (no require trailing comma)`, async () => {
+ addWorkspaceTarget(tree, 'test', {
+ 'builder': Builders.Karma,
+ 'options': {
+ 'karmaConfig': './karma.conf.js',
+ 'polyfills': ['zone.js', 'zone.js/testing'],
+ 'tsConfig': 'projects/app-a/tsconfig.spec.json',
+ },
+ });
+ tree.create(
+ './karma.conf.js',
+ `
+ module.exports = function (config) {
+ config.set({
+ basePath: '',
+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
+ plugins: [
+ require('karma-jasmine'),
+ require('karma-chrome-launcher'),
+ require('karma-jasmine-html-reporter'),
+ require('karma-coverage'),
+ require('@angular-devkit/build-angular/plugins/karma')
+ ]
+ });
+ };`,
+ );
+
+ const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
+ const {
+ projects: { app },
+ } = JSON.parse(newTree.readContent('/angular.json'));
+
+ const { karmaConfig } = app.architect['test'].options;
+ expect(karmaConfig).toBe('./karma.conf.js');
+
+ const karmaConfigText = newTree.readText('./karma.conf.js');
+ expect(karmaConfigText).not.toContain(`require('@angular-devkit/build-angular/plugins/karma')`);
+ });
+
+ it(`should update file for 'karmaConfig' karma option (require trailing comma)`, async () => {
+ addWorkspaceTarget(tree, 'test', {
+ 'builder': Builders.Karma,
+ 'options': {
+ 'karmaConfig': './karma.conf.js',
+ 'polyfills': ['zone.js', 'zone.js/testing'],
+ 'tsConfig': 'projects/app-a/tsconfig.spec.json',
+ },
+ });
+ tree.create(
+ './karma.conf.js',
+ `
+ module.exports = function (config) {
+ config.set({
+ basePath: '',
+ frameworks: ['jasmine', '@angular-devkit/build-angular'],
+ plugins: [
+ require('karma-jasmine'),
+ require('karma-chrome-launcher'),
+ require('karma-jasmine-html-reporter'),
+ require('karma-coverage'),
+ require('@angular-devkit/build-angular/plugins/karma'),
+ ]
+ });
+ };`,
+ );
+
+ const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
+ const {
+ projects: { app },
+ } = JSON.parse(newTree.readContent('/angular.json'));
+
+ const { karmaConfig } = app.architect['test'].options;
+ expect(karmaConfig).toBe('./karma.conf.js');
+
+ const karmaConfigText = newTree.readText('./karma.conf.js');
+ expect(karmaConfigText).not.toContain(`require('@angular-devkit/build-angular/plugins/karma')`);
+ });
+
+ it(`should ignore missing file for 'karmaConfig' karma option`, async () => {
+ addWorkspaceTarget(tree, 'test', {
+ 'builder': Builders.Karma,
+ 'options': {
+ 'karmaConfig': './karma.conf.js',
+ 'polyfills': ['zone.js', 'zone.js/testing'],
+ 'tsConfig': 'projects/app-a/tsconfig.spec.json',
+ },
+ });
+
+ const newTree = await schematicRunner.runSchematic(schematicName, {}, tree);
+ const {
+ projects: { app },
+ } = JSON.parse(newTree.readContent('/angular.json'));
+
+ const { karmaConfig } = app.architect['test'].options;
+ expect(karmaConfig).toBe('./karma.conf.js');
+ });
+
it('should remove tilde prefix from CSS @import specifiers', async () => {
// Replace outputPath
tree.create(
diff --git a/packages/schematics/angular/utility/json-file.ts b/packages/schematics/angular/utility/json-file.ts
index dffb7a94f997..e7c767745b90 100644
--- a/packages/schematics/angular/utility/json-file.ts
+++ b/packages/schematics/angular/utility/json-file.ts
@@ -95,9 +95,16 @@ export class JSONFile {
},
});
- this.content = applyEdits(this.content, edits);
- this.host.overwrite(this.path, this.content);
- this._jsonAst = undefined;
+ if (edits.length > 0) {
+ const editedContent = applyEdits(this.content, edits);
+
+ // Update the file content if it changed
+ if (editedContent !== this.content) {
+ this.content = editedContent;
+ this.host.overwrite(this.path, editedContent);
+ this._jsonAst = undefined;
+ }
+ }
}
remove(jsonPath: JSONPath): void {
diff --git a/packages/schematics/angular/workspace/files/package.json.template b/packages/schematics/angular/workspace/files/package.json.template
index 4ee0cdd9ab73..dcbe6939829d 100644
--- a/packages/schematics/angular/workspace/files/package.json.template
+++ b/packages/schematics/angular/workspace/files/package.json.template
@@ -17,8 +17,7 @@
"@angular/platform-browser": "<%= latestVersions.Angular %>",
"@angular/router": "<%= latestVersions.Angular %>",
"rxjs": "<%= latestVersions['rxjs'] %>",
- "tslib": "<%= latestVersions['tslib'] %>",
- "zone.js": "<%= latestVersions['zone.js'] %>"
+ "tslib": "<%= latestVersions['tslib'] %>"
},
"devDependencies": {
"@angular/cli": "<%= '^' + version %>",
diff --git a/packages/schematics/angular/workspace/index_spec.ts b/packages/schematics/angular/workspace/index_spec.ts
index 520452e71b89..21efd7275b82 100644
--- a/packages/schematics/angular/workspace/index_spec.ts
+++ b/packages/schematics/angular/workspace/index_spec.ts
@@ -58,7 +58,6 @@ describe('Workspace Schematic', () => {
const pkg = JSON.parse(tree.readContent('/package.json'));
expect(pkg.dependencies['@angular/core']).toEqual(latestVersions.Angular);
expect(pkg.dependencies['rxjs']).toEqual(latestVersions['rxjs']);
- expect(pkg.dependencies['zone.js']).toEqual(latestVersions['zone.js']);
expect(pkg.devDependencies['typescript']).toEqual(latestVersions['typescript']);
});
diff --git a/tests/legacy-cli/e2e/tests/test/karma-junit-output.ts b/tests/legacy-cli/e2e/tests/test/karma-junit-output.ts
new file mode 100644
index 000000000000..056adea26ab3
--- /dev/null
+++ b/tests/legacy-cli/e2e/tests/test/karma-junit-output.ts
@@ -0,0 +1,27 @@
+import { expectFileMatchToExist, replaceInFile } from '../../utils/fs';
+import { installPackage } from '../../utils/packages';
+import { silentNg } from '../../utils/process';
+
+const E2E_CUSTOM_LAUNCHER = `
+ customLaunchers: {
+ ChromeHeadlessNoSandbox: {
+ base: 'ChromeHeadless',
+ flags: ['--no-sandbox', '--headless', '--disable-gpu', '--disable-dev-shm-usage'],
+ },
+ },
+ restartOnFileChange: true,
+`;
+
+export default async function () {
+ await installPackage('karma-junit-reporter');
+ await silentNg('generate', 'config', 'karma');
+
+ await replaceInFile('karma.conf.js', 'karma-jasmine-html-reporter', 'karma-junit-reporter');
+ await replaceInFile('karma.conf.js', `'kjhtml'`, `'junit'`);
+
+ await replaceInFile('karma.conf.js', `restartOnFileChange: true`, E2E_CUSTOM_LAUNCHER);
+
+ await silentNg('test', '--no-watch');
+
+ await expectFileMatchToExist('.', /TESTS\-.+\.xml/);
+}