From 12332c5b262c9a06da1e074b8a416042b559a01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Fri, 21 Nov 2025 11:16:29 +0100 Subject: [PATCH 1/2] feat(angular): generate vitest unit test runner by default --- e2e/angular/src/config.test.ts | 2 +- e2e/angular/src/misc.test.ts | 12 +- e2e/angular/src/ngrx.test.ts | 172 +++++----- packages/angular/executors.json | 5 + .../src/executors/unit-test/schema.d.ts | 7 + .../src/executors/unit-test/schema.json | 320 ++++++++++++++++++ .../src/executors/unit-test/unit-test.impl.ts | 131 +++++++ .../__snapshots__/application.spec.ts.snap | 140 ++++---- .../application/application.spec.ts | 268 ++++++++++----- .../files/base/tsconfig.app.json__tpl__ | 6 +- .../application/lib/add-unit-test-runner.ts | 1 + .../application/lib/normalize-options.ts | 7 +- .../src/generators/application/schema.json | 7 +- ...press-component-configuration.spec.ts.snap | 2 +- .../federate-module/federate-module.ts | 8 +- .../federate-module/lib/add-remote.ts | 2 +- .../generators/federate-module/schema.json | 7 +- .../angular/src/generators/host/schema.json | 4 +- .../library-secondary-entry-point.spec.ts | 11 +- .../files/base/tsconfig.lib.json__tpl__ | 8 +- .../library/lib/normalize-options.ts | 11 +- .../library/lib/update-tsconfig-files.ts | 3 +- .../src/generators/library/library.spec.ts | 17 +- .../angular/src/generators/library/library.ts | 2 + .../src/generators/library/schema.json | 7 +- .../angular/src/generators/ngrx/ngrx.spec.ts | 14 +- .../angular/src/generators/remote/schema.json | 4 +- .../generators/setup-ssr/setup-ssr.spec.ts | 26 +- .../src/generators/utils/add-jest.spec.ts | 4 + .../angular/src/generators/utils/add-jest.ts | 17 + .../src/generators/utils/add-vitest.ts | 189 ++++++++++- .../utils/ensure-angular-dependencies.spec.ts | 4 +- .../src/generators/utils/version-utils.ts | 49 ++- packages/angular/src/plugins/plugin.ts | 21 +- .../src/utils/backward-compatible-versions.ts | 62 ++-- packages/angular/src/utils/targets.ts | 27 +- packages/angular/src/utils/version-utils.ts | 10 +- packages/angular/src/utils/versions.ts | 3 + .../angular/legacy-angular-versions.ts | 1 + .../generators/configuration/configuration.ts | 29 +- .../src/generators/configuration/schema.d.ts | 1 + .../src/generators/configuration/schema.json | 6 + packages/vitest/src/utils/generator-utils.ts | 15 +- .../src/generators/preset/preset.spec.ts | 2 - 44 files changed, 1227 insertions(+), 417 deletions(-) create mode 100644 packages/angular/src/executors/unit-test/schema.d.ts create mode 100644 packages/angular/src/executors/unit-test/schema.json create mode 100644 packages/angular/src/executors/unit-test/unit-test.impl.ts diff --git a/e2e/angular/src/config.test.ts b/e2e/angular/src/config.test.ts index 07490b54a4d15..d5d9419e23f46 100644 --- a/e2e/angular/src/config.test.ts +++ b/e2e/angular/src/config.test.ts @@ -14,7 +14,7 @@ describe('angular.json v1 config', () => { beforeAll(() => { newProject({ packages: ['@nx/angular'] }); runCLI( - `generate @nx/angular:app ${app1} --bundler=webpack --no-interactive` + `generate @nx/angular:app ${app1} --bundler=webpack --unit-test-runner=jest --no-interactive` ); // reset workspace to use v1 config updateFile(`angular.json`, angularV1Json(app1)); diff --git a/e2e/angular/src/misc.test.ts b/e2e/angular/src/misc.test.ts index 23206997570c0..d34599a3fbeda 100644 --- a/e2e/angular/src/misc.test.ts +++ b/e2e/angular/src/misc.test.ts @@ -20,7 +20,9 @@ describe('Move Angular Project', () => { app1 = uniq('app1'); app2 = uniq('app2'); newPath = `subfolder/${app2}`; - runCLI(`generate @nx/angular:app ${app1} --no-interactive`); + runCLI( + `generate @nx/angular:app ${app1} --unit-test-runner=jest --no-interactive` + ); }); afterAll(() => cleanupProject()); @@ -93,13 +95,17 @@ describe('Move Angular Project', () => { it('should work for libraries', () => { const lib1 = uniq('mylib'); const lib2 = uniq('mylib'); - runCLI(`generate @nx/angular:lib ${lib1} --no-standalone --no-interactive`); + runCLI( + `generate @nx/angular:lib ${lib1} --unit-test-runner=jest --no-standalone --no-interactive` + ); /** * Create a library which imports the module from the other lib */ - runCLI(`generate @nx/angular:lib ${lib2} --no-standalone --no-interactive`); + runCLI( + `generate @nx/angular:lib ${lib2} --unit-test-runner=jest --no-standalone --no-interactive` + ); updateFile( `${lib2}/src/lib/${lib2}-module.ts`, diff --git a/e2e/angular/src/ngrx.test.ts b/e2e/angular/src/ngrx.test.ts index ea8c1ee77bd50..7846855c5cb6b 100644 --- a/e2e/angular/src/ngrx.test.ts +++ b/e2e/angular/src/ngrx.test.ts @@ -8,105 +8,103 @@ import { uniq, } from '@nx/e2e-utils'; -describe('Angular Package', () => { - describe('ngrx', () => { - beforeAll(() => { - newProject({ packages: ['@nx/angular'] }); - }); - afterAll(() => { - cleanupProject(); - }); +describe('NgRx', () => { + beforeAll(() => { + newProject({ packages: ['@nx/angular'] }); + }); + afterAll(() => { + cleanupProject(); + }); - it('should work', async () => { - const myapp = uniq('myapp'); - runCLI( - `generate @nx/angular:app ${myapp} --no-standalone --no-interactive` - ); + it('should work', async () => { + const myapp = uniq('myapp'); + runCLI( + `generate @nx/angular:app ${myapp} --no-standalone --no-interactive` + ); - // Generate root ngrx state management - runCLI( - `generate @nx/angular:ngrx users --parent=${myapp}/src/app/app-module.ts --root --minimal=false` - ); - const packageJson = readJson('package.json'); - expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/router-store']).toBeDefined(); - expect(packageJson.devDependencies['@ngrx/store-devtools']).toBeDefined(); + // Generate root ngrx state management + runCLI( + `generate @nx/angular:ngrx users --parent=${myapp}/src/app/app-module.ts --root --minimal=false` + ); + const packageJson = readJson('package.json'); + expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/router-store']).toBeDefined(); + expect(packageJson.devDependencies['@ngrx/store-devtools']).toBeDefined(); - const mylib = uniq('mylib'); - // Generate feature library and ngrx state within that library - runCLI(`g @nx/angular:lib ${mylib} --prefix=fl --no-standalone`); - runCLI( - `generate @nx/angular:ngrx flights --parent=${mylib}/src/lib/${mylib}-module.ts --facade` - ); + const mylib = uniq('mylib'); + // Generate feature library and ngrx state within that library + runCLI(`g @nx/angular:lib ${mylib} --prefix=fl --no-standalone`); + runCLI( + `generate @nx/angular:ngrx flights --parent=${mylib}/src/lib/${mylib}-module.ts --facade` + ); - expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); - expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); - expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); - }, 1000000); + expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); + expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); + expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); + }, 1000000); - it('should work with creators', async () => { - const myapp = uniq('myapp'); - runCLI( - `generate @nx/angular:app ${myapp} --routing --no-standalone --no-interactive` - ); + it('should work with creators', async () => { + const myapp = uniq('myapp'); + runCLI( + `generate @nx/angular:app ${myapp} --routing --no-standalone --no-interactive` + ); - // Generate root ngrx state management - runCLI( - `generate @nx/angular:ngrx users --parent=${myapp}/src/app/app-module.ts --root` - ); - const packageJson = readJson('package.json'); - expect(packageJson.dependencies['@ngrx/entity']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/router-store']).toBeDefined(); - expect(packageJson.devDependencies['@ngrx/schematics']).toBeDefined(); - expect(packageJson.devDependencies['@ngrx/store-devtools']).toBeDefined(); + // Generate root ngrx state management + runCLI( + `generate @nx/angular:ngrx users --parent=${myapp}/src/app/app-module.ts --root` + ); + const packageJson = readJson('package.json'); + expect(packageJson.dependencies['@ngrx/entity']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/router-store']).toBeDefined(); + expect(packageJson.devDependencies['@ngrx/schematics']).toBeDefined(); + expect(packageJson.devDependencies['@ngrx/store-devtools']).toBeDefined(); - const mylib = uniq('mylib'); - // Generate feature library and ngrx state within that library - runCLI(`g @nx/angular:lib ${mylib} --prefix=fl --no-standalone`); + const mylib = uniq('mylib'); + // Generate feature library and ngrx state within that library + runCLI(`g @nx/angular:lib ${mylib} --prefix=fl --no-standalone`); - const flags = `--facade --barrels`; - runCLI( - `generate @nx/angular:ngrx flights --parent=${mylib}/src/lib/${mylib}-module.ts ${flags}` - ); + const flags = `--facade --barrels`; + runCLI( + `generate @nx/angular:ngrx flights --parent=${mylib}/src/lib/${mylib}-module.ts ${flags}` + ); - expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); - expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); - expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); - }, 1000000); + expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); + expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); + expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); + }, 1000000); - it('should work with creators using --module', async () => { - const myapp = uniq('myapp'); - runCLI( - `generate @nx/angular:app ${myapp} --routing --no-standalone --no-interactive` - ); + it('should work with creators using --module', async () => { + const myapp = uniq('myapp'); + runCLI( + `generate @nx/angular:app ${myapp} --routing --no-standalone --no-interactive` + ); - // Generate root ngrx state management - runCLI( - `generate @nx/angular:ngrx users --parent=${myapp}/src/app/app-module.ts --root` - ); - const packageJson = readJson('package.json'); - expect(packageJson.dependencies['@ngrx/entity']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); - expect(packageJson.dependencies['@ngrx/router-store']).toBeDefined(); - expect(packageJson.devDependencies['@ngrx/schematics']).toBeDefined(); - expect(packageJson.devDependencies['@ngrx/store-devtools']).toBeDefined(); + // Generate root ngrx state management + runCLI( + `generate @nx/angular:ngrx users --parent=${myapp}/src/app/app-module.ts --root` + ); + const packageJson = readJson('package.json'); + expect(packageJson.dependencies['@ngrx/entity']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); + expect(packageJson.dependencies['@ngrx/router-store']).toBeDefined(); + expect(packageJson.devDependencies['@ngrx/schematics']).toBeDefined(); + expect(packageJson.devDependencies['@ngrx/store-devtools']).toBeDefined(); - const mylib = uniq('mylib'); - // Generate feature library and ngrx state within that library - runCLI(`g @nx/angular:lib ${mylib} --prefix=fl --no-standalone`); + const mylib = uniq('mylib'); + // Generate feature library and ngrx state within that library + runCLI(`g @nx/angular:lib ${mylib} --prefix=fl --no-standalone`); - const flags = `--facade --barrels`; - runCLI( - `generate @nx/angular:ngrx flights --module=${mylib}/src/lib/${mylib}-module.ts ${flags}` - ); + const flags = `--facade --barrels`; + runCLI( + `generate @nx/angular:ngrx flights --module=${mylib}/src/lib/${mylib}-module.ts ${flags}` + ); - expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); - expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); - expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); - }, 1000000); - }); + expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); + expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); + expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); + }, 1000000); }); diff --git a/packages/angular/executors.json b/packages/angular/executors.json index 3b00b1f02d229..0da882ebaf5cc 100644 --- a/packages/angular/executors.json +++ b/packages/angular/executors.json @@ -39,6 +39,11 @@ "implementation": "./src/executors/extract-i18n/extract-i18n.impl", "schema": "./src/executors/extract-i18n/schema.json", "description": "Extracts i18n messages from source code." + }, + "unit-test": { + "implementation": "./src/executors/unit-test/unit-test.impl", + "schema": "./src/executors/unit-test/schema.json", + "description": "Run application unit tests. _Note: this is only supported in Angular versions >= 21.0.0_." } }, "builders": { diff --git a/packages/angular/src/executors/unit-test/schema.d.ts b/packages/angular/src/executors/unit-test/schema.d.ts new file mode 100644 index 0000000000000..f58a2f03e8edc --- /dev/null +++ b/packages/angular/src/executors/unit-test/schema.d.ts @@ -0,0 +1,7 @@ +import type { UnitTestBuilderOptions } from '@angular/build'; +import type { PluginSpec } from '../utilities/esbuild-extensions'; + +export interface UnitTestExecutorOptions extends UnitTestBuilderOptions { + indexHtmlTransformer?: string; + plugins?: string[] | PluginSpec[]; +} diff --git a/packages/angular/src/executors/unit-test/schema.json b/packages/angular/src/executors/unit-test/schema.json new file mode 100644 index 0000000000000..e49cc9ec5662d --- /dev/null +++ b/packages/angular/src/executors/unit-test/schema.json @@ -0,0 +1,320 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Schema for Nx Unit Test Executor", + "description": "Run application unit tests. _Note: this is only supported in Angular versions >= 21.0.0_.", + "outputCapture": "direct-nodejs", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "Specifies the build target to use for the unit test build in the format `project:target[:configuration]`. This defaults to the `build` target of the current project with the `development` configuration. You can also pass a comma-separated list of configurations. Example: `project:target:production,staging`.", + "pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$" + }, + "tsConfig": { + "type": "string", + "description": "The path to the TypeScript configuration file, relative to the workspace root. Defaults to `tsconfig.spec.json` in the project root if it exists. If not specified and the default does not exist, the `tsConfig` from the specified `buildTarget` will be used." + }, + "runner": { + "type": "string", + "description": "Specifies the test runner to use for test execution.", + "default": "vitest", + "enum": ["karma", "vitest"] + }, + "runnerConfig": { + "type": ["string", "boolean"], + "description": "Specifies the configuration file for the selected test runner. If a string is provided, it will be used as the path to the configuration file. If `true`, the builder will search for a default configuration file (e.g., `vitest.config.ts` or `karma.conf.js`). If `false`, no external configuration file will be used.\\nFor Vitest, this enables advanced options and the use of custom plugins. Please note that while the file is loaded, the Angular team does not provide direct support for its specific contents or any third-party plugins used within it.", + "default": false + }, + "browsers": { + "description": "Specifies the browsers to use for test execution. When not specified, tests are run in a Node.js environment using jsdom. For both Vitest and Karma, browser names ending with 'Headless' (e.g., 'ChromeHeadless') will enable headless mode.", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "browserViewport": { + "description": "Specifies the browser viewport dimensions for browser-based tests in the format `widthxheight`.", + "type": "string", + "pattern": "^\\d+x\\d+$" + }, + "include": { + "type": "array", + "items": { + "type": "string" + }, + "default": ["**/*.spec.ts", "**/*.test.ts"], + "description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Specifies glob patterns of files to exclude from testing, relative to the project root." + }, + "filter": { + "type": "string", + "description": "Specifies a regular expression pattern to match against test suite and test names. Only tests with a name matching the pattern will be executed. For example, `^App` will run only tests in suites beginning with 'App'." + }, + "watch": { + "type": "boolean", + "description": "Enables watch mode, which re-runs tests when source files change. Defaults to `true` in TTY environments and `false` otherwise." + }, + "debug": { + "type": "boolean", + "description": "Enables debugging mode for tests, allowing the use of the Node Inspector.", + "default": false + }, + "ui": { + "type": "boolean", + "description": "Enables the Vitest UI for interactive test execution. This option is only available for the Vitest runner.", + "default": false + }, + "coverage": { + "type": "boolean", + "description": "Enables coverage reporting for tests.", + "default": false + }, + "coverageInclude": { + "type": "array", + "description": "Specifies glob patterns of files to include in the coverage report.", + "items": { + "type": "string" + } + }, + "coverageExclude": { + "type": "array", + "description": "Specifies glob patterns of files to exclude from the coverage report.", + "items": { + "type": "string" + } + }, + "coverageReporters": { + "type": "array", + "description": "Specifies the reporters to use for coverage results. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'html', 'lcov', 'lcovonly', 'text', 'text-summary', 'cobertura', 'json', and 'json-summary'.", + "items": { + "oneOf": [ + { + "enum": [ + "html", + "lcov", + "lcovonly", + "text", + "text-summary", + "cobertura", + "json", + "json-summary" + ] + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "enum": [ + "html", + "lcov", + "lcovonly", + "text", + "text-summary", + "cobertura", + "json", + "json-summary" + ] + }, + { + "type": "object" + } + ] + } + ] + } + }, + "coverageThresholds": { + "type": "object", + "description": "Specifies minimum coverage thresholds that must be met. If thresholds are not met, the builder will exit with an error.", + "properties": { + "perFile": { + "type": "boolean", + "description": "When true, thresholds are enforced for each file individually." + }, + "statements": { + "type": "number", + "description": "Minimum percentage of statements covered." + }, + "branches": { + "type": "number", + "description": "Minimum percentage of branches covered." + }, + "functions": { + "type": "number", + "description": "Minimum percentage of functions covered." + }, + "lines": { + "type": "number", + "description": "Minimum percentage of lines covered." + } + }, + "additionalProperties": false + }, + "coverageWatermarks": { + "type": "object", + "description": "Specifies coverage watermarks for the HTML reporter. These determine the color coding for high, medium, and low coverage.", + "properties": { + "statements": { + "type": "array", + "description": "The high and low watermarks for statements coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "branches": { + "type": "array", + "description": "The high and low watermarks for branches coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "functions": { + "type": "array", + "description": "The high and low watermarks for functions coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "lines": { + "type": "array", + "description": "The high and low watermarks for lines coverage. `[low, high]`", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + } + }, + "additionalProperties": false + }, + "reporters": { + "type": "array", + "description": "Specifies the reporters to use during test execution. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'default', 'verbose', 'dots', 'json', 'junit', 'tap', 'tap-flat', and 'html'. You can also provide a path to a custom reporter.", + "items": { + "oneOf": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "enum": [ + "default", + "verbose", + "dots", + "json", + "junit", + "tap", + "tap-flat", + "html" + ] + } + ] + }, + { + "type": "array", + "minItems": 1, + "maxItems": 2, + "items": [ + { + "anyOf": [ + { + "type": "string" + }, + { + "enum": [ + "default", + "verbose", + "dots", + "json", + "junit", + "tap", + "tap-flat", + "html" + ] + } + ] + }, + { + "type": "object" + } + ] + } + ] + } + }, + "outputFile": { + "type": "string", + "description": "Specifies a file path for the test report, applying only to the first reporter. To configure output files for multiple reporters, use the tuple format `['reporter-name', { outputFile: '...' }]` within the `reporters` option. When not provided, output is written to the console." + }, + "providersFile": { + "type": "string", + "description": "Specifies the path to a TypeScript file that provides an array of Angular providers for the test environment. The file must contain a default export of the provider array.", + "minLength": 1 + }, + "setupFiles": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of paths to global setup files that are executed before the test files. The application's polyfills and the Angular TestBed are always initialized before these files." + }, + "progress": { + "type": "boolean", + "description": "Shows build progress information in the console. Defaults to the `progress` setting of the specified `buildTarget`." + }, + "listTests": { + "type": "boolean", + "description": "Lists all discovered test files and exits the process without building or executing the tests.", + "default": false + }, + "dumpVirtualFiles": { + "type": "boolean", + "description": "Dumps build output files to the `.angular/cache` directory for debugging purposes.", + "default": false, + "visible": false + }, + "plugins": { + "description": "A list of ESBuild plugins.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to the plugin. Relative to the workspace root." + }, + "options": { + "type": "object", + "description": "The options to provide to the plugin.", + "properties": {}, + "additionalProperties": true + } + }, + "additionalProperties": false, + "required": ["path"] + }, + { + "type": "string", + "description": "The path to the plugin. Relative to the workspace root." + } + ] + } + }, + "indexHtmlTransformer": { + "description": "Path to a file exposing a default function to transform the `index.html` file.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [] +} diff --git a/packages/angular/src/executors/unit-test/unit-test.impl.ts b/packages/angular/src/executors/unit-test/unit-test.impl.ts new file mode 100644 index 0000000000000..2d3588f6270b1 --- /dev/null +++ b/packages/angular/src/executors/unit-test/unit-test.impl.ts @@ -0,0 +1,131 @@ +import type { BuilderContext, BuilderOutput } from '@angular-devkit/architect'; +import type { + ApplicationBuilderOptions, + NgPackagrBuilderOptions, +} from '@angular/build'; +import type { ExecutorContext, Target } from '@nx/devkit'; +import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter'; +import { targetFromTargetString } from '../../utils/targets'; +import type { ApplicationExecutorOptions } from '../application/schema'; +import type { BuildAngularLibraryExecutorOptions } from '../package/schema'; +import { getInstalledAngularVersionInfo } from '../utilities/angular-version-utils'; +import { assertBuilderPackageIsInstalled } from '../utilities/builder-package'; +import { + loadIndexHtmlTransformer, + loadPlugins, +} from '../utilities/esbuild-extensions'; +import type { UnitTestExecutorOptions } from './schema'; + +export default async function* unitTestExecutor( + options: UnitTestExecutorOptions, + context: ExecutorContext +): AsyncIterable { + validateOptions(); + + const { + plugins: pluginPaths, + indexHtmlTransformer: indexHtmlTransformerPath, + ...delegateExecutorOptions + } = options; + + const plugins = await loadPlugins(pluginPaths, options.tsConfig); + const indexHtmlTransformer = indexHtmlTransformerPath + ? await loadIndexHtmlTransformer(indexHtmlTransformerPath, options.tsConfig) + : undefined; + + const builderContext = await createBuilderContext( + { + builderName: 'unit-test', + description: 'Run application unit tests.', + optionSchema: require('./schema.json'), + }, + context + ); + + const buildTargetSpecifier = options.buildTarget ?? `::development`; + const buildTarget = targetFromTargetString( + buildTargetSpecifier, + context.projectName, + 'build' + ); + patchBuilderContext(builderContext, buildTarget); + + assertBuilderPackageIsInstalled('@angular/build'); + const { executeUnitTestBuilder } = await import('@angular/build'); + return yield* executeUnitTestBuilder( + delegateExecutorOptions, + builderContext, + { + codePlugins: plugins, + indexHtmlTransformer, + } + ); +} + +function validateOptions(): void { + const { version: angularVersion, major: angularMajorVersion } = + getInstalledAngularVersionInfo(); + + if (angularMajorVersion < 21) { + throw new Error( + `The "unit-test" executor is only available for Angular versions >= 21.0.0. You are currently using version ${angularVersion}.` + ); + } +} + +/** + * The Angular CLI unit-test builder only accepts the `@angular/build:application` + * and `@angular/build:ng-packagr` builders. We need to patch the builder context + * so that it accepts the `@nx/angular:*` executors. + * + * https://github.com/angular/angular-cli/blob/f9de11d67d3e0e0524372819583bc77756596d4f/packages/angular/build/src/builders/unit-test/builder.ts#L246-L262 + */ +function patchBuilderContext(context: BuilderContext, buildTarget: Target) { + const executorToBuilderMap = new Map([ + ['@nx/angular:application', '@angular/build:application'], + ['@nx/angular:ng-packagr-lite', '@angular/build:ng-packagr'], + ['@nx/angular:package', '@angular/build:ng-packagr'], + ]); + + const originalGetBuilderNameForTarget = context.getBuilderNameForTarget; + context.getBuilderNameForTarget = async (target) => { + const builderName = await originalGetBuilderNameForTarget(target); + + if (executorToBuilderMap.has(builderName)) { + return executorToBuilderMap.get(builderName)!; + } + + return builderName; + }; + + const originalGetTargetOptions = context.getTargetOptions; + context.getTargetOptions = async (target) => { + const options = await originalGetTargetOptions(target); + + if ( + target.project === buildTarget.project && + target.target === buildTarget.target && + target.configuration === buildTarget.configuration + ) { + cleanBuildTargetOptions(options); + } + + return options; + }; +} + +function cleanBuildTargetOptions( + options: ApplicationExecutorOptions | BuildAngularLibraryExecutorOptions +): ApplicationBuilderOptions | NgPackagrBuilderOptions { + if ( + 'buildLibsFromSource' in options || + 'indexHtmlTransformer' in options || + 'plugins' in options + ) { + delete options.buildLibsFromSource; + delete options.indexHtmlTransformer; + delete options.plugins; + } + + return options; +} diff --git a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap index fb8b97fb5e622..5ca76ab6b1bc2 100644 --- a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap @@ -625,7 +625,71 @@ exports[`app --strict should enable strict type checking: e2e tsconfig.json 1`] } `; -exports[`app --unit-test-runner vitest should add tsconfig.spec.json 1`] = ` +exports[`app angular compat support should generate components with the "component" type for versions lower than v20 1`] = ` +"import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +@Component({ + imports: [NxWelcomeComponent, RouterModule], + selector: 'app-root', + templateUrl: './app.component.html', + styleUrl: './app.component.css', +}) +export class AppComponent { + title = 'myapp'; +} +" +`; + +exports[`app angular compat support should generate components with the "component" type for versions lower than v20 2`] = ` +" + +" +`; + +exports[`app angular compat support should generate components with the "component" type for versions lower than v20 3`] = ` +"import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +describe('AppComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppComponent, NxWelcomeComponent], + }).compileComponents(); + }); + + it('should render title', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; + expect(compiled.querySelector('h1')?.textContent).toContain( + 'Welcome myapp' + ); + }); + + it(\`should have as title 'myapp'\`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.componentInstance; + expect(app.title).toEqual('myapp'); + }); +}); +" +`; + +exports[`app angular compat support should generate components with the "component" type for versions lower than v20 4`] = ` +"import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err) +); +" +`; + +exports[`app angular compat support vitest & angular < 21 should add tsconfig.spec.json 1`] = ` "{ "extends": "./tsconfig.json", "compilerOptions": { @@ -658,7 +722,7 @@ exports[`app --unit-test-runner vitest should add tsconfig.spec.json 1`] = ` " `; -exports[`app --unit-test-runner vitest should generate src/test-setup.ts 1`] = ` +exports[`app angular compat support vitest & angular < 21 should generate src/test-setup.ts 1`] = ` "import '@angular/compiler'; import '@analogjs/vitest-angular/setup-zone'; @@ -675,7 +739,7 @@ getTestBed().initTestEnvironment( " `; -exports[`app --unit-test-runner vitest should generate vite.config.mts 1`] = ` +exports[`app angular compat support vitest & angular < 21 should generate vite.config.mts 1`] = ` "/// import { defineConfig } from 'vite'; import angular from '@analogjs/vite-plugin-angular'; @@ -707,70 +771,6 @@ export default defineConfig(() => ({ " `; -exports[`app angular compat support should generate components with the "component" type for versions lower than v20 1`] = ` -"import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { NxWelcomeComponent } from './nx-welcome.component'; - -@Component({ - imports: [NxWelcomeComponent, RouterModule], - selector: 'app-root', - templateUrl: './app.component.html', - styleUrl: './app.component.css', -}) -export class AppComponent { - title = 'myapp'; -} -" -`; - -exports[`app angular compat support should generate components with the "component" type for versions lower than v20 2`] = ` -" - -" -`; - -exports[`app angular compat support should generate components with the "component" type for versions lower than v20 3`] = ` -"import { TestBed } from '@angular/core/testing'; -import { AppComponent } from './app.component'; -import { NxWelcomeComponent } from './nx-welcome.component'; - -describe('AppComponent', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [AppComponent, NxWelcomeComponent], - }).compileComponents(); - }); - - it('should render title', () => { - const fixture = TestBed.createComponent(AppComponent); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain( - 'Welcome myapp' - ); - }); - - it(\`should have as title 'myapp'\`, () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app.title).toEqual('myapp'); - }); -}); -" -`; - -exports[`app angular compat support should generate components with the "component" type for versions lower than v20 4`] = ` -"import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { AppComponent } from './app/app.component'; - -bootstrapApplication(AppComponent, appConfig).catch((err) => - console.error(err) -); -" -`; - exports[`app at the root should accept numbers in the path 1`] = `"src/9-websites/my-app"`; exports[`app format files should format files 1`] = ` @@ -1091,11 +1091,11 @@ exports[`app not nested should generate files: tsconfig.app.json 1`] = ` "types": [], }, "exclude": [ - "jest.config.ts", - "src/test-setup.ts", - "src/**/*.test.ts", "src/**/*.spec.ts", + "src/**/*.test.ts", + "jest.config.ts", "jest.config.cts", + "src/test-setup.ts", ], "extends": "./tsconfig.json", "include": [ diff --git a/packages/angular/src/generators/application/application.spec.ts b/packages/angular/src/generators/application/application.spec.ts index 8e37167acf9eb..7f2d0def705e5 100644 --- a/packages/angular/src/generators/application/application.spec.ts +++ b/packages/angular/src/generators/application/application.spec.ts @@ -355,11 +355,11 @@ describe('app', () => { path: 'my-dir/my-app/tsconfig.app.json', lookupFn: (json) => json.exclude, expectedValue: [ - 'jest.config.ts', - 'src/test-setup.ts', - 'src/**/*.test.ts', 'src/**/*.spec.ts', + 'src/**/*.test.ts', + 'jest.config.ts', 'jest.config.cts', + 'src/test-setup.ts', ], }, { @@ -445,11 +445,11 @@ describe('app', () => { path: 'my-dir/my-app/tsconfig.app.json', lookupFn: (json) => json.exclude, expectedValue: [ - 'jest.config.ts', - 'src/test-setup.ts', - 'src/**/*.test.ts', 'src/**/*.spec.ts', + 'src/**/*.test.ts', + 'jest.config.ts', 'jest.config.cts', + 'src/test-setup.ts', ], }, { @@ -751,91 +751,68 @@ describe('app', () => { }); describe('vitest', () => { - it('should generate vite.config.mts', async () => { + it('should setup test target with @angular/build:unit-test and configure target defaults', async () => { await generateApp(appTree, 'my-app', { - skipFormat: false, unitTestRunner: UnitTestRunner.Vitest, }); - expect( - appTree.read('my-app/vite.config.mts', 'utf-8') - ).toMatchSnapshot(); - }); - - it('should generate src/test-setup.ts', async () => { - await generateApp(appTree, 'my-app', { - unitTestRunner: UnitTestRunner.Vitest, + const project = readProjectConfiguration(appTree, 'my-app'); + expect(project.targets.test).toStrictEqual({ + executor: '@angular/build:unit-test', + options: {}, }); - - expect( - appTree.read('my-app/src/test-setup.ts', 'utf-8') - ).toMatchSnapshot(); - }); - - it('should exclude src/test-setup.ts in tsconfig.app.json', async () => { - await generateApp(appTree, 'my-app', { - unitTestRunner: UnitTestRunner.Vitest, - }); - - const tsConfig = readJson(appTree, 'my-app/tsconfig.app.json'); - expect(tsConfig.exclude).toContain('src/test-setup.ts'); + const nxJson = readNxJson(appTree); + expect(nxJson.targetDefaults['@angular/build:unit-test']).toStrictEqual( + { + cache: true, + inputs: ['default', '^default'], + } + ); }); - it('should add tsconfig.spec.json', async () => { + it('should install vitest, jsdom and @angular/build packages', async () => { await generateApp(appTree, 'my-app', { unitTestRunner: UnitTestRunner.Vitest, }); + const { devDependencies } = readJson(appTree, 'package.json'); + expect(devDependencies['vitest']).toBeDefined(); + expect(devDependencies['jsdom']).toBeDefined(); + expect(devDependencies['@angular/build']).toBeDefined(); + // should not install packages related to the older setup with @analogjs expect( - appTree.read('my-app/tsconfig.spec.json', 'utf-8') - ).toMatchSnapshot(); + devDependencies['@analogjs/vite-plugin-angular'] + ).toBeUndefined(); + expect(devDependencies['@analogjs/vitest-angular']).toBeUndefined(); }); - it('should add a reference to tsconfig.spec.json in tsconfig.json', async () => { + it('should configure typescript correctly', async () => { await generateApp(appTree, 'my-app', { unitTestRunner: UnitTestRunner.Vitest, }); + const tsconfigSpec = appTree.read('my-app/tsconfig.spec.json', 'utf-8'); + expect(tsconfigSpec).toMatchInlineSnapshot(` + "{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "types": [ + "vitest/globals" + ] + }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts" + ] + } + " + `); const { references } = readJson(appTree, 'my-app/tsconfig.json'); expect(references).toContainEqual({ path: './tsconfig.spec.json', }); }); - - it('should add @nx/vitest dependency', async () => { - await generateApp(appTree, 'my-app', { - unitTestRunner: UnitTestRunner.Vitest, - }); - - const { devDependencies } = readJson(appTree, 'package.json'); - expect(devDependencies['@nx/vitest']).toBeDefined(); - }); - - it('should add vitest-angular', async () => { - await generateApp(appTree, 'my-app', { - unitTestRunner: UnitTestRunner.Vitest, - }); - - const { devDependencies } = readJson(appTree, 'package.json'); - expect(devDependencies['@analogjs/vite-plugin-angular']).toBeDefined(); - expect(devDependencies['@analogjs/vitest-angular']).toBeDefined(); - }); - - it('should not override build configuration when using vitest as a test runner', async () => { - await generateApp(appTree, 'my-app', { - unitTestRunner: UnitTestRunner.Vitest, - }); - const { targets } = readProjectConfiguration(appTree, 'my-app'); - expect(targets.build.executor).toBe('@angular/build:application'); - }); - - it('should not override serve configuration when using vitest as a test runner', async () => { - await generateApp(appTree, 'my-app', { - unitTestRunner: UnitTestRunner.Vitest, - }); - const { targets } = readProjectConfiguration(appTree, 'my-app'); - expect(targets.serve.executor).toBe('@angular/build:dev-server'); - }); }); describe('none', () => { @@ -1256,22 +1233,23 @@ describe('app', () => { expect(jestPlugin).toBeDefined(); }); - it('should generate use crystal vitest when --bundler=rspack', async () => { + it('should generate test target with @angular/build:unit-test executor for vitest when --bundler=rspack', async () => { await generateApp(appTree, 'app1', { bundler: 'rspack', unitTestRunner: UnitTestRunner.Vitest, }); const project = readProjectConfiguration(appTree, 'app1'); - expect(project.targets.test).not.toBeDefined(); + expect(project.targets.test).toStrictEqual({ + executor: '@angular/build:unit-test', + options: {}, + }); const nxJson = readNxJson(appTree); - const vitestPlugin = nxJson.plugins.find( - (p) => - (typeof p === 'string' && p === '@nx/vitest') || - (typeof p !== 'string' && p.plugin === '@nx/vitest') - ); - expect(vitestPlugin).toBeDefined(); + expect(nxJson.targetDefaults['@angular/build:unit-test']).toStrictEqual({ + cache: true, + inputs: ['default', '^default'], + }); }); it('should generate target options "browser" and "buildTarget"', async () => { @@ -1311,13 +1289,13 @@ describe('app', () => { const { devDependencies } = readJson(appTree, 'package.json'); expect(devDependencies['@angular-devkit/build-angular']).toEqual( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); expect(devDependencies['@angular-devkit/schematics']).toEqual( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); expect(devDependencies['@schematics/angular']).toEqual( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); }); @@ -1603,11 +1581,11 @@ describe('app', () => { "src/**/*.d.ts" ], "exclude": [ - "jest.config.ts", - "src/test-setup.ts", - "src/**/*.test.ts", "src/**/*.spec.ts", - "jest.config.cts" + "src/**/*.test.ts", + "jest.config.ts", + "jest.config.cts", + "src/test-setup.ts" ] } " @@ -1621,11 +1599,11 @@ describe('app', () => { ], "compilerOptions": {}, "exclude": [ - "jest.config.ts", - "src/test-setup.ts", - "src/**/*.test.ts", "src/**/*.spec.ts", - "jest.config.cts" + "src/**/*.test.ts", + "jest.config.ts", + "jest.config.cts", + "src/test-setup.ts" ] } " @@ -1753,6 +1731,122 @@ describe('app', () => { " `); }); + + describe('vitest & angular < 21', () => { + beforeEach(() => { + updateJson(appTree, 'package.json', (json) => ({ + ...json, + dependencies: { + ...json.dependencies, + '@angular/core': '~20.3.0', + }, + })); + }); + + it('should generate vite.config.mts', async () => { + await generateApp(appTree, 'my-app', { + skipFormat: false, + unitTestRunner: UnitTestRunner.Vitest, + }); + + expect( + appTree.read('my-app/vite.config.mts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should generate src/test-setup.ts', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + + expect( + appTree.read('my-app/src/test-setup.ts', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should exclude src/test-setup.ts in tsconfig.app.json', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + + const tsConfig = readJson(appTree, 'my-app/tsconfig.app.json'); + expect(tsConfig.exclude).toContain('src/test-setup.ts'); + }); + + it('should add tsconfig.spec.json', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + + expect( + appTree.read('my-app/tsconfig.spec.json', 'utf-8') + ).toMatchSnapshot(); + }); + + it('should add a reference to tsconfig.spec.json in tsconfig.json', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + + const { references } = readJson(appTree, 'my-app/tsconfig.json'); + expect(references).toContainEqual({ + path: './tsconfig.spec.json', + }); + }); + + it('should add @nx/vitest dependency', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + + const { devDependencies } = readJson(appTree, 'package.json'); + expect(devDependencies['@nx/vitest']).toBeDefined(); + }); + + it('should add vitest-angular', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + + const { devDependencies } = readJson(appTree, 'package.json'); + expect(devDependencies['@analogjs/vite-plugin-angular']).toBeDefined(); + expect(devDependencies['@analogjs/vitest-angular']).toBeDefined(); + }); + + it('should not override build configuration when using vitest as a test runner', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + const { targets } = readProjectConfiguration(appTree, 'my-app'); + expect(targets.build.executor).toBe('@angular/build:application'); + }); + + it('should not override serve configuration when using vitest as a test runner', async () => { + await generateApp(appTree, 'my-app', { + unitTestRunner: UnitTestRunner.Vitest, + }); + const { targets } = readProjectConfiguration(appTree, 'my-app'); + expect(targets.serve.executor).toBe('@angular/build:dev-server'); + }); + + it('should use crystal vitest when --bundler=rspack', async () => { + await generateApp(appTree, 'app1', { + bundler: 'rspack', + unitTestRunner: UnitTestRunner.Vitest, + }); + + const project = readProjectConfiguration(appTree, 'app1'); + expect(project.targets.test).not.toBeDefined(); + + const nxJson = readNxJson(appTree); + const vitestPlugin = nxJson.plugins.find( + (p) => + (typeof p === 'string' && p === '@nx/vitest') || + (typeof p !== 'string' && p.plugin === '@nx/vitest') + ); + expect(vitestPlugin).toBeDefined(); + }); + }); }); }); diff --git a/packages/angular/src/generators/application/files/base/tsconfig.app.json__tpl__ b/packages/angular/src/generators/application/files/base/tsconfig.app.json__tpl__ index dbb52b9f9a451..e974679c85eb5 100644 --- a/packages/angular/src/generators/application/files/base/tsconfig.app.json__tpl__ +++ b/packages/angular/src/generators/application/files/base/tsconfig.app.json__tpl__ @@ -11,9 +11,7 @@ "include": ["src/**/*.ts"], <%_ } _%> "exclude": [ - "jest.config.ts", - "src/test-setup.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts" + "src/**/*.spec.ts", + "src/**/*.test.ts" ] } diff --git a/packages/angular/src/generators/application/lib/add-unit-test-runner.ts b/packages/angular/src/generators/application/lib/add-unit-test-runner.ts index 074aa4630bcf1..2ce39452c6469 100644 --- a/packages/angular/src/generators/application/lib/add-unit-test-runner.ts +++ b/packages/angular/src/generators/application/lib/add-unit-test-runner.ts @@ -13,6 +13,7 @@ export async function addUnitTestRunner(host: Tree, options: NormalizedSchema) { skipPackageJson: options.skipPackageJson, strict: options.strict, addPlugin: options.addPlugin, + runtimeTsconfigFileName: 'tsconfig.app.json', zoneless: options.zoneless, }); break; diff --git a/packages/angular/src/generators/application/lib/normalize-options.ts b/packages/angular/src/generators/application/lib/normalize-options.ts index bb039e67cae01..dbe53680c4d62 100644 --- a/packages/angular/src/generators/application/lib/normalize-options.ts +++ b/packages/angular/src/generators/application/lib/normalize-options.ts @@ -52,6 +52,9 @@ export async function normalizeOptions( const { major: angularMajorVersion } = getInstalledAngularVersionInfo(host); const zonelessDefaultValue = angularMajorVersion >= 21 ? true : false; + const unitTestRunner = + options.unitTestRunner ?? + (angularMajorVersion >= 21 ? UnitTestRunner.Vitest : UnitTestRunner.Jest); // Set defaults and then overwrite with user options return { @@ -60,7 +63,7 @@ export async function normalizeOptions( routing: true, inlineStyle: false, inlineTemplate: false, - skipTests: options.unitTestRunner === UnitTestRunner.None, + skipTests: unitTestRunner === UnitTestRunner.None, skipFormat: false, e2eTestRunner: E2eTestRunner.Playwright, linter: 'eslint', @@ -81,7 +84,7 @@ export async function normalizeOptions( !options.rootProject ? appProjectRoot : appProjectName ), ssr: options.ssr ?? false, - unitTestRunner: options.unitTestRunner ?? UnitTestRunner.Jest, + unitTestRunner, zoneless: options.zoneless ?? zonelessDefaultValue, }; } diff --git a/packages/angular/src/generators/application/schema.json b/packages/angular/src/generators/application/schema.json index 4dd8737943716..004eca81cd73d 100644 --- a/packages/angular/src/generators/application/schema.json +++ b/packages/angular/src/generators/application/schema.json @@ -99,10 +99,9 @@ }, "unitTestRunner": { "type": "string", - "enum": ["jest", "vitest", "none"], - "description": "Test runner to use for unit tests.", - "x-prompt": "Which unit test runner would you like to use?", - "default": "jest" + "enum": ["vitest", "jest", "none"], + "description": "Test runner to use for unit tests. It defaults to `vitest` when using the `esbuild` bundler for Angular versions >= 21.0.0. Otherwise, it defaults to `jest`.", + "x-prompt": "Which unit test runner would you like to use?" }, "e2eTestRunner": { "type": "string", diff --git a/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap b/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap index aa07fee091982..528f6d02101de 100644 --- a/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap +++ b/packages/angular/src/generators/cypress-component-configuration/__snapshots__/cypress-component-configuration.spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Cypress Component Testing Configuration should setup angular specific configs: component.ts 1`] = ` "import { mount } from 'cypress/angular'; diff --git a/packages/angular/src/generators/federate-module/federate-module.ts b/packages/angular/src/generators/federate-module/federate-module.ts index 88f83a1909f08..5a8632549ea8e 100644 --- a/packages/angular/src/generators/federate-module/federate-module.ts +++ b/packages/angular/src/generators/federate-module/federate-module.ts @@ -5,13 +5,15 @@ import { stripIndents, type Tree, } from '@nx/devkit'; -import { type Schema } from './schema'; +import { UnitTestRunner } from '../../utils/test-runners'; +import { getInstalledAngularVersionInfo } from '../utils/version-utils'; import { addFileToRemoteTsconfig, addPathToExposes, addPathToTsConfig, addRemote, } from './lib'; +import type { Schema } from './schema'; export async function federateModuleGenerator(tree: Tree, schema: Schema) { if (!tree.exists(schema.path)) { @@ -22,6 +24,10 @@ export async function federateModuleGenerator(tree: Tree, schema: Schema) { schema.standalone = schema.standalone ?? true; + const { major: angularMajorVersion } = getInstalledAngularVersionInfo(tree); + schema.unitTestRunner ??= + angularMajorVersion >= 21 ? UnitTestRunner.Vitest : UnitTestRunner.Jest; + const { tasks, projectRoot, remoteName } = await addRemote(tree, schema); addFileToRemoteTsconfig(tree, remoteName, schema.path); diff --git a/packages/angular/src/generators/federate-module/lib/add-remote.ts b/packages/angular/src/generators/federate-module/lib/add-remote.ts index 17164bf5a32bf..d7476dcf49d29 100644 --- a/packages/angular/src/generators/federate-module/lib/add-remote.ts +++ b/packages/angular/src/generators/federate-module/lib/add-remote.ts @@ -17,7 +17,7 @@ export async function addRemote(tree: Tree, schema: Schema) { directory: schema.remoteDirectory, host: schema.host, standalone: schema.standalone, - unitTestRunner: schema.unitTestRunner ?? UnitTestRunner.Jest, + unitTestRunner: schema.unitTestRunner, e2eTestRunner: schema.e2eTestRunner ?? E2eTestRunner.Cypress, skipFormat: true, }); diff --git a/packages/angular/src/generators/federate-module/schema.json b/packages/angular/src/generators/federate-module/schema.json index e0bcf30ca5b74..34910112f6d68 100644 --- a/packages/angular/src/generators/federate-module/schema.json +++ b/packages/angular/src/generators/federate-module/schema.json @@ -57,10 +57,9 @@ }, "unitTestRunner": { "type": "string", - "enum": ["jest", "vitest", "none"], - "description": "Test runner to use for unit tests of the Producer (remote) if it needs to be created.", - "x-prompt": "Which unit test runner would you like to use?", - "default": "jest" + "enum": ["vitest", "jest", "none"], + "description": "Test runner to use for unit tests of the Producer (remote) if it needs to be created. It defaults to `vitest` for Angular versions >= 21.0.0. Otherwise, it defaults to `jest`.", + "x-prompt": "Which unit test runner would you like to use?" }, "e2eTestRunner": { "type": "string", diff --git a/packages/angular/src/generators/host/schema.json b/packages/angular/src/generators/host/schema.json index 0fd9a4f228101..15e0bf5117951 100644 --- a/packages/angular/src/generators/host/schema.json +++ b/packages/angular/src/generators/host/schema.json @@ -119,8 +119,8 @@ }, "unitTestRunner": { "type": "string", - "enum": ["jest", "vitest", "none"], - "description": "Test runner to use for unit tests.", + "enum": ["vitest", "jest", "none"], + "description": "Test runner to use for unit tests. It defaults to `vitest` for Angular versions >= 21.0.0. Otherwise, it defaults to `jest`.", "x-prompt": "Which unit test runner would you like to use?", "default": "jest" }, diff --git a/packages/angular/src/generators/library-secondary-entry-point/library-secondary-entry-point.spec.ts b/packages/angular/src/generators/library-secondary-entry-point/library-secondary-entry-point.spec.ts index 0fb39a79ac59a..823784e6e9242 100644 --- a/packages/angular/src/generators/library-secondary-entry-point/library-secondary-entry-point.spec.ts +++ b/packages/angular/src/generators/library-secondary-entry-point/library-secondary-entry-point.spec.ts @@ -204,10 +204,7 @@ describe('librarySecondaryEntryPoint generator', () => { expect(tsConfig.include).toStrictEqual(['src/**/*.ts']); expect(tsConfig.exclude).toStrictEqual([ 'src/**/*.spec.ts', - 'src/test-setup.ts', - 'jest.config.ts', 'src/**/*.test.ts', - 'jest.config.cts', ]); await librarySecondaryEntryPointGenerator(tree, { @@ -218,13 +215,7 @@ describe('librarySecondaryEntryPoint generator', () => { tsConfig = readJson(tree, 'libs/lib1/tsconfig.lib.json'); expect(tsConfig.include).toStrictEqual(['**/*.ts']); - expect(tsConfig.exclude).toStrictEqual([ - '**/*.spec.ts', - 'test-setup.ts', - 'jest.config.ts', - '**/*.test.ts', - 'jest.config.cts', - ]); + expect(tsConfig.exclude).toStrictEqual(['**/*.spec.ts', '**/*.test.ts']); }); it('should format files', async () => { diff --git a/packages/angular/src/generators/library/files/base/tsconfig.lib.json__tpl__ b/packages/angular/src/generators/library/files/base/tsconfig.lib.json__tpl__ index 3bcec8083ed8f..c566cd6485ad6 100644 --- a/packages/angular/src/generators/library/files/base/tsconfig.lib.json__tpl__ +++ b/packages/angular/src/generators/library/files/base/tsconfig.lib.json__tpl__ @@ -7,11 +7,9 @@ "inlineSources": true, "types": [] }, + "include": ["src/**/*.ts"], "exclude": [ - "src/**/*.spec.ts",<% if(unitTesting) { %> - "src/test-setup.ts",<% } %> - "jest.config.ts", + "src/**/*.spec.ts", "src/**/*.test.ts" - ], - "include": ["src/**/*.ts"] + ] } diff --git a/packages/angular/src/generators/library/lib/normalize-options.ts b/packages/angular/src/generators/library/lib/normalize-options.ts index 415e2f79a081b..5d04515fa8222 100644 --- a/packages/angular/src/generators/library/lib/normalize-options.ts +++ b/packages/angular/src/generators/library/lib/normalize-options.ts @@ -8,6 +8,7 @@ import { getComponentType, getModuleTypeSeparator, } from '../../utils/artifact-types'; +import { getInstalledAngularVersionInfo } from '../../utils/version-utils'; import type { Schema } from '../schema'; import type { NormalizedSchema } from './normalized-schema'; @@ -22,7 +23,6 @@ export async function normalizeOptions( linter: 'eslint', publishable: false, skipFormat: false, - unitTestRunner: UnitTestRunner.Jest, // Publishable libs cannot use `full` yet, so if its false then use the passed value or default to `full` compilationMode: schema.publishable ? 'partial' @@ -53,11 +53,16 @@ export async function normalizeOptions( const moduleTypeSeparator = getModuleTypeSeparator(host); const modulePath = `${projectRoot}/src/lib/${fileName}${moduleTypeSeparator}module.ts`; + const { major: angularMajorVersion } = getInstalledAngularVersionInfo(host); + const unitTestRunner = + options.unitTestRunner ?? + (angularMajorVersion >= 21 ? UnitTestRunner.Vitest : UnitTestRunner.Jest); + const ngCliSchematicLibRoot = projectName; const allNormalizedOptions = { ...options, linter: options.linter ?? 'eslint', - unitTestRunner: options.unitTestRunner ?? UnitTestRunner.Jest, + unitTestRunner, prefix: options.prefix ?? 'lib', name: projectName, projectRoot, @@ -68,7 +73,7 @@ export async function normalizeOptions( fileName, importPath, ngCliSchematicLibRoot, - skipTests: options.unitTestRunner === 'none' ? true : options.skipTests, + skipTests: unitTestRunner === 'none' ? true : options.skipTests, standaloneComponentName: `${ names(projectNames.projectSimpleName).className }Component`, diff --git a/packages/angular/src/generators/library/lib/update-tsconfig-files.ts b/packages/angular/src/generators/library/lib/update-tsconfig-files.ts index 38a8655700d46..4ffb7dad7d53e 100644 --- a/packages/angular/src/generators/library/lib/update-tsconfig-files.ts +++ b/packages/angular/src/generators/library/lib/update-tsconfig-files.ts @@ -126,9 +126,8 @@ function updateProjectConfig( json.exclude = [ ...new Set([ ...(json.exclude || []), - 'jest.config.ts', - 'src/**/*.test.ts', 'src/**/*.spec.ts', + 'src/**/*.test.ts', ]), ]; return json; diff --git a/packages/angular/src/generators/library/library.spec.ts b/packages/angular/src/generators/library/library.spec.ts index 84a8ad220d109..01767a5812ab9 100644 --- a/packages/angular/src/generators/library/library.spec.ts +++ b/packages/angular/src/generators/library/library.spec.ts @@ -410,10 +410,10 @@ describe('lib', () => { const tsconfigJson = readJson(tree, 'my-lib/tsconfig.lib.json'); expect(tsconfigJson.exclude).toEqual([ 'src/**/*.spec.ts', - 'src/test-setup.ts', - 'jest.config.ts', 'src/**/*.test.ts', + 'jest.config.ts', 'jest.config.cts', + 'src/test-setup.ts', ]); }); @@ -427,7 +427,6 @@ describe('lib', () => { const tsconfigJson = readJson(tree, 'my-lib/tsconfig.lib.json'); expect(tsconfigJson.exclude).toEqual([ 'src/**/*.spec.ts', - 'jest.config.ts', 'src/**/*.test.ts', ]); }); @@ -860,10 +859,10 @@ describe('lib', () => { expect(tsConfigLibJson.exclude).toEqual([ 'src/**/*.spec.ts', - 'src/test-setup.ts', - 'jest.config.ts', 'src/**/*.test.ts', + 'jest.config.ts', 'jest.config.cts', + 'src/test-setup.ts', ]); expect(moduleContents2).toMatchInlineSnapshot(` @@ -894,20 +893,20 @@ describe('lib', () => { expect(tsConfigLibJson2.exclude).toEqual([ 'src/**/*.spec.ts', - 'src/test-setup.ts', - 'jest.config.ts', 'src/**/*.test.ts', + 'jest.config.ts', 'jest.config.cts', + 'src/test-setup.ts', ]); expect(moduleContents3).toMatchSnapshot(); expect(tsConfigLibJson3.exclude).toEqual([ 'src/**/*.spec.ts', - 'src/test-setup.ts', - 'jest.config.ts', 'src/**/*.test.ts', + 'jest.config.ts', 'jest.config.cts', + 'src/test-setup.ts', ]); }); diff --git a/packages/angular/src/generators/library/library.ts b/packages/angular/src/generators/library/library.ts index 14b5303a47dd5..8234c5408378a 100644 --- a/packages/angular/src/generators/library/library.ts +++ b/packages/angular/src/generators/library/library.ts @@ -139,6 +139,7 @@ async function addUnitTestRunner( projectRoot: options.projectRoot, skipPackageJson: options.skipPackageJson, strict: options.strict, + runtimeTsconfigFileName: 'tsconfig.lib.json', zoneless, }); break; @@ -148,6 +149,7 @@ async function addUnitTestRunner( projectRoot: options.projectRoot, skipPackageJson: options.skipPackageJson, strict: options.strict, + useNxUnitTestRunner: options.buildable || options.publishable, }); break; } diff --git a/packages/angular/src/generators/library/schema.json b/packages/angular/src/generators/library/schema.json index 07bf086eb280d..bc1428206f961 100644 --- a/packages/angular/src/generators/library/schema.json +++ b/packages/angular/src/generators/library/schema.json @@ -83,10 +83,9 @@ }, "unitTestRunner": { "type": "string", - "enum": ["jest", "vitest", "none"], - "description": "Test runner to use for unit tests.", - "x-prompt": "Which unit test runner would you like to use?", - "default": "jest" + "enum": ["vitest", "jest", "none"], + "description": "Test runner to use for unit tests. It defaults to `vitest` when using Angular versions >= 21.0.0. Otherwise, it defaults to `jest`.", + "x-prompt": "Which unit test runner would you like to use?" }, "importPath": { "type": "string", diff --git a/packages/angular/src/generators/ngrx/ngrx.spec.ts b/packages/angular/src/generators/ngrx/ngrx.spec.ts index b167b5c837a14..de7ac691417df 100644 --- a/packages/angular/src/generators/ngrx/ngrx.spec.ts +++ b/packages/angular/src/generators/ngrx/ngrx.spec.ts @@ -681,25 +681,25 @@ export const appRoutes: Routes = [{ path: 'home', component: NxWelcome }]; const packageJson = devkit.readJson(tree, 'package.json'); expect(packageJson.dependencies['@ngrx/store']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.dependencies['@ngrx/effects']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.dependencies['@ngrx/entity']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.dependencies['@ngrx/router-store']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.dependencies['@ngrx/component-store']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.devDependencies['@ngrx/schematics']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.devDependencies['@ngrx/store-devtools']).toEqual( - backwardCompatibleVersions.angularV19.ngrxVersion + backwardCompatibleVersions[19].ngrxVersion ); expect(packageJson.devDependencies['jasmine-marbles']).toBeDefined(); }); diff --git a/packages/angular/src/generators/remote/schema.json b/packages/angular/src/generators/remote/schema.json index 5cad7f1c9637e..3749755797294 100644 --- a/packages/angular/src/generators/remote/schema.json +++ b/packages/angular/src/generators/remote/schema.json @@ -113,8 +113,8 @@ }, "unitTestRunner": { "type": "string", - "enum": ["jest", "vitest", "none"], - "description": "Test runner to use for unit tests.", + "enum": ["vitest", "jest", "none"], + "description": "Test runner to use for unit tests. It defaults to `vitest` for Angular versions >= 21.0.0. Otherwise, it defaults to `jest`.", "x-prompt": "Which unit test runner would you like to use?", "default": "jest" }, diff --git a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts index 56b5f5dfd6e83..0f14419d2cd28 100644 --- a/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts +++ b/packages/angular/src/generators/setup-ssr/setup-ssr.spec.ts @@ -62,13 +62,7 @@ describe('setupSSR', () => { "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": [ - "jest.config.ts", - "src/test-setup.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "jest.config.cts" - ] + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] } " `); @@ -170,13 +164,7 @@ describe('setupSSR', () => { "types": ["node"] }, "include": ["src/**/*.ts"], - "exclude": [ - "jest.config.ts", - "src/test-setup.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "jest.config.cts" - ] + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] } " `); @@ -750,22 +738,22 @@ describe('setupSSR', () => { // ASSERT const pkgJson = readJson(tree, 'package.json'); expect(pkgJson.dependencies['@angular/ssr']).toBe( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); expect(pkgJson.dependencies['@angular/platform-server']).toEqual( - backwardCompatibleVersions.angularV19.angularVersion + backwardCompatibleVersions[19].angularVersion ); expect(pkgJson.dependencies['@angular/ssr']).toEqual( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); expect(pkgJson.dependencies['express']).toEqual( - backwardCompatibleVersions.angularV19.expressVersion + backwardCompatibleVersions[19].expressVersion ); expect( pkgJson.dependencies['@nguniversal/express-engine'] ).toBeUndefined(); expect(pkgJson.devDependencies['@types/express']).toBe( - backwardCompatibleVersions.angularV19.typesExpressVersion + backwardCompatibleVersions[19].typesExpressVersion ); expect(pkgJson.devDependencies['@nguniversal/builders']).toBeUndefined(); }); diff --git a/packages/angular/src/generators/utils/add-jest.spec.ts b/packages/angular/src/generators/utils/add-jest.spec.ts index c0cc6ece0cfae..1fa9927f6c452 100644 --- a/packages/angular/src/generators/utils/add-jest.spec.ts +++ b/packages/angular/src/generators/utils/add-jest.spec.ts @@ -23,6 +23,7 @@ describe('addJest', () => { projectRoot: 'app1', skipPackageJson: false, strict: false, + runtimeTsconfigFileName: 'tsconfig.app.json', zoneless: true, }); @@ -40,6 +41,7 @@ describe('addJest', () => { projectRoot: 'app1', skipPackageJson: false, strict: true, + runtimeTsconfigFileName: 'tsconfig.app.json', zoneless: true, }); @@ -60,6 +62,7 @@ describe('addJest', () => { projectRoot: 'app1', skipPackageJson: false, strict: false, + runtimeTsconfigFileName: 'tsconfig.app.json', zoneless: false, }); @@ -77,6 +80,7 @@ describe('addJest', () => { projectRoot: 'app1', skipPackageJson: false, strict: true, + runtimeTsconfigFileName: 'tsconfig.app.json', zoneless: false, }); diff --git a/packages/angular/src/generators/utils/add-jest.ts b/packages/angular/src/generators/utils/add-jest.ts index a670c04faccf8..4ce11d22d121f 100644 --- a/packages/angular/src/generators/utils/add-jest.ts +++ b/packages/angular/src/generators/utils/add-jest.ts @@ -2,6 +2,7 @@ import { addDependenciesToPackageJson, ensurePackage, joinPathFragments, + updateJson, type Tree, } from '@nx/devkit'; import { nxVersion } from '../../utils/versions'; @@ -12,6 +13,7 @@ export type AddJestOptions = { projectRoot: string; skipPackageJson: boolean; strict: boolean; + runtimeTsconfigFileName: 'tsconfig.app.json' | 'tsconfig.lib.json'; zoneless: boolean; addPlugin?: boolean; }; @@ -70,6 +72,21 @@ export async function addJest( } else { tree.write(setupFile, getZoneSetupFile(options.strict)); } + + const runtimeTsconfigPath = joinPathFragments( + options.projectRoot, + options.runtimeTsconfigFileName + ); + if (tree.exists(runtimeTsconfigPath)) { + updateJson(tree, runtimeTsconfigPath, (json) => { + const excludeSet = new Set([ + ...(json.exclude ?? []), + 'src/test-setup.ts', + ]); + json.exclude = Array.from(excludeSet); + return json; + }); + } } const strictTestEnvOptions = `{ diff --git a/packages/angular/src/generators/utils/add-vitest.ts b/packages/angular/src/generators/utils/add-vitest.ts index a8afd37df8c5b..3a15fa8f92f0c 100644 --- a/packages/angular/src/generators/utils/add-vitest.ts +++ b/packages/angular/src/generators/utils/add-vitest.ts @@ -1,10 +1,26 @@ import { addDependenciesToPackageJson, ensurePackage, + getDependencyVersionFromPackageJson, + joinPathFragments, + logger, + offsetFromRoot, + readNxJson, + readProjectConfiguration, type Tree, + updateJson, + updateNxJson, + updateProjectConfiguration, + writeJson, } from '@nx/devkit'; +import { readModulePackageJson } from 'nx/src/devkit-internals'; +import { intersects, satisfies, valid, validRange } from 'semver'; import { nxVersion } from '../../utils/versions'; -import { getInstalledAngularDevkitVersion, versions } from './version-utils'; +import { + getInstalledAngularDevkitVersion, + getInstalledAngularVersionInfo, + versions, +} from './version-utils'; export type AddVitestOptions = { name: string; @@ -12,11 +28,72 @@ export type AddVitestOptions = { skipPackageJson: boolean; strict: boolean; addPlugin?: boolean; + useNxUnitTestRunner?: boolean; }; export async function addVitest( tree: Tree, options: AddVitestOptions +): Promise { + const { major: angularMajorVersion } = getInstalledAngularVersionInfo(tree); + if (angularMajorVersion >= 21) { + await configureAngularUnitTestBuilderTarget(tree, options); + } else { + await configureVitestWithAnalog(tree, options); + } +} + +async function configureAngularUnitTestBuilderTarget( + tree: Tree, + options: AddVitestOptions +): Promise { + validateVitestVersion(tree); + + const executor = options.useNxUnitTestRunner + ? '@nx/angular:unit-test' + : '@angular/build:unit-test'; + const project = readProjectConfiguration(tree, options.name); + project.targets ??= {}; + project.targets.test = { executor, options: {} }; + updateProjectConfiguration(tree, options.name, project); + + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults[executor] ??= { + cache: true, + inputs: + nxJson.namedInputs && 'production' in nxJson.namedInputs + ? ['default', '^production'] + : ['default', '^default'], + }; + updateNxJson(tree, nxJson); + + configureTypeScriptForVitest(tree, options); + addVitestScreenshotsToGitIgnore(tree); + + if (!options.skipPackageJson) { + const pkgVersions = versions(tree, { minAngularMajorVersion: 21 }); + const angularDevkitVersion = + getInstalledAngularDevkitVersion(tree) ?? + pkgVersions.angularDevkitVersion; + + addDependenciesToPackageJson( + tree, + {}, + { + '@angular/build': angularDevkitVersion, + jsdom: pkgVersions.jsdomVersion, + vitest: pkgVersions.vitestVersion, + }, + undefined, + true + ); + } +} + +async function configureVitestWithAnalog( + tree: Tree, + options: AddVitestOptions ): Promise { ensurePackage('@nx/vitest', nxVersion); const { configurationGenerator } = await import('@nx/vitest/generators'); @@ -27,6 +104,7 @@ export async function addVitest( testEnvironment: 'jsdom', coverageProvider: 'v8', addPlugin: options.addPlugin ?? false, + skipPackageJson: options.skipPackageJson, }); if (!options.skipPackageJson) { @@ -43,3 +121,112 @@ export async function addVitest( ); } } + +function validateVitestVersion(tree: Tree): void { + let installedVitestVersion: string | null = null; + + // Try to get the actual installed version from node_modules + try { + const { packageJson } = readModulePackageJson('vitest'); + installedVitestVersion = packageJson.version; + } catch {} + + const pkgVersions = versions(tree, { minAngularMajorVersion: 21 }); + const requiredRange = pkgVersions.vitestVersion; + + if (installedVitestVersion) { + if ( + !satisfies(installedVitestVersion, requiredRange, { + includePrerelease: true, + }) + ) { + throw new Error( + `The installed vitest version "${installedVitestVersion}" is not compatible with the version range Angular requires: "${requiredRange}".` + ); + } + + return; + } + + // not installed, get it from package.json + installedVitestVersion = getDependencyVersionFromPackageJson(tree, 'vitest'); + if (!installedVitestVersion) { + // not declared anywhere, it'll be installed with the correct version + return; + } + + if (valid(installedVitestVersion)) { + if ( + !satisfies(installedVitestVersion, requiredRange, { + includePrerelease: true, + }) + ) { + throw new Error( + `The installed vitest version "${installedVitestVersion}" is not compatible with the version range Angular requires: "${requiredRange}".` + ); + } + } else if (validRange(installedVitestVersion)) { + // it's a range from package.json, check if it intersects with the required range + if ( + !intersects(installedVitestVersion, requiredRange, { + includePrerelease: true, + }) + ) { + throw new Error( + `The declared vitest version range "${installedVitestVersion}" does not overlap with the version range Angular requires: "${requiredRange}". When installed, this may cause compatibility issues.` + ); + } + } else { + // it can be anything, we don't have a way to validate it + // log a warning and continue + logger.warn( + `The declared vitest version "${installedVitestVersion}" is not a valid semver range, ` + + `so we can't validate if it's compatible with the version range Angular requires: "${requiredRange}". ` + + `The generation will continue, but you may encounter issues if the version is not compatible.` + ); + } +} + +function configureTypeScriptForVitest( + tree: Tree, + options: AddVitestOptions +): void { + writeJson( + tree, + joinPathFragments(options.projectRoot, 'tsconfig.spec.json'), + { + extends: './tsconfig.json', + compilerOptions: { + outDir: `${offsetFromRoot(options.projectRoot)}dist/out-tsc`, + types: ['vitest/globals'], + }, + include: ['src/**/*.ts', 'src/**/*.d.ts'], + } + ); + + const projectTsconfigPath = joinPathFragments( + options.projectRoot, + 'tsconfig.json' + ); + updateJson(tree, projectTsconfigPath, (json) => { + json.references ??= []; + if (!json.references.some((ref) => ref.path === './tsconfig.spec.json')) { + json.references.push({ path: './tsconfig.spec.json' }); + } + return json; + }); +} + +function addVitestScreenshotsToGitIgnore(tree: Tree): void { + if (tree.exists('.gitignore')) { + let content = tree.read('.gitignore', 'utf-8'); + if (/^__screenshots__\/$/gm.test(content)) { + return; + } + + content = `${content}\n__screenshots__/\n`; + tree.write('.gitignore', content); + } else { + logger.warn(`Couldn't find .gitignore file to update`); + } +} diff --git a/packages/angular/src/generators/utils/ensure-angular-dependencies.spec.ts b/packages/angular/src/generators/utils/ensure-angular-dependencies.spec.ts index bf7cd39e57fce..bdcd30c46d465 100644 --- a/packages/angular/src/generators/utils/ensure-angular-dependencies.spec.ts +++ b/packages/angular/src/generators/utils/ensure-angular-dependencies.spec.ts @@ -53,10 +53,10 @@ describe('ensureAngularDependencies', () => { const { devDependencies } = readJson(tree, 'package.json'); expect(devDependencies['@angular/build']).toBe( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); expect(devDependencies['@angular-devkit/build-angular']).toBe( - backwardCompatibleVersions.angularV19.angularDevkitVersion + backwardCompatibleVersions[19].angularDevkitVersion ); }); diff --git a/packages/angular/src/generators/utils/version-utils.ts b/packages/angular/src/generators/utils/version-utils.ts index 8c65c4e5d4e3e..d2470b1110417 100644 --- a/packages/angular/src/generators/utils/version-utils.ts +++ b/packages/angular/src/generators/utils/version-utils.ts @@ -3,7 +3,9 @@ import { clean, coerce, major } from 'semver'; import { backwardCompatibleVersions, type PackageCompatVersions, - type PackageLatestVersions, + type SupportedVersion, + supportedVersions, + type VersionMap, } from '../../utils/backward-compatible-versions'; import * as latestVersions from '../../utils/versions'; import { angularVersion } from '../../utils/versions'; @@ -55,16 +57,28 @@ export function getInstalledPackageVersionInfo(tree: Tree, pkgName: string) { return version ? { major: major(coerce(version)), version } : null; } +export function versions(tree: Tree): PackageCompatVersions; +export function versions( + tree: Tree, + options: { minAngularMajorVersion: V } +): MinVersionReturnType; export function versions( - tree: Tree -): PackageLatestVersions | PackageCompatVersions { + tree: Tree, + options?: { minAngularMajorVersion: SupportedVersion } +): PackageCompatVersions { const majorAngularVersion = getInstalledAngularMajorVersion(tree); - switch (majorAngularVersion) { - case 19: - return backwardCompatibleVersions.angularV19; - default: - return latestVersions; + + if ( + options?.minAngularMajorVersion && + majorAngularVersion < options.minAngularMajorVersion + ) { + throw new Error( + `This operation requires Angular ${options.minAngularMajorVersion}+, but found version ${majorAngularVersion}. ` + + `This shouldn't happen. Please report it as a bug and include the stack trace.` + ); } + + return backwardCompatibleVersions[majorAngularVersion] ?? latestVersions; } /** @@ -77,6 +91,23 @@ export function getAngularRspackVersion(tree: Tree): string { // Starting with Angular 20, we can use an Angular Rspack version that is // aligned with the Nx version return majorAngularVersion === 19 - ? backwardCompatibleVersions.angularV19.angularRspackVersion + ? backwardCompatibleVersions[19].angularRspackVersion : latestVersions.nxVersion; } + +// Helper types + +type TakeUntil = Arr extends readonly [ + infer Head, + ...infer Rest +] + ? Head extends Target + ? [Head] + : [Head, ...TakeUntil] + : []; +type VersionsAtLeast = Extract< + SupportedVersion, + TakeUntil[number] +>; +type MinVersionReturnType = + VersionMap[VersionsAtLeast]; diff --git a/packages/angular/src/plugins/plugin.ts b/packages/angular/src/plugins/plugin.ts index 7a35381566bd3..8093fc74fe8ab 100644 --- a/packages/angular/src/plugins/plugin.ts +++ b/packages/angular/src/plugins/plugin.ts @@ -19,6 +19,7 @@ import { dirname, join, relative } from 'node:path'; import * as posix from 'node:path/posix'; import { hashObject } from 'nx/src/devkit-internals'; import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; +import { targetFromTargetString } from '../utils/targets'; export interface AngularPluginOptions { targetNamePrefix?: string; @@ -767,26 +768,6 @@ function mergeInputs( ]; } -// angular support abbreviated target specifiers, this is adapter from: -// https://github.com/angular/angular-cli/blob/7d9ce246a33c60ec96eb4bf99520f5475716a910/packages/angular_devkit/architect/src/api.ts#L336 -function targetFromTargetString( - specifier: string, - abbreviatedProjectName?: string, - abbreviatedTargetName?: string -) { - const tuple = specifier.split(':', 3); - if (tuple.length < 2) { - // invalid target, ignore - return undefined; - } - - // we only care about project and target - return { - project: tuple[0] || abbreviatedProjectName || '', - target: tuple[1] || abbreviatedTargetName || '', - }; -} - function getOutput( path: string, workspaceRoot: string, diff --git a/packages/angular/src/utils/backward-compatible-versions.ts b/packages/angular/src/utils/backward-compatible-versions.ts index 9db0d69b794c0..dbf2cc298432c 100644 --- a/packages/angular/src/utils/backward-compatible-versions.ts +++ b/packages/angular/src/utils/backward-compatible-versions.ts @@ -1,38 +1,34 @@ import * as latestVersions from './versions'; -type SupportedVersions = 'angularV19' | 'angularV20'; +export const supportedVersions = [21, 20, 19] as const; +export type SupportedVersion = (typeof supportedVersions)[number]; -type LatestPackageVersionNames = Exclude< +export type PackageVersionNames = Exclude< keyof typeof latestVersions, 'nxVersion' >; -type CompatPackageVersionNames = LatestPackageVersionNames; - -export type PackageVersionNames = - | LatestPackageVersionNames - | CompatPackageVersionNames; - export type VersionMap = { - angularV19: Record< - CompatPackageVersionNames | 'angularRspackVersion', + 21: Record; + 20: Record< + Exclude, string >; - angularV20: Record< - CompatPackageVersionNames | 'angularRspackVersion', + 19: Record< + | Exclude + | 'angularRspackVersion', string >; }; -export type PackageLatestVersions = Record; -export type PackageCompatVersions = VersionMap[SupportedVersions]; +export type PackageCompatVersions = VersionMap[SupportedVersion]; export const backwardCompatibleVersions: VersionMap = { - angularV19: { - angularVersion: '~19.2.0', - angularDevkitVersion: '~19.2.0', - ngPackagrVersion: '~19.2.0', - angularRspackVersion: '~20.6.1', - ngrxVersion: '~19.1.0', + 21: { ...latestVersions }, + 20: { + angularVersion: '~20.3.0', + angularDevkitVersion: '~20.3.0', + ngPackagrVersion: '~20.3.0', + ngrxVersion: '^20.0.0', rxjsVersion: '~7.8.0', zoneJsVersion: '~0.15.0', tsLibVersion: '^2.3.0', @@ -41,9 +37,9 @@ export const backwardCompatibleVersions: VersionMap = { expressVersion: '^4.21.2', typesExpressVersion: '^4.17.21', browserSyncVersion: '^3.0.0', - moduleFederationNodeVersion: '^2.6.26', - moduleFederationEnhancedVersion: '^0.9.0', - angularEslintVersion: '^19.2.0', + moduleFederationNodeVersion: '^2.7.21', + moduleFederationEnhancedVersion: '^0.21.2', + angularEslintVersion: '^20.3.0', typescriptEslintVersion: '^7.16.0', tailwindVersion: '^3.0.2', postcssVersion: '^8.4.5', @@ -51,18 +47,18 @@ export const backwardCompatibleVersions: VersionMap = { autoprefixerVersion: '^10.4.0', tsNodeVersion: '10.9.1', lessVersion: '^4.3.0', - jestPresetAngularVersion: '~14.4.0', + jestPresetAngularVersion: '~14.6.1', typesNodeVersion: '20.19.9', jasmineMarblesVersion: '^0.9.2', jsoncEslintParserVersion: '^2.1.0', webpackMergeVersion: '^5.8.0', }, - angularV20: { - angularVersion: '~20.3.0', - angularDevkitVersion: '~20.3.0', - ngPackagrVersion: '~20.3.0', + 19: { + angularVersion: '~19.2.0', + angularDevkitVersion: '~19.2.0', + ngPackagrVersion: '~19.2.0', angularRspackVersion: '~20.6.1', - ngrxVersion: '^20.0.0', + ngrxVersion: '~19.1.0', rxjsVersion: '~7.8.0', zoneJsVersion: '~0.15.0', tsLibVersion: '^2.3.0', @@ -71,9 +67,9 @@ export const backwardCompatibleVersions: VersionMap = { expressVersion: '^4.21.2', typesExpressVersion: '^4.17.21', browserSyncVersion: '^3.0.0', - moduleFederationNodeVersion: '^2.7.21', - moduleFederationEnhancedVersion: '^0.21.2', - angularEslintVersion: '^20.3.0', + moduleFederationNodeVersion: '^2.6.26', + moduleFederationEnhancedVersion: '^0.9.0', + angularEslintVersion: '^19.2.0', typescriptEslintVersion: '^7.16.0', tailwindVersion: '^3.0.2', postcssVersion: '^8.4.5', @@ -81,7 +77,7 @@ export const backwardCompatibleVersions: VersionMap = { autoprefixerVersion: '^10.4.0', tsNodeVersion: '10.9.1', lessVersion: '^4.3.0', - jestPresetAngularVersion: '~14.6.1', + jestPresetAngularVersion: '~14.4.0', typesNodeVersion: '20.19.9', jasmineMarblesVersion: '^0.9.2', jsoncEslintParserVersion: '^2.1.0', diff --git a/packages/angular/src/utils/targets.ts b/packages/angular/src/utils/targets.ts index 5beb60936b7a7..2c22b27a3abd7 100644 --- a/packages/angular/src/utils/targets.ts +++ b/packages/angular/src/utils/targets.ts @@ -1,4 +1,8 @@ -import type { ProjectConfiguration, TargetConfiguration } from '@nx/devkit'; +import type { + ProjectConfiguration, + Target, + TargetConfiguration, +} from '@nx/devkit'; export function* allProjectTargets( project: ProjectConfiguration @@ -25,3 +29,24 @@ export function* allTargetOptions( } } } + +/** + * Return a Target tuple from a specifier string. + * Supports abbreviated target specifiers (examples: `::`, `::development`, or `:build:production`). + */ +export function targetFromTargetString( + specifier: string, + abbreviatedProjectName?: string, + abbreviatedTargetName?: string +): Target { + const tuple = specifier.split(':', 3); + if (tuple.length < 2) { + throw new Error('Invalid target string: ' + JSON.stringify(specifier)); + } + + return { + project: tuple[0] || abbreviatedProjectName || '', + target: tuple[1] || abbreviatedTargetName || '', + ...(tuple[2] !== undefined && { configuration: tuple[2] }), + }; +} diff --git a/packages/angular/src/utils/version-utils.ts b/packages/angular/src/utils/version-utils.ts index 469296ba582ec..fa7ad65724069 100644 --- a/packages/angular/src/utils/version-utils.ts +++ b/packages/angular/src/utils/version-utils.ts @@ -1,7 +1,6 @@ import { coerce, major } from 'semver'; import type { PackageCompatVersions, - PackageLatestVersions, PackageVersionNames, } from './backward-compatible-versions'; import { backwardCompatibleVersions } from './backward-compatible-versions'; @@ -13,16 +12,15 @@ export function getPkgVersionForAngularMajorVersion( angularMajorVersion: number ): string { return angularMajorVersion < major(coerce(angularVersion)) - ? backwardCompatibleVersions[`angularV${angularMajorVersion}`]?.[ - pkgVersionName - ] ?? versions[pkgVersionName] + ? backwardCompatibleVersions[angularMajorVersion]?.[pkgVersionName] ?? + versions[pkgVersionName] : versions[pkgVersionName]; } export function getPkgVersionsForAngularMajorVersion( angularMajorVersion: number -): PackageLatestVersions | PackageCompatVersions { +): PackageCompatVersions { return angularMajorVersion < major(coerce(angularVersion)) - ? backwardCompatibleVersions[`angularV${angularMajorVersion}`] ?? versions + ? backwardCompatibleVersions[angularMajorVersion] ?? versions : versions; } diff --git a/packages/angular/src/utils/versions.ts b/packages/angular/src/utils/versions.ts index 3c4b4c7ca8ae4..4a45944309ca3 100644 --- a/packages/angular/src/utils/versions.ts +++ b/packages/angular/src/utils/versions.ts @@ -30,4 +30,7 @@ export const jestPresetAngularVersion = '~15.0.0'; export const typesNodeVersion = '20.19.9'; export const jasmineMarblesVersion = '^0.9.2'; +export const vitestVersion = '^4.0.8'; +export const jsdomVersion = '^27.1.0'; + export const jsoncEslintParserVersion = '^2.1.0'; diff --git a/packages/nx/src/command-line/init/implementation/angular/legacy-angular-versions.ts b/packages/nx/src/command-line/init/implementation/angular/legacy-angular-versions.ts index 6cfa0e233e374..1c4489272ef97 100644 --- a/packages/nx/src/command-line/init/implementation/angular/legacy-angular-versions.ts +++ b/packages/nx/src/command-line/init/implementation/angular/legacy-angular-versions.ts @@ -23,6 +23,7 @@ const nxAngularLegacyVersionMap: Record = { 15: '~19.0.0', 16: '~20.1.0', 17: '~21.1.0', + 18: '~22.1.0', }; // min major angular version supported in latest Nx const minMajorAngularVersionSupported = diff --git a/packages/vitest/src/generators/configuration/configuration.ts b/packages/vitest/src/generators/configuration/configuration.ts index 50ac3898cc262..e892e18a1b3c3 100644 --- a/packages/vitest/src/generators/configuration/configuration.ts +++ b/packages/vitest/src/generators/configuration/configuration.ts @@ -82,9 +82,13 @@ export async function configurationGeneratorInternal( addPlugin: schema.addPlugin, projectRoot: root, viteVersion: schema.viteVersion, + skipPackageJson: schema.skipPackageJson, }); tasks.push(initTask); - tasks.push(await ensureDependencies(tree, { ...schema, uiFramework })); + + if (!schema.skipPackageJson) { + tasks.push(await ensureDependencies(tree, { ...schema, uiFramework })); + } addOrChangeTestTarget(tree, schema, hasPlugin); @@ -144,7 +148,8 @@ getTestBed().initTestEnvironment( setupFile: relativeTestSetupPath, useEsmExtension: true, }, - true + true, + { skipPackageJson: schema.skipPackageJson } ); } else if (uiFramework === 'react') { createOrEditViteConfig( @@ -167,7 +172,8 @@ getTestBed().initTestEnvironment( plugins: ['react()'], coverageProvider: schema.coverageProvider, }, - true + true, + { skipPackageJson: schema.skipPackageJson } ); } else { createOrEditViteConfig( @@ -177,7 +183,8 @@ getTestBed().initTestEnvironment( includeVitest: true, includeLib: getProjectType(tree, root, projectType) === 'library', }, - true + true, + { skipPackageJson: schema.skipPackageJson } ); } } @@ -207,12 +214,14 @@ getTestBed().initTestEnvironment( ); devDependencies['@types/node'] = typesNodeVersion; - const installDependenciesTask = addDependenciesToPackageJson( - tree, - {}, - devDependencies - ); - tasks.push(installDependenciesTask); + if (!schema.skipPackageJson) { + const installDependenciesTask = addDependenciesToPackageJson( + tree, + {}, + devDependencies + ); + tasks.push(installDependenciesTask); + } // Setup workspace config file (https://vitest.dev/guide/workspace.html) if ( diff --git a/packages/vitest/src/generators/configuration/schema.d.ts b/packages/vitest/src/generators/configuration/schema.d.ts index 47e012b4470e3..14dca11625603 100644 --- a/packages/vitest/src/generators/configuration/schema.d.ts +++ b/packages/vitest/src/generators/configuration/schema.d.ts @@ -6,6 +6,7 @@ export interface VitestGeneratorSchema { skipViteConfig?: boolean; testTarget?: string; skipFormat?: boolean; + skipPackageJson?: boolean; testEnvironment?: 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime' | string; addPlugin?: boolean; runtimeTsconfigFileName?: string; diff --git a/packages/vitest/src/generators/configuration/schema.json b/packages/vitest/src/generators/configuration/schema.json index 08b795f17307b..75a977692dd7e 100644 --- a/packages/vitest/src/generators/configuration/schema.json +++ b/packages/vitest/src/generators/configuration/schema.json @@ -59,6 +59,12 @@ "enum": ["babel", "swc"], "default": "babel", "description": "The compiler to use" + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": "Do not add dependencies to `package.json`.", + "x-priority": "internal" } }, "required": ["project"] diff --git a/packages/vitest/src/utils/generator-utils.ts b/packages/vitest/src/utils/generator-utils.ts index 0fa95252be368..a87dfcfed0a35 100644 --- a/packages/vitest/src/utils/generator-utils.ts +++ b/packages/vitest/src/utils/generator-utils.ts @@ -98,13 +98,16 @@ export function createOrEditViteConfig( tree: Tree, options: ViteConfigFileOptions, onlyVitest: boolean, - projectAlreadyHasViteTargets?: TargetFlags, - vitestFileName?: boolean + extraOptions: { + projectAlreadyHasViteTargets?: TargetFlags; + skipPackageJson?: boolean; + vitestFileName?: boolean; + } = {} ) { const { root: projectRoot } = readProjectConfiguration(tree, options.project); const extension = options.useEsmExtension ? 'mts' : 'ts'; - const viteConfigPath = vitestFileName + const viteConfigPath = extraOptions.vitestFileName ? `${projectRoot}/vitest.config.${extension}` : `${projectRoot}/vite.config.${extension}`; @@ -166,7 +169,9 @@ export function createOrEditViteConfig( `import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'` ); plugins.push(`nxViteTsPaths()`, `nxCopyAssetsPlugin(['*.md'])`); - addDependenciesToPackageJson(tree, {}, { '@nx/vite': nxVersion }); + if (!extraOptions.skipPackageJson) { + addDependenciesToPackageJson(tree, {}, { '@nx/vite': nxVersion }); + } } if (!onlyVitest && options.includeLib) { @@ -263,7 +268,7 @@ ${ cacheDir, projectRoot, offsetFromRoot(projectRoot), - projectAlreadyHasViteTargets + extraOptions.projectAlreadyHasViteTargets ); return; } diff --git a/packages/workspace/src/generators/preset/preset.spec.ts b/packages/workspace/src/generators/preset/preset.spec.ts index 88b44b3035190..6513f9a73f464 100644 --- a/packages/workspace/src/generators/preset/preset.spec.ts +++ b/packages/workspace/src/generators/preset/preset.spec.ts @@ -23,7 +23,6 @@ describe('preset', () => { expect(tree.children(`apps/${name}`).sort()).toMatchInlineSnapshot(` [ ".eslintrc.json", - "jest.config.cts", "project.json", "public", "src", @@ -38,7 +37,6 @@ describe('preset', () => { "index.html", "main.ts", "styles.css", - "test-setup.ts", ] `); expect(tree.children(`apps/${name}/src/app`).sort()).toMatchInlineSnapshot(` From 986ea1e1f1ce56ce1c33386a6d8d64f66624badf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Fri, 21 Nov 2025 17:13:07 +0100 Subject: [PATCH 2/2] fix(angular): fix analog setup and bump version --- e2e/angular/src/ngrx.test.ts | 25 +++++-- .../executors/application/application.impl.ts | 2 +- .../browser-esbuild/browser-esbuild.impl.ts | 2 +- .../extract-i18n/extract-i18n.impl.ts | 2 +- .../src/executors/unit-test/unit-test.impl.ts | 2 +- .../__snapshots__/application.spec.ts.snap | 7 +- .../application/lib/add-unit-test-runner.ts | 2 + .../angular/src/generators/library/library.ts | 8 ++- .../src/generators/utils/add-vitest.ts | 66 +++++++++++++++++-- packages/nx/src/adapter/ngcli-adapter.ts | 40 +++++++++-- packages/vite/migrations.json | 13 ++++ packages/vite/src/utils/versions.ts | 2 +- packages/vitest/migrations.json | 13 ++++ packages/vitest/src/utils/versions.ts | 2 +- 14 files changed, 157 insertions(+), 29 deletions(-) diff --git a/e2e/angular/src/ngrx.test.ts b/e2e/angular/src/ngrx.test.ts index 7846855c5cb6b..c24c1a07bb3bc 100644 --- a/e2e/angular/src/ngrx.test.ts +++ b/e2e/angular/src/ngrx.test.ts @@ -1,6 +1,5 @@ import { cleanupProject, - expectTestsPass, newProject, readJson, runCLI, @@ -40,8 +39,12 @@ describe('NgRx', () => { ); expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); - expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); - expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); + expect( + (await runCLIAsync(`test ${myapp} --no-watch`)).combinedOutput + ).toContain(`Successfully ran target test for project ${myapp}`); + expect( + (await runCLIAsync(`test ${mylib} --no-watch`)).combinedOutput + ).toContain(`Successfully ran target test for project ${mylib}`); }, 1000000); it('should work with creators', async () => { @@ -72,8 +75,12 @@ describe('NgRx', () => { ); expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); - expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); - expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); + expect( + (await runCLIAsync(`test ${myapp} --no-watch`)).combinedOutput + ).toContain(`Successfully ran target test for project ${myapp}`); + expect( + (await runCLIAsync(`test ${mylib} --no-watch`)).combinedOutput + ).toContain(`Successfully ran target test for project ${mylib}`); }, 1000000); it('should work with creators using --module', async () => { @@ -104,7 +111,11 @@ describe('NgRx', () => { ); expect(runCLI(`build ${myapp}`)).toMatch(/main-[a-zA-Z0-9]+\.js/); - expectTestsPass(await runCLIAsync(`test ${myapp} --no-watch`)); - expectTestsPass(await runCLIAsync(`test ${mylib} --no-watch`)); + expect( + (await runCLIAsync(`test ${myapp} --no-watch`)).combinedOutput + ).toContain(`Successfully ran target test for project ${myapp}`); + expect( + (await runCLIAsync(`test ${mylib} --no-watch`)).combinedOutput + ).toContain(`Successfully ran target test for project ${mylib}`); }, 1000000); }); diff --git a/packages/angular/src/executors/application/application.impl.ts b/packages/angular/src/executors/application/application.impl.ts index 275cdc5cd7224..480673553c63e 100644 --- a/packages/angular/src/executors/application/application.impl.ts +++ b/packages/angular/src/executors/application/application.impl.ts @@ -46,7 +46,7 @@ export default async function* applicationExecutor( const builderContext = await createBuilderContext( { - builderName: 'application', + builderName: '@nx/angular:application', description: 'Build an application.', optionSchema: require('./schema.json'), }, diff --git a/packages/angular/src/executors/browser-esbuild/browser-esbuild.impl.ts b/packages/angular/src/executors/browser-esbuild/browser-esbuild.impl.ts index c0e9b016443a3..a8c3fed3faee8 100644 --- a/packages/angular/src/executors/browser-esbuild/browser-esbuild.impl.ts +++ b/packages/angular/src/executors/browser-esbuild/browser-esbuild.impl.ts @@ -38,7 +38,7 @@ export default async function* esbuildExecutor( const builderContext = await createBuilderContext( { - builderName: 'browser-esbuild', + builderName: '@nx/angular:browser-esbuild', description: 'Build a browser application', optionSchema: require('@angular-devkit/build-angular/src/builders/browser-esbuild/schema.json'), }, diff --git a/packages/angular/src/executors/extract-i18n/extract-i18n.impl.ts b/packages/angular/src/executors/extract-i18n/extract-i18n.impl.ts index 152a150fb2abc..be1b96c8b0d98 100644 --- a/packages/angular/src/executors/extract-i18n/extract-i18n.impl.ts +++ b/packages/angular/src/executors/extract-i18n/extract-i18n.impl.ts @@ -27,7 +27,7 @@ export default async function* extractI18nExecutor( const builderContext = await createBuilderContext( { - builderName: 'extrct-i18n', + builderName: '@nx/angular:extract-i18n', description: 'Extracts i18n messages from source code.', optionSchema: require('./schema.json'), }, diff --git a/packages/angular/src/executors/unit-test/unit-test.impl.ts b/packages/angular/src/executors/unit-test/unit-test.impl.ts index 2d3588f6270b1..a589d20e5acc9 100644 --- a/packages/angular/src/executors/unit-test/unit-test.impl.ts +++ b/packages/angular/src/executors/unit-test/unit-test.impl.ts @@ -35,7 +35,7 @@ export default async function* unitTestExecutor( const builderContext = await createBuilderContext( { - builderName: 'unit-test', + builderName: '@nx/angular:unit-test', description: 'Run application unit tests.', optionSchema: require('./schema.json'), }, diff --git a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap index 5ca76ab6b1bc2..a3f9de0fe6ba5 100644 --- a/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap +++ b/packages/angular/src/generators/application/__snapshots__/application.spec.ts.snap @@ -717,7 +717,9 @@ exports[`app angular compat support vitest & angular < 21 should add tsconfig.sp "src/**/*.spec.jsx", "src/**/*.d.ts" ], - "files": ["src/test-setup.ts"] + "files": [ + "src/test-setup.ts" + ] } " `; @@ -725,7 +727,6 @@ exports[`app angular compat support vitest & angular < 21 should add tsconfig.sp exports[`app angular compat support vitest & angular < 21 should generate src/test-setup.ts 1`] = ` "import '@angular/compiler'; import '@analogjs/vitest-angular/setup-zone'; - import { BrowserTestingModule, platformBrowserTesting, @@ -734,7 +735,7 @@ import { getTestBed } from '@angular/core/testing'; getTestBed().initTestEnvironment( BrowserTestingModule, - platformBrowserTesting() + platformBrowserTesting(), ); " `; diff --git a/packages/angular/src/generators/application/lib/add-unit-test-runner.ts b/packages/angular/src/generators/application/lib/add-unit-test-runner.ts index 2ce39452c6469..589aca875ec9f 100644 --- a/packages/angular/src/generators/application/lib/add-unit-test-runner.ts +++ b/packages/angular/src/generators/application/lib/add-unit-test-runner.ts @@ -21,9 +21,11 @@ export async function addUnitTestRunner(host: Tree, options: NormalizedSchema) { await addVitest(host, { name: options.name, projectRoot: options.appProjectRoot, + skipFormat: options.skipFormat, skipPackageJson: options.skipPackageJson, strict: options.strict, addPlugin: options.addPlugin, + zoneless: options.zoneless, }); break; } diff --git a/packages/angular/src/generators/library/library.ts b/packages/angular/src/generators/library/library.ts index 8234c5408378a..0668fa3991071 100644 --- a/packages/angular/src/generators/library/library.ts +++ b/packages/angular/src/generators/library/library.ts @@ -147,9 +147,15 @@ async function addUnitTestRunner( await addVitest(host, { name: options.name, projectRoot: options.projectRoot, + skipFormat: options.skipFormat, skipPackageJson: options.skipPackageJson, strict: options.strict, - useNxUnitTestRunner: options.buildable || options.publishable, + // the unit-test builder requires a build target, force using analog + // if there's no build target + forceAnalog: !options.buildable && !options.publishable, + // use the nx unit-test runner executor if there's a build target + useNxUnitTestRunnerExecutor: options.buildable || options.publishable, + zoneless, }); break; } diff --git a/packages/angular/src/generators/utils/add-vitest.ts b/packages/angular/src/generators/utils/add-vitest.ts index 3a15fa8f92f0c..f63acdd048546 100644 --- a/packages/angular/src/generators/utils/add-vitest.ts +++ b/packages/angular/src/generators/utils/add-vitest.ts @@ -25,10 +25,13 @@ import { export type AddVitestOptions = { name: string; projectRoot: string; + skipFormat: boolean; skipPackageJson: boolean; strict: boolean; + zoneless: boolean; addPlugin?: boolean; - useNxUnitTestRunner?: boolean; + forceAnalog?: boolean; + useNxUnitTestRunnerExecutor?: boolean; }; export async function addVitest( @@ -36,10 +39,10 @@ export async function addVitest( options: AddVitestOptions ): Promise { const { major: angularMajorVersion } = getInstalledAngularVersionInfo(tree); - if (angularMajorVersion >= 21) { + if (angularMajorVersion >= 21 && !options.forceAnalog) { await configureAngularUnitTestBuilderTarget(tree, options); } else { - await configureVitestWithAnalog(tree, options); + await configureVitestWithAnalog(tree, options, angularMajorVersion); } } @@ -49,7 +52,7 @@ async function configureAngularUnitTestBuilderTarget( ): Promise { validateVitestVersion(tree); - const executor = options.useNxUnitTestRunner + const executor = options.useNxUnitTestRunnerExecutor ? '@nx/angular:unit-test' : '@angular/build:unit-test'; const project = readProjectConfiguration(tree, options.name); @@ -93,7 +96,8 @@ async function configureAngularUnitTestBuilderTarget( async function configureVitestWithAnalog( tree: Tree, - options: AddVitestOptions + options: AddVitestOptions, + angularMajorVersion: number ): Promise { ensurePackage('@nx/vitest', nxVersion); const { configurationGenerator } = await import('@nx/vitest/generators'); @@ -104,9 +108,12 @@ async function configureVitestWithAnalog( testEnvironment: 'jsdom', coverageProvider: 'v8', addPlugin: options.addPlugin ?? false, + skipFormat: options.skipFormat, skipPackageJson: options.skipPackageJson, }); + createAnalogSetupFile(tree, options, angularMajorVersion); + if (!options.skipPackageJson) { const angularDevkitVersion = getInstalledAngularDevkitVersion(tree) ?? @@ -230,3 +237,52 @@ function addVitestScreenshotsToGitIgnore(tree: Tree): void { logger.warn(`Couldn't find .gitignore file to update`); } } + +function createAnalogSetupFile( + tree: Tree, + options: AddVitestOptions, + angularMajorVersion: number +): void { + let setupFile: string; + + if (angularMajorVersion >= 21) { + setupFile = `import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-snapshots'; +import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed'; + +setupTestBed(${options.zoneless ? '' : '{ zoneless: false }'}); +`; + } else if (angularMajorVersion === 20) { + setupFile = `import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-zone'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; +import { getTestBed } from '@angular/core/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), +); +`; + } else { + setupFile = `import '@analogjs/vitest-angular/setup-zone'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; +import { getTestBed } from '@angular/core/testing'; + +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); +`; + } + + tree.write( + joinPathFragments(options.projectRoot, 'src/test-setup.ts'), + setupFile + ); +} diff --git a/packages/nx/src/adapter/ngcli-adapter.ts b/packages/nx/src/adapter/ngcli-adapter.ts index 6d64c5848e192..7cd03a9823883 100644 --- a/packages/nx/src/adapter/ngcli-adapter.ts +++ b/packages/nx/src/adapter/ngcli-adapter.ts @@ -100,7 +100,16 @@ export async function createBuilderContext( ); const registry = new schema.CoreSchemaRegistry(); - registry.addPostTransform(schema.transforms.addUndefinedDefaults); + const isAngularBuild = + builderInfo.builderName.startsWith('@angular/build:') || + ['@nx/angular:application', '@nx/angular:unit-test'].includes( + builderInfo.builderName + ); + if (isAngularBuild) { + registry.addPostTransform(schema.transforms.addUndefinedObjectDefaults); + } else { + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + } registry.addSmartDefaultProvider('unparsed', () => { // This happens when context.scheduleTarget is used to run a target using nx:run-commands return []; @@ -214,19 +223,36 @@ export async function scheduleTarget( 'angular.json', workspaces.createWorkspaceHost(fsHost) ); + const architectHost = await getWrappedWorkspaceNodeModulesArchitectHost( + workspace, + root, + opts.projects + ); + + const builderName = workspace.projects + .get(opts.project) + ?.targets?.get(opts.target)?.builder; + if (!builderName) { + throw new Error( + `Cannot find target '${opts.target}' for project '${opts.project}'` + ); + } + + const isAngularBuild = + builderName.startsWith('@angular/build:') || + ['@nx/angular:application', '@nx/angular:unit-test'].includes(builderName); const registry = new schema.CoreSchemaRegistry(); - registry.addPostTransform(schema.transforms.addUndefinedDefaults); + if (isAngularBuild) { + registry.addPostTransform(schema.transforms.addUndefinedObjectDefaults); + } else { + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + } registry.addSmartDefaultProvider('unparsed', () => { // This happens when context.scheduleTarget is used to run a target using nx:run-commands return []; }); - const architectHost = await getWrappedWorkspaceNodeModulesArchitectHost( - workspace, - root, - opts.projects - ); const architect: Architect = new Architect(architectHost, registry); const run = await architect.scheduleTarget( { diff --git a/packages/vite/migrations.json b/packages/vite/migrations.json index da0ed39332a3d..b19a98f133c1e 100644 --- a/packages/vite/migrations.json +++ b/packages/vite/migrations.json @@ -99,6 +99,19 @@ "incompatibleWith": { "@remix-run/dev": "*" } + }, + "22.2.0": { + "version": "22.2.0-beta.0", + "packages": { + "@analogjs/vite-plugin-angular": { + "version": "^2.1.0", + "alwaysAddToPackageJson": false + }, + "@analogjs/vitest-angular": { + "version": "^2.1.0", + "alwaysAddToPackageJson": false + } + } } } } diff --git a/packages/vite/src/utils/versions.ts b/packages/vite/src/utils/versions.ts index 7ad8e12ea36fe..4251cf3dc9c97 100644 --- a/packages/vite/src/utils/versions.ts +++ b/packages/vite/src/utils/versions.ts @@ -18,7 +18,7 @@ export const happyDomVersion = '~9.20.3'; export const edgeRuntimeVmVersion = '~3.0.2'; export const jitiVersion = '2.4.2'; -export const analogVitestAngular = '~1.19.1'; +export const analogVitestAngular = '^2.1.0'; // Coverage providers export const vitestV4CoverageV8Version = '^4.0.0'; diff --git a/packages/vitest/migrations.json b/packages/vitest/migrations.json index 3583bf6fde5d6..bbcfdee2a3bfa 100644 --- a/packages/vitest/migrations.json +++ b/packages/vitest/migrations.json @@ -32,6 +32,19 @@ "alwaysAddToPackageJson": false } } + }, + "22.2.0": { + "version": "22.2.0-beta.0", + "packages": { + "@analogjs/vite-plugin-angular": { + "version": "^2.1.0", + "alwaysAddToPackageJson": false + }, + "@analogjs/vitest-angular": { + "version": "^2.1.0", + "alwaysAddToPackageJson": false + } + } } } } diff --git a/packages/vitest/src/utils/versions.ts b/packages/vitest/src/utils/versions.ts index 8e84bb8f0ab08..1218a39c5371d 100644 --- a/packages/vitest/src/utils/versions.ts +++ b/packages/vitest/src/utils/versions.ts @@ -16,7 +16,7 @@ export const happyDomVersion = '~9.20.3'; export const edgeRuntimeVmVersion = '~3.0.2'; export const jitiVersion = '2.4.2'; -export const analogVitestAngular = '~1.19.1'; +export const analogVitestAngular = '^2.1.0'; // Coverage providers export const vitestV4CoverageV8Version = '^4.0.0';