diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 9a58b082dab7fc..53ffda011232b7 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -7176,6 +7176,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/cypress/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index e96181dace35c1..6bccce029a82fd 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -523,6 +523,15 @@ "originalFilePath": "/packages/cypress/src/generators/migrate-to-cypress-11/schema.json", "path": "/nx-api/cypress/generators/migrate-to-cypress-11", "type": "generator" + }, + "/nx-api/cypress/generators/convert-to-inferred": { + "description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "file": "generated/packages/cypress/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/cypress/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/cypress/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/cypress" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 2d380a87453d68..fbeb82b0920451 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -515,6 +515,15 @@ "originalFilePath": "/packages/cypress/src/generators/migrate-to-cypress-11/schema.json", "path": "cypress/generators/migrate-to-cypress-11", "type": "generator" + }, + { + "description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "file": "generated/packages/cypress/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/cypress/src/generators/convert-to-inferred/schema.json", + "path": "cypress/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/cypress/generators/convert-to-inferred.json b/docs/generated/packages/cypress/generators/convert-to-inferred.json new file mode 100644 index 00000000000000..e86a9dccffb744 --- /dev/null +++ b/docs/generated/packages/cypress/generators/convert-to-inferred.json @@ -0,0 +1,35 @@ +{ + "name": "convert-to-inferred", + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": { + "$schema": "https://json-schema.org/schema", + "$id": "NxCypressConvertToInferred", + "description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "title": "Convert Cypress project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "x-priority": "important" + }, + "all": { + "type": "boolean", + "description": "Convert all projects using the `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "implementation": "/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/cypress/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 32ac649dd018db..1b3292855d65b2 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -376,6 +376,7 @@ - [configuration](/nx-api/cypress/generators/configuration) - [component-configuration](/nx-api/cypress/generators/component-configuration) - [migrate-to-cypress-11](/nx-api/cypress/generators/migrate-to-cypress-11) + - [convert-to-inferred](/nx-api/cypress/generators/convert-to-inferred) - [detox](/nx-api/detox) - [documents](/nx-api/detox/documents) - [Overview](/nx-api/detox/documents/overview) diff --git a/packages/cypress/generators.json b/packages/cypress/generators.json index 99bf59cfb43fb1..113ad18782e218 100644 --- a/packages/cypress/generators.json +++ b/packages/cypress/generators.json @@ -32,6 +32,11 @@ "factory": "./src/generators/migrate-to-cypress-11/migrate-to-cypress-11#migrateCypressProject", "schema": "./src/generators/migrate-to-cypress-11/schema.json", "description": "Migrate existing Cypress e2e projects to Cypress v11" + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`." } } } diff --git a/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 00000000000000..78dfad54e21c66 --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,465 @@ +import { + getRelativeProjectJsonSchemaPath, + updateProjectConfiguration, +} from 'nx/src/generators/utils/project-configuration'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { convertToInferred } from './convert-to-inferred'; +import { + addProjectConfiguration as _addProjectConfiguration, + type ExpandedPluginConfiguration, + joinPathFragments, + type ProjectConfiguration, + type ProjectGraph, + readNxJson, + readProjectConfiguration, + type Tree, + updateNxJson, + writeJson, +} from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; +import { join } from 'node:path'; + +let fs: TempFs; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest.fn().mockImplementation(async () => { + return projectGraph; + }), + updateProjectConfiguration: jest + .fn() + .mockImplementation((tree, projectName, projectConfiguration) => { + function handleEmptyTargets( + projectName: string, + projectConfiguration: ProjectConfiguration + ): void { + if ( + projectConfiguration.targets && + !Object.keys(projectConfiguration.targets).length + ) { + // Re-order `targets` to appear after the `// target` comment. + delete projectConfiguration.targets; + projectConfiguration[ + '// targets' + ] = `to see all targets run: nx show project ${projectName} --web`; + projectConfiguration.targets = {}; + } else { + delete projectConfiguration['// targets']; + } + } + + const projectConfigFile = joinPathFragments( + projectConfiguration.root, + 'project.json' + ); + + if (!tree.exists(projectConfigFile)) { + throw new Error( + `Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.` + ); + } + handleEmptyTargets(projectName, projectConfiguration); + writeJson(tree, projectConfigFile, { + name: projectConfiguration.name ?? projectName, + $schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration), + ...projectConfiguration, + root: undefined, + }); + projectGraph.nodes[projectName].data = projectConfiguration; + }), +})); + +function addProjectConfiguration( + tree: Tree, + name: string, + project: ProjectConfiguration +) { + _addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: project.projectType === 'application' ? 'app' : 'lib', + data: { + projectType: project.projectType, + root: project.root, + targets: project.targets, + }, + }; +} + +interface CreateCypressTestProjectOptions { + appName: string; + appRoot: string; + e2eTargetName: string; +} + +const defaultCreateCypressTestProjectOptions: CreateCypressTestProjectOptions = + { + appName: 'myapp-e2e', + appRoot: 'myapp-e2e', + e2eTargetName: 'e2e', + }; + +function createTestProject( + tree: Tree, + opts: Partial = defaultCreateCypressTestProjectOptions +) { + let projectOpts = { ...defaultCreateCypressTestProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.e2eTargetName]: { + executor: '@nx/cypress:cypress', + options: { + cypressConfig: `${projectOpts.appRoot}/cypress.config.ts`, + testingType: `e2e`, + devServerTarget: 'myapp:serve', + }, + }, + }, + }; + + const cypressConfigContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: 'http://localhost:4200', + }, +});`; + + tree.write(`${projectOpts.appRoot}/cypress.config.ts`, cypressConfigContents); + fs.createFileSync( + `${projectOpts.appRoot}/cypress.config.ts`, + cypressConfigContents + ); + jest.doMock( + join(fs.tempDir, `${projectOpts.appRoot}/cypress.config.ts`), + () => ({ + default: { + e2e: { + baseUrl: 'http://localhost:4200', + }, + }, + }), + { + virtual: true, + } + ); + + addProjectConfiguration(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('Cypress - Convert Executors To Plugin', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('cypress'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.reset(); + }); + + describe('--project', () => { + it('should setup a new Cypress plugin and only migrate one specific project', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + e2eTargetName: 'e2e', + }); + const project = createTestProject(tree, { + e2eTargetName: 'test', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + e2eTargetName: 'test', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + e2eTargetName: 'integration', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { project: 'myapp-e2e', skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + ['test'].forEach((key) => expect(targetKeys).not.toContain(key)); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const addedTestCypressPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/cypress/plugin' && + plugin.include?.length === 1 + ) { + return true; + } + }); + expect(addedTestCypressPlugin).toBeTruthy(); + expect( + (addedTestCypressPlugin as ExpandedPluginConfiguration).include + ).toEqual(['myapp-e2e/**/*']); + }); + }); + + describe('--all', () => { + it('should successfully migrate a project using Cypress executors to plugin', async () => { + const project = createTestProject(tree); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + expect(targetKeys).not.toContain('e2e'); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasCypressPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/cypress/plugin' + : plugin.plugin === '@nx/cypress/plugin' + ); + expect(hasCypressPlugin).toBeTruthy(); + if (typeof hasCypressPlugin !== 'string') { + [ + ['targetName', 'e2e'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasCypressPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + + it('should setup Cypress plugin to match projects', async () => { + // ARRANGE + const project = createTestProject(tree, { + e2eTargetName: 'test', + }); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + ['test'].forEach((key) => expect(targetKeys).not.toContain(key)); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasCypressPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/cypress/plugin' + : plugin.plugin === '@nx/cypress/plugin' + ); + expect(hasCypressPlugin).toBeTruthy(); + if (typeof hasCypressPlugin !== 'string') { + [ + ['targetName', 'test'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasCypressPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + + it('should setup a new Cypress plugin to match only projects migrated', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + e2eTargetName: 'e2e', + }); + const project = createTestProject(tree, { + e2eTargetName: 'test', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + e2eTargetName: 'test', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + e2eTargetName: 'integration', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + }); + updateNxJson(tree, nxJson); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + const targetKeys = Object.keys(updatedProject.targets); + ['test'].forEach((key) => expect(targetKeys).not.toContain(key)); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const addedTestCypressPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/cypress/plugin' && + plugin.include?.length === 2 + ) { + return true; + } + }); + expect(addedTestCypressPlugin).toBeTruthy(); + expect( + (addedTestCypressPlugin as ExpandedPluginConfiguration).include + ).toEqual(['myapp-e2e/**/*', 'second/**/*']); + + const addedIntegrationCypressPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/cypress/plugin' && + plugin.include?.length === 1 + ) { + return true; + } + }); + expect(addedIntegrationCypressPlugin).toBeTruthy(); + expect( + (addedIntegrationCypressPlugin as ExpandedPluginConfiguration).include + ).toEqual(['third/**/*']); + }); + + it('should keep Cypress options in project.json', async () => { + // ARRANGE + const project = createTestProject(tree); + project.targets.e2e.options.runnerUi = true; + updateProjectConfiguration(tree, project.name, project); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.e2e).toMatchInlineSnapshot(` + { + "options": { + "runner-ui": true, + }, + } + `); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasCypressPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/cypress/plugin' + : plugin.plugin === '@nx/cypress/plugin' + ); + expect(hasCypressPlugin).toBeTruthy(); + if (typeof hasCypressPlugin !== 'string') { + [ + ['targetName', 'e2e'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasCypressPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + + it('should add Cypress options found in targetDefaults for the executor to the project.json', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['@nx/cypress:cypress'] = { + options: { + exit: false, + }, + }; + updateNxJson(tree, nxJson); + const project = createTestProject(tree); + + // ACT + await convertToInferred(tree, { skipFormat: true }); + + // ASSERT + // project.json modifications + const updatedProject = readProjectConfiguration(tree, project.name); + expect(updatedProject.targets.e2e).toMatchInlineSnapshot(` + { + "options": { + "no-exit": true, + }, + } + `); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasCypressPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/cypress/plugin' + : plugin.plugin === '@nx/cypress/plugin' + ); + expect(hasCypressPlugin).toBeTruthy(); + if (typeof hasCypressPlugin !== 'string') { + [ + ['targetName', 'e2e'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasCypressPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + }); +}); diff --git a/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 00000000000000..b1a2ae6ac861ca --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,38 @@ +import { + formatFiles, + type ProjectConfiguration, + readProjectConfiguration, + type Tree, +} from '@nx/devkit'; +import { migrateExecutorToPlugin } from './lib/migrate-executor-to-plugin'; + +interface Schema { + project?: string; + all?: boolean; + skipFormat?: boolean; +} + +export async function convertToInferred(tree: Tree, options: Schema) { + if (!options.project && !options.all) { + options.all = true; + } + + if (options.project && options.all) { + throw new Error( + `Both "--project" and "--all" options were passed. Please select one.` + ); + } + + let project: ProjectConfiguration; + if (options.project) { + project = readProjectConfiguration(tree, options.project); + } + + await migrateExecutorToPlugin(tree, project?.name, project?.root); + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +export default convertToInferred; diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/add-dev-server-target-to-config.spec.ts b/packages/cypress/src/generators/convert-to-inferred/lib/add-dev-server-target-to-config.spec.ts new file mode 100644 index 00000000000000..e710c292357892 --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/add-dev-server-target-to-config.spec.ts @@ -0,0 +1,201 @@ +import type { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; +import { addDevServerTargetToConfig } from './add-dev-server-target-to-config'; + +describe('addDevServerTargetToConfig', () => { + let tree: Tree; + const configFilePath = 'cypress.config.ts'; + const configFileContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, +});`; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write(configFilePath, configFileContents); + }); + + describe('devServerTarget only', () => { + it('should add webServerCommands when it does not exist', () => { + // ACT + addDevServerTargetToConfig(tree, configFilePath, 'myorg:serve'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, {webServerCommands: { default: "npx nx run myorg:serve" }, cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + + it('should do nothing if the webServerCommands exists and matches the devServerTarget', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"} }), + baseUrl: "http://localhost:4200", + }, +});` + ); + // ACT + addDevServerTargetToConfig(tree, configFilePath, 'myorg:serve'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"} }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + + it('should update the webServerCommands if it does not match', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run test:serve"} }), + baseUrl: "http://localhost:4200", + }, +});` + ); + // ACT + addDevServerTargetToConfig(tree, configFilePath, 'myorg:serve'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"} }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + }); + + describe('devServerTarget and ci.devServerTarget', () => { + it('should add webServerCommands and ciWebServerCommand when it does not exist', () => { + // ACT + addDevServerTargetToConfig( + tree, + configFilePath, + 'myorg:serve', + 'myorg:static-serve' + ); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, {ciWebServerCommand: "npx nx run myorg:static-serve",webServerCommands: { default: "npx nx run myorg:serve" }, cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + + it('should do nothing if the webServerCommands and ciWebServerCommand exists and matches the devServerTarget', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"}, ciWebServerCommand: "npx nx run myorg:static-serve" }), + baseUrl: "http://localhost:4200", + }, +});` + ); + // ACT + addDevServerTargetToConfig( + tree, + configFilePath, + 'myorg:serve', + 'myorg:static-serve' + ); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"}, ciWebServerCommand: "npx nx run myorg:static-serve" }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + + it('should update the webServerCommands and ciWebServerCommand if it does not match', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run test:serve"}, ciWebServerCommand: "npx nx run test:static-serve" }), + baseUrl: "http://localhost:4200", + }, +});` + ); + // ACT + addDevServerTargetToConfig( + tree, + configFilePath, + 'myorg:serve', + 'myorg:static-serve' + ); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"}, ciWebServerCommand: "npx nx run myorg:static-serve" }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + }); +}); diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/add-dev-server-target-to-config.ts b/packages/cypress/src/generators/convert-to-inferred/lib/add-dev-server-target-to-config.ts new file mode 100644 index 00000000000000..cf86b432a854ff --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/add-dev-server-target-to-config.ts @@ -0,0 +1,123 @@ +import type { Tree } from '@nx/devkit'; +import { tsquery } from '@phenomnomnominal/tsquery'; + +/** + * Add or update the webServerCommands and ciWebServerCommand options in the Cypress Config + * Scenarios Covered: + * 1. Only devServerTarget Exists + * 2. devServerTarget and configuration.ci.devServerTarget Exists + * + * For each, the following scenarios are covered: + * a. The command is not listed in the config, so it is added + * b. The command is listed in the config and it includes the devServerTarget, so do nothing + * c. The command is listed in the config and it does not include the devServerTarget, so update to use it + * i. We update it as the @nx/cypress:cypress executor updates the config from cypress.config.ts with options from the target configuration + */ +export function addDevServerTargetToConfig( + tree: Tree, + configFilePath: string, + devServerTarget: string, + ciDevServerTarget?: string +) { + let configFileContents = tree.read(configFilePath, 'utf-8'); + + let ast = tsquery.ast(configFileContents); + + const NX_E2E_PRESET_OPTIONS_SELECTOR = + 'PropertyAssignment:has(Identifier[name=e2e]) CallExpression:has(Identifier[name=nxE2EPreset]) > ObjectLiteralExpression'; + const nxE2ePresetOptionsNodes = tsquery(ast, NX_E2E_PRESET_OPTIONS_SELECTOR, { + visitAllChildren: true, + }); + if (nxE2ePresetOptionsNodes.length !== 0) { + let nxE2ePresetOptionsNode = nxE2ePresetOptionsNodes[0]; + const WEB_SERVER_COMMANDS_SELECTOR = + 'PropertyAssignment:has(Identifier[name=webServerCommands])'; + const webServerCommandsNodes = tsquery( + nxE2ePresetOptionsNode, + WEB_SERVER_COMMANDS_SELECTOR, + { visitAllChildren: true } + ); + if (webServerCommandsNodes.length !== 0) { + // Already exists, check it matches + const defaultWebServerCommandNodes = tsquery( + webServerCommandsNodes[0], + 'PropertyAssignment:has(Identifier[name=default])', + { visitAllChildren: true } + ); + + if (defaultWebServerCommandNodes.length !== 0) { + const defaultWebServerCommandNode = + defaultWebServerCommandNodes[1].getChildAt(2); + const defaultWebServerCommand = defaultWebServerCommandNode + .getText() + .replace(/["']/g, ''); + if (!defaultWebServerCommand.includes(devServerTarget)) { + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + defaultWebServerCommandNode.getStart() + )}"npx nx run ${devServerTarget}"${configFileContents.slice( + defaultWebServerCommandNode.getEnd() + )}` + ); + } + } + } else { + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + nxE2ePresetOptionsNode.getStart() + 1 + )}webServerCommands: { default: "npx nx run ${devServerTarget}" },${configFileContents.slice( + nxE2ePresetOptionsNode.getStart() + 1 + )}` + ); + } + + if (ciDevServerTarget) { + configFileContents = tree.read(configFilePath, 'utf-8'); + ast = tsquery.ast(configFileContents); + nxE2ePresetOptionsNode = tsquery(ast, NX_E2E_PRESET_OPTIONS_SELECTOR, { + visitAllChildren: true, + })[0]; + + const CI_WEB_SERVER_COMMANDS_SELECTOR = + 'PropertyAssignment:has(Identifier[name=ciWebServerCommand])'; + const ciWebServerCommandsNodes = tsquery( + nxE2ePresetOptionsNode, + CI_WEB_SERVER_COMMANDS_SELECTOR, + { visitAllChildren: true } + ); + + if (ciWebServerCommandsNodes.length !== 0) { + const ciWebServerCommandNode = + ciWebServerCommandsNodes[0].getChildAt(2); + const ciWebServerCommand = ciWebServerCommandNode + .getText() + .replace(/["']/g, ''); + if (!ciWebServerCommand.includes(ciDevServerTarget)) { + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + ciWebServerCommandNode.getStart() + )}"npx nx run ${ciDevServerTarget}"${configFileContents.slice( + ciWebServerCommandNode.getEnd() + )}` + ); + } + } else { + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + nxE2ePresetOptionsNode.getStart() + 1 + )}ciWebServerCommand: "npx nx run ${ciDevServerTarget}",${configFileContents.slice( + nxE2ePresetOptionsNode.getStart() + 1 + )}` + ); + } + } + } +} diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/add-exclude-spec-pattern.spec.ts b/packages/cypress/src/generators/convert-to-inferred/lib/add-exclude-spec-pattern.spec.ts new file mode 100644 index 00000000000000..ccfef97170ddb0 --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/add-exclude-spec-pattern.spec.ts @@ -0,0 +1,200 @@ +import type { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; +import { addExcludeSpecPattern } from './add-exclude-spec-pattern'; + +describe('addExcludeSpecPattern', () => { + let tree: Tree; + const configFilePath = 'cypress.config.ts'; + const configFileContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, +});`; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write(configFilePath, configFileContents); + }); + + it('should add excludeSpecPattern string if it does not exist', () => { + // ACT + addExcludeSpecPattern(tree, configFilePath, 'mytests/**/*.spec.ts'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: {excludeSpecPattern: "mytests/**/*.spec.ts", + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + + it('should add excludeSpecPattern array if it does not exist', () => { + // ACT + addExcludeSpecPattern(tree, configFilePath, [ + 'mytests/**/*.spec.ts', + 'mysecondtests/**/*.spec.ts', + ]); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: {excludeSpecPattern: ["mytests/**/*.spec.ts","mysecondtests/**/*.spec.ts"], + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); + + it('should update the existing excludeSpecPattern if one exists when using string', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: "somefile.spec.ts" + }, +});` + ); + + // ACT + addExcludeSpecPattern(tree, configFilePath, 'mytests/**/*.spec.ts'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: ["somefile.spec.ts","mytests/**/*.spec.ts"] + }, + });" + `); + }); + + it('should update the existing excludeSpecPattern if one exists when using array', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: ["somefile.spec.ts"] + }, +});` + ); + + // ACT + addExcludeSpecPattern(tree, configFilePath, ['mytests/**/*.spec.ts']); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: ["somefile.spec.ts","mytests/**/*.spec.ts"] + }, + });" + `); + }); + + it('should update the existing excludeSpecPattern if one exists when using string with an array of new options', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: "somefile.spec.ts" + }, +});` + ); + + // ACT + addExcludeSpecPattern(tree, configFilePath, [ + 'mytests/**/*.spec.ts', + 'mysecondtests/**/*.spec.ts', + ]); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: ["somefile.spec.ts","mytests/**/*.spec.ts","mysecondtests/**/*.spec.ts"] + }, + });" + `); + }); + + it('should update the existing excludeSpecPattern if one exists when using array with a new pattern string', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: ["somefile.spec.ts"] + }, +});` + ); + + // ACT + addExcludeSpecPattern(tree, configFilePath, 'mytests/**/*.spec.ts'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + excludeSpecPattern: ["somefile.spec.ts","mytests/**/*.spec.ts"] + }, + });" + `); + }); +}); diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/add-exclude-spec-pattern.ts b/packages/cypress/src/generators/convert-to-inferred/lib/add-exclude-spec-pattern.ts new file mode 100644 index 00000000000000..7174987eb4f8c0 --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/add-exclude-spec-pattern.ts @@ -0,0 +1,55 @@ +import type { Tree } from '@nx/devkit'; +import { tsquery } from '@phenomnomnominal/tsquery'; +export function addExcludeSpecPattern( + tree: Tree, + configFilePath: string, + excludeSpecPattern: string | string[] +) { + let configFileContents = tree.read(configFilePath, 'utf-8'); + + let ast = tsquery.ast(configFileContents); + + const E2E_CONFIG_SELECTOR = + 'PropertyAssignment:has(Identifier[name=e2e]) > ObjectLiteralExpression'; + const e2eConfigNodes = tsquery(ast, E2E_CONFIG_SELECTOR, { + visitAllChildren: true, + }); + if (e2eConfigNodes.length !== 0) { + const e2eConfigNode = e2eConfigNodes[0]; + const EXCLUDE_SPEC_PATTERN_SELECTOR = + 'PropertyAssignment:has(Identifier[name="excludeSpecPattern"])'; + const excludeSpecPatternNodes = tsquery( + e2eConfigNode, + EXCLUDE_SPEC_PATTERN_SELECTOR, + { visitAllChildren: true } + ); + + if (excludeSpecPatternNodes.length !== 0) { + const excludeSpecPatternNode = excludeSpecPatternNodes[0]; + + let updatedExcludePattern = Array.isArray(excludeSpecPattern) + ? excludeSpecPattern + : [excludeSpecPattern]; + + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + excludeSpecPatternNode.getStart() + )}excludeSpecPattern: ${JSON.stringify( + updatedExcludePattern + )}${configFileContents.slice(excludeSpecPatternNode.getEnd())}` + ); + } else { + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + e2eConfigNode.getStart() + 1 + )}excludeSpecPattern: ${JSON.stringify( + excludeSpecPattern + )},${configFileContents.slice(e2eConfigNode.getStart() + 1)}` + ); + } + } +} diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/migrate-executor-to-plugin.ts b/packages/cypress/src/generators/convert-to-inferred/lib/migrate-executor-to-plugin.ts new file mode 100644 index 00000000000000..864c79c0810ba6 --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/migrate-executor-to-plugin.ts @@ -0,0 +1,179 @@ +import { + type TargetConfiguration, + type Tree, + CreateNodesContext, + joinPathFragments, + createProjectGraphAsync, +} from '@nx/devkit'; +import { + addPluginWithOptions, + getProjectsToMigrate, + migrateExecutorToPlugin as _migrateExecutorToPlugin, +} from '@nx/devkit/src/generators/plugin-migrations/plugin-migration-utils'; +import { + createNodes, + type CypressPluginOptions, +} from '../../../plugins/plugin'; +import {} from '@nx/devkit'; +import { targetOptionsToCliMap } from './target-options-map'; +import { upsertBaseUrl } from './upsert-baseUrl'; +import { addDevServerTargetToConfig } from './add-dev-server-target-to-config'; +import { addExcludeSpecPattern } from './add-exclude-spec-pattern'; + +export async function migrateExecutorToPlugin( + tree: Tree, + projectName?: string, + projectRoot?: string +) { + const projectGraph = await createProjectGraphAsync(); + const { allProjectsWithExecutor } = getProjectsToMigrate( + tree, + '@nx/cypress:cypress' + ); + + if (projectName && !allProjectsWithExecutor.has(projectName)) { + throw new Error( + `Project "${projectName}" does not use "@nx/cypress:cypress executor. Please select a project that does."` + ); + } + + if (projectName) { + allProjectsWithExecutor.clear(); + allProjectsWithExecutor.add(projectName); + } + + while (allProjectsWithExecutor.size !== 0) { + const { targetName, include, migratedProjects } = + await _migrateExecutorToPlugin( + tree, + projectGraph, + createProjectConfigs(projectRoot), + '@nx/cypress:cypress', + createNodes, + projectOptionsTransformer + ); + + addPluginWithOptions( + tree, + '@nx/cypress/plugin', + include, + { targetName, ciTargetName: 'e2e-ci' } + ); + + for (const project of migratedProjects) { + allProjectsWithExecutor.delete(project); + } + } +} + +function createProjectConfigs(onlyProjectRoot?: string) { + return ( + tree: Tree, + root: string, + targetName: string, + context: CreateNodesContext + ) => { + if (onlyProjectRoot && root !== onlyProjectRoot) { + return; + } + + const cypressConfigPath = ['js', 'ts', 'cjs', 'cts', 'mjs', 'mts'] + .map((ext) => joinPathFragments(root, `cypress.config.${ext}`)) + .find((path) => tree.exists(path)); + if (!cypressConfigPath) { + return; + } + + return createNodes[1]( + cypressConfigPath, + { + targetName, + }, + context + ); + }; +} + +function projectOptionsTransformer( + target: TargetConfiguration, + tree: Tree +): TargetConfiguration { + if (target.options) { + const configFilePath = target.options.cypressConfig; + + delete target.options.cypressConfig; + delete target.options.copyFiles; + delete target.options.skipServe; + + for (const key in targetOptionsToCliMap) { + if (target.options[key]) { + target.options[targetOptionsToCliMap[key]] = target.options[key]; + delete target.options[key]; + } + } + + if ('exit' in target.options && !target.options.exit) { + delete target.options.exit; + target.options['no-exit'] = true; + } + + if (target.options.testingType) { + delete target.options.testingType; + } + + if (target.options.watch) { + target.options.headed = true; + target.options['no-exit'] = true; + delete target.options.watch; + } + + if (target.options.baseUrl) { + upsertBaseUrl(tree, configFilePath, target.options.baseUrl); + delete target.options.baseUrl; + } + + if (target.options.devServerTarget) { + // TODO get all configurations and add as records to the webServerCommands + addDevServerTargetToConfig( + tree, + configFilePath, + target.options.devServerTarget, + target.configurations?.ci?.devServerTarget + ); + delete target.options.devServerTarget; + if (target.configurations) { + for (const configuration in target.configurations) { + delete target.configurations[configuration]?.devServerTarget; + } + } + } + + if (target.options.ignoreTestFiles) { + addExcludeSpecPattern( + tree, + configFilePath, + target.options.ignoreTestFiles + ); + delete target.options.ignoreTestFiles; + } + + if (Object.keys(target.options).length === 0) { + delete target.options; + } + if ( + target.configurations && + Object.keys(target.configurations).length !== 0 + ) { + for (const configuration in target.configurations) { + if (Object.keys(target.configurations[configuration]).length === 0) { + delete target.configurations[configuration]; + } + } + if (Object.keys(target.configurations).length === 0) { + delete target.configurations; + } + } + } + + return target; +} diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/target-options-map.ts b/packages/cypress/src/generators/convert-to-inferred/lib/target-options-map.ts new file mode 100644 index 00000000000000..e0e45e560f938f --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/target-options-map.ts @@ -0,0 +1,18 @@ +export const targetOptionsToCliMap = { + headed: 'headed', + headless: 'headless', + key: 'key', + record: 'record', + parallel: 'parallel', + browser: 'browser', + env: 'env', + spec: 'spec', + ciBuildId: 'ci-build-id', + group: 'group', + reporter: 'reporter', + reporterOptions: 'reporter-options', + tag: 'tag', + port: 'port', + quiet: 'quiet', + runnerUi: 'runner-ui', +}; diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/upsert-baseUrl.spec.ts b/packages/cypress/src/generators/convert-to-inferred/lib/upsert-baseUrl.spec.ts new file mode 100644 index 00000000000000..665b43530b5eeb --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/upsert-baseUrl.spec.ts @@ -0,0 +1,78 @@ +import type { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; +import { upsertBaseUrl } from './upsert-baseUrl'; + +describe('upsertBaseUrl', () => { + let tree: Tree; + const configFilePath = 'cypress.config.ts'; + const configFileContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, +});`; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + tree.write(configFilePath, configFileContents); + }); + + it('should do nothing if the baseUrl value exists and matches', () => { + // ACT + upsertBaseUrl(tree, configFilePath, 'http://localhost:4200'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toEqual(configFileContents); + }); + + it('should update the config if the baseUrl value exists and does not match', () => { + // ACT + upsertBaseUrl(tree, configFilePath, 'http://localhost:4201'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4201", + }, + });" + `); + }); + + it('should add the baseUrl property if it does not exist', () => { + // ARRANGE + tree.write( + configFilePath, + `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + }, +});` + ); + // ACT + upsertBaseUrl(tree, configFilePath, 'http://localhost:4200'); + + // ASSERT + expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: "http://localhost:4200", + }, + });" + `); + }); +}); diff --git a/packages/cypress/src/generators/convert-to-inferred/lib/upsert-baseUrl.ts b/packages/cypress/src/generators/convert-to-inferred/lib/upsert-baseUrl.ts new file mode 100644 index 00000000000000..f2d443c7caf92e --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/lib/upsert-baseUrl.ts @@ -0,0 +1,56 @@ +import type { Tree } from '@nx/devkit'; + +import { tsquery } from '@phenomnomnominal/tsquery'; + +export function upsertBaseUrl( + tree: Tree, + configFilePath: string, + baseUrlValueInProject: string +) { + const configFileContents = tree.read(configFilePath, 'utf-8'); + + const ast = tsquery.ast(configFileContents); + const BASE_URL_SELECTOR = + 'PropertyAssignment:has(Identifier[name=e2e]) PropertyAssignment:has(Identifier[name="baseUrl"])'; + + const baseUrlNodes = tsquery(ast, BASE_URL_SELECTOR, { + visitAllChildren: true, + }); + if (baseUrlNodes.length !== 0) { + // The property exists in the config + const baseUrlValueNode = baseUrlNodes[0].getChildAt(2); + const baseUrlValue = baseUrlValueNode.getText().replace(/(["'])/, ''); + + if (baseUrlValue === baseUrlValueInProject) { + return; + } + + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + baseUrlValueNode.getStart() + )}"${baseUrlValueInProject}"${configFileContents.slice( + baseUrlValueNode.getEnd() + )}` + ); + } else { + const E2E_OBJECT_SELECTOR = + 'PropertyAssignment:has(Identifier[name=e2e]) ObjectLiteralExpression'; + + const e2eConfigNodes = tsquery(ast, E2E_OBJECT_SELECTOR, { + visitAllChildren: true, + }); + if (e2eConfigNodes.length !== 0) { + const e2eConfigNode = e2eConfigNodes[0]; + tree.write( + configFilePath, + `${configFileContents.slice( + 0, + e2eConfigNode.getEnd() - 1 + )}baseUrl: "${baseUrlValueInProject}", + ${configFileContents.slice(e2eConfigNode.getEnd() - 1)}` + ); + } + } +} diff --git a/packages/cypress/src/generators/convert-to-inferred/schema.json b/packages/cypress/src/generators/convert-to-inferred/schema.json new file mode 100644 index 00000000000000..d60b9d9d55179a --- /dev/null +++ b/packages/cypress/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxCypressConvertToInferred", + "description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "title": "Convert Cypress project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.", + "x-priority": "important" + }, + "all": { + "type": "boolean", + "description": "Convert all projects using the `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +} diff --git a/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts index efa7a1eb082872..c11761784d52a2 100644 --- a/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts +++ b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts @@ -53,7 +53,7 @@ const { * ); * } * - * function projectOptionsTransformer( + * function postTargetTransformer( * target: TargetConfiguration * ): TargetConfiguration { * if (target.options) { @@ -73,7 +73,7 @@ const { * createProjectConfigs, * '@nx/playwright:playwright', * createNodes, - * projectOptionsTransformer + * postTargetTransformer * ); * * @@ -82,7 +82,7 @@ const { * @param createProjectsConfig Function returning the CreateNodesResult for the plugin using the projects that have been marked for migration * @param executor The executor to migrate from * @param createNodes The CreateNodes tuple used by the plugin - * @param projectOptionsTransformer Apply transformations to the remaining options in the project's target, if there are any + * @param postTargetTransformer Apply transformations to the project's target, if there are any */ export async function migrateExecutorToPlugin( tree: Tree, @@ -95,8 +95,10 @@ export async function migrateExecutorToPlugin( ) => CreateNodesResult | Promise, executor: string, createNodes: CreateNodes, - projectOptionsTransformer: ( - target: TargetConfiguration + postTargetTransformer: ( + target: TargetConfiguration, + tree?: Tree, + projectRoot?: string ) => TargetConfiguration = (targetConfiguration) => targetConfiguration ): Promise<{ targetName: string; @@ -151,7 +153,7 @@ export async function migrateExecutorToPlugin( deleteMatchingProperties(target, createdTarget); - target = projectOptionsTransformer(target); + target = postTargetTransformer(target, tree, projectFromGraph.data.root); if (Object.keys(target).length > 0) { projectConfig.targets[targetName] = target; @@ -308,8 +310,8 @@ export function deleteMatchingProperties( for (const key in targetToMigrate) { if (Array.isArray(targetToMigrate[key])) { if ( - targetToMigrate[key].every((v) => createdTarget[key].includes(v)) && - targetToMigrate[key].length === createdTarget[key].length + targetToMigrate[key].every((v) => createdTarget[key]?.includes(v)) && + targetToMigrate[key].length === createdTarget[key]?.length ) { delete targetToMigrate[key]; }