feat(testing): add support for cypress v14 (#30618)

## Current Behavior

Cypress v14 is not supported.

## Expected Behavior

Cypress v14 is supported.

## Related Issue(s)

Fixes #30097
This commit is contained in:
Leosvel Pérez Espinosa 2025-04-09 23:12:39 +02:00 committed by GitHub
parent 3b91e0b32d
commit 5feafd64d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 4404 additions and 573 deletions

View File

@ -1372,6 +1372,56 @@
} }
}, },
"migrations": { "migrations": {
"/nx-api/cypress/migrations/set-inject-document-domain": {
"description": "Replaces the `experimentalSkipDomainInjection` configuration option with the new `injectDocumentDomain` configuration option.",
"file": "generated/packages/cypress/migrations/set-inject-document-domain.json",
"hidden": false,
"name": "set-inject-document-domain",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "/nx-api/cypress/migrations/set-inject-document-domain",
"type": "migration"
},
"/nx-api/cypress/migrations/remove-experimental-fetch-polyfill": {
"description": "Removes the `experimentalFetchPolyfill` configuration option.",
"file": "generated/packages/cypress/migrations/remove-experimental-fetch-polyfill.json",
"hidden": false,
"name": "remove-experimental-fetch-polyfill",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "/nx-api/cypress/migrations/remove-experimental-fetch-polyfill",
"type": "migration"
},
"/nx-api/cypress/migrations/replace-experimental-just-in-time-compile": {
"description": "Replaces the `experimentalJustInTimeCompile` configuration option with the new `justInTimeCompile` configuration option.",
"file": "generated/packages/cypress/migrations/replace-experimental-just-in-time-compile.json",
"hidden": false,
"name": "replace-experimental-just-in-time-compile",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "/nx-api/cypress/migrations/replace-experimental-just-in-time-compile",
"type": "migration"
},
"/nx-api/cypress/migrations/update-component-testing-mount-imports": {
"description": "Updates the module specifier for the Component Testing `mount` function.",
"file": "generated/packages/cypress/migrations/update-component-testing-mount-imports.json",
"hidden": false,
"name": "update-component-testing-mount-imports",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "/nx-api/cypress/migrations/update-component-testing-mount-imports",
"type": "migration"
},
"/nx-api/cypress/migrations/20.8.0-package-updates": {
"description": "",
"file": "generated/packages/cypress/migrations/20.8.0-package-updates.json",
"hidden": false,
"name": "20.8.0-package-updates",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "/nx-api/cypress/migrations/20.8.0-package-updates",
"type": "migration"
},
"/nx-api/cypress/migrations/update-19-6-0-update-ci-webserver-for-vite": { "/nx-api/cypress/migrations/update-19-6-0-update-ci-webserver-for-vite": {
"description": "Update ciWebServerCommand to use static serve for the application.", "description": "Update ciWebServerCommand to use static serve for the application.",
"file": "generated/packages/cypress/migrations/update-19-6-0-update-ci-webserver-for-vite.json", "file": "generated/packages/cypress/migrations/update-19-6-0-update-ci-webserver-for-vite.json",

View File

@ -1364,6 +1364,56 @@
} }
], ],
"migrations": [ "migrations": [
{
"description": "Replaces the `experimentalSkipDomainInjection` configuration option with the new `injectDocumentDomain` configuration option.",
"file": "generated/packages/cypress/migrations/set-inject-document-domain.json",
"hidden": false,
"name": "set-inject-document-domain",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "cypress/migrations/set-inject-document-domain",
"type": "migration"
},
{
"description": "Removes the `experimentalFetchPolyfill` configuration option.",
"file": "generated/packages/cypress/migrations/remove-experimental-fetch-polyfill.json",
"hidden": false,
"name": "remove-experimental-fetch-polyfill",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "cypress/migrations/remove-experimental-fetch-polyfill",
"type": "migration"
},
{
"description": "Replaces the `experimentalJustInTimeCompile` configuration option with the new `justInTimeCompile` configuration option.",
"file": "generated/packages/cypress/migrations/replace-experimental-just-in-time-compile.json",
"hidden": false,
"name": "replace-experimental-just-in-time-compile",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "cypress/migrations/replace-experimental-just-in-time-compile",
"type": "migration"
},
{
"description": "Updates the module specifier for the Component Testing `mount` function.",
"file": "generated/packages/cypress/migrations/update-component-testing-mount-imports.json",
"hidden": false,
"name": "update-component-testing-mount-imports",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "cypress/migrations/update-component-testing-mount-imports",
"type": "migration"
},
{
"description": "",
"file": "generated/packages/cypress/migrations/20.8.0-package-updates.json",
"hidden": false,
"name": "20.8.0-package-updates",
"version": "20.8.0-beta.0",
"originalFilePath": "/packages/cypress",
"path": "cypress/migrations/20.8.0-package-updates",
"type": "migration"
},
{ {
"description": "Update ciWebServerCommand to use static serve for the application.", "description": "Update ciWebServerCommand to use static serve for the application.",
"file": "generated/packages/cypress/migrations/update-19-6-0-update-ci-webserver-for-vite.json", "file": "generated/packages/cypress/migrations/update-19-6-0-update-ci-webserver-for-vite.json",

View File

@ -0,0 +1,24 @@
{
"name": "20.8.0-package-updates",
"version": "20.8.0-beta.0",
"x-prompt": "Do you want to update the Cypress version to v14?",
"requires": { "cypress": ">=13.0.0 <14.0.0" },
"packages": {
"cypress": { "version": "^14.2.1", "alwaysAddToPackageJson": false },
"@cypress/vite-dev-server": {
"version": "^6.0.3",
"alwaysAddToPackageJson": false
},
"@cypress/webpack-dev-server": {
"version": "^4.0.2",
"alwaysAddToPackageJson": false
}
},
"aliases": [],
"description": "",
"hidden": false,
"implementation": "",
"path": "/packages/cypress",
"schema": null,
"type": "migration"
}

View File

@ -0,0 +1,14 @@
{
"name": "remove-experimental-fetch-polyfill",
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": { "cypress": ">=14.0.0" },
"description": "Removes the `experimentalFetchPolyfill` configuration option.",
"implementation": "/packages/cypress/src/migrations/update-20-8-0/remove-experimental-fetch-polyfill.ts",
"aliases": [],
"hidden": false,
"path": "/packages/cypress",
"schema": null,
"type": "migration",
"examplesFile": "#### Remove `experimentalFetchPolyfill` Configuration Option\n\nRemoves the `experimentalFetchPolyfill` configuration option that was removed in Cypress v14. Read more at the [migration notes](<https://docs.cypress.io/app/references/changelog#:~:text=The%20experimentalFetchPolyfill%20configuration%20option%20was,cy.intercept()%20for%20handling%20fetch%20requests>).\n\n#### Examples\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```ts {% fileName=\"apps/app1-e2e/cypress.config.ts\" %}\nimport { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n e2e: {\n ...nxE2EPreset(__filename, {\n cypressDir: 'src',\n bundler: 'vite',\n webServerCommands: {\n default: 'pnpm exec nx run app1:dev',\n production: 'pnpm exec nx run app1:dev',\n },\n ciWebServerCommand: 'pnpm exec nx run app1:dev',\n ciBaseUrl: 'http://localhost:4200',\n }),\n baseUrl: 'http://localhost:4200',\n experimentalFetchPolyfill: true,\n },\n});\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```ts {% fileName=\"apps/app1-e2e/cypress.config.ts\" %}\nimport { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n e2e: {\n ...nxE2EPreset(__filename, {\n cypressDir: 'src',\n bundler: 'vite',\n webServerCommands: {\n default: 'pnpm exec nx run app1:dev',\n production: 'pnpm exec nx run app1:dev',\n },\n ciWebServerCommand: 'pnpm exec nx run app1:dev',\n ciBaseUrl: 'http://localhost:4200',\n }),\n baseUrl: 'http://localhost:4200',\n },\n});\n```\n\n{% /tab %}\n\n{% /tabs %}\n"
}

View File

@ -0,0 +1,14 @@
{
"name": "replace-experimental-just-in-time-compile",
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": { "cypress": ">=14.0.0" },
"description": "Replaces the `experimentalJustInTimeCompile` configuration option with the new `justInTimeCompile` configuration option.",
"implementation": "/packages/cypress/src/migrations/update-20-8-0/replace-experimental-just-in-time-compile.ts",
"aliases": [],
"hidden": false,
"path": "/packages/cypress",
"schema": null,
"type": "migration",
"examplesFile": "#### Replace the `experimentalJustInTimeCompile` Configuration Option with `justInTimeCompile`\n\nReplaces the `experimentalJustInTimeCompile` configuration option with the new `justInTimeCompile` configuration option. Read more at the [migration notes](https://docs.cypress.io/app/references/migration-guide#CT-Just-in-Time-Compile-changes).\n\n#### Examples\n\nIf the `experimentalJustInTimeCompile` configuration option is present and set to `true`, the migration will remove it. This is to account for the fact that JIT compilation is the default behavior in Cypress v14.\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```ts {% fileName=\"apps/app1/cypress.config.ts\" %}\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n component: {\n devServer: {\n framework: 'angular',\n bundler: 'webpack',\n },\n experimentalJustInTimeCompile: true,\n },\n});\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```ts {% fileName=\"apps/app1/cypress.config.ts\" %}\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n component: {\n devServer: {\n framework: 'angular',\n bundler: 'webpack',\n },\n },\n});\n```\n\n{% /tab %}\n{% /tabs %}\n\nIf the `experimentalJustInTimeCompile` configuration option is set to `false` and it is using webpack, the migration will rename it to `justInTimeCompile`.\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```ts {% fileName=\"apps/app1/cypress.config.ts\" %}\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n component: {\n devServer: {\n framework: 'angular',\n bundler: 'webpack',\n },\n experimentalJustInTimeCompile: false,\n },\n});\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```ts {% fileName=\"apps/app1/cypress.config.ts\" highlightLines=[9] %}\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n component: {\n devServer: {\n framework: 'angular',\n bundler: 'webpack',\n },\n justInTimeCompile: false,\n },\n});\n```\n\n{% /tab %}\n{% /tabs %}\n\nIf the `experimentalJustInTimeCompile` configuration is set to any value and it is using Vite, the migration will remove it. This is to account for the fact that JIT compilation no longer applies to Vite.\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```ts {% fileName=\"apps/app1/cypress.config.ts\" %}\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n component: {\n devServer: {\n framework: 'react',\n bundler: 'vite',\n },\n experimentalJustInTimeCompile: false,\n },\n});\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```ts {% fileName=\"apps/app1/cypress.config.ts\" %}\nimport { defineConfig } from 'cypress';\n\nexport default defineConfig({\n component: {\n devServer: {\n framework: 'react',\n bundler: 'vite',\n },\n },\n});\n```\n\n{% /tab %}\n{% /tabs %}\n"
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
{
"name": "update-component-testing-mount-imports",
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": { "cypress": ">=14.0.0" },
"description": "Updates the module specifier for the Component Testing `mount` function.",
"implementation": "/packages/cypress/src/migrations/update-20-8-0/update-component-testing-mount-imports.ts",
"aliases": [],
"hidden": false,
"path": "/packages/cypress",
"schema": null,
"type": "migration",
"examplesFile": "#### Update Component Testing `mount` Imports\n\nUpdates the relevant module specifiers when importing the `mount` function and using the Angular or React frameworks. Read more at the [Angular migration notes](https://docs.cypress.io/app/references/migration-guide#Angular-1720-CT-no-longer-supported) and the [React migration notes](https://docs.cypress.io/app/references/migration-guide#React-18-CT-no-longer-supported).\n\n#### Examples\n\nIf using the Angular framework with a version greater than or equal to v17.2.0 and importing the `mount` function from the `cypress/angular-signals` module, the migration will update the import to use the `cypress/angular` module.\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```ts {% fileName=\"apps/app1/cypress/support/component.ts\" %}\nimport { mount } from 'cypress/angular-signals';\nimport './commands';\n\ndeclare global {\n namespace Cypress {\n interface Chainable<Subject> {\n mount: typeof mount;\n }\n }\n}\n\nCypress.Commands.add('mount', mount);\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```ts {% fileName=\"apps/app1/cypress/support/component.ts\" highlightLines=[1] %}\nimport { mount } from 'cypress/angular';\nimport './commands';\n\ndeclare global {\n namespace Cypress {\n interface Chainable<Subject> {\n mount: typeof mount;\n }\n }\n}\n\nCypress.Commands.add('mount', mount);\n```\n\n{% /tab %}\n{% /tabs %}\n\nIf using the Angular framework with a version lower than v17.2.0 and importing the `mount` function from the `cypress/angular` module, the migration will install the `@cypress/angular@2` package and update the import to use the `@cypress/angular` module.\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```json {% fileName=\"package.json\" %}\n{\n \"name\": \"@my-repo/source\",\n \"dependencies\": {\n ...\n \"cypress\": \"^14.2.1\"\n }\n}\n```\n\n```ts {% fileName=\"apps/app1/cypress/support/component.ts\" %}\nimport { mount } from 'cypress/angular';\nimport './commands';\n\ndeclare global {\n namespace Cypress {\n interface Chainable<Subject> {\n mount: typeof mount;\n }\n }\n}\n\nCypress.Commands.add('mount', mount);\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```json {% fileName=\"package.json\" highlightLines=[6] %}\n{\n \"name\": \"@my-repo/source\",\n \"dependencies\": {\n ...\n \"cypress\": \"^14.2.1\",\n \"@cypress/angular\": \"^2.1.0\"\n }\n}\n```\n\n```ts {% fileName=\"apps/app1/cypress/support/component.ts\" highlightLines=[1] %}\nimport { mount } from '@cypress/angular';\nimport './commands';\n\ndeclare global {\n namespace Cypress {\n interface Chainable<Subject> {\n mount: typeof mount;\n }\n }\n}\n\nCypress.Commands.add('mount', mount);\n```\n\n{% /tab %}\n{% /tabs %}\n\nIf using the React framework and importing the `mount` function from the `cypress/react18` module, the migration will update the import to use the `cypress/react` module.\n\n{% tabs %}\n{% tab label=\"Before\" %}\n\n```ts {% fileName=\"apps/app1/cypress/support/component.ts\" %}\nimport { mount } from 'cypress/react18';\nimport './commands';\n\ndeclare global {\n namespace Cypress {\n interface Chainable<Subject> {\n mount: typeof mount;\n }\n }\n}\n\nCypress.Commands.add('mount', mount);\n```\n\n{% /tab %}\n\n{% tab label=\"After\" %}\n\n```ts {% fileName=\"apps/app1/cypress/support/component.ts\" highlightLines=[1] %}\nimport { mount } from 'cypress/react';\nimport './commands';\n\ndeclare global {\n namespace Cypress {\n interface Chainable<Subject> {\n mount: typeof mount;\n }\n }\n}\n\nCypress.Commands.add('mount', mount);\n```\n\n{% /tab %}\n{% /tabs %}\n"
}

View File

@ -1,5 +1,4 @@
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { Tree, updateProjectConfiguration, writeJson } from '@nx/devkit';
import * as devkit from '@nx/devkit'; import * as devkit from '@nx/devkit';
import { import {
NxJsonConfiguration, NxJsonConfiguration,
@ -7,7 +6,9 @@ import {
readJson, readJson,
readNxJson, readNxJson,
readProjectConfiguration, readProjectConfiguration,
Tree,
updateJson, updateJson,
updateProjectConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '@nx/eslint'; import { Linter } from '@nx/eslint';
@ -25,7 +26,10 @@ import { generateTestApplication } from '../utils/testing';
import type { Schema } from './schema'; import type { Schema } from './schema';
// need to mock cypress otherwise it'll use installed version in this repo's package.json // need to mock cypress otherwise it'll use installed version in this repo's package.json
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
jest.mock('enquirer'); jest.mock('enquirer');
jest.mock('@nx/devkit', () => { jest.mock('@nx/devkit', () => {
const original = jest.requireActual('@nx/devkit'); const original = jest.requireActual('@nx/devkit');
@ -42,8 +46,8 @@ jest.mock('@nx/devkit', () => {
describe('app', () => { describe('app', () => {
let appTree: Tree; let appTree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(() => { beforeEach(() => {
mockedInstalledCypressVersion.mockReturnValue(null); mockedInstalledCypressVersion.mockReturnValue(null);

View File

@ -1,6 +1,6 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { assertMinimumCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { assertMinimumCypressVersion } from '@nx/cypress/src/utils/versions';
import { Tree } from '@nx/devkit'; import { Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '@nx/eslint'; import { Linter } from '@nx/eslint';
@ -10,7 +10,7 @@ import { generateTestLibrary } from '../utils/testing';
import { componentTestGenerator } from './component-test'; import { componentTestGenerator } from './component-test';
import { EOL } from 'node:os'; import { EOL } from 'node:os';
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions');
describe('Angular Cypress Component Test Generator', () => { describe('Angular Cypress Component Test Generator', () => {
let tree: Tree; let tree: Tree;

View File

@ -19,8 +19,8 @@ export async function componentTestGenerator(
) { ) {
ensurePackage('@nx/cypress', nxVersion); ensurePackage('@nx/cypress', nxVersion);
const { assertMinimumCypressVersion } = < const { assertMinimumCypressVersion } = <
typeof import('@nx/cypress/src/utils/cypress-version') typeof import('@nx/cypress/src/utils/versions')
>require('@nx/cypress/src/utils/cypress-version'); >require('@nx/cypress/src/utils/versions');
assertMinimumCypressVersion(10); assertMinimumCypressVersion(10);
const { root } = readProjectConfiguration(tree, options.project); const { root } = readProjectConfiguration(tree, options.project);
const componentDirPath = joinPathFragments(root, options.componentDir); const componentDirPath = joinPathFragments(root, options.componentDir);

View File

@ -1,4 +1,4 @@
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
DependencyType, DependencyType,
joinPathFragments, joinPathFragments,
@ -23,7 +23,10 @@ import { librarySecondaryEntryPointGenerator } from '../library-secondary-entry-
import { generateTestApplication, generateTestLibrary } from '../utils/testing'; import { generateTestApplication, generateTestLibrary } from '../utils/testing';
import { cypressComponentConfiguration } from './cypress-component-configuration'; import { cypressComponentConfiguration } from './cypress-component-configuration';
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
// nested code imports graph from the repo, which might have innacurate graph version // nested code imports graph from the repo, which might have innacurate graph version
jest.mock('nx/src/project-graph/project-graph', () => ({ jest.mock('nx/src/project-graph/project-graph', () => ({
...jest.requireActual<any>('nx/src/project-graph/project-graph'), ...jest.requireActual<any>('nx/src/project-graph/project-graph'),
@ -33,8 +36,8 @@ jest.mock('nx/src/project-graph/project-graph', () => ({
describe('Cypress Component Testing Configuration', () => { describe('Cypress Component Testing Configuration', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
// TODO(@leosvelperez): Turn this to adding the plugin // TODO(@leosvelperez): Turn this to adding the plugin
beforeEach(() => { beforeEach(() => {

View File

@ -1,7 +1,10 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
// mock so we can test multiple versions // mock so we can test multiple versions
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual<any>('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
// mock bc the nxE2EPreset uses fs for path normalization // mock bc the nxE2EPreset uses fs for path normalization
jest.mock('fs', () => { jest.mock('fs', () => {
return { return {
@ -12,7 +15,7 @@ jest.mock('fs', () => {
}; };
}); });
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { formatFiles, ProjectConfiguration, Tree } from '@nx/devkit'; import { formatFiles, ProjectConfiguration, Tree } from '@nx/devkit';
import { import {
joinPathFragments, joinPathFragments,
@ -30,8 +33,9 @@ const mockedLogger = { warn: jest.fn() };
describe('e2e migrator', () => { describe('e2e migrator', () => {
let tree: Tree; let tree: Tree;
let migrator: E2eMigrator; let migrator: E2eMigrator;
let mockedInstalledCypressVersion = installedCypressVersion as jest.Mock< let mockedInstalledCypressVersion =
ReturnType<typeof installedCypressVersion> getInstalledCypressMajorVersion as jest.Mock<
ReturnType<typeof getInstalledCypressMajorVersion>
>; >;
function addProject( function addProject(

View File

@ -262,9 +262,9 @@ export class E2eMigrator extends ProjectMigrator<SupportedTargets> {
} else if (this.isCypressE2eProject()) { } else if (this.isCypressE2eProject()) {
ensurePackage('@nx/cypress', nxVersion); ensurePackage('@nx/cypress', nxVersion);
const { const {
installedCypressVersion, getInstalledCypressMajorVersion,
} = require('@nx/cypress/src/utils/cypress-version'); } = require('@nx/cypress/src/utils/versions');
this.cypressInstalledVersion = installedCypressVersion(); this.cypressInstalledVersion = getInstalledCypressMajorVersion(this.tree);
this.project = { this.project = {
...this.project, ...this.project,
name, name,

View File

@ -11,6 +11,42 @@
"version": "19.6.0-beta.4", "version": "19.6.0-beta.4",
"description": "Update ciWebServerCommand to use static serve for the application.", "description": "Update ciWebServerCommand to use static serve for the application.",
"implementation": "./src/migrations/update-19-6-0/update-ci-webserver-for-static-serve" "implementation": "./src/migrations/update-19-6-0/update-ci-webserver-for-static-serve"
},
"set-inject-document-domain": {
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": {
"cypress": ">=14.0.0"
},
"description": "Replaces the `experimentalSkipDomainInjection` configuration option with the new `injectDocumentDomain` configuration option.",
"implementation": "./src/migrations/update-20-8-0/set-inject-document-domain"
},
"remove-experimental-fetch-polyfill": {
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": {
"cypress": ">=14.0.0"
},
"description": "Removes the `experimentalFetchPolyfill` configuration option.",
"implementation": "./src/migrations/update-20-8-0/remove-experimental-fetch-polyfill"
},
"replace-experimental-just-in-time-compile": {
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": {
"cypress": ">=14.0.0"
},
"description": "Replaces the `experimentalJustInTimeCompile` configuration option with the new `justInTimeCompile` configuration option.",
"implementation": "./src/migrations/update-20-8-0/replace-experimental-just-in-time-compile"
},
"update-component-testing-mount-imports": {
"cli": "nx",
"version": "20.8.0-beta.0",
"requires": {
"cypress": ">=14.0.0"
},
"description": "Updates the module specifier for the Component Testing `mount` function.",
"implementation": "./src/migrations/update-20-8-0/update-component-testing-mount-imports"
} }
}, },
"packageJsonUpdates": { "packageJsonUpdates": {
@ -59,6 +95,27 @@
"alwaysAddToPackageJson": false "alwaysAddToPackageJson": false
} }
} }
},
"20.8.0": {
"version": "20.8.0-beta.0",
"x-prompt": "Do you want to update the Cypress version to v14?",
"requires": {
"cypress": ">=13.0.0 <14.0.0"
},
"packages": {
"cypress": {
"version": "^14.2.1",
"alwaysAddToPackageJson": false
},
"@cypress/vite-dev-server": {
"version": "^6.0.3",
"alwaysAddToPackageJson": false
},
"@cypress/webpack-dev-server": {
"version": "^4.0.2",
"alwaysAddToPackageJson": false
}
}
} }
} }
} }

View File

@ -41,10 +41,11 @@
"@nx/js": "file:../js", "@nx/js": "file:../js",
"@phenomnomnominal/tsquery": "~5.0.1", "@phenomnomnominal/tsquery": "~5.0.1",
"detect-port": "^1.5.1", "detect-port": "^1.5.1",
"semver": "^7.6.3",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"cypress": ">= 3 < 14" "cypress": ">= 3 < 15"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"cypress": { "cypress": {

View File

@ -1,15 +1,18 @@
import { getTempTailwindPath } from '../../utils/ct-helpers';
import { ExecutorContext, stripIndents } from '@nx/devkit'; import { ExecutorContext, stripIndents } from '@nx/devkit';
import * as detectPort from 'detect-port';
import * as executorUtils from 'nx/src/command-line/run/executor-utils'; import * as executorUtils from 'nx/src/command-line/run/executor-utils';
import * as path from 'path'; import * as path from 'path';
import { installedCypressVersion } from '../../utils/cypress-version'; import { getTempTailwindPath } from '../../utils/ct-helpers';
import { getInstalledCypressMajorVersion } from '../../utils/versions';
import cypressExecutor, { CypressExecutorOptions } from './cypress.impl'; import cypressExecutor, { CypressExecutorOptions } from './cypress.impl';
jest.mock('@nx/devkit'); jest.mock('@nx/devkit');
let devkit = require('@nx/devkit'); let devkit = require('@nx/devkit');
jest.mock('detect-port', () => jest.fn().mockResolvedValue(4200)); jest.mock('detect-port', () => jest.fn().mockResolvedValue(4200));
import * as detectPort from 'detect-port'; jest.mock('../../utils/versions', () => ({
jest.mock('../../utils/cypress-version'); ...jest.requireActual('../../utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
jest.mock('../../utils/ct-helpers'); jest.mock('../../utils/ct-helpers');
const Cypress = require('cypress'); const Cypress = require('cypress');
@ -29,8 +32,8 @@ describe('Cypress builder', () => {
}; };
let mockContext: ExecutorContext; let mockContext: ExecutorContext;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as any; > = getInstalledCypressMajorVersion as any;
mockContext = { mockContext = {
root: '/root', root: '/root',
workspace: { projects: {} }, workspace: { projects: {} },

View File

@ -2,7 +2,7 @@ import { ExecutorContext, logger, stripIndents } from '@nx/devkit';
import { existsSync, readdirSync, unlinkSync } from 'fs'; import { existsSync, readdirSync, unlinkSync } from 'fs';
import { basename, dirname } from 'path'; import { basename, dirname } from 'path';
import { getTempTailwindPath } from '../../utils/ct-helpers'; import { getTempTailwindPath } from '../../utils/ct-helpers';
import { installedCypressVersion } from '../../utils/cypress-version'; import { getInstalledCypressMajorVersion } from '../../utils/versions';
import { startDevServer } from '../../utils/start-dev-server'; import { startDevServer } from '../../utils/start-dev-server';
const Cypress = require('cypress'); // @NOTE: Importing via ES6 messes the whole test dependencies. const Cypress = require('cypress'); // @NOTE: Importing via ES6 messes the whole test dependencies.
@ -53,7 +53,8 @@ export default async function cypressExecutor(
options: CypressExecutorOptions, options: CypressExecutorOptions,
context: ExecutorContext context: ExecutorContext
) { ) {
options = normalizeOptions(options, context); const installedCypressMajorVersion = getInstalledCypressMajorVersion();
options = normalizeOptions(options, context, installedCypressMajorVersion);
// this is used by cypress component testing presets to build the executor contexts with the correct configuration options. // this is used by cypress component testing presets to build the executor contexts with the correct configuration options.
process.env.NX_CYPRESS_TARGET_CONFIGURATION = context.configurationName; process.env.NX_CYPRESS_TARGET_CONFIGURATION = context.configurationName;
let success; let success;
@ -61,10 +62,14 @@ export default async function cypressExecutor(
const generatorInstance = startDevServer(options, context); const generatorInstance = startDevServer(options, context);
for await (const devServerValues of generatorInstance) { for await (const devServerValues of generatorInstance) {
try { try {
success = await runCypress(devServerValues.baseUrl, { success = await runCypress(
devServerValues.baseUrl,
{
...options, ...options,
portLockFilePath: devServerValues.portLockFilePath, portLockFilePath: devServerValues.portLockFilePath,
}); },
installedCypressMajorVersion
);
if (!options.watch) { if (!options.watch) {
generatorInstance.return(); generatorInstance.return();
break; break;
@ -81,7 +86,8 @@ export default async function cypressExecutor(
function normalizeOptions( function normalizeOptions(
options: CypressExecutorOptions, options: CypressExecutorOptions,
context: ExecutorContext context: ExecutorContext,
installedCypressMajorVersion: number | null
): NormalizedCypressExecutorOptions { ): NormalizedCypressExecutorOptions {
options.env = options.env || {}; options.env = options.env || {};
if (options.testingType === 'component') { if (options.testingType === 'component') {
@ -90,19 +96,22 @@ function normalizeOptions(
options.ctTailwindPath = getTempTailwindPath(context); options.ctTailwindPath = getTempTailwindPath(context);
} }
} }
checkSupportedBrowser(options); checkSupportedBrowser(options, installedCypressMajorVersion);
warnDeprecatedHeadless(options); warnDeprecatedHeadless(options, installedCypressMajorVersion);
warnDeprecatedCypressVersion(); warnDeprecatedCypressVersion(installedCypressMajorVersion);
return options; return options;
} }
function checkSupportedBrowser({ browser }: CypressExecutorOptions) { function checkSupportedBrowser(
{ browser }: CypressExecutorOptions,
installedCypressMajorVersion: number | null
) {
// Browser was not passed in as an option, cypress will use whatever default it has set and we dont need to check it // Browser was not passed in as an option, cypress will use whatever default it has set and we dont need to check it
if (!browser) { if (!browser) {
return; return;
} }
if (installedCypressVersion() >= 4 && browser == 'canary') { if (installedCypressMajorVersion >= 4 && browser == 'canary') {
logger.warn(stripIndents` logger.warn(stripIndents`
Warning: Warning:
You are using a browser that is not supported by cypress v4+. You are using a browser that is not supported by cypress v4+.
@ -115,7 +124,7 @@ function checkSupportedBrowser({ browser }: CypressExecutorOptions) {
const supportedV3Browsers = ['electron', 'chrome', 'canary', 'chromium']; const supportedV3Browsers = ['electron', 'chrome', 'canary', 'chromium'];
if ( if (
installedCypressVersion() <= 3 && installedCypressMajorVersion <= 3 &&
!supportedV3Browsers.includes(browser) !supportedV3Browsers.includes(browser)
) { ) {
logger.warn(stripIndents` logger.warn(stripIndents`
@ -126,8 +135,11 @@ function checkSupportedBrowser({ browser }: CypressExecutorOptions) {
} }
} }
function warnDeprecatedHeadless({ headless }: CypressExecutorOptions) { function warnDeprecatedHeadless(
if (installedCypressVersion() < 8 || headless === undefined) { { headless }: CypressExecutorOptions,
installedCypressMajorVersion: number | null
) {
if (installedCypressMajorVersion < 8 || headless === undefined) {
return; return;
} }
@ -140,8 +152,10 @@ function warnDeprecatedHeadless({ headless }: CypressExecutorOptions) {
} }
} }
function warnDeprecatedCypressVersion() { function warnDeprecatedCypressVersion(
if (installedCypressVersion() < 10) { installedCypressMajorVersion: number | null
) {
if (installedCypressMajorVersion < 10) {
logger.warn(stripIndents` logger.warn(stripIndents`
NOTE: NOTE:
Support for Cypress versions < 10 is deprecated. Please upgrade to at least Cypress version 10. Support for Cypress versions < 10 is deprecated. Please upgrade to at least Cypress version 10.
@ -157,9 +171,9 @@ A generator to migrate from v8 to v10 is provided. See https://nx.dev/cypress/v1
*/ */
async function runCypress( async function runCypress(
baseUrl: string, baseUrl: string,
opts: NormalizedCypressExecutorOptions opts: NormalizedCypressExecutorOptions,
installedCypressMajorVersion: number | null
) { ) {
const cypressVersion = installedCypressVersion();
// Cypress expects the folder where a cypress config is present // Cypress expects the folder where a cypress config is present
const projectFolderPath = dirname(opts.cypressConfig); const projectFolderPath = dirname(opts.cypressConfig);
const options: any = { const options: any = {
@ -200,7 +214,7 @@ async function runCypress(
options.ciBuildId = opts.ciBuildId?.toString(); options.ciBuildId = opts.ciBuildId?.toString();
options.group = opts.group; options.group = opts.group;
// renamed in cy 10 // renamed in cy 10
if (cypressVersion >= 10) { if (installedCypressMajorVersion >= 10) {
options.config ??= {}; options.config ??= {};
options.config[opts.testingType] = { options.config[opts.testingType] = {
excludeSpecPattern: opts.ignoreTestFiles, excludeSpecPattern: opts.ignoreTestFiles,

View File

@ -12,11 +12,15 @@ import {
updateProjectConfiguration, updateProjectConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { installedCypressVersion } from '../../utils/cypress-version'; import { getInstalledCypressMajorVersion } from '../../utils/versions';
import { componentConfigurationGenerator } from './component-configuration'; import { componentConfigurationGenerator } from './component-configuration';
import { cypressInitGenerator } from '../init/init'; import { cypressInitGenerator } from '../init/init';
jest.mock('../../utils/cypress-version'); jest.mock('../../utils/versions', () => ({
...jest.requireActual('../../utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
let projectConfig: ProjectConfiguration = { let projectConfig: ProjectConfiguration = {
projectType: 'library', projectType: 'library',
sourceRoot: 'libs/cool-lib/src', sourceRoot: 'libs/cool-lib/src',
@ -39,8 +43,8 @@ let projectConfig: ProjectConfiguration = {
describe('Cypress Component Configuration', () => { describe('Cypress Component Configuration', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });

View File

@ -2,31 +2,27 @@ import {
addDependenciesToPackageJson, addDependenciesToPackageJson,
formatFiles, formatFiles,
generateFiles, generateFiles,
GeneratorCallback,
joinPathFragments, joinPathFragments,
offsetFromRoot, offsetFromRoot,
ProjectConfiguration, ProjectConfiguration,
readJson,
readNxJson, readNxJson,
readProjectConfiguration, readProjectConfiguration,
runTasksInSerial,
Tree, Tree,
updateJson, updateJson,
updateProjectConfiguration,
updateNxJson, updateNxJson,
runTasksInSerial, updateProjectConfiguration,
GeneratorCallback,
readJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { assertNotUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup'; import { assertNotUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { installedCypressVersion } from '../../utils/cypress-version';
import { import {
cypressVersion, getInstalledCypressMajorVersion,
cypressViteDevServerVersion, versions,
cypressWebpackVersion,
htmlWebpackPluginVersion,
} from '../../utils/versions'; } from '../../utils/versions';
import { CypressComponentConfigurationSchema } from './schema';
import { addBaseCypressSetup } from '../base-setup/base-setup'; import { addBaseCypressSetup } from '../base-setup/base-setup';
import init from '../init/init'; import init from '../init/init';
import { CypressComponentConfigurationSchema } from './schema';
type NormalizeCTOptions = ReturnType<typeof normalizeOptions>; type NormalizeCTOptions = ReturnType<typeof normalizeOptions>;
@ -49,12 +45,14 @@ export async function componentConfigurationGeneratorInternal(
const tasks: GeneratorCallback[] = []; const tasks: GeneratorCallback[] = [];
const opts = normalizeOptions(tree, options); const opts = normalizeOptions(tree, options);
if (!getInstalledCypressMajorVersion(tree)) {
tasks.push( tasks.push(
await init(tree, { await init(tree, {
...opts, ...opts,
skipFormat: true, skipFormat: true,
}) })
); );
}
const nxJson = readNxJson(tree); const nxJson = readNxJson(tree);
const hasPlugin = nxJson.plugins?.some((p) => const hasPlugin = nxJson.plugins?.some((p) =>
@ -86,7 +84,7 @@ function normalizeOptions(
tree: Tree, tree: Tree,
options: CypressComponentConfigurationSchema options: CypressComponentConfigurationSchema
) { ) {
const cyVersion = installedCypressVersion(); const cyVersion = getInstalledCypressMajorVersion(tree);
if (cyVersion && cyVersion < 10) { if (cyVersion && cyVersion < 10) {
throw new Error( throw new Error(
'Cypress version of 10 or higher is required to use component testing. See the migration guide to upgrade. https://nx.dev/cypress/v11-migration-guide' 'Cypress version of 10 or higher is required to use component testing. See the migration guide to upgrade. https://nx.dev/cypress/v11-migration-guide'
@ -107,18 +105,21 @@ function normalizeOptions(
} }
function updateDeps(tree: Tree, opts: NormalizeCTOptions) { function updateDeps(tree: Tree, opts: NormalizeCTOptions) {
const pkgVersions = versions(tree);
const devDeps = { const devDeps = {
cypress: cypressVersion, cypress: pkgVersions.cypressVersion,
}; };
if (opts.bundler === 'vite') { if (opts.bundler === 'vite') {
devDeps['@cypress/vite-dev-server'] = cypressViteDevServerVersion; devDeps['@cypress/vite-dev-server'] =
pkgVersions.cypressViteDevServerVersion;
} else { } else {
devDeps['@cypress/webpack-dev-server'] = cypressWebpackVersion; devDeps['@cypress/webpack-dev-server'] = pkgVersions.cypressWebpackVersion;
devDeps['html-webpack-plugin'] = htmlWebpackPluginVersion; devDeps['html-webpack-plugin'] = pkgVersions.htmlWebpackPluginVersion;
} }
return addDependenciesToPackageJson(tree, {}, devDeps); return addDependenciesToPackageJson(tree, {}, devDeps, undefined, true);
} }
function addProjectFiles( function addProjectFiles(

View File

@ -1,58 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Cypress e2e configuration v10+ should not override eslint settings if preset 1`] = `
{
"extends": [
"plugin:cypress/recommended",
"../../.eslintrc.json",
],
"ignorePatterns": [
"!**/*",
],
"overrides": [
{
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates",
],
"files": [
"*.ts",
],
"rules": {
"@angular-eslint/component-selector": [
"error",
{
"prefix": "cy-port-test",
"style": "kebab-case",
"type": "element",
},
],
"@angular-eslint/directive-selector": [
"error",
{
"prefix": "cyPortTest",
"style": "camelCase",
"type": "attribute",
},
],
},
},
{
"extends": [
"plugin:@nx/angular-template",
],
"files": [
"*.html",
],
"rules": {},
},
{
"files": [
"*.cy.{ts,js,tsx,jsx}",
"cypress/**/*.{ts,js,tsx,jsx}",
],
"rules": {},
},
],
}
`;

View File

@ -12,32 +12,16 @@ import {
} from '@nx/devkit'; } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import cypressE2EConfigurationGenerator from './configuration'; import cypressE2EConfigurationGenerator from './configuration';
import { installedCypressVersion } from '../../utils/cypress-version';
import { cypressInitGenerator } from '../init/init'; import { cypressInitGenerator } from '../init/init';
jest.mock('../../utils/cypress-version');
describe('Cypress e2e configuration', () => { describe('Cypress e2e configuration', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion>
> = installedCypressVersion as never;
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
tree.write('.eslintrc.json', '{}'); // we are explicitly checking for existance of config type tree.write('.eslintrc.json', '{}'); // we are explicitly checking for existance of config type
}); });
afterAll(() => {
jest.resetAllMocks();
});
describe('v10+', () => {
beforeAll(() => {
mockedInstalledCypressVersion.mockReturnValue(10);
});
it('should add web server commands to the cypress config when the @nx/cypress/plugin is present', async () => { it('should add web server commands to the cypress config when the @nx/cypress/plugin is present', async () => {
await cypressInitGenerator(tree, { await cypressInitGenerator(tree, {
addPlugin: true, addPlugin: true,
@ -77,10 +61,9 @@ describe('Cypress e2e configuration', () => {
`); `);
expect( expect(
readProjectConfiguration(tree, 'my-app').targets.e2e readProjectConfiguration(tree, 'my-app').targets.e2e
).toMatchInlineSnapshot(`undefined`); ).toBeUndefined();
expect(readJson(tree, 'apps/my-app/tsconfig.json')) expect(readJson(tree, 'apps/my-app/tsconfig.json')).toMatchInlineSnapshot(`
.toMatchInlineSnapshot(`
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
@ -106,13 +89,14 @@ describe('Cypress e2e configuration', () => {
assertCypressFiles(tree, 'apps/my-app/src'); assertCypressFiles(tree, 'apps/my-app/src');
}); });
it('should add e2e target to existing app', async () => { it('should add e2e target to existing app when not using plugin', async () => {
addProject(tree, { name: 'my-app', type: 'apps' }); addProject(tree, { name: 'my-app', type: 'apps' });
await cypressE2EConfigurationGenerator(tree, { await cypressE2EConfigurationGenerator(tree, {
project: 'my-app', project: 'my-app',
addPlugin: true, addPlugin: false,
}); });
expect(tree.read('apps/my-app/cypress.config.ts', 'utf-8')) expect(tree.read('apps/my-app/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
@ -122,22 +106,31 @@ describe('Cypress e2e configuration', () => {
e2e: { e2e: {
...nxE2EPreset(__filename, { ...nxE2EPreset(__filename, {
cypressDir: 'src', cypressDir: 'src',
webServerCommands: {
default: 'nx run my-app:serve',
production: 'nx run my-app:serve:production',
},
ciWebServerCommand: 'nx run my-app:serve-static',
}), }),
}, },
}); });
" "
`); `);
expect( expect(readProjectConfiguration(tree, 'my-app').targets.e2e)
readProjectConfiguration(tree, 'my-app').targets.e2e
).toMatchInlineSnapshot(`undefined`);
expect(readJson(tree, 'apps/my-app/tsconfig.json'))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{
"configurations": {
"ci": {
"devServerTarget": "my-app:serve-static",
},
"production": {
"devServerTarget": "my-app:serve:production",
},
},
"executor": "@nx/cypress:cypress",
"options": {
"cypressConfig": "apps/my-app/cypress.config.ts",
"devServerTarget": "my-app:serve",
"testingType": "e2e",
},
}
`);
expect(readJson(tree, 'apps/my-app/tsconfig.json')).toMatchInlineSnapshot(`
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
@ -377,7 +370,62 @@ describe('Cypress e2e configuration', () => {
baseUrl: 'http://localhost:4200', baseUrl: 'http://localhost:4200',
addPlugin: true, addPlugin: true,
}); });
expect(readJson(tree, 'libs/my-lib/.eslintrc.json')).toMatchSnapshot(); expect(readJson(tree, 'libs/my-lib/.eslintrc.json')).toMatchInlineSnapshot(`
{
"extends": [
"plugin:cypress/recommended",
"../../.eslintrc.json",
],
"ignorePatterns": [
"!**/*",
],
"overrides": [
{
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates",
],
"files": [
"*.ts",
],
"rules": {
"@angular-eslint/component-selector": [
"error",
{
"prefix": "cy-port-test",
"style": "kebab-case",
"type": "element",
},
],
"@angular-eslint/directive-selector": [
"error",
{
"prefix": "cyPortTest",
"style": "camelCase",
"type": "attribute",
},
],
},
},
{
"extends": [
"plugin:@nx/angular-template",
],
"files": [
"*.html",
],
"rules": {},
},
{
"files": [
"*.cy.{ts,js,tsx,jsx}",
"cypress/**/*.{ts,js,tsx,jsx}",
],
"rules": {},
},
],
}
`);
}); });
it('should add serve-static target to CI configuration', async () => { it('should add serve-static target to CI configuration', async () => {
@ -397,8 +445,7 @@ describe('Cypress e2e configuration', () => {
}); });
assertCypressFiles(tree, 'libs/my-lib/cypress'); assertCypressFiles(tree, 'libs/my-lib/cypress');
expect( expect(
readProjectConfiguration(tree, 'my-lib').targets['e2e'].configurations readProjectConfiguration(tree, 'my-lib').targets['e2e'].configurations.ci
.ci
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
"devServerTarget": "my-app:serve-static", "devServerTarget": "my-app:serve-static",
@ -465,9 +512,7 @@ export default defineConfig({
expect( expect(
tree.exists('libs/my-lib/cypress/fixtures/example.json') tree.exists('libs/my-lib/cypress/fixtures/example.json')
).toBeFalsy(); ).toBeFalsy();
expect( expect(tree.exists('libs/my-lib/cypress/support/commands.ts')).toBeFalsy();
tree.exists('libs/my-lib/cypress/support/commands.ts')
).toBeFalsy();
}); });
it('should not throw if e2e is already defined', async () => { it('should not throw if e2e is already defined', async () => {
@ -628,7 +673,6 @@ export default defineConfig({
}); });
}); });
}); });
});
function addProject( function addProject(
tree: Tree, tree: Tree,

View File

@ -20,6 +20,7 @@ import {
writeJson, writeJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { resolveImportPath } from '@nx/devkit/src/generators/project-name-and-root-utils'; import { resolveImportPath } from '@nx/devkit/src/generators/project-name-and-root-utils';
import { promptWhenInteractive } from '@nx/devkit/src/generators/prompt';
import { Linter, LinterType } from '@nx/eslint'; import { Linter, LinterType } from '@nx/eslint';
import { import {
getRelativePathToRootTsConfig, getRelativePathToRootTsConfig,
@ -35,11 +36,12 @@ import { PackageJson } from 'nx/src/utils/package-json';
import { join } from 'path'; import { join } from 'path';
import { addLinterToCyProject } from '../../utils/add-linter'; import { addLinterToCyProject } from '../../utils/add-linter';
import { addDefaultE2EConfig } from '../../utils/config'; import { addDefaultE2EConfig } from '../../utils/config';
import { installedCypressVersion } from '../../utils/cypress-version'; import {
import { typesNodeVersion, viteVersion } from '../../utils/versions'; getInstalledCypressMajorVersion,
versions,
} from '../../utils/versions';
import { addBaseCypressSetup } from '../base-setup/base-setup'; import { addBaseCypressSetup } from '../base-setup/base-setup';
import cypressInitGenerator, { addPlugin } from '../init/init'; import cypressInitGenerator, { addPlugin } from '../init/init';
import { promptWhenInteractive } from '@nx/devkit/src/generators/prompt';
export interface CypressE2EConfigSchema { export interface CypressE2EConfigSchema {
project: string; project: string;
@ -82,7 +84,7 @@ export async function configurationGeneratorInternal(
const tasks: GeneratorCallback[] = []; const tasks: GeneratorCallback[] = [];
const projectGraph = await createProjectGraphAsync(); const projectGraph = await createProjectGraphAsync();
if (!installedCypressVersion()) { if (!getInstalledCypressMajorVersion(tree)) {
tasks.push(await jsInitGenerator(tree, { ...options, skipFormat: true })); tasks.push(await jsInitGenerator(tree, { ...options, skipFormat: true }));
tasks.push( tasks.push(
await cypressInitGenerator(tree, { await cypressInitGenerator(tree, {
@ -174,15 +176,23 @@ export async function configurationGeneratorInternal(
} }
function ensureDependencies(tree: Tree, options: NormalizedSchema) { function ensureDependencies(tree: Tree, options: NormalizedSchema) {
const pkgVersions = versions(tree);
const devDependencies: Record<string, string> = { const devDependencies: Record<string, string> = {
'@types/node': typesNodeVersion, '@types/node': pkgVersions.typesNodeVersion,
}; };
if (options.bundler === 'vite') { if (options.bundler === 'vite') {
devDependencies['vite'] = viteVersion; devDependencies['vite'] = pkgVersions.viteVersion;
} }
return addDependenciesToPackageJson(tree, {}, devDependencies); return addDependenciesToPackageJson(
tree,
{},
devDependencies,
undefined,
true
);
} }
async function normalizeOptions(tree: Tree, options: CypressE2EConfigSchema) { async function normalizeOptions(tree: Tree, options: CypressE2EConfigSchema) {
@ -280,7 +290,7 @@ async function addFiles(
hasPlugin: boolean hasPlugin: boolean
) { ) {
const projectConfig = readProjectConfiguration(tree, options.project); const projectConfig = readProjectConfiguration(tree, options.project);
const cyVersion = installedCypressVersion(); const cyVersion = getInstalledCypressMajorVersion(tree);
const filesToUse = cyVersion && cyVersion < 10 ? 'v9' : 'v10'; const filesToUse = cyVersion && cyVersion < 10 ? 'v9' : 'v10';
const hasTsConfig = tree.exists( const hasTsConfig = tree.exists(
@ -400,7 +410,7 @@ function addTarget(
projectGraph: ProjectGraph projectGraph: ProjectGraph
) { ) {
const projectConfig = readProjectConfiguration(tree, opts.project); const projectConfig = readProjectConfiguration(tree, opts.project);
const cyVersion = installedCypressVersion(); const cyVersion = getInstalledCypressMajorVersion(tree);
projectConfig.targets ??= {}; projectConfig.targets ??= {};
projectConfig.targets.e2e = { projectConfig.targets.e2e = {
executor: '@nx/cypress:cypress', executor: '@nx/cypress:cypress',

View File

@ -11,7 +11,7 @@ import {
writeJson, writeJson,
} from '@nx/devkit'; } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { installedCypressVersion } from '../../utils/cypress-version'; import { getInstalledCypressMajorVersion } from '../../utils/versions';
import { configurationGenerator } from '../configuration/configuration'; import { configurationGenerator } from '../configuration/configuration';
import { import {
createSupportFileImport, createSupportFileImport,
@ -21,13 +21,16 @@ import {
} from './conversion.util'; } from './conversion.util';
import { migrateCypressProject } from './migrate-to-cypress-11'; import { migrateCypressProject } from './migrate-to-cypress-11';
jest.mock('../../utils/cypress-version'); jest.mock('../../utils/versions', () => ({
...jest.requireActual('../../utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
describe('convertToCypressTen', () => { describe('convertToCypressTen', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });

View File

@ -1,7 +1,3 @@
import {
assertMinimumCypressVersion,
installedCypressVersion,
} from '../../utils/cypress-version';
import { import {
formatFiles, formatFiles,
installPackagesTask, installPackagesTask,
@ -16,6 +12,10 @@ import {
} from '@nx/devkit'; } from '@nx/devkit';
import { forEachExecutorOptions } from '@nx/devkit/src/generators/executor-options-utils'; import { forEachExecutorOptions } from '@nx/devkit/src/generators/executor-options-utils';
import { CypressExecutorOptions } from '../../executors/cypress/cypress.impl'; import { CypressExecutorOptions } from '../../executors/cypress/cypress.impl';
import {
assertMinimumCypressVersion,
getInstalledCypressMajorVersion,
} from '../../utils/versions';
import { import {
addConfigToTsConfig, addConfigToTsConfig,
createNewCypressConfig, createNewCypressConfig,
@ -119,7 +119,7 @@ https://nx.dev/cypress/v10-migration-guide
export async function migrateCypressProject(tree: Tree) { export async function migrateCypressProject(tree: Tree) {
assertMinimumCypressVersion(8); assertMinimumCypressVersion(8);
if (installedCypressVersion() >= 10) { if (getInstalledCypressMajorVersion(tree) >= 10) {
logger.info('NX This workspace is already using Cypress v10+'); logger.info('NX This workspace is already using Cypress v10+');
return; return;
} }

View File

@ -1,6 +1,6 @@
import { readJson, updateJson, type Tree } from '@nx/devkit'; import { readJson, updateJson, type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import * as cypressVersionUtils from '../../utils/cypress-version'; import * as cypressVersions from '../../utils/versions';
import migration from './update-cypress-version-13-6-6'; import migration from './update-cypress-version-13-6-6';
describe('update-cypress-version migration', () => { describe('update-cypress-version migration', () => {
@ -14,7 +14,7 @@ describe('update-cypress-version migration', () => {
}); });
const major = parseInt(version.split('.')[0].replace('^', ''), 10); const major = parseInt(version.split('.')[0].replace('^', ''), 10);
jest jest
.spyOn(cypressVersionUtils, 'installedCypressVersion') .spyOn(cypressVersions, 'getInstalledCypressMajorVersion')
.mockReturnValue(major); .mockReturnValue(major);
} }

View File

@ -3,10 +3,10 @@ import {
formatFiles, formatFiles,
type Tree, type Tree,
} from '@nx/devkit'; } from '@nx/devkit';
import { installedCypressVersion } from '../../utils/cypress-version'; import { getInstalledCypressMajorVersion } from '../../utils/versions';
export default async function (tree: Tree) { export default async function (tree: Tree) {
if (installedCypressVersion() < 13) { if (getInstalledCypressMajorVersion(tree) < 13) {
return; return;
} }

View File

@ -0,0 +1,59 @@
#### Remove `experimentalFetchPolyfill` Configuration Option
Removes the `experimentalFetchPolyfill` configuration option that was removed in Cypress v14. Read more at the [migration notes](<https://docs.cypress.io/app/references/changelog#:~:text=The%20experimentalFetchPolyfill%20configuration%20option%20was,cy.intercept()%20for%20handling%20fetch%20requests>).
#### Examples
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalFetchPolyfill: true,
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
});
```
{% /tab %}
{% /tabs %}

View File

@ -0,0 +1,159 @@
import { addProjectConfiguration, type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration from './remove-experimental-fetch-polyfill';
describe('remove-experimental-fetch-polyfill', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should do nothing when there are no projects with cypress config', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.exists('apps/app1-e2e/cypress.config.ts')).toBe(false);
});
it('should do nothing when the cypress config cannot be parsed as expected', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write('apps/app1-e2e/cypress.config.ts', `export const foo = 'bar';`);
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"export const foo = 'bar';
"
`);
});
it('should handle when the cypress config path in the executor is not valid', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {
e2e: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1-e2e/non-existent-cypress.config.ts',
},
},
},
});
await expect(migration(tree)).resolves.not.toThrow();
});
it('should remove the experimentalFetchPolyfill property even if defined multiple times', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
experimentalFetchPolyfill: true,
},
e2e: {
experimentalFetchPolyfill: true,
},
experimentalFetchPolyfill: true,
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
e2e: {},
});
"
`);
});
it('should handle cypress config files in projects using the "@nx/cypress:cypress" executor', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {
e2e: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1-e2e/cypress.custom-config.ts',
},
},
},
});
tree.write(
'apps/app1-e2e/cypress.custom-config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalFetchPolyfill: true,
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.custom-config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
});
"
`);
});
});

View File

@ -0,0 +1,79 @@
import { formatFiles, type Tree } from '@nx/devkit';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import { tsquery } from '@phenomnomnominal/tsquery';
import type { Printer } from 'typescript';
import { resolveCypressConfigObject } from '../../utils/config';
import { cypressProjectConfigs } from '../../utils/migrations';
let printer: Printer;
let ts: typeof import('typescript');
// https://docs.cypress.io/app/references/changelog#:~:text=The%20experimentalFetchPolyfill%20configuration%20option%20was,cy.intercept()%20for%20handling%20fetch%20requests
export default async function (tree: Tree) {
for await (const { cypressConfigPath } of cypressProjectConfigs(tree)) {
if (!tree.exists(cypressConfigPath)) {
// cypress config file doesn't exist, so skip
continue;
}
ts ??= ensureTypescript();
printer ??= ts.createPrinter();
const cypressConfig = tree.read(cypressConfigPath, 'utf-8');
const updatedConfig = removeExperimentalFetchPolyfill(cypressConfig);
tree.write(cypressConfigPath, updatedConfig);
}
await formatFiles(tree);
}
function removeExperimentalFetchPolyfill(cypressConfig: string): string {
const config = resolveCypressConfigObject(cypressConfig);
if (!config) {
// couldn't find the config object, leave as is
return cypressConfig;
}
const sourceFile = tsquery.ast(cypressConfig);
const updatedConfig = ts.factory.updateObjectLiteralExpression(
config,
config.properties
// remove the experimentalFetchPolyfill property from the top level config object
.filter(
(p) =>
!ts.isPropertyAssignment(p) ||
p.name.getText() !== 'experimentalFetchPolyfill'
)
.map((p) => {
if (
ts.isPropertyAssignment(p) &&
['component', 'e2e'].includes(p.name.getText()) &&
ts.isObjectLiteralExpression(p.initializer)
) {
// remove the experimentalFetchPolyfill property from the component or e2e config object
return ts.factory.updatePropertyAssignment(
p,
p.name,
ts.factory.updateObjectLiteralExpression(
p.initializer,
p.initializer.properties.filter(
(ip) =>
!ts.isPropertyAssignment(ip) ||
ip.name.getText() !== 'experimentalFetchPolyfill'
)
)
);
}
return p;
})
);
return cypressConfig.replace(
config.getText(),
printer.printNode(ts.EmitHint.Unspecified, updatedConfig, sourceFile)
);
}

View File

@ -0,0 +1,123 @@
#### Replace the `experimentalJustInTimeCompile` Configuration Option with `justInTimeCompile`
Replaces the `experimentalJustInTimeCompile` configuration option with the new `justInTimeCompile` configuration option. Read more at the [migration notes](https://docs.cypress.io/app/references/migration-guide#CT-Just-in-Time-Compile-changes).
#### Examples
If the `experimentalJustInTimeCompile` configuration option is present and set to `true`, the migration will remove it. This is to account for the fact that JIT compilation is the default behavior in Cypress v14.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1/cypress.config.ts" %}
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
experimentalJustInTimeCompile: true,
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1/cypress.config.ts" %}
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
},
});
```
{% /tab %}
{% /tabs %}
If the `experimentalJustInTimeCompile` configuration option is set to `false` and it is using webpack, the migration will rename it to `justInTimeCompile`.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1/cypress.config.ts" %}
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
experimentalJustInTimeCompile: false,
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1/cypress.config.ts" highlightLines=[9] %}
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
justInTimeCompile: false,
},
});
```
{% /tab %}
{% /tabs %}
If the `experimentalJustInTimeCompile` configuration is set to any value and it is using Vite, the migration will remove it. This is to account for the fact that JIT compilation no longer applies to Vite.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1/cypress.config.ts" %}
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
experimentalJustInTimeCompile: false,
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1/cypress.config.ts" %}
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
},
});
```
{% /tab %}
{% /tabs %}

View File

@ -0,0 +1,319 @@
import { addProjectConfiguration, type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration from './replace-experimental-just-in-time-compile';
describe('replace-experimental-just-in-time-compile', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should do nothing when there are no projects with cypress config', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.exists('apps/app1/cypress.config.ts')).toBe(false);
});
it('should do nothing when the cypress config cannot be parsed as expected', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write('apps/app1/cypress.config.ts', `export const foo = 'bar';`);
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.read('apps/app1/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"export const foo = 'bar';
"
`);
});
it('should handle when the cypress config path in the executor is not valid', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {
ct: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1/non-existent-cypress.config.ts',
},
},
},
});
await expect(migration(tree)).resolves.not.toThrow();
});
it('should handle cypress config files in projects using the "@nx/cypress:cypress" executor', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {
ct: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1/cypress.custom-config.ts',
},
},
},
});
tree.write(
'apps/app1/cypress.custom-config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
experimentalJustInTimeCompile: false,
});
`
);
await migration(tree);
const config = tree.read('apps/app1/cypress.custom-config.ts', 'utf-8');
expect(config).not.toContain('experimentalJustInTimeCompile');
expect(config).not.toContain('justInTimeCompile');
});
it('should remove the experimentalJustInTimeCompile property from the top-level config when using vite', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
},
experimentalJustInTimeCompile: false,
});
`
);
await migration(tree);
const config = tree.read('apps/app1/cypress.config.ts', 'utf-8');
expect(config).not.toContain('experimentalJustInTimeCompile');
expect(config).not.toContain('justInTimeCompile');
});
it('should remove the experimentalJustInTimeCompile property from the component config when using vite', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
experimentalJustInTimeCompile: false,
},
});
`
);
await migration(tree);
const config = tree.read('apps/app1/cypress.config.ts', 'utf-8');
expect(config).not.toContain('experimentalJustInTimeCompile');
expect(config).not.toContain('justInTimeCompile');
});
it('should remove the experimentalJustInTimeCompile property from both the top-level config and the component config when using vite', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
experimentalJustInTimeCompile: false,
},
experimentalJustInTimeCompile: false,
});
`
);
await migration(tree);
const config = tree.read('apps/app1/cypress.config.ts', 'utf-8');
expect(config).not.toContain('experimentalJustInTimeCompile');
expect(config).not.toContain('justInTimeCompile');
});
it('should remove the experimentalJustInTimeCompile property from the top-level config when set to true and it is using webpack', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
},
experimentalJustInTimeCompile: true,
});
`
);
await migration(tree);
const config = tree.read('apps/app1/cypress.config.ts', 'utf-8');
expect(config).not.toContain('experimentalJustInTimeCompile');
expect(config).not.toContain('justInTimeCompile');
});
it('should rename the experimentalJustInTimeCompile property to justInTimeCompile in the top-level config when set to false and it is using webpack', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
},
experimentalJustInTimeCompile: false,
});
`
);
await migration(tree);
expect(tree.read('apps/app1/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
},
justInTimeCompile: false,
});
"
`);
});
it('should remove the experimentalJustInTimeCompile property from the component config when set to true and it is using webpack', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
experimentalJustInTimeCompile: true,
},
});
`
);
await migration(tree);
const config = tree.read('apps/app1/cypress.config.ts', 'utf-8');
expect(config).not.toContain('experimentalJustInTimeCompile');
expect(config).not.toContain('justInTimeCompile');
});
it('should rename the experimentalJustInTimeCompile property to justInTimeCompile in the component config when set to false and it is using webpack', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from "cypress";
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
experimentalJustInTimeCompile: false,
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
},
justInTimeCompile: false,
},
});
"
`);
});
});

View File

@ -0,0 +1,159 @@
import { formatFiles, workspaceRoot, type Tree } from '@nx/devkit';
import { loadConfigFile } from '@nx/devkit/src/utils/config-utils';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import { tsquery } from '@phenomnomnominal/tsquery';
import { join } from 'node:path';
import type {
ObjectLiteralExpression,
Printer,
PropertyAssignment,
} from 'typescript';
import { resolveCypressConfigObject } from '../../utils/config';
import {
cypressProjectConfigs,
getObjectProperty,
removeObjectProperty,
updateObjectProperty,
} from '../../utils/migrations';
let printer: Printer;
let ts: typeof import('typescript');
// https://docs.cypress.io/app/references/migration-guide#CT-Just-in-Time-Compile-changes
export default async function (tree: Tree) {
for await (const { cypressConfigPath } of cypressProjectConfigs(tree)) {
if (!tree.exists(cypressConfigPath)) {
// cypress config file doesn't exist, so skip
continue;
}
const updatedConfig = await updateCtJustInTimeCompile(
tree,
cypressConfigPath
);
tree.write(cypressConfigPath, updatedConfig);
}
await formatFiles(tree);
}
async function updateCtJustInTimeCompile(
tree: Tree,
cypressConfigPath: string
): Promise<string> {
const cypressConfig = tree.read(cypressConfigPath, 'utf-8');
const config = resolveCypressConfigObject(cypressConfig);
if (!config) {
// couldn't find the config object, leave as is
return cypressConfig;
}
if (!getObjectProperty(config, 'component')) {
// no component config, leave as is
return cypressConfig;
}
ts ??= ensureTypescript();
printer ??= ts.createPrinter();
const sourceFile = tsquery.ast(cypressConfig);
let updatedConfig = config;
const bundler = await resolveBundler(updatedConfig, cypressConfigPath);
const isViteBundler = bundler === 'vite';
const existingJustInTimeCompileProperty = getObjectProperty(
updatedConfig,
'experimentalJustInTimeCompile'
);
if (existingJustInTimeCompileProperty) {
if (
isViteBundler ||
existingJustInTimeCompileProperty.initializer.kind ===
ts.SyntaxKind.TrueKeyword
) {
// if it's using vite or it's set to true (the new default value), remove it
updatedConfig = removeObjectProperty(
updatedConfig,
existingJustInTimeCompileProperty
);
} else {
// rename to justInTimeCompile
updatedConfig = updateObjectProperty(
updatedConfig,
existingJustInTimeCompileProperty,
{ newName: 'justInTimeCompile' }
);
}
}
const componentProperty = getObjectProperty(updatedConfig, 'component');
if (
componentProperty &&
ts.isObjectLiteralExpression(componentProperty.initializer)
) {
const componentConfigObject = componentProperty.initializer;
const existingJustInTimeCompileProperty = getObjectProperty(
componentConfigObject,
'experimentalJustInTimeCompile'
);
if (existingJustInTimeCompileProperty) {
if (
isViteBundler ||
existingJustInTimeCompileProperty.initializer.kind ===
ts.SyntaxKind.TrueKeyword
) {
// if it's using vite or it's set to true (the new default value), remove it
updatedConfig = updateObjectProperty(updatedConfig, componentProperty, {
newValue: removeObjectProperty(
componentConfigObject,
existingJustInTimeCompileProperty
),
});
} else {
// rename to justInTimeCompile
updatedConfig = updateObjectProperty(updatedConfig, componentProperty, {
newValue: updateObjectProperty(
componentConfigObject,
existingJustInTimeCompileProperty,
{ newName: 'justInTimeCompile' }
),
});
}
}
}
return cypressConfig.replace(
config.getText(),
printer.printNode(ts.EmitHint.Unspecified, updatedConfig, sourceFile)
);
}
async function resolveBundler(
config: ObjectLiteralExpression,
cypressConfigPath: string
): Promise<string | null> {
const bundlerProperty = tsquery.query<PropertyAssignment>(
config,
'PropertyAssignment:has(Identifier[name=component]) PropertyAssignment:has(Identifier[name=devServer]) PropertyAssignment:has(Identifier[name=bundler])'
)[0];
if (bundlerProperty) {
return ts.isStringLiteral(bundlerProperty.initializer)
? bundlerProperty.initializer.getText().replace(/['"`]/g, '')
: null;
}
try {
// we can't statically resolve the bundler from the config, so we load the config
const cypressConfig = await loadConfigFile(
join(workspaceRoot, cypressConfigPath)
);
return cypressConfig.component?.devServer?.bundler;
} catch {
return null;
}
}

View File

@ -0,0 +1,178 @@
#### Set `injectDocumentDomain` Configuration Option
Replaces the removed `experimentalSkipDomainInjection` configuration option with the new `injectDocumentDomain` configuration option when needed. Skipping domain injection is the default behavior in Cypress v14 and therefore, it is required to use the `cy.origin()` command when navigating between domains. The `injectDocumentDomain` option was introduced to ease the transition to v14, but it is deprecated and will be removed in Cypress v15. Read more at the [migration notes](https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin).
#### Examples
If the `experimentalSkipDomainInjection` configuration option is present, the migration will remove it. This is to account for the fact that skipping domain injection is the default behavior in Cypress v14.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalSkipDomainInjection: ['https://example.com'],
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
});
```
{% /tab %}
{% /tabs %}
If the `experimentalSkipDomainInjection` configuration option is present and set to an empty array (no domain injection is skipped), the migration will remove it and will set the `injectDocumentDomain` option to `true`.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalSkipDomainInjection: [],
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" highlightLines=["17-19"] %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use `cy.origin()` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
```
{% /tab %}
{% /tabs %}
If the `experimentalSkipDomainInjection` configuration option is not present (no domain injection is skipped), the migration will set the `injectDocumentDomain` option to `true`.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
});
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1-e2e/cypress.config.ts" highlightLines=["17-19"] %}
import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use `cy.origin()` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
```
{% /tab %}
{% /tabs %}

View File

@ -0,0 +1,712 @@
import { addProjectConfiguration, type Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration from './set-inject-document-domain';
describe('set-inject-document-domain', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should do nothing when there are no projects with cypress config', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.exists('apps/app1-e2e/cypress.config.ts')).toBe(false);
});
it('should do nothing when the cypress config cannot be parsed as expected', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write('apps/app1-e2e/cypress.config.ts', `export const foo = 'bar';`);
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"export const foo = 'bar';
"
`);
});
it('should handle when the cypress config path in the executor is not valid', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {
e2e: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1-e2e/non-existent-cypress.config.ts',
},
},
},
});
await expect(migration(tree)).resolves.not.toThrow();
});
it('should set the injectDocumentDomain property to true in the top-level config when there is no e2e or component config', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
});
"
`);
});
it('should replace the experimentalSkipDomainInjection property in the top-level config with injectDocumentDomain when it is set to an empty array', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalSkipDomainInjection: [],
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
});
"
`);
});
it('should replace the experimentalSkipDomainInjection property in the top-level config with injectDocumentDomain when it is set to an empty array and there is an e2e or component config without experimentalSkipDomainInjection', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
experimentalSkipDomainInjection: [],
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
});
"
`);
});
it('should remove the experimentalSkipDomainInjection property from the top-level config when it is set to a non-empty array', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalSkipDomainInjection: ['https://example.com'],
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
});
"
`);
});
it('should set the injectDocumentDomain property to true in the e2e config when defined and experimentalSkipDomainInjection is not set', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
"
`);
});
it('should set the injectDocumentDomain property to true in the e2e config when it is not an object literal', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
"
`);
});
it('should replace the experimentalSkipDomainInjection property in the e2e config with injectDocumentDomain when it is set to an empty array', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalSkipDomainInjection: [],
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
"
`);
});
it('should remove the experimentalSkipDomainInjection property from the e2e config when it is set to a non-empty array', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
experimentalSkipDomainInjection: ['https://example.com'],
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
},
});
"
`);
});
it('should set the injectDocumentDomain property to true in the component config when defined and experimentalSkipDomainInjection is not set', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
"
`);
});
it('should set the injectDocumentDomain property to true in the component config when it is not an object literal', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: nxComponentTestingPreset(__filename, { bundler: 'vite' }),
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
"
`);
});
it('should replace the experimentalSkipDomainInjection property in the component config with injectDocumentDomain when it is set to an empty array', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
experimentalSkipDomainInjection: [],
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
},
});
"
`);
});
it('should remove the experimentalSkipDomainInjection property in the component config when it is set to a non-empty array', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1-e2e/cypress.config.ts',
`import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
experimentalSkipDomainInjection: ['https://example.com'],
},
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename, { bundler: 'vite' }),
},
});
"
`);
});
it('should handle cypress config files in projects using the "@nx/cypress:cypress" executor', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {
e2e: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1-e2e/cypress.custom-config.ts',
},
},
},
});
tree.write(
'apps/app1-e2e/cypress.custom-config.ts',
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
});
`
);
await migration(tree);
expect(tree.read('apps/app1-e2e/cypress.custom-config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
...nxE2EPreset(__filename, {
cypressDir: 'src',
bundler: 'vite',
webServerCommands: {
default: 'pnpm exec nx run app1:dev',
production: 'pnpm exec nx run app1:dev',
},
ciWebServerCommand: 'pnpm exec nx run app1:dev',
ciBaseUrl: 'http://localhost:4200',
}),
baseUrl: 'http://localhost:4200',
// Please ensure you use \`cy.origin()\` when navigating between domains and remove this option.
// See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
injectDocumentDomain: true,
});
"
`);
});
});

View File

@ -0,0 +1,251 @@
import { formatFiles, type Tree } from '@nx/devkit';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import { tsquery } from '@phenomnomnominal/tsquery';
import type {
Expression,
ObjectLiteralExpression,
Printer,
PropertyAssignment,
} from 'typescript';
import { resolveCypressConfigObject } from '../../utils/config';
import {
cypressProjectConfigs,
getObjectProperty,
removeObjectProperty,
updateObjectProperty,
} from '../../utils/migrations';
let printer: Printer;
let ts: typeof import('typescript');
// https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
// https://docs.cypress.io/app/references/changelog#:~:text=The%20experimentalSkipDomainInjection%20configuration%20has%20been,injectDocumentDomain%20configuration
export default async function (tree: Tree) {
for await (const { cypressConfigPath } of cypressProjectConfigs(tree)) {
if (!tree.exists(cypressConfigPath)) {
// cypress config file doesn't exist, so skip
continue;
}
ts ??= ensureTypescript();
printer ??= ts.createPrinter();
const cypressConfig = tree.read(cypressConfigPath, 'utf-8');
const updatedConfig = setInjectDocumentDomain(cypressConfig);
tree.write(cypressConfigPath, updatedConfig);
}
await formatFiles(tree);
}
function setInjectDocumentDomain(cypressConfig: string): string {
const config = resolveCypressConfigObject(cypressConfig);
if (!config) {
// couldn't find the config object, leave as is
return cypressConfig;
}
const sourceFile = tsquery.ast(cypressConfig);
let e2eProperty = getObjectProperty(config, 'e2e');
let componentProperty = getObjectProperty(config, 'component');
let updatedConfig = config;
const topLevelExperimentalSkipDomainInjectionProperty = getObjectProperty(
updatedConfig,
'experimentalSkipDomainInjection'
);
const topLevelSkipDomainState: 'not-set' | 'skipping' | 'not-skipping' =
!topLevelExperimentalSkipDomainInjectionProperty
? 'not-set'
: !ts.isArrayLiteralExpression(
topLevelExperimentalSkipDomainInjectionProperty.initializer
) ||
topLevelExperimentalSkipDomainInjectionProperty.initializer.elements
.length > 0
? 'skipping'
: 'not-skipping';
let e2eSkipDomainState: 'not-set' | 'skipping' | 'not-skipping' = 'not-set';
if (e2eProperty) {
let experimentalSkipDomainInjectionProperty: PropertyAssignment | undefined;
let isObjectLiteral = false;
if (ts.isObjectLiteralExpression(e2eProperty.initializer)) {
experimentalSkipDomainInjectionProperty = getObjectProperty(
e2eProperty.initializer,
'experimentalSkipDomainInjection'
);
isObjectLiteral = true;
}
if (experimentalSkipDomainInjectionProperty) {
e2eSkipDomainState =
!ts.isArrayLiteralExpression(
experimentalSkipDomainInjectionProperty.initializer
) ||
experimentalSkipDomainInjectionProperty.initializer.elements.length > 0
? 'skipping'
: 'not-skipping';
}
if (
e2eSkipDomainState === 'not-set' &&
topLevelSkipDomainState === 'not-set'
) {
updatedConfig = updateObjectProperty(updatedConfig, e2eProperty, {
newValue: setInjectDocumentDomainInObject(e2eProperty.initializer),
});
} else if (e2eSkipDomainState === 'not-skipping') {
updatedConfig = updateObjectProperty(updatedConfig, e2eProperty, {
newValue: replaceExperimentalSkipDomainInjectionInObject(
e2eProperty.initializer
),
});
} else if (e2eSkipDomainState === 'skipping') {
updatedConfig = updateObjectProperty(updatedConfig, e2eProperty, {
newValue: removeObjectProperty(
// we only determine that it's skipping if it's an object literal
e2eProperty.initializer as ObjectLiteralExpression,
getObjectProperty(
e2eProperty.initializer as ObjectLiteralExpression,
'experimentalSkipDomainInjection'
)
),
});
}
}
let componentSkipDomainState: 'not-set' | 'skipping' | 'not-skipping' =
'not-set';
if (componentProperty) {
let experimentalSkipDomainInjectionProperty: PropertyAssignment | undefined;
let isObjectLiteral = false;
if (ts.isObjectLiteralExpression(componentProperty.initializer)) {
experimentalSkipDomainInjectionProperty = getObjectProperty(
componentProperty.initializer,
'experimentalSkipDomainInjection'
);
isObjectLiteral = true;
}
if (experimentalSkipDomainInjectionProperty) {
componentSkipDomainState =
!ts.isArrayLiteralExpression(
experimentalSkipDomainInjectionProperty.initializer
) ||
experimentalSkipDomainInjectionProperty.initializer.elements.length > 0
? 'skipping'
: 'not-skipping';
}
if (
componentSkipDomainState === 'not-set' &&
topLevelSkipDomainState === 'not-set'
) {
updatedConfig = updateObjectProperty(updatedConfig, componentProperty, {
newValue: setInjectDocumentDomainInObject(
componentProperty.initializer
),
});
} else if (componentSkipDomainState === 'not-skipping') {
updatedConfig = updateObjectProperty(updatedConfig, componentProperty, {
newValue: replaceExperimentalSkipDomainInjectionInObject(
componentProperty.initializer
),
});
} else if (componentSkipDomainState === 'skipping') {
updatedConfig = updateObjectProperty(updatedConfig, componentProperty, {
newValue: removeObjectProperty(
// we only determine that it's skipping if it's an object literal
componentProperty.initializer as ObjectLiteralExpression,
getObjectProperty(
componentProperty.initializer as ObjectLiteralExpression,
'experimentalSkipDomainInjection'
)
),
});
}
}
if (
topLevelSkipDomainState === 'not-set' &&
!e2eProperty &&
!componentProperty
) {
updatedConfig = setInjectDocumentDomainInObject(updatedConfig);
} else if (topLevelSkipDomainState === 'not-skipping') {
updatedConfig =
replaceExperimentalSkipDomainInjectionInObject(updatedConfig);
} else if (topLevelSkipDomainState === 'skipping') {
updatedConfig = removeObjectProperty(
updatedConfig,
topLevelExperimentalSkipDomainInjectionProperty
);
}
return cypressConfig.replace(
config.getText(),
printer.printNode(ts.EmitHint.Unspecified, updatedConfig, sourceFile)
);
}
function setInjectDocumentDomainInObject(
config: Expression
): ObjectLiteralExpression {
let configToUpdate: ObjectLiteralExpression;
if (ts.isObjectLiteralExpression(config)) {
configToUpdate = config;
} else {
// spread the current expression into a new object literal
configToUpdate = ts.factory.createObjectLiteralExpression([
ts.factory.createSpreadAssignment(config),
]);
}
return ts.factory.updateObjectLiteralExpression(
configToUpdate,
ts.factory.createNodeArray([
...configToUpdate.properties,
getInjectDocumentDomainPropertyAssignment(),
])
);
}
function replaceExperimentalSkipDomainInjectionInObject(
config: Expression
): ObjectLiteralExpression {
let configToUpdate: ObjectLiteralExpression;
if (ts.isObjectLiteralExpression(config)) {
configToUpdate = config;
} else {
// spread the current expression into a new object literal
configToUpdate = ts.factory.createObjectLiteralExpression([
ts.factory.createSpreadAssignment(config),
]);
}
return ts.factory.updateObjectLiteralExpression(
configToUpdate,
configToUpdate.properties.map((property) =>
property.name?.getText() === 'experimentalSkipDomainInjection'
? getInjectDocumentDomainPropertyAssignment()
: property
)
);
}
function getInjectDocumentDomainPropertyAssignment(): PropertyAssignment {
return ts.addSyntheticLeadingComment(
ts.addSyntheticLeadingComment(
ts.factory.createPropertyAssignment(
ts.factory.createIdentifier('injectDocumentDomain'),
ts.factory.createTrue()
),
ts.SyntaxKind.SingleLineCommentTrivia,
' Please ensure you use `cy.origin()` when navigating between domains and remove this option.'
),
ts.SyntaxKind.SingleLineCommentTrivia,
' See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin'
);
}

View File

@ -0,0 +1,152 @@
#### Update Component Testing `mount` Imports
Updates the relevant module specifiers when importing the `mount` function and using the Angular or React frameworks. Read more at the [Angular migration notes](https://docs.cypress.io/app/references/migration-guide#Angular-1720-CT-no-longer-supported) and the [React migration notes](https://docs.cypress.io/app/references/migration-guide#React-18-CT-no-longer-supported).
#### Examples
If using the Angular framework with a version greater than or equal to v17.2.0 and importing the `mount` function from the `cypress/angular-signals` module, the migration will update the import to use the `cypress/angular` module.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1/cypress/support/component.ts" %}
import { mount } from 'cypress/angular-signals';
import './commands';
declare global {
namespace Cypress {
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1/cypress/support/component.ts" highlightLines=[1] %}
import { mount } from 'cypress/angular';
import './commands';
declare global {
namespace Cypress {
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
```
{% /tab %}
{% /tabs %}
If using the Angular framework with a version lower than v17.2.0 and importing the `mount` function from the `cypress/angular` module, the migration will install the `@cypress/angular@2` package and update the import to use the `@cypress/angular` module.
{% tabs %}
{% tab label="Before" %}
```json {% fileName="package.json" %}
{
"name": "@my-repo/source",
"dependencies": {
...
"cypress": "^14.2.1"
}
}
```
```ts {% fileName="apps/app1/cypress/support/component.ts" %}
import { mount } from 'cypress/angular';
import './commands';
declare global {
namespace Cypress {
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
```
{% /tab %}
{% tab label="After" %}
```json {% fileName="package.json" highlightLines=[6] %}
{
"name": "@my-repo/source",
"dependencies": {
...
"cypress": "^14.2.1",
"@cypress/angular": "^2.1.0"
}
}
```
```ts {% fileName="apps/app1/cypress/support/component.ts" highlightLines=[1] %}
import { mount } from '@cypress/angular';
import './commands';
declare global {
namespace Cypress {
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
```
{% /tab %}
{% /tabs %}
If using the React framework and importing the `mount` function from the `cypress/react18` module, the migration will update the import to use the `cypress/react` module.
{% tabs %}
{% tab label="Before" %}
```ts {% fileName="apps/app1/cypress/support/component.ts" %}
import { mount } from 'cypress/react18';
import './commands';
declare global {
namespace Cypress {
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
```
{% /tab %}
{% tab label="After" %}
```ts {% fileName="apps/app1/cypress/support/component.ts" highlightLines=[1] %}
import { mount } from 'cypress/react';
import './commands';
declare global {
namespace Cypress {
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
```
{% /tab %}
{% /tabs %}

View File

@ -0,0 +1,443 @@
import {
addProjectConfiguration,
readJson,
type ProjectConfiguration,
type ProjectGraph,
type Tree,
} from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import migration from './update-component-testing-mount-imports';
let projectGraph: ProjectGraph;
jest.mock('@nx/devkit', () => ({
...jest.requireActual('@nx/devkit'),
createProjectGraphAsync: jest.fn(() => Promise.resolve(projectGraph)),
}));
describe('update-component-testing-mount-imports', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});
it('should do nothing when there are no projects with cypress config', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.exists('apps/app1-e2e/cypress.config.ts')).toBe(false);
});
it('should do nothing when the cypress config cannot be parsed as expected', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {},
});
tree.write('apps/app1-e2e/cypress.config.ts', `export const foo = 'bar';`);
await expect(migration(tree)).resolves.not.toThrow();
expect(tree.read('apps/app1-e2e/cypress.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"export const foo = 'bar';
"
`);
});
it('should handle when the cypress config path in the executor is not valid', async () => {
addProjectConfiguration(tree, 'app1-e2e', {
root: 'apps/app1-e2e',
projectType: 'application',
targets: {
e2e: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: 'apps/app1-e2e/non-existent-cypress.config.ts',
},
},
},
});
await expect(migration(tree)).resolves.not.toThrow();
});
it('should convert import from cypress/react18 to cypress/react when the framework information is directly set in the config', async () => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
},
});`
);
tree.write(
'apps/app1/src/app/App.cy.tsx',
`import { mount } from 'cypress/react18';
describe('App', () => {
it('should render', () => {
mount(<App />);
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/App.cy.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/react';
describe('App', () => {
it('should render', () => {
mount(<App />);
});
});
"
`);
});
it.each(['react', 'next', 'remix'])(
'should convert import from cypress/react18 to cypress/react when the framework information is set in the Nx preset import for %s',
async (framework: string) => {
addProjectConfiguration(tree, 'app1', {
root: 'apps/app1',
projectType: 'application',
targets: {},
});
tree.write(
'apps/app1/cypress.config.ts',
`import { nxComponentTestingPreset } from '@nx/${framework}/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: nxComponentTestingPreset(__filename),
});`
);
tree.write(
'apps/app1/src/app/App.cy.tsx',
`import { mount } from 'cypress/react18';
describe('App', () => {
it('should render', () => {
mount(<App />);
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/App.cy.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/react';
describe('App', () => {
it('should render', () => {
mount(<App />);
});
});
"
`);
}
);
it.each(['react', 'next'])(
'should convert import from cypress/react18 to cypress/react when the framework cannot be determined from statically analysing the config but the project depends on %s',
async (pkg: string) => {
const project: ProjectConfiguration = {
root: 'apps/app1',
projectType: 'application',
targets: {},
};
projectGraph = {
nodes: { app1: { name: 'app1', type: 'app', data: project } },
dependencies: {
app1: [{ target: `npm:${pkg}`, type: 'static', source: 'app1' }],
},
externalNodes: {
[`npm:${pkg}`]: {
name: `npm:${pkg}`,
type: 'npm',
data: { packageName: pkg, version: '19.0.0' },
},
},
};
addProjectConfiguration(tree, 'app1', project);
tree.write(
'apps/app1/cypress.config.ts',
`import { somePreset } from '@org/some-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
component: somePreset(__filename),
});`
);
tree.write(
'apps/app1/src/app/App.cy.tsx',
`import { mount } from 'cypress/react18';
describe('App', () => {
it('should render', () => {
mount(<App />);
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/App.cy.tsx', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/react';
describe('App', () => {
it('should render', () => {
mount(<App />);
});
});
"
`);
}
);
it('should convert import from cypress/angular-signals to cypress/angular when the framework information is directly set in the config', async () => {
const project: ProjectConfiguration = {
root: 'apps/app1',
projectType: 'application',
targets: {},
};
projectGraph = {
nodes: { app1: { name: 'app1', type: 'app', data: project } },
dependencies: {
app1: [{ target: 'npm:@angular/core', type: 'static', source: 'app1' }],
},
externalNodes: {
'npm:@angular/core': {
name: 'npm:@angular/core',
type: 'npm',
data: {
packageName: '@angular/core',
version: '19.0.0',
},
},
},
};
addProjectConfiguration(tree, 'app1', project);
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
},
});`
);
tree.write(
'apps/app1/src/app/app.component.cy.ts',
`import { mount } from 'cypress/angular-signals';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {})
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/app.component.cy.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/angular';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {});
});
});
"
`);
});
it('should convert import from cypress/angular-signals to cypress/angular when the framework is set in the Nx preset import', async () => {
const project: ProjectConfiguration = {
root: 'apps/app1',
projectType: 'application',
targets: {},
};
projectGraph = {
nodes: { app1: { name: 'app1', type: 'app', data: project } },
dependencies: {
app1: [{ target: 'npm:@angular/core', type: 'static', source: 'app1' }],
},
externalNodes: {
'npm:@angular/core': {
name: 'npm:@angular/core',
type: 'npm',
data: {
packageName: '@angular/core',
version: '19.0.0',
},
},
},
};
addProjectConfiguration(tree, 'app1', project);
tree.write(
'apps/app1/cypress.config.ts',
`import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing';
import { defineConfig } from 'cypress';
export default defineConfig({
component: nxComponentTestingPreset(__filename),
});`
);
tree.write(
'apps/app1/src/app/app.component.cy.ts',
`import { mount } from 'cypress/angular-signals';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {})
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/app.component.cy.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/angular';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {});
});
});
"
`);
});
it('should convert import from cypress/angular-signals to cypress/angular when the framework cannot be determined from statically analysing the config but the project depends on angular', async () => {
const project: ProjectConfiguration = {
root: 'apps/app1',
projectType: 'application',
targets: {},
};
projectGraph = {
nodes: { app1: { name: 'app1', type: 'app', data: project } },
dependencies: {
app1: [{ target: 'npm:@angular/core', type: 'static', source: 'app1' }],
},
externalNodes: {
'npm:@angular/core': {
name: 'npm:@angular/core',
type: 'npm',
data: {
packageName: '@angular/core',
version: '19.0.0',
},
},
},
};
addProjectConfiguration(tree, 'app1', project);
tree.write(
'apps/app1/cypress.config.ts',
`import { somePreset } from '@org/some-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
component: somePreset(__filename),
});`
);
tree.write(
'apps/app1/src/app/app.component.cy.ts',
`import { mount } from 'cypress/angular-signals';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {})
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/app.component.cy.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/angular';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {});
});
});
"
`);
});
it('should convert import from cypress/angular to @cypress/angular and install the package if it is a version lower than v17.2.0', async () => {
const project: ProjectConfiguration = {
root: 'apps/app1',
projectType: 'application',
targets: {},
};
projectGraph = {
nodes: { app1: { name: 'app1', type: 'app', data: project } },
dependencies: {
app1: [{ target: 'npm:@angular/core', type: 'static', source: 'app1' }],
},
externalNodes: {
'npm:@angular/core': {
name: 'npm:@angular/core',
type: 'npm',
data: {
packageName: '@angular/core',
version: '17.1.3',
},
},
},
};
addProjectConfiguration(tree, 'app1', project);
tree.write(
'apps/app1/cypress.config.ts',
`import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
},
},
});`
);
tree.write(
'apps/app1/src/app/app.component.cy.ts',
`import { mount } from 'cypress/angular';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {})
});
});
`
);
await migration(tree);
expect(tree.read('apps/app1/src/app/app.component.cy.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from '@cypress/angular';
describe('App', () => {
it('should render', () => {
mount(AppComponent, {});
});
});
"
`);
expect(
readJson(tree, 'package.json').devDependencies['@cypress/angular']
).toBeDefined();
});
});

