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/); +}