From 615955afecb32a8f739ac0c0dab8aa071347f187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Mon, 28 Feb 2022 11:03:09 +0000 Subject: [PATCH] feat(angular): support migrating angular cli workspaces using cypress for e2e tests (#9105) --- docs/shared/migration/migration-angular.md | 32 +- e2e/angular-core/src/ng-add.test.ts | 97 +++++- .../init/__snapshots__/init.spec.ts.snap | 156 +++++++++ .../src/generators/init/init.spec.ts | 196 ++++++++++- .../workspace/src/generators/init/init.ts | 320 +++++++++++++++--- 5 files changed, 735 insertions(+), 66 deletions(-) diff --git a/docs/shared/migration/migration-angular.md b/docs/shared/migration/migration-angular.md index 84bf565440298..bfa7ca01ceb41 100644 --- a/docs/shared/migration/migration-angular.md +++ b/docs/shared/migration/migration-angular.md @@ -8,9 +8,9 @@ using a monorepo approach. If you are currently using an Angular CLI workspace, - The major version of your `Angular CLI` must align with the version of `Nx` you are upgrading to. For example, if you're using Angular CLI version 7, you must transition using the latest version 7 release of Nx. - Currently, transforming an Angular CLI workspace to an Nx workspace automatically only supports a single project. If you have more than one project in your Angular CLI workspace, you can still migrate manually. -## Using ng add and preserving your existing structure +## Using the Nx CLI while preserving the existing structure -To add Nx to an existing Angular CLI workspace to an Nx workspace, with keeping your existing file structure in place, use the `ng add` command with the `--preserveAngularCLILayout` option: +To use the Nx CLI in an existing Angular CLI workspace while keeping your existing file structure in place, use the `ng add` command with the `--preserveAngularCLILayout` option: ```bash ng add @nrwl/workspace --preserveAngularCLILayout @@ -19,14 +19,14 @@ ng add @nrwl/workspace --preserveAngularCLILayout This installs the `@nrwl/workspace` package into your workspace and applies the following changes to your workspace: - Adds and installs the `@nrwl/workspace` package in your development dependencies. -- Creates an nx.json file in the root of your workspace. -- Adds a `decorate-angular-cli.js` to the root of your workspace, and a `postinstall` script in your `package.json` to run the script when your dependencies are updated. The script forwards the `ng` commands to the Nx CLI(nx) to enable features such as Computation Caching. +- Creates an `nx.json` file in the root of your workspace. +- Adds a `decorate-angular-cli.js` to the root of your workspace, and a `postinstall` script in your `package.json` to run the script when your dependencies are updated. The script forwards the `ng` commands to the Nx CLI (`nx`) to enable features such as [Computation Caching](/using-nx/caching). -After the process completes, you continue using the same serve/build/lint/test commands. +After the process completes, you can continue using the same `serve/build/lint/test` commands you are used to. -## Using ng add +## Transforming an Angular CLI workspace to an Nx workspace -To transform a Angular CLI workspace to an Nx workspace, use the `ng add` command: +To transform an Angular CLI workspace to an Nx workspace, run the following command: ```bash ng add @nrwl/workspace @@ -34,8 +34,8 @@ ng add @nrwl/workspace This installs the `@nrwl/workspace` package into your workspace and runs a generator (or schematic) to transform your workspace. The generator applies the following changes to your workspace: -- Installs the packages for the `Nx` plugin `@nrwl/angular` in your package.json. -- Creates an nx.json file in the root of your workspace. +- Installs the packages for the `Nx` plugin `@nrwl/angular` in your `package.json`. +- Creates an `nx.json` file in the root of your workspace. - Creates configuration files for Prettier. - Creates an `apps` folder for generating applications. - Creates a `libs` folder for generating libraries. @@ -45,7 +45,7 @@ This installs the `@nrwl/workspace` package into your workspace and runs a gener - Updates your `package.json` with scripts to run various `Nx` workspace commands. - Updates your `angular.json` configuration to reflect the new paths. -After the changes are applied, your workspace file structure should look similar to below: +After the changes are applied, your workspace file structure should look similar to the one below: ```treeview / @@ -61,20 +61,26 @@ After the changes are applied, your workspace file structure should look similar │ │ │ ├── polyfills.ts │ │ │ ├── styles.css │ │ │ └── test.ts -│ │ ├── browserslist +│ │ ├── .browserslistrc │ │ ├── karma.conf.js │ │ ├── tsconfig.app.json │ │ └── tsconfig.spec.json │ └── -e2e/ │ ├── src/ -│ ├── protractor.conf.js +│ ├── protractor.conf.js | cypress.json │ └── tsconfig.json ├── libs/ ├── tools/ -├── README.md +├── .editorconfig +├── .gitignore +├── .prettierignore +├── .prettierrc ├── angular.json +├── decorate-angular-cli.js +├── karma.conf.js ├── nx.json ├── package.json +├── README.md └── tsconfig.base.json ``` diff --git a/e2e/angular-core/src/ng-add.test.ts b/e2e/angular-core/src/ng-add.test.ts index ddd59aaac94af..b31a8cc289c90 100644 --- a/e2e/angular-core/src/ng-add.test.ts +++ b/e2e/angular-core/src/ng-add.test.ts @@ -1,6 +1,7 @@ process.env.SELECTED_CLI = 'angular'; import { + checkFilesDoNotExist, checkFilesExist, cleanupProject, getSelectedPackageManager, @@ -50,6 +51,12 @@ describe('convert Angular CLI workspace to an Nx workspace', () => { updateFile('angular.json', JSON.stringify(angularJson, null, 2)); } + function addCypress() { + runCommand( + 'npx ng add @cypress/schematic --skip-confirmation --e2e-update' + ); + } + beforeEach(() => { project = uniq('proj'); packageManager = getSelectedPackageManager(); @@ -272,7 +279,7 @@ describe('convert Angular CLI workspace to an Nx workspace', () => { // Remove e2e directory runCommand('mv e2e e2e-bak'); expect(() => runNgAdd('--npm-scope projscope --skip-install')).toThrow( - 'An e2e project was specified but e2e/protractor.conf.js could not be found.' + 'An e2e project with Protractor was found but "e2e/protractor.conf.js" could not be found.' ); // Restore e2e directory runCommand('mv e2e-bak e2e'); @@ -288,6 +295,94 @@ describe('convert Angular CLI workspace to an Nx workspace', () => { // runCommand('mv src-bak src'); }); + it('should handle wrong cypress setup', () => { + addCypress(); + + // Remove cypress.json + runCommand('mv cypress.json cypress.json.bak'); + expect(() => runNgAdd('--npm-scope projscope --skip-install')).toThrow( + 'An e2e project with Cypress was found but "cypress.json" could not be found.' + ); + // Restore cypress.json + runCommand('mv cypress.json.bak cypress.json'); + + // Remove cypress directory + runCommand('mv cypress cypress-bak'); + expect(() => runNgAdd('--npm-scope projscope --skip-install')).toThrow( + 'An e2e project with Cypress was found but the "cypress" directory could not be found.' + ); + // Restore cypress.json + runCommand('mv cypress-bak cypress'); + }); + + it('should handle a workspace with cypress', () => { + addCypress(); + + runNgAdd('--npm-scope projscope --skip-install'); + + const e2eProject = `${project}-e2e`; + //check e2e project files + checkFilesDoNotExist( + 'cypress.json', + 'cypress/tsconfig.json', + 'cypress/integration/spec.ts', + 'cypress/plugins/index.ts', + 'cypress/support/commands.ts', + 'cypress/support/index.ts' + ); + checkFilesExist( + `apps/${e2eProject}/cypress.json`, + `apps/${e2eProject}/tsconfig.json`, + `apps/${e2eProject}/src/integration/spec.ts`, + `apps/${e2eProject}/src/plugins/index.ts`, + `apps/${e2eProject}/src/support/commands.ts`, + `apps/${e2eProject}/src/support/index.ts` + ); + + const angularJson = readJson('angular.json'); + // check e2e project config + expect( + angularJson.projects[project].architect['cypress-run'] + ).toBeUndefined(); + expect( + angularJson.projects[project].architect['cypress-open'] + ).toBeUndefined(); + expect(angularJson.projects[project].architect.e2e).toBeUndefined(); + expect(angularJson.projects[e2eProject].root).toEqual(`apps/${e2eProject}`); + expect(angularJson.projects[e2eProject].architect['cypress-run']).toEqual({ + builder: '@nrwl/cypress:cypress', + options: { + devServerTarget: `${project}:serve`, + cypressConfig: `apps/${e2eProject}/cypress.json`, + }, + configurations: { + production: { + devServerTarget: `${project}:serve:production`, + }, + }, + }); + expect(angularJson.projects[e2eProject].architect['cypress-open']).toEqual({ + builder: '@nrwl/cypress:cypress', + options: { + watch: true, + cypressConfig: `apps/${e2eProject}/cypress.json`, + }, + }); + expect(angularJson.projects[e2eProject].architect.e2e).toEqual({ + builder: '@nrwl/cypress:cypress', + options: { + devServerTarget: `${project}:serve`, + watch: true, + cypressConfig: `apps/${e2eProject}/cypress.json`, + }, + configurations: { + production: { + devServerTarget: `${project}:serve:production`, + }, + }, + }); + }); + it('should support preserveAngularCliLayout', () => { runNgAdd('--preserve-angular-cli-layout'); diff --git a/packages/workspace/src/generators/init/__snapshots__/init.spec.ts.snap b/packages/workspace/src/generators/init/__snapshots__/init.spec.ts.snap index a98dbe5dfde7c..f9bd3a1ea1c71 100644 --- a/packages/workspace/src/generators/init/__snapshots__/init.spec.ts.snap +++ b/packages/workspace/src/generators/init/__snapshots__/init.spec.ts.snap @@ -1,5 +1,161 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`workspace move to nx layout cypress should handle project configuration without cypress-run or cypress-open 1`] = ` +Object { + "implicitDependencies": Array [ + "myApp", + ], + "projectType": "application", + "root": "apps/myApp-e2e", + "sourceRoot": "apps/myApp-e2e/src", + "tags": Array [], + "targets": Object { + "e2e": Object { + "configurations": Object { + "production": Object { + "devServerTarget": "ng-cypress:serve:production", + }, + }, + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "devServerTarget": "ng-cypress:serve", + "watch": true, + }, + }, + }, +} +`; + +exports[`workspace move to nx layout cypress should migrate e2e tests correctly 1`] = ` +Object { + "compilerOptions": Object { + "outDir": "../../dist/out-tsc", + }, + "extends": "../../tsconfig.base.json", +} +`; + +exports[`workspace move to nx layout cypress should migrate e2e tests correctly 2`] = ` +Object { + "baseUrl": "http://localhost:4200", + "fileServerFolder": ".", + "fixturesFolder": "./src/fixtures", + "integrationFolder": "./src/integration", + "pluginsFile": "./src/plugins/index.ts", + "screenshotsFolder": "../../dist/cypress/apps/myApp-e2e/screenshots", + "supportFile": "./src/support/index.ts", + "videosFolder": "../../dist/cypress/apps/myApp-e2e/videos", +} +`; + +exports[`workspace move to nx layout cypress should migrate e2e tests correctly 3`] = ` +Object { + "implicitDependencies": Array [ + "myApp", + ], + "projectType": "application", + "root": "apps/myApp-e2e", + "sourceRoot": "apps/myApp-e2e/src", + "tags": Array [], + "targets": Object { + "cypress-open": Object { + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "watch": true, + }, + }, + "cypress-run": Object { + "configurations": Object { + "production": Object { + "devServerTarget": "ng-cypress:serve:production", + }, + }, + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "devServerTarget": "ng-cypress:serve", + }, + }, + "e2e": Object { + "configurations": Object { + "production": Object { + "devServerTarget": "ng-cypress:serve:production", + }, + }, + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "devServerTarget": "ng-cypress:serve", + "watch": true, + }, + }, + }, +} +`; + +exports[`workspace move to nx layout cypress should migrate e2e tests when configFile is set to false and there is no cypress.json 1`] = ` +Object { + "chromeWebSecurity": false, + "fileServerFolder": ".", + "fixturesFolder": "./src/fixtures", + "integrationFolder": "./src/integration", + "modifyObstructiveCode": false, + "pluginsFile": "./src/plugins/index.ts", + "screenshotsFolder": "../../dist/cypress/apps/myApp-e2e/screenshots", + "supportFile": "./src/support/index.ts", + "video": true, + "videosFolder": "../../dist/cypress/apps/myApp-e2e/videos", +} +`; + +exports[`workspace move to nx layout cypress should migrate e2e tests when configFile is set to false and there is no cypress.json 2`] = ` +Object { + "implicitDependencies": Array [ + "myApp", + ], + "projectType": "application", + "root": "apps/myApp-e2e", + "sourceRoot": "apps/myApp-e2e/src", + "tags": Array [], + "targets": Object { + "cypress-open": Object { + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "watch": true, + }, + }, + "cypress-run": Object { + "configurations": Object { + "production": Object { + "devServerTarget": "ng-cypress:serve:production", + }, + }, + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "devServerTarget": "ng-cypress:serve", + }, + }, + "e2e": Object { + "configurations": Object { + "production": Object { + "devServerTarget": "ng-cypress:serve:production", + }, + }, + "executor": "@nrwl/cypress:cypress", + "options": Object { + "cypressConfig": "apps/myApp-e2e/cypress.json", + "devServerTarget": "ng-cypress:serve", + "watch": true, + }, + }, + }, +} +`; + exports[`workspace move to nx layout should create nx.json 1`] = ` Object { "affected": Object { diff --git a/packages/workspace/src/generators/init/init.spec.ts b/packages/workspace/src/generators/init/init.spec.ts index c58a034cc5ea1..18675dc38d7c3 100644 --- a/packages/workspace/src/generators/init/init.spec.ts +++ b/packages/workspace/src/generators/init/init.spec.ts @@ -91,19 +91,55 @@ describe('workspace', () => { await initGenerator(tree, { name: 'proj1' }); } catch (e) { expect(e.message).toContain( - 'An e2e project was specified but e2e/protractor.conf.js could not be found.' + 'An e2e project with Protractor was found but "e2e/protractor.conf.js" could not be found.' ); } }); - it('should not error if project does not use protractor', async () => { - tree.delete('/e2e/protractor.conf.js'); + it('should error when using cypress and cypress.json is not found', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + project.targets.e2e.executor = '@cypress/schematic:cypress'; + updateProjectConfiguration(tree, 'myApp', project); - const proj = readProjectConfiguration(tree, 'myApp'); - proj.targets.e2e.executor = '@nrwl/cypress'; - updateProjectConfiguration(tree, 'myApp', proj); + await expect(initGenerator(tree, { name: 'myApp' })).rejects.toThrow( + 'An e2e project with Cypress was found but "cypress.json" could not be found.' + ); + }); - await initGenerator(tree, { name: 'myApp' }); + it('should error when using cypress and the specified cypress config file is not found', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + project.targets.e2e = { + executor: '@cypress/schematic:cypress', + options: { + configFile: 'cypress.config.json', + }, + }; + updateProjectConfiguration(tree, 'myApp', project); + + await expect(initGenerator(tree, { name: 'myApp' })).rejects.toThrow( + 'An e2e project with Cypress was found but "cypress.config.json" could not be found.' + ); + }); + + it('should error when using cypress and the cypress folder is not found', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + project.targets.e2e.executor = '@cypress/schematic:cypress'; + updateProjectConfiguration(tree, 'myApp', project); + tree.write('cypress.json', '{}'); + + await expect(initGenerator(tree, { name: 'myApp' })).rejects.toThrow( + 'An e2e project with Cypress was found but the "cypress" directory could not be found.' + ); + }); + + it('should error when having an e2e project with an unknown executor', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + project.targets.e2e.executor = '@my-org/my-package:my-executor'; + updateProjectConfiguration(tree, 'myApp', project); + + await expect(initGenerator(tree, { name: 'myApp' })).rejects.toThrow( + `An e2e project was found but it's using an unsupported executor "@my-org/my-package:my-executor".` + ); }); it('should error if no angular.json is present', async () => { @@ -185,6 +221,7 @@ describe('workspace', () => { }, }, e2e: { + builder: '@angular-devkit/build-angular:protractor', options: { protractorConfig: 'projects/myApp/e2e/protractor.conf.js', }, @@ -264,6 +301,7 @@ describe('workspace', () => { }, }, e2e: { + builder: '@angular-devkit/build-angular:protractor', options: { protractorConfig: 'e2e/protractor.conf.js', }, @@ -319,6 +357,7 @@ describe('workspace', () => { }, }, e2e: { + builder: '@angular-devkit/build-angular:protractor', options: { protractorConfig: 'projects/myApp/e2e/protractor.conf.js', }, @@ -397,6 +436,149 @@ describe('workspace', () => { expect(tree.exists('/tslint.json')).toBe(false); }); + + describe('cypress', () => { + beforeEach(() => { + tree.write( + '/angular.json', + JSON.stringify({ + version: 1, + defaultProject: 'myApp', + projects: { + myApp: { + root: '', + sourceRoot: 'src', + architect: { + build: { + options: { + tsConfig: 'tsconfig.app.json', + }, + configurations: {}, + }, + test: { + options: { + tsConfig: 'tsconfig.spec.json', + }, + }, + 'cypress-run': { + builder: '@cypress/schematic:cypress', + options: { + devServerTarget: 'ng-cypress:serve', + }, + configurations: { + production: { + devServerTarget: 'ng-cypress:serve:production', + }, + }, + }, + 'cypress-open': { + builder: '@cypress/schematic:cypress', + options: { + watch: true, + headless: false, + }, + }, + e2e: { + builder: '@cypress/schematic:cypress', + options: { + devServerTarget: 'ng-cypress:serve', + watch: true, + headless: false, + }, + configurations: { + production: { + devServerTarget: 'ng-cypress:serve:production', + }, + }, + }, + }, + }, + }, + }) + ); + + tree.write( + 'cypress.json', + JSON.stringify({ + integrationFolder: 'cypress/integration', + supportFile: 'cypress/support/index.ts', + videosFolder: 'cypress/videos', + screenshotsFolder: 'cypress/screenshots', + pluginsFile: 'cypress/plugins/index.ts', + fixturesFolder: 'cypress/fixtures', + baseUrl: 'http://localhost:4200', + }) + ); + tree.write('/cypress/fixtures/example.json', '{}'); + tree.write('/cypress/integration/spec.ts', '// content'); + tree.write('/cypress/plugins/index.ts', '// content'); + tree.write('/cypress/support/commands.ts', '// content'); + tree.write('/cypress/support/index.ts', '// content'); + tree.write('/cypress/tsconfig.json', '{"extends": "../tsconfig.json"}'); + }); + + it('should migrate e2e tests correctly', async () => { + await initGenerator(tree, { name: 'myApp' }); + + expect(tree.exists('cypress.json')).toBe(false); + expect(tree.exists('cypress')).toBe(false); + expect(tree.exists('/apps/myApp-e2e/tsconfig.json')).toBe(true); + expect( + readJson(tree, '/apps/myApp-e2e/tsconfig.json') + ).toMatchSnapshot(); + expect(tree.exists('/apps/myApp-e2e/cypress.json')).toBe(true); + expect( + readJson(tree, '/apps/myApp-e2e/cypress.json') + ).toMatchSnapshot(); + expect(tree.exists('/apps/myApp-e2e/src/fixtures/example.json')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/integration/spec.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/plugins/index.ts')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src/support/commands.ts')).toBe( + true + ); + expect(tree.exists('/apps/myApp-e2e/src/support/index.ts')).toBe(true); + expect(readProjectConfiguration(tree, 'myApp-e2e')).toMatchSnapshot(); + }); + + it('should migrate e2e tests when configFile is set to false and there is no cypress.json', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + project.targets.e2e.options.configFile = false; + updateProjectConfiguration(tree, 'myApp', project); + tree.delete('cypress.json'); + + await initGenerator(tree, { name: 'myApp' }); + + expect(tree.exists('cypress.json')).toBe(false); + expect(tree.exists('cypress')).toBe(false); + expect(tree.exists('/apps/myApp-e2e/tsconfig.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/cypress.json')).toBe(true); + expect( + readJson(tree, '/apps/myApp-e2e/cypress.json') + ).toMatchSnapshot(); + expect(tree.exists('/apps/myApp-e2e/src')).toBe(true); + expect(readProjectConfiguration(tree, 'myApp-e2e')).toMatchSnapshot(); + }); + + it('should handle project configuration without cypress-run or cypress-open', async () => { + const project = readProjectConfiguration(tree, 'myApp'); + delete project.targets['cypress-run']; + delete project.targets['cypress-open']; + updateProjectConfiguration(tree, 'myApp', project); + + await initGenerator(tree, { name: 'myApp' }); + + expect(tree.exists('cypress.json')).toBe(false); + expect(tree.exists('cypress')).toBe(false); + expect(tree.exists('/apps/myApp-e2e/tsconfig.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/cypress.json')).toBe(true); + expect(tree.exists('/apps/myApp-e2e/src')).toBe(true); + expect(readProjectConfiguration(tree, 'myApp-e2e')).toMatchSnapshot(); + }); + }); }); describe('preserve angular cli layout', () => { diff --git a/packages/workspace/src/generators/init/init.ts b/packages/workspace/src/generators/init/init.ts index 2b3cf91011526..3de10d9185433 100755 --- a/packages/workspace/src/generators/init/init.ts +++ b/packages/workspace/src/generators/init/init.ts @@ -21,9 +21,11 @@ import { updateWorkspaceConfiguration, visitNotIgnoredFiles, writeJson, + ProjectConfiguration, + TargetConfiguration, } from '@nrwl/devkit'; import { readFileSync } from 'fs'; -import { basename } from 'path'; +import { basename, relative } from 'path'; import { resolveUserExistingPrettierConfig } from '../../utilities/prettier'; import { deduceDefaultBase } from '../../utilities/default-base'; import { @@ -58,13 +60,12 @@ function updatePackageJson(tree) { 'workspace-schematic': 'nx workspace-schematic', help: 'nx help', }; - packageJson.devDependencies = packageJson.devDependencies || {}; - if (!packageJson.dependencies) { - packageJson.dependencies = {}; - } + packageJson.devDependencies = packageJson.devDependencies ?? {}; + packageJson.dependencies = packageJson.dependencies ?? {}; if (!packageJson.dependencies['@nrwl/angular']) { packageJson.dependencies['@nrwl/angular'] = nxVersion; } + delete packageJson.dependencies['@nrwl/workspace']; if (!packageJson.devDependencies['@nrwl/workspace']) { packageJson.devDependencies['@nrwl/workspace'] = nxVersion; } @@ -187,28 +188,70 @@ function updateAngularCLIJson(host: Tree, options: Schema) { if (defaultProject.targets.e2e) { const lintTargetOptions = lintTarget ? lintTarget.options : {}; - addProjectConfiguration(host, e2eName, { - root: e2eRoot, - projectType: 'application', - targets: { - e2e: { - ...defaultProject.targets.e2e, - options: { - ...defaultProject.targets.e2e.options, - protractorConfig: joinPathFragments(e2eRoot, 'protractor.conf.js'), + + if (isProtractorE2eProject(defaultProject)) { + addProjectConfiguration(host, e2eName, { + root: e2eRoot, + projectType: 'application', + targets: { + e2e: { + ...defaultProject.targets.e2e, + options: { + ...defaultProject.targets.e2e.options, + protractorConfig: joinPathFragments( + e2eRoot, + 'protractor.conf.js' + ), + }, }, - }, - lint: { - executor: '@angular-devkit/build-angular:tslint', - options: { - ...lintTargetOptions, - tsConfig: joinPathFragments(e2eRoot, 'tsconfig.json'), + lint: { + executor: '@angular-devkit/build-angular:tslint', + options: { + ...lintTargetOptions, + tsConfig: joinPathFragments(e2eRoot, 'tsconfig.json'), + }, }, }, - }, - implicitDependencies: [appName], - tags: [], - }); + implicitDependencies: [appName], + tags: [], + }); + } else if (isCypressE2eProject(defaultProject)) { + const cypressConfig = joinPathFragments( + e2eRoot, + basename(getCypressConfigFile(defaultProject) ?? 'cypress.json') + ); + + const e2eProjectConfig: ProjectConfiguration = { + root: e2eRoot, + sourceRoot: joinPathFragments(e2eRoot, 'src'), + projectType: 'application', + targets: { + e2e: updateE2eCypressTarget( + defaultProject.targets.e2e, + cypressConfig + ), + }, + implicitDependencies: [appName], + tags: [], + }; + + if (defaultProject.targets['cypress-run']) { + e2eProjectConfig.targets['cypress-run'] = updateE2eCypressTarget( + defaultProject.targets['cypress-run'], + cypressConfig + ); + } + if (defaultProject.targets['cypress-open']) { + e2eProjectConfig.targets['cypress-open'] = updateE2eCypressTarget( + defaultProject.targets['cypress-open'], + cypressConfig + ); + } + + addProjectConfiguration(host, e2eName, e2eProjectConfig); + delete defaultProject.targets['cypress-run']; + delete defaultProject.targets['cypress-open']; + } delete defaultProject.targets.e2e; } @@ -237,6 +280,48 @@ function updateAngularCLIJson(host: Tree, options: Schema) { } } +function getCypressConfigFile( + e2eProject: ProjectConfiguration +): string | undefined { + let cypressConfig = 'cypress.json'; + const configFileOption = e2eProject.targets.e2e.options.configFile; + if (configFileOption === false) { + cypressConfig = undefined; + } else if (typeof configFileOption === 'string') { + cypressConfig = basename(configFileOption); + } + + return cypressConfig; +} + +function updateE2eCypressTarget( + target: TargetConfiguration, + cypressConfig: string +): TargetConfiguration { + const updatedTarget = { + ...target, + executor: '@nrwl/cypress:cypress', + options: { + ...target.options, + cypressConfig, + }, + }; + delete updatedTarget.options.configFile; + delete updatedTarget.options.tsConfig; + + if (updatedTarget.options.headless && updatedTarget.options.watch) { + updatedTarget.options.headed = false; + } else if ( + updatedTarget.options.headless === false && + !updatedTarget.options.watch + ) { + updatedTarget.options.headed = true; + } + delete updatedTarget.options.headless; + + return updatedTarget; +} + function updateTsConfig(host: Tree) { updateJson(host, 'nx.json', (json) => { json.implicitDependencies['tsconfig.base.json'] = '*'; @@ -284,7 +369,11 @@ function updateTsConfigsJson(host: Tree, options: Schema) { } if (!!e2eProject) { - updateJson(host, e2eProject.targets.lint.options.tsConfig, (json) => { + const tsConfig = isProtractorE2eProject(e2eProject) + ? e2eProject.targets.lint.options.tsConfig + : joinPathFragments(e2eProject.root, 'tsconfig.json'); + + updateJson(host, tsConfig, (json) => { json.extends = `${offsetFromRoot(e2eProject.root)}${tsConfigPath}`; json.compilerOptions = { ...json.compilerOptions, @@ -379,9 +468,19 @@ function getE2eProject(host: Tree) { } } +function isCypressE2eProject(e2eProject: ProjectConfiguration): boolean { + return e2eProject.targets.e2e.executor === '@cypress/schematic:cypress'; +} + +function isProtractorE2eProject(e2eProject: ProjectConfiguration): boolean { + return ( + e2eProject.targets.e2e.executor === + '@angular-devkit/build-angular:protractor' + ); +} + function moveExistingFiles(host: Tree, options: Schema) { const app = readProjectConfiguration(host, options.name); - const e2eApp = getE2eProject(host); // it is not required to have a browserslist moveOutOfSrc(host, options.name, 'browserslist', false); @@ -407,17 +506,126 @@ function moveExistingFiles(host: Tree, options: Schema) { const oldAppSourceRoot = app.sourceRoot; const newAppSourceRoot = joinPathFragments('apps', options.name, 'src'); renameDirSyncInTree(host, oldAppSourceRoot, newAppSourceRoot); - if (e2eApp) { - const oldE2eRoot = joinPathFragments(app.root || '', 'e2e'); - const newE2eRoot = joinPathFragments('apps', `${getE2eKey(host)}-e2e`); - renameDirSyncInTree(host, oldE2eRoot, newE2eRoot); - } else { + + moveE2eProjectFiles(host, app, options); +} + +function moveE2eProjectFiles( + tree: Tree, + app: ProjectConfiguration, + options: Schema +): void { + const e2eProject = getE2eProject(tree); + + if (!e2eProject) { console.warn( - 'No e2e project was migrated because there was none declared in angular.json' + 'No e2e project was migrated because there was none declared in angular.json.' ); + return; } - return host; + if (isProtractorE2eProject(e2eProject)) { + const oldE2eRoot = joinPathFragments(app.root || '', 'e2e'); + const newE2eRoot = joinPathFragments('apps', `${getE2eKey(tree)}-e2e`); + renameDirSyncInTree(tree, oldE2eRoot, newE2eRoot); + } else if (isCypressE2eProject(e2eProject)) { + const e2eProjectName = `${options.name}-e2e`; + const configFile = getCypressConfigFile(e2eProject); + const oldE2eRoot = 'cypress'; + const newE2eRoot = joinPathFragments('apps', e2eProjectName); + if (configFile) { + updateCypressConfigFilePaths(tree, configFile, oldE2eRoot, newE2eRoot); + moveOutOfSrc(tree, e2eProjectName, configFile); + } else { + tree.write( + joinPathFragments('apps', e2eProjectName, 'cypress.json'), + JSON.stringify({ + fileServerFolder: '.', + fixturesFolder: './src/fixtures', + integrationFolder: './src/integration', + modifyObstructiveCode: false, + supportFile: './src/support/index.ts', + pluginsFile: './src/plugins/index.ts', + video: true, + videosFolder: `../../dist/cypress/${newE2eRoot}/videos`, + screenshotsFolder: `../../dist/cypress/${newE2eRoot}/screenshots`, + chromeWebSecurity: false, + }) + ); + } + moveOutOfSrc(tree, e2eProjectName, `${oldE2eRoot}/tsconfig.json`); + renameDirSyncInTree( + tree, + oldE2eRoot, + joinPathFragments('apps', e2eProjectName, 'src') + ); + } +} + +function updateCypressConfigFilePaths( + tree: Tree, + configFile: string, + oldE2eRoot: string, + newE2eRoot: string +): void { + const srcFoldersAndFiles = [ + 'integrationFolder', + 'supportFile', + 'pluginsFile', + 'fixturesFolder', + ]; + const distFolders = ['videosFolder', 'screenshotsFolder']; + const stringOrArrayGlobs = ['ignoreTestFiles', 'testFiles']; + + const cypressConfig = readJson(tree, configFile); + + cypressConfig.fileServerFolder = '.'; + srcFoldersAndFiles.forEach((folderOrFile) => { + if (cypressConfig[folderOrFile]) { + cypressConfig[folderOrFile] = `./src/${relative( + oldE2eRoot, + cypressConfig[folderOrFile] + )}`; + } + }); + + distFolders.forEach((folder) => { + if (cypressConfig[folder]) { + cypressConfig[folder] = `../../dist/cypress/${newE2eRoot}/${relative( + oldE2eRoot, + cypressConfig[folder] + )}`; + } + }); + + stringOrArrayGlobs.forEach((stringOrArrayGlob) => { + if (!cypressConfig[stringOrArrayGlob]) { + return; + } + + if (Array.isArray(cypressConfig[stringOrArrayGlob])) { + cypressConfig[stringOrArrayGlob] = cypressConfig[stringOrArrayGlob].map( + (glob: string) => replaceCypressGlobConfig(glob, oldE2eRoot) + ); + } else { + cypressConfig[stringOrArrayGlob] = replaceCypressGlobConfig( + cypressConfig[stringOrArrayGlob], + oldE2eRoot + ); + } + }); + + writeJson(tree, configFile, cypressConfig); +} + +function replaceCypressGlobConfig( + globPattern: string, + oldE2eRoot: string +): string { + return globPattern.replace( + new RegExp(`^(\\.\\/|\\/)?${oldE2eRoot}\\/`), + './src/' + ); } async function createAdditionalFiles(host: Tree, options: Schema) { @@ -495,23 +703,47 @@ function checkCanConvertToWorkspace(host: Tree) { if (Object.keys(workspaceJson.projects).length > 2 || hasLibraries) { throw new Error('Can only convert projects with one app'); } + const e2eKey = getE2eKey(host); const e2eApp = getE2eProject(host); - if ( - e2eApp && - e2eApp.targets.e2e.executor === - '@angular-devkit/build-angular:protractor' && - !host.exists(e2eApp.targets.e2e.options.protractorConfig) - ) { + + if (!e2eApp) { + return; + } + + if (isProtractorE2eProject(e2eApp)) { + if (host.exists(e2eApp.targets.e2e.options.protractorConfig)) { + return; + } + console.info( - `Make sure the ${e2eKey}.architect.e2e.options.protractorConfig is valid or the ${e2eKey} project is removed from angular.json.` + `Make sure the "${e2eKey}.architect.e2e.options.protractorConfig" is valid or the "${e2eKey}" project is removed from "angular.json".` ); throw new Error( - `An e2e project was specified but ${e2eApp.targets.e2e.options.protractorConfig} could not be found.` + `An e2e project with Protractor was found but "${e2eApp.targets.e2e.options.protractorConfig}" could not be found.` ); } - return host; + if (isCypressE2eProject(e2eApp)) { + const configFile = getCypressConfigFile(e2eApp); + if (configFile && !host.exists(configFile)) { + throw new Error( + `An e2e project with Cypress was found but "${configFile}" could not be found.` + ); + } + + if (!host.exists('cypress')) { + throw new Error( + `An e2e project with Cypress was found but the "cypress" directory could not be found.` + ); + } + + return; + } + + throw new Error( + `An e2e project was found but it's using an unsupported executor "${e2eApp.targets.e2e.executor}".` + ); } catch (e) { console.error(e.message); console.error( @@ -631,9 +863,7 @@ function renameDirSyncInTree(tree: Tree, from: string, to: string) { export async function initGenerator(tree: Tree, schema: Schema) { if (schema.preserveAngularCliLayout) { updateJson(tree, 'package.json', (json) => { - if (json.dependencies?.['@nrwl/workspace']) { - delete json.dependencies['@nrwl/workspace']; - } + delete json.dependencies?.['@nrwl/workspace']; return json; }); addDependenciesToPackageJson(tree, {}, { '@nrwl/workspace': nxVersion });