View File

@ -0,0 +1,299 @@
import {
addDependenciesToPackageJson,
createProjectGraphAsync,
formatFiles,
visitNotIgnoredFiles,
type ProjectConfiguration,
type ProjectGraph,
type Tree,
} from '@nx/devkit';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import { tsquery } from '@phenomnomnominal/tsquery';
import { lt, valid } from 'semver';
import type {
ImportDeclaration,
ObjectLiteralExpression,
Printer,
PropertyAssignment,
} from 'typescript';
import { resolveCypressConfigObject } from '../../utils/config';
import {
cypressProjectConfigs,
getObjectProperty,
} from '../../utils/migrations';
let printer: Printer;
let ts: typeof import('typescript');
export default async function (tree: Tree) {
const projectGraph = await createProjectGraphAsync();
for await (const {
cypressConfigPath,
projectName,
projectConfig,
} of cypressProjectConfigs(tree)) {
if (!tree.exists(cypressConfigPath)) {
// cypress config file doesn't exist, so skip
continue;
}
ts ??= ensureTypescript();
printer ??= ts.createPrinter();
const migrationInfo = parseMigrationInfo(
tree,
cypressConfigPath,
projectName,
projectGraph
);
if (!migrationInfo) {
continue;
}
if (migrationInfo.framework === 'angular') {
migrateAngularFramework(
tree,
projectConfig,
migrationInfo.isLegacyVersion
);
} else if (migrationInfo.framework === 'react') {
migrateReactFramework(tree, projectConfig);
}
}
await formatFiles(tree);
}
function parseMigrationInfo(
tree: Tree,
cypressConfigPath: string,
projectName: string,
projectGraph: ProjectGraph
): {
framework?: 'angular' | 'react';
isLegacyVersion?: boolean;
} | null {
const cypressConfig = tree.read(cypressConfigPath, 'utf-8');
const config = resolveCypressConfigObject(cypressConfig);
if (!config) {
// couldn't find the config object, leave as is
return null;
}
if (!getObjectProperty(config, 'component')) {
// no component config, leave as is
return null;
}
const framework = resolveFramework(
cypressConfig,
config,
projectName,
projectGraph
);
if (framework === 'react') {
return { framework: 'react' };
}
if (framework === 'angular') {
const angularCoreDep = projectGraph.dependencies[projectName].find((d) =>
// account for possible different versions of angular core
d.target.startsWith('npm:@angular/core')
);
if (angularCoreDep) {
const angularVersion =
projectGraph.externalNodes?.[angularCoreDep.target]?.data?.version;
if (valid(angularVersion) && lt(angularVersion, '17.2.0')) {
return {
framework: 'angular',
isLegacyVersion: true,
};
}
}
return {
framework: 'angular',
isLegacyVersion: false,
};
}
return null;
}
function resolveFramework(
cypressConfig: string,
config: ObjectLiteralExpression,
projectName: string,
projectGraph: ProjectGraph
): string | null {
const frameworkProperty = tsquery.query<PropertyAssignment>(
config,
'PropertyAssignment:has(Identifier[name=component]) PropertyAssignment:has(Identifier[name=devServer]) PropertyAssignment:has(Identifier[name=framework])'
)[0];
if (frameworkProperty) {
return ts.isStringLiteral(frameworkProperty.initializer)
? frameworkProperty.initializer.getText().replace(/['"`]/g, '')
: null;
}
// component might be assigned to an Nx preset function call, so we try to
// infer the framework from the Nx preset import
const sourceFile = tsquery.ast(cypressConfig);
const nxPresetModuleSpecifiers = [
'@nx/angular/plugins/component-testing',
'@nx/react/plugins/component-testing',
'@nx/next/plugins/component-testing',
'@nx/remix/plugins/component-testing',
];
const nxPresetImportModuleSpecifier = sourceFile.statements
.find(
(s): s is ImportDeclaration =>
ts.isImportDeclaration(s) &&
nxPresetModuleSpecifiers.includes(
s.moduleSpecifier.getText().replace(/['"`]/g, '')
)
)
?.moduleSpecifier.getText()
.replace(/['"`]/g, '');
if (nxPresetImportModuleSpecifier) {
const plugin = nxPresetImportModuleSpecifier.split('/').at(1);
return plugin === 'angular' ? 'angular' : 'react';
}
// it might be set to something else, so we fall back to checking the
// project dependencies
if (
projectGraph.dependencies[projectName]?.some((d) =>
d.target.startsWith('npm:@angular/core')
)
) {
return 'angular';
}
if (
projectGraph.dependencies[projectName]?.some(
(d) => d.target.startsWith('npm:react') || d.target.startsWith('npm:next')
)
) {
return 'react';
}
return null;
}
// https://docs.cypress.io/app/references/migration-guide#Angular-1720-CT-no-longer-supported
function migrateAngularFramework(
tree: Tree,
projectConfig: ProjectConfiguration,
isLegacyVersion: boolean
) {
visitNotIgnoredFiles(tree, projectConfig.root, (filePath) => {
if (!isJsTsFile(filePath)) {
return;
}
const content = tree.read(filePath, 'utf-8');
let updatedFileContent: string;
if (isLegacyVersion) {
let needPackage = false;
updatedFileContent = tsquery.replace(
content,
'ImportDeclaration',
importTransformerFactory(
content,
'cypress/angular',
'@cypress/angular',
() => {
needPackage = true;
}
)
);
if (needPackage) {
addDependenciesToPackageJson(
tree,
{},
{ '@cypress/angular': '^2.1.0' },
undefined,
true
);
}
} else {
updatedFileContent = tsquery.replace(
content,
'ImportDeclaration',
importTransformerFactory(
content,
'cypress/angular-signals',
'cypress/angular'
)
);
}
tree.write(filePath, updatedFileContent);
});
}
// https://docs.cypress.io/app/references/migration-guide#React-18-CT-no-longer-supported
function migrateReactFramework(
tree: Tree,
projectConfig: ProjectConfiguration
) {
visitNotIgnoredFiles(tree, projectConfig.root, (filePath) => {
if (!isJsTsFile(filePath)) {
return;
}
const content = tree.read(filePath, 'utf-8');
const updatedContent = tsquery.replace(
content,
'ImportDeclaration',
importTransformerFactory(content, 'cypress/react18', 'cypress/react')
);
tree.write(filePath, updatedContent);
});
}
function importTransformerFactory(
fileContent: string,
sourceModuleSpecifier: string,
targetModuleSpecifier: string,
matchImportCallback?: () => void
): Parameters<typeof tsquery.replace>[2] {
return (node: ImportDeclaration) => {
if (
node.moduleSpecifier.getText().replace(/['"`]/g, '') ===
sourceModuleSpecifier
) {
matchImportCallback?.();
const updatedImport = ts.factory.updateImportDeclaration(
node,
node.modifiers,
node.importClause,
ts.factory.createStringLiteral(targetModuleSpecifier),
node.attributes
);
return printer.printNode(
ts.EmitHint.Unspecified,
updatedImport,
tsquery.ast(fileContent)
);
}
return node.getText();
};
}
function isJsTsFile(filePath: string) {
return /\.[cm]?[jt]sx?$/.test(filePath);
}

View File

@ -7,8 +7,10 @@ import {
Tree, Tree,
} from '@nx/devkit'; } from '@nx/devkit';
import { Linter, LinterType, lintProjectGenerator } from '@nx/eslint'; import { Linter, LinterType, lintProjectGenerator } from '@nx/eslint';
import { installedCypressVersion } from './cypress-version'; import {
import { eslintPluginCypressVersion } from './versions'; javaScriptOverride,
typeScriptOverride,
} from '@nx/eslint/src/generators/init/global-eslint-config';
import { import {
addExtendsToLintConfig, addExtendsToLintConfig,
addOverrideToLintConfig, addOverrideToLintConfig,
@ -18,11 +20,8 @@ import {
isEslintConfigSupported, isEslintConfigSupported,
replaceOverridesInLintConfig, replaceOverridesInLintConfig,
} from '@nx/eslint/src/generators/utils/eslint-file'; } from '@nx/eslint/src/generators/utils/eslint-file';
import {
javaScriptOverride,
typeScriptOverride,
} from '@nx/eslint/src/generators/init/global-eslint-config';
import { useFlatConfig } from '@nx/eslint/src/utils/flat-config'; import { useFlatConfig } from '@nx/eslint/src/utils/flat-config';
import { getInstalledCypressMajorVersion, versions } from './versions';
export interface CyLinterOptions { export interface CyLinterOptions {
project: string; project: string;
@ -77,15 +76,17 @@ export async function addLinterToCyProject(
options.overwriteExisting = options.overwriteExisting || !eslintFile; options.overwriteExisting = options.overwriteExisting || !eslintFile;
if (!options.skipPackageJson) {
const pkgVersions = versions(tree);
tasks.push( tasks.push(
!options.skipPackageJson addDependenciesToPackageJson(
? addDependenciesToPackageJson(
tree, tree,
{}, {},
{ 'eslint-plugin-cypress': eslintPluginCypressVersion } { 'eslint-plugin-cypress': pkgVersions.eslintPluginCypressVersion }
) )
: () => {}
); );
}
if ( if (
isEslintConfigSupported(tree, projectConfig.root) || isEslintConfigSupported(tree, projectConfig.root) ||
@ -119,7 +120,7 @@ export async function addLinterToCyProject(
); );
tasks.push(addExtendsTask); tasks.push(addExtendsTask);
} }
const cyVersion = installedCypressVersion(); const cyVersion = getInstalledCypressMajorVersion(tree);
/** /**
* We need this override because we enabled allowJS in the tsconfig to allow for JS based Cypress tests. * We need this override because we enabled allowJS in the tsconfig to allow for JS based Cypress tests.
* That however leads to issues with the CommonJS Cypress plugin file. * That however leads to issues with the CommonJS Cypress plugin file.

View File

@ -2,7 +2,9 @@ import {
addDefaultCTConfig, addDefaultCTConfig,
addDefaultE2EConfig, addDefaultE2EConfig,
addMountDefinition, addMountDefinition,
resolveCypressConfigObject,
} from './config'; } from './config';
describe('Cypress Config parser', () => { describe('Cypress Config parser', () => {
it('should add CT config to existing e2e config', async () => { it('should add CT config to existing e2e config', async () => {
const actual = await addDefaultCTConfig( const actual = await addDefaultCTConfig(
@ -261,3 +263,133 @@ Cypress.Commands.add('mount', customMount);
`); `);
}); });
}); });
describe('resolveCypressConfigObject', () => {
it('should handle "export default defineConfig()"', async () => {
const config = resolveCypressConfigObject(
`import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'https://example.com',
},
});
`
);
expect(config).toBeDefined();
expect(config.getText()).toMatchInlineSnapshot(`
"{
e2e: {
baseUrl: 'https://example.com',
},
}"
`);
});
it('should handle "export default {}"', async () => {
const config = resolveCypressConfigObject(
`export default {
e2e: {
baseUrl: 'https://example.com',
},
};
`
);
expect(config).toBeDefined();
expect(config.getText()).toMatchInlineSnapshot(`
"{
e2e: {
baseUrl: 'https://example.com',
},
}"
`);
});
it('should handle "export default <variable>" when <variable> is defined in the file and is an object literal', async () => {
const config = resolveCypressConfigObject(
`const config = {
e2e: {
baseUrl: 'https://example.com',
},
};
export default config;
`
);
expect(config).toBeDefined();
expect(config.getText()).toMatchInlineSnapshot(`
"{
e2e: {
baseUrl: 'https://example.com',
},
}"
`);
});
it('should handle "module.exports = defineConfig()"', async () => {
const config = resolveCypressConfigObject(
`const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
baseUrl: 'https://example.com',
},
});
`
);
expect(config).toBeDefined();
expect(config.getText()).toMatchInlineSnapshot(`
"{
e2e: {
baseUrl: 'https://example.com',
},
}"
`);
});
it('should handle "module.exports = {}"', async () => {
const config = resolveCypressConfigObject(
`module.exports = {
e2e: {
baseUrl: 'https://example.com',
},
};
`
);
expect(config).toBeDefined();
expect(config.getText()).toMatchInlineSnapshot(`
"{
e2e: {
baseUrl: 'https://example.com',
},
}"
`);
});
it('should handle "module.exports = <variable>" when <variable> is defined in the file and is an object literal', async () => {
const config = resolveCypressConfigObject(
`const config = {
e2e: {
baseUrl: 'https://example.com',
},
};
module.exports = config;
`
);
expect(config).toBeDefined();
expect(config.getText()).toMatchInlineSnapshot(`
"{
e2e: {
baseUrl: 'https://example.com',
},
}"
`);
});
});

View File

@ -1,10 +1,16 @@
import { glob, joinPathFragments, type Tree } from '@nx/devkit'; import { glob, joinPathFragments, type Tree } from '@nx/devkit';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import type { import type {
BinaryExpression,
ExportAssignment,
Expression,
ExpressionStatement,
InterfaceDeclaration, InterfaceDeclaration,
MethodSignature, MethodSignature,
ObjectLiteralExpression, ObjectLiteralExpression,
PropertyAssignment, PropertyAssignment,
PropertySignature, PropertySignature,
SourceFile,
} from 'typescript'; } from 'typescript';
import type { import type {
NxComponentTestingOptions, NxComponentTestingOptions,
@ -184,3 +190,88 @@ export function getProjectCypressConfigPath(
return cypressConfigPaths[0]; return cypressConfigPaths[0];
} }
export function resolveCypressConfigObject(
cypressConfigContents: string
): ObjectLiteralExpression | null {
const ts = ensureTypescript();
const { tsquery } = <typeof import('@phenomnomnominal/tsquery')>(
require('@phenomnomnominal/tsquery')
);
const sourceFile = tsquery.ast(cypressConfigContents);
const exportDefaultStatement = sourceFile.statements.find(
(statement): statement is ExportAssignment =>
ts.isExportAssignment(statement)
);
if (exportDefaultStatement) {
return resolveCypressConfigObjectFromExportExpression(
exportDefaultStatement.expression,
sourceFile
);
}
const moduleExportsStatement = sourceFile.statements.find(
(
statement
): statement is ExpressionStatement & { expression: BinaryExpression } =>
ts.isExpressionStatement(statement) &&
ts.isBinaryExpression(statement.expression) &&
statement.expression.left.getText() === 'module.exports'
);
if (moduleExportsStatement) {
return resolveCypressConfigObjectFromExportExpression(
moduleExportsStatement.expression.right,
sourceFile
);
}
return null;
}
function resolveCypressConfigObjectFromExportExpression(
exportExpression: Expression,
sourceFile: SourceFile
): ObjectLiteralExpression | null {
const ts = ensureTypescript();
if (ts.isObjectLiteralExpression(exportExpression)) {
return exportExpression;
}
if (ts.isIdentifier(exportExpression)) {
// try to locate the identifier in the source file
const variableStatements = sourceFile.statements.filter((statement) =>
ts.isVariableStatement(statement)
);
for (const variableStatement of variableStatements) {
for (const declaration of variableStatement.declarationList
.declarations) {
if (
ts.isIdentifier(declaration.name) &&
declaration.name.getText() === exportExpression.getText() &&
ts.isObjectLiteralExpression(declaration.initializer)
) {
return declaration.initializer;
}
}
}
return null;
}
if (
ts.isCallExpression(exportExpression) &&
ts.isIdentifier(exportExpression.expression) &&
exportExpression.expression.getText() === 'defineConfig' &&
ts.isObjectLiteralExpression(exportExpression.arguments[0])
) {
return exportExpression.arguments[0];
}
return null;
}

View File

@ -1,6 +1,10 @@
let cypressPackageJson; let cypressPackageJson;
let loadedCypress = false; let loadedCypress = false;
/**
* @deprecated use the `getInstalledCypressMajorVersion` exported from
* `@nx/cypress/src/utils/versions` instead. It will be removed in v22.
*/
export function installedCypressVersion() { export function installedCypressVersion() {
if (!loadedCypress) { if (!loadedCypress) {
try { try {
@ -21,6 +25,8 @@ export function installedCypressVersion() {
/** /**
* will not throw if cypress is not installed * will not throw if cypress is not installed
* @deprecated use the `assertMinimumCypressVersion` exported from
* `@nx/cypress/src/utils/versions` instead. It will be removed in v22.
*/ */
export function assertMinimumCypressVersion(minVersion: number) { export function assertMinimumCypressVersion(minVersion: number) {
const version = installedCypressVersion(); const version = installedCypressVersion();

View File

@ -0,0 +1,124 @@
import {
getProjects,
globAsync,
type ProjectConfiguration,
type TargetConfiguration,
type Tree,
} from '@nx/devkit';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import { posix } from 'path';
import type {
Expression,
ObjectLiteralExpression,
PropertyAssignment,
} from 'typescript';
import type { CypressExecutorOptions } from '../executors/cypress/cypress.impl';
import { CYPRESS_CONFIG_FILE_NAME_PATTERN } from './config';
let ts: typeof import('typescript');
export async function* cypressProjectConfigs(tree: Tree): AsyncGenerator<{
projectName: string;
projectConfig: ProjectConfiguration;
cypressConfigPath: string;
}> {
const projects = getProjects(tree);
for (const [projectName, projectConfig] of projects) {
const targetWithExecutor = Object.values(projectConfig.targets ?? {}).find(
(target) => target.executor === '@nx/cypress:cypress'
);
if (targetWithExecutor) {
for (const [, options] of allTargetOptions<CypressExecutorOptions>(
targetWithExecutor
)) {
if (options.cypressConfig) {
yield {
projectName,
projectConfig,
cypressConfigPath: options.cypressConfig,
};
}
}
} else {
// might be using the crystal plugin
const result = await globAsync(tree, [
posix.join(projectConfig.root, CYPRESS_CONFIG_FILE_NAME_PATTERN),
]);
if (result.length > 0) {
yield {
projectName,
projectConfig,
cypressConfigPath: result[0],
};
}
}
}
}
export function getObjectProperty(
config: ObjectLiteralExpression,
name: string
): PropertyAssignment | undefined {
ts ??= ensureTypescript();
return config.properties.find(
(p): p is PropertyAssignment =>
ts.isPropertyAssignment(p) && p.name.getText() === name
);
}
export function removeObjectProperty(
config: ObjectLiteralExpression,
property: PropertyAssignment
): ObjectLiteralExpression {
ts ??= ensureTypescript();
return ts.factory.updateObjectLiteralExpression(
config,
config.properties.filter((p) => p !== property)
);
}
export function updateObjectProperty(
config: ObjectLiteralExpression,
property: PropertyAssignment,
{ newName, newValue }: { newName?: string; newValue?: Expression }
): ObjectLiteralExpression {
ts ??= ensureTypescript();
if (!newName && !newValue) {
throw new Error('newName or newValue must be provided');
}
return ts.factory.updateObjectLiteralExpression(
config,
config.properties.map((p) =>
p === property
? ts.factory.updatePropertyAssignment(
p,
newName ? ts.factory.createIdentifier(newName) : p.name,
newValue ? newValue : p.initializer
)
: p
)
);
}
function* allTargetOptions<T>(
target: TargetConfiguration<T>
): Iterable<[string | undefined, T]> {
if (target.options) {
yield [undefined, target.options];
}
if (!target.configurations) {
return;
}
for (const [name, options] of Object.entries(target.configurations)) {
if (options !== undefined) {
yield [name, options];
}
}
}

View File

@ -1,8 +1,111 @@
import { readJson, type Tree } from '@nx/devkit';
import type { PackageJson } from 'nx/src/utils/package-json';
import { clean, coerce, major } from 'semver';
export const nxVersion = require('../../package.json').version; export const nxVersion = require('../../package.json').version;
export const eslintPluginCypressVersion = '^3.5.0'; export const eslintPluginCypressVersion = '^3.5.0';
export const typesNodeVersion = '18.16.9'; export const typesNodeVersion = '18.16.9';
export const cypressViteDevServerVersion = '^2.2.1'; export const cypressViteDevServerVersion = '^6.0.3';
export const cypressVersion = '^13.13.0'; export const cypressVersion = '^14.2.1';
export const cypressWebpackVersion = '^3.8.0'; export const cypressWebpackVersion = '^4.0.2';
export const viteVersion = '~5.0.0'; export const viteVersion = '^6.0.0';
export const htmlWebpackPluginVersion = '^5.5.0'; export const htmlWebpackPluginVersion = '^5.5.0';
const latestVersions: Omit<
typeof import('./versions'),
'versions' | 'getInstalledCypressMajorVersion' | 'assertMinimumCypressVersion'
> = {
nxVersion,
eslintPluginCypressVersion,
typesNodeVersion,
cypressViteDevServerVersion,
cypressVersion,
cypressWebpackVersion,
viteVersion,
htmlWebpackPluginVersion,
};
export function versions(
tree: Tree,
cypressMajorVersion = getInstalledCypressMajorVersion(tree)
) {
if (!cypressMajorVersion) {
return latestVersions;
}
if (cypressMajorVersion > 14) {
throw new Error(`Unsupported Cypress version: ${cypressVersion}`);
}
if (cypressMajorVersion === 14) {
return latestVersions;
}
return {
nxVersion,
eslintPluginCypressVersion: '^3.5.0',
typesNodeVersion: '18.16.9',
cypressViteDevServerVersion: '^2.2.1',
cypressVersion: '^13.13.0',
cypressWebpackVersion: '^3.8.0',
viteVersion: '~5.0.0',
htmlWebpackPluginVersion: '^5.5.0',
};
}
export function getInstalledCypressMajorVersion(tree?: Tree): number | null {
try {
let version: string | null;
if (tree) {
version = getCypressVersionFromTree(tree);
} else {
version = getCypressVersionFromFileSystem();
}
return version ? major(version) : null;
} catch {
return null;
}
}
export function assertMinimumCypressVersion(
minVersion: number,
tree?: Tree
): void {
const version = getInstalledCypressMajorVersion(tree);
if (version && version < minVersion) {
throw new Error(
`Cypress version of ${minVersion} or higher is not installed. Expected Cypress v${minVersion}+, found Cypress v${version} instead.`
);
}
}
function getCypressVersionFromTree(tree: Tree): string | null {
const packageJson = readJson(tree, 'package.json');
const installedVersion =
packageJson.devDependencies?.cypress ?? packageJson.dependencies?.cypress;
if (!installedVersion) {
return null;
}
if (installedVersion === 'latest' || installedVersion === 'next') {
return clean(cypressVersion) ?? coerce(cypressVersion)?.version;
}
return clean(installedVersion) ?? coerce(installedVersion)?.version;
}
function getCypressVersionFromFileSystem(): string | null {
let packageJson: PackageJson | undefined;
try {
packageJson = <PackageJson>require('cypress/package.json');
} catch {}
if (!packageJson) {
return null;
}
return packageJson.version;
}

View File

@ -1,17 +1,26 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { readJson, readProjectConfiguration, Tree } from '@nx/devkit'; import { readJson, readProjectConfiguration, Tree } from '@nx/devkit';
import { cypressComponentConfiguration } from './cypress-component-configuration'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { setupTailwindGenerator } from '@nx/react';
import { applicationGenerator } from '../application/application'; import { applicationGenerator } from '../application/application';
import { libraryGenerator } from '../library/library'; import { libraryGenerator } from '../library/library';
import { setupTailwindGenerator } from '@nx/react'; import { cypressComponentConfiguration } from './cypress-component-configuration';
import { Linter } from '@nx/eslint';
jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual<any>('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
describe('cypress-component-configuration generator', () => { describe('cypress-component-configuration generator', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressMajorVersion: jest.Mock<
ReturnType<typeof getInstalledCypressMajorVersion>
> = getInstalledCypressMajorVersion as never;
// TODO(@leosvelperez): Turn this back to adding the plugin // TODO(@leosvelperez): Turn this back to adding the plugin
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace(); tree = createTreeWithEmptyWorkspace();
mockedInstalledCypressMajorVersion.mockReturnValue(14);
}); });
it('should setup nextjs app', async () => { it('should setup nextjs app', async () => {
@ -57,7 +66,7 @@ describe('cypress-component-configuration generator', () => {
`); `);
expect(tree.read('demo/cypress/support/component.ts', 'utf-8')) expect(tree.read('demo/cypress/support/component.ts', 'utf-8'))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"import { mount } from 'cypress/react18'; "import { mount } from 'cypress/react';
import './styles.ct.css'; import './styles.ct.css';
// *********************************************************** // ***********************************************************
// This example support/component.ts is processed and // This example support/component.ts is processed and
@ -104,6 +113,56 @@ describe('cypress-component-configuration generator', () => {
}); });
}); });
it('should import "mount" from "cypress/react18" when cypress version is lower than v14', async () => {
mockedInstalledCypressMajorVersion.mockReturnValue(13);
await applicationGenerator(tree, {
directory: 'demo',
style: 'css',
});
await cypressComponentConfiguration(tree, {
generateTests: true,
project: 'demo',
});
expect(tree.read('demo/cypress/support/component.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/react18';
import './styles.ct.css';
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.ts using ES2015 syntax:
import './commands';
// add component testing only related command here, such as mount
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
"
`);
});
it('should add styles setup in app', async () => { it('should add styles setup in app', async () => {
await applicationGenerator(tree, { await applicationGenerator(tree, {
directory: 'demo', directory: 'demo',
@ -131,7 +190,7 @@ describe('cypress-component-configuration generator', () => {
it('should setup nextjs lib', async () => { it('should setup nextjs lib', async () => {
await libraryGenerator(tree, { await libraryGenerator(tree, {
directory: 'demo', directory: 'demo',
linter: Linter.EsLint, linter: 'eslint',
style: 'css', style: 'css',
unitTestRunner: 'jest', unitTestRunner: 'jest',
component: true, component: true,

View File

@ -91,6 +91,10 @@ async function addFiles(
const { addMountDefinition, addDefaultCTConfig } = await import( const { addMountDefinition, addDefaultCTConfig } = await import(
'@nx/cypress/src/utils/config' '@nx/cypress/src/utils/config'
); );
const { getInstalledCypressMajorVersion } = await import(
'@nx/cypress/src/utils/versions'
);
const installedCypressMajorVersion = getInstalledCypressMajorVersion(tree);
const ctFile = joinPathFragments( const ctFile = joinPathFragments(
projectConfig.root, projectConfig.root,
@ -102,9 +106,11 @@ async function addFiles(
const updatedCommandFile = await addMountDefinition( const updatedCommandFile = await addMountDefinition(
tree.read(ctFile, 'utf-8') tree.read(ctFile, 'utf-8')
); );
const moduleSpecifier =
installedCypressMajorVersion >= 14 ? 'cypress/react' : 'cypress/react18';
tree.write( tree.write(
ctFile, ctFile,
`import { mount } from 'cypress/react18';\nimport './styles.ct.css';\n${updatedCommandFile}` `import { mount } from '${moduleSpecifier}';\nimport './styles.ct.css';\n${updatedCommandFile}`
); );
const cyFile = joinPathFragments(projectConfig.root, 'cypress.config.ts'); const cyFile = joinPathFragments(projectConfig.root, 'cypress.config.ts');

View File

@ -1,4 +1,4 @@
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
readJson, readJson,
readProjectConfiguration, readProjectConfiguration,
@ -13,12 +13,15 @@ import { Schema } from './schema';
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
describe('next library', () => { describe('next library', () => {
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
it('should use @nx/next images.d.ts file', async () => { it('should use @nx/next images.d.ts file', async () => {
const baseOptions: Schema = { const baseOptions: Schema = {
directory: '', directory: '',

View File

@ -1,6 +1,6 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { readProjectConfiguration, Tree } from '@nx/devkit'; import { readProjectConfiguration, Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '@nx/eslint'; import { Linter } from '@nx/eslint';
@ -8,7 +8,10 @@ import { applicationGenerator } from './application';
import { Schema } from './schema'; import { Schema } from './schema';
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
describe('react app generator (legacy)', () => { describe('react app generator (legacy)', () => {
let appTree: Tree; let appTree: Tree;
let schema: Schema = { let schema: Schema = {
@ -22,8 +25,8 @@ describe('react app generator (legacy)', () => {
addPlugin: false, addPlugin: false,
}; };
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(() => { beforeEach(() => {
mockedInstalledCypressVersion.mockReturnValue(10); mockedInstalledCypressVersion.mockReturnValue(10);

View File

@ -1,4 +1,4 @@
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
detectPackageManager, detectPackageManager,
getPackageManagerCommand, getPackageManagerCommand,
@ -20,7 +20,10 @@ import { Schema } from './schema';
const { load } = require('@zkochan/js-yaml'); const { load } = require('@zkochan/js-yaml');
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
let projectGraph: ProjectGraph; let projectGraph: ProjectGraph;
jest.mock('@nx/devkit', () => { jest.mock('@nx/devkit', () => {
@ -49,8 +52,8 @@ describe('app', () => {
addPlugin: true, addPlugin: true,
}; };
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(() => { beforeEach(() => {
mockedInstalledCypressVersion.mockReturnValue(10); mockedInstalledCypressVersion.mockReturnValue(10);
appTree = createTreeWithEmptyWorkspace(); appTree = createTreeWithEmptyWorkspace();

View File

@ -1,13 +1,13 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { assertMinimumCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { assertMinimumCypressVersion } from '@nx/cypress/src/utils/versions';
import { Tree } from '@nx/devkit'; import { Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '@nx/eslint'; import { Linter } from '@nx/eslint';
import libraryGenerator from '../library/library'; import libraryGenerator from '../library/library';
import { componentTestGenerator } from './component-test'; import { componentTestGenerator } from './component-test';
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions');
describe(componentTestGenerator.name, () => { describe(componentTestGenerator.name, () => {
let tree: Tree; let tree: Tree;
let mockedAssertMinimumCypressVersion: jest.Mock< let mockedAssertMinimumCypressVersion: jest.Mock<

View File

@ -23,7 +23,7 @@ export async function componentTestGenerator(
) { ) {
ensurePackage('@nx/cypress', nxVersion); ensurePackage('@nx/cypress', nxVersion);
const { assertMinimumCypressVersion } = await import( const { assertMinimumCypressVersion } = await import(
'@nx/cypress/src/utils/cypress-version' '@nx/cypress/src/utils/versions'
); );
assertMinimumCypressVersion(10); assertMinimumCypressVersion(10);
// normalize any windows paths // normalize any windows paths

View File

@ -1,6 +1,6 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
logger, logger,
readJson, readJson,
@ -14,14 +14,17 @@ import { componentGenerator } from './component';
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
describe('component', () => { describe('component', () => {
let appTree: Tree; let appTree: Tree;
let projectName: string; let projectName: string;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(async () => { beforeEach(async () => {
mockedInstalledCypressVersion.mockReturnValue(10); mockedInstalledCypressVersion.mockReturnValue(10);

View File

@ -1,4 +1,4 @@
import { assertMinimumCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
DependencyType, DependencyType,
ProjectGraph, ProjectGraph,
@ -7,6 +7,11 @@ import {
updateProjectConfiguration, updateProjectConfiguration,
} from '@nx/devkit'; } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { Linter } from '@nx/eslint';
import { applicationGenerator } from '../application/application';
import { componentGenerator } from '../component/component';
import { libraryGenerator } from '../library/library';
import { cypressComponentConfigGenerator } from './cypress-component-configuration';
let projectGraph: ProjectGraph; let projectGraph: ProjectGraph;
jest.mock('@nx/devkit', () => ({ jest.mock('@nx/devkit', () => ({
@ -16,14 +21,10 @@ jest.mock('@nx/devkit', () => ({
.fn() .fn()
.mockImplementation(async () => projectGraph), .mockImplementation(async () => projectGraph),
})); }));
jest.mock('@nx/cypress/src/utils/versions', () => ({
import { Linter } from '@nx/eslint'; ...jest.requireActual<any>('@nx/cypress/src/utils/versions'),
import { applicationGenerator } from '../application/application'; getInstalledCypressMajorVersion: jest.fn(),
import { componentGenerator } from '../component/component'; }));
import { libraryGenerator } from '../library/library';
import { cypressComponentConfigGenerator } from './cypress-component-configuration';
jest.mock('@nx/cypress/src/utils/cypress-version');
// nested code imports graph from the repo, which might have innacurate graph version // nested code imports graph from the repo, which might have innacurate graph version
jest.mock('nx/src/project-graph/project-graph', () => ({ jest.mock('nx/src/project-graph/project-graph', () => ({
...jest.requireActual<any>('nx/src/project-graph/project-graph'), ...jest.requireActual<any>('nx/src/project-graph/project-graph'),
@ -32,20 +33,12 @@ jest.mock('nx/src/project-graph/project-graph', () => ({
describe('React:CypressComponentTestConfiguration', () => { describe('React:CypressComponentTestConfiguration', () => {
let tree: Tree; let tree: Tree;
let mockedAssertCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof assertMinimumCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = assertMinimumCypressVersion as never; > = getInstalledCypressMajorVersion as never;
// TODO(@jaysoo): Turn this back to adding the plugin // TODO(@jaysoo): Turn this back to adding the plugin
let originalEnv: string; let originalEnv: string;
beforeEach(() => {
originalEnv = process.env.NX_ADD_PLUGINS;
process.env.NX_ADD_PLUGINS = 'false';
});
afterEach(() => {
process.env.NX_ADD_PLUGINS = originalEnv;
});
beforeEach(() => { beforeEach(() => {
tree = createTreeWithEmptyWorkspace(); tree = createTreeWithEmptyWorkspace();
@ -53,6 +46,14 @@ describe('React:CypressComponentTestConfiguration', () => {
nodes: {}, nodes: {},
dependencies: {}, dependencies: {},
}; };
originalEnv = process.env.NX_ADD_PLUGINS;
process.env.NX_ADD_PLUGINS = 'false';
mockedInstalledCypressVersion.mockReturnValue(14);
});
afterEach(() => {
process.env.NX_ADD_PLUGINS = originalEnv;
}); });
afterAll(() => { afterAll(() => {
@ -60,8 +61,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should generate cypress config with vite', async () => { it('should generate cypress config with vite', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -116,8 +115,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should generate cypress component test config with --build-target', async () => { it('should generate cypress component test config with --build-target', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -184,7 +181,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should generate cypress component test config with project graph', async () => { it('should generate cypress component test config with project graph', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -250,7 +246,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should generate cypress component test config with webpack', async () => { it('should generate cypress component test config with webpack', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -315,7 +310,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
}); });
it('should generate tests for existing tsx components', async () => { it('should generate tests for existing tsx components', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -360,7 +354,6 @@ describe('React:CypressComponentTestConfiguration', () => {
).toBeFalsy(); ).toBeFalsy();
}); });
it('should generate tests for existing js components', async () => { it('should generate tests for existing js components', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -415,7 +408,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should throw error when an invalid --build-target is provided', async () => { it('should throw error when an invalid --build-target is provided', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -470,8 +462,6 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
it('should setup cypress config files correctly', async () => { it('should setup cypress config files correctly', async () => {
mockedAssertCypressVersion.mockReturnValue();
await applicationGenerator(tree, { await applicationGenerator(tree, {
e2eTestRunner: 'none', e2eTestRunner: 'none',
linter: Linter.EsLint, linter: Linter.EsLint,
@ -531,6 +521,95 @@ describe('React:CypressComponentTestConfiguration', () => {
}); });
" "
`); `);
expect(tree.read('some-lib/cypress/support/component.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { mount } from 'cypress/react';
// ***********************************************************
// This example support/component.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.ts using ES2015 syntax:
import './commands';
// add component testing only related command here, such as mount
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
"
`);
});
it('should import "mount" from "cypress/react18" when cypress version is lower than v14', async () => {
mockedInstalledCypressVersion.mockReturnValue(13);
await applicationGenerator(tree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: true,
style: 'scss',
unitTestRunner: 'none',
directory: 'my-app',
bundler: 'vite',
});
await libraryGenerator(tree, {
linter: Linter.EsLint,
directory: 'some-lib',
skipFormat: true,
skipTsConfig: false,
style: 'scss',
unitTestRunner: 'none',
component: true,
});
projectGraph = {
nodes: {
'my-app': {
name: 'my-app',
type: 'app',
data: {
...readProjectConfiguration(tree, 'my-app'),
} as any,
},
'some-lib': {
name: 'some-lib',
type: 'lib',
data: {
...readProjectConfiguration(tree, 'some-lib'),
} as any,
},
},
dependencies: {
'my-app': [
{ type: DependencyType.static, source: 'my-app', target: 'some-lib' },
],
},
};
await cypressComponentConfigGenerator(tree, {
project: 'some-lib',
generateTests: false,
buildTarget: 'my-app:build',
});
expect(tree.read('some-lib/cypress/support/component.ts', 'utf-8')) expect(tree.read('some-lib/cypress/support/component.ts', 'utf-8'))
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
"import { mount } from 'cypress/react18'; "import { mount } from 'cypress/react18';

View File

@ -1,3 +1,4 @@
import type { FoundTarget } from '@nx/cypress/src/utils/find-target-options';
import { import {
addDependenciesToPackageJson, addDependenciesToPackageJson,
joinPathFragments, joinPathFragments,
@ -7,10 +8,9 @@ import {
visitNotIgnoredFiles, visitNotIgnoredFiles,
} from '@nx/devkit'; } from '@nx/devkit';
import { nxVersion } from 'nx/src/utils/versions'; import { nxVersion } from 'nx/src/utils/versions';
import { getActualBundler, isComponent } from '../../../utils/ct-utils';
import { componentTestGenerator } from '../../component-test/component-test'; import { componentTestGenerator } from '../../component-test/component-test';
import type { CypressComponentConfigurationSchema } from '../schema'; import type { CypressComponentConfigurationSchema } from '../schema';
import { getActualBundler, isComponent } from '../../../utils/ct-utils';
import { FoundTarget } from '@nx/cypress/src/utils/find-target-options';
export async function addFiles( export async function addFiles(
tree: Tree, tree: Tree,
@ -18,11 +18,13 @@ export async function addFiles(
options: CypressComponentConfigurationSchema, options: CypressComponentConfigurationSchema,
found: FoundTarget found: FoundTarget
) { ) {
// must dyanmicaly import to prevent packages not using cypress from erroring out // must dynamicaly import to prevent packages not using cypress from erroring out
// when importing react // when importing react
const { addMountDefinition, addDefaultCTConfig } = await import( const { addMountDefinition } = await import('@nx/cypress/src/utils/config');
'@nx/cypress/src/utils/config' const { getInstalledCypressMajorVersion } = await import(
'@nx/cypress/src/utils/versions'
); );
const installedCypressMajorVersion = getInstalledCypressMajorVersion(tree);
// Specifically undefined to allow Remix workaround of passing an empty string // Specifically undefined to allow Remix workaround of passing an empty string
const actualBundler = await getActualBundler(tree, options, found); const actualBundler = await getActualBundler(tree, options, found);
@ -46,9 +48,11 @@ export async function addFiles(
const updatedCommandFile = await addMountDefinition( const updatedCommandFile = await addMountDefinition(
tree.read(commandFile, 'utf-8') tree.read(commandFile, 'utf-8')
); );
const moduleSpecifier =
installedCypressMajorVersion >= 14 ? 'cypress/react' : 'cypress/react18';
tree.write( tree.write(
commandFile, commandFile,
`import { mount } from 'cypress/react18';\n${updatedCommandFile}` `import { mount } from '${moduleSpecifier}';\n${updatedCommandFile}`
); );
if ( if (

View File

@ -1,6 +1,6 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
getProjects, getProjects,
readJson, readJson,
@ -18,12 +18,15 @@ import { Schema } from './schema';
const { load } = require('@zkochan/js-yaml'); const { load } = require('@zkochan/js-yaml');
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
describe('lib', () => { describe('lib', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
let defaultSchema: Schema = { let defaultSchema: Schema = {
directory: 'my-lib', directory: 'my-lib',
linter: Linter.EsLint, linter: Linter.EsLint,

View File

@ -1,13 +1,16 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { getProjects, readProjectConfiguration, Tree } from '@nx/devkit'; import { getProjects, readProjectConfiguration, Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { applicationGenerator } from './application'; import { applicationGenerator } from './application';
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
jest.mock('@nx/devkit', () => { jest.mock('@nx/devkit', () => {
return { return {
...jest.requireActual('@nx/devkit'), ...jest.requireActual('@nx/devkit'),
@ -17,8 +20,8 @@ jest.mock('@nx/devkit', () => {
describe('web app generator (legacy)', () => { describe('web app generator (legacy)', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
let originalEnv: string; let originalEnv: string;

View File

@ -1,6 +1,6 @@
import 'nx/src/internal-testing-utils/mock-project-graph'; import 'nx/src/internal-testing-utils/mock-project-graph';
import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; import { getInstalledCypressMajorVersion } from '@nx/cypress/src/utils/versions';
import { import {
readNxJson, readNxJson,
readProjectConfiguration, readProjectConfiguration,
@ -18,7 +18,10 @@ import { Schema } from './schema';
import { PackageManagerCommands } from 'nx/src/utils/package-manager'; import { PackageManagerCommands } from 'nx/src/utils/package-manager';
// need to mock cypress otherwise it'll use the nx installed version from package.json // need to mock cypress otherwise it'll use the nx installed version from package.json
// which is v9 while we are testing for the new v10 version // which is v9 while we are testing for the new v10 version
jest.mock('@nx/cypress/src/utils/cypress-version'); jest.mock('@nx/cypress/src/utils/versions', () => ({
...jest.requireActual('@nx/cypress/src/utils/versions'),
getInstalledCypressMajorVersion: jest.fn(),
}));
jest.mock('@nx/devkit', () => { jest.mock('@nx/devkit', () => {
return { return {
...jest.requireActual('@nx/devkit'), ...jest.requireActual('@nx/devkit'),
@ -29,8 +32,8 @@ jest.mock('@nx/devkit', () => {
describe('app', () => { describe('app', () => {
let tree: Tree; let tree: Tree;
let mockedInstalledCypressVersion: jest.Mock< let mockedInstalledCypressVersion: jest.Mock<
ReturnType<typeof installedCypressVersion> ReturnType<typeof getInstalledCypressMajorVersion>
> = installedCypressVersion as never; > = getInstalledCypressMajorVersion as never;
beforeEach(() => { beforeEach(() => {
mockedInstalledCypressVersion.mockReturnValue(10); mockedInstalledCypressVersion.mockReturnValue(10);
jest jest

View File

@ -46,9 +46,11 @@ function check() {
// which is @angular/core/testing. and the tests check for this // which is @angular/core/testing. and the tests check for this
'packages/cypress/src/migrations/update-15-1-0/cypress-11.spec.ts', 'packages/cypress/src/migrations/update-15-1-0/cypress-11.spec.ts',
'packages/cypress/src/migrations/update-15-1-0/cypress-11.ts', 'packages/cypress/src/migrations/update-15-1-0/cypress-11.ts',
// this migration looks for projects depending on @angular/core, it doesn't require it // these migrations looks for projects depending on @angular/core, it doesn't require it
'packages/cypress/src/migrations/update-16-4-0/warn-incompatible-angular-cypress.spec.ts', 'packages/cypress/src/migrations/update-16-4-0/warn-incompatible-angular-cypress.spec.ts',
'packages/cypress/src/migrations/update-16-4-0/warn-incompatible-angular-cypress.ts', 'packages/cypress/src/migrations/update-16-4-0/warn-incompatible-angular-cypress.ts',
'packages/cypress/src/migrations/update-20-8-0/update-component-testing-mount-imports.spec.ts',
'packages/cypress/src/migrations/update-20-8-0/update-component-testing-mount-imports.ts',
]; ];
const files = [ const files = [