From 0bc93ee83d710fc15d705cbdc5dae71abe321452 Mon Sep 17 00:00:00 2001 From: Caleb Ukle Date: Tue, 10 Jan 2023 18:48:29 -0600 Subject: [PATCH] feat(testing): Cypress 12 Support (#14058) --- packages/cypress/migrations.json | 6 + packages/cypress/package.json | 2 +- .../migrate-to-cypress-11.ts | 2 +- .../update-to-cypress-12.spec.ts.snap | 87 +++ .../src/migrations/update-15-5-0/helpers.ts | 38 + .../update-to-cypress-12.spec.ts | 717 ++++++++++++++++++ .../update-15-5-0/update-to-cypress-12.ts | 245 ++++++ packages/cypress/src/utils/versions.ts | 2 +- 8 files changed, 1096 insertions(+), 3 deletions(-) create mode 100644 packages/cypress/src/migrations/update-15-5-0/__snapshots__/update-to-cypress-12.spec.ts.snap create mode 100644 packages/cypress/src/migrations/update-15-5-0/helpers.ts create mode 100644 packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.spec.ts create mode 100644 packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.ts diff --git a/packages/cypress/migrations.json b/packages/cypress/migrations.json index b228458191715..03e377d6601f6 100644 --- a/packages/cypress/migrations.json +++ b/packages/cypress/migrations.json @@ -35,6 +35,12 @@ "version": "15.1.0-beta.0", "description": "Update to Cypress v11. This migration will only update if the workspace is already on v10. https://www.cypress.io/blog/2022/11/04/upcoming-changes-to-component-testing/", "factory": "./src/migrations/update-15-1-0/cypress-11" + }, + "update-to-cypress-12": { + "cli": "nx", + "version": "15.5.0-beta.0", + "description": "Update to Cypress v12. Cypress 12 contains a handful of breaking changes that might causes tests to start failing that nx cannot directly fix. Read more Cypress 12 changes: https://docs.cypress.io/guides/references/migration-guide#Migrating-to-Cypress-12-0.This migration will only run if you are already using Cypress v11.", + "factory": "./src/migrations/update-15-5-0/update-to-cypress-12" } }, "packageJsonUpdates": {} diff --git a/packages/cypress/package.json b/packages/cypress/package.json index c8b0a7c6d4c67..804ab662e87c6 100644 --- a/packages/cypress/package.json +++ b/packages/cypress/package.json @@ -43,7 +43,7 @@ "semver": "7.3.4" }, "peerDependencies": { - "cypress": ">= 3 < 12" + "cypress": ">= 3 < 13" }, "peerDependenciesMeta": { "cypress": { diff --git a/packages/cypress/src/generators/migrate-to-cypress-11/migrate-to-cypress-11.ts b/packages/cypress/src/generators/migrate-to-cypress-11/migrate-to-cypress-11.ts index a29b8bb0af2a5..844fe4e3d79c6 100644 --- a/packages/cypress/src/generators/migrate-to-cypress-11/migrate-to-cypress-11.ts +++ b/packages/cypress/src/generators/migrate-to-cypress-11/migrate-to-cypress-11.ts @@ -120,7 +120,7 @@ https://nx.dev/cypress/v10-migration-guide ); updateJson(tree, 'package.json', (json) => { - json.devDependencies['cypress'] = cypressVersion; + json.devDependencies['cypress'] = '^11.2.0'; return json; }); diff --git a/packages/cypress/src/migrations/update-15-5-0/__snapshots__/update-to-cypress-12.spec.ts.snap b/packages/cypress/src/migrations/update-15-5-0/__snapshots__/update-to-cypress-12.spec.ts.snap new file mode 100644 index 0000000000000..b6dfeb09c74f7 --- /dev/null +++ b/packages/cypress/src/migrations/update-15-5-0/__snapshots__/update-to-cypress-12.spec.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Cypress 12 Migration should migrate to cy 12 1`] = ` +"describe('something', () => { + it('should do the thing', () => { + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes +Cypress.Cookies.defaults() + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes +Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +cy.server() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + /** +* TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. +* You should use .then() to chain commands instead. +* More Info: https://docs.cypress.io/guides/references/migration-guide#-should +**/ +cy.should(($s) => { + cy.get('@table').find('tr').should('have.length', 3) +}) + }) +})" +`; + +exports[`Cypress 12 Migration should migrate to cy 12 2`] = ` +"describe('something', () => { + it('should do the thing', () => { + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes +Cypress.Cookies.defaults() + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes +Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +cy.server() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + /** +* TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. +* You should use .then() to chain commands instead. +* More Info: https://docs.cypress.io/guides/references/migration-guide#-should +**/ +cy.should(($s) => { + cy.get('@table').find('tr').should('have.length', 3) +}) + }) +})" +`; diff --git a/packages/cypress/src/migrations/update-15-5-0/helpers.ts b/packages/cypress/src/migrations/update-15-5-0/helpers.ts new file mode 100644 index 0000000000000..50f2712987d2c --- /dev/null +++ b/packages/cypress/src/migrations/update-15-5-0/helpers.ts @@ -0,0 +1,38 @@ +import type { Node } from 'typescript'; + +export function isAlreadyCommented(node: Node) { + return node.getFullText().includes('TODO(@nrwl/cypress)'); +} + +export const BANNED_COMMANDS = [ + 'as', + 'children', + 'closest', + 'contains', + 'debug', + 'document', + 'eq', + 'filter', + 'find', + 'first', + 'focused', + 'get', + 'hash', + 'its', + 'last', + 'location', + 'next', + 'nextAll', + 'not', + 'parent', + 'parents', + 'parentsUntil', + 'prev', + 'prevUntil', + 'root', + 'shadow', + 'siblings', + 'title', + 'url', + 'window', +]; diff --git a/packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.spec.ts b/packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.spec.ts new file mode 100644 index 0000000000000..c1e4872c848af --- /dev/null +++ b/packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.spec.ts @@ -0,0 +1,717 @@ +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { + addProjectConfiguration, + stripIndents, + Tree, + readJson, +} from '@nrwl/devkit'; +import { + shouldNotOverrideCommands, + shouldNotUseCyInShouldCB, + shouldUseCyIntercept, + shouldUseCySession, + turnOffTestIsolation, + updateToCypress12, +} from './update-to-cypress-12'; +import { installedCypressVersion } from '../../utils/cypress-version'; +jest.mock('../../utils/cypress-version'); + +describe('Cypress 12 Migration', () => { + let tree: Tree; + let mockInstalledCypressVersion: jest.Mock< + ReturnType + > = installedCypressVersion as never; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + jest.resetAllMocks(); + }); + + it('should migrate to cy 12', () => { + mockInstalledCypressVersion.mockReturnValue(11); + addCypressProject(tree, 'my-app-e2e'); + addCypressProject(tree, 'my-other-app-e2e'); + updateToCypress12(tree); + assertMigration(tree, 'my-app-e2e'); + assertMigration(tree, 'my-other-app-e2e'); + const pkgJson = readJson(tree, 'package.json'); + expect(pkgJson.devDependencies['cypress']).toEqual('^12.2.0'); + }); + + it('should not migrate if cypress version is < 11', () => { + mockInstalledCypressVersion.mockReturnValue(10); + addCypressProject(tree, 'my-app-e2e'); + updateToCypress12(tree); + expect(tree.read('apps/my-app-e2e/cypress.config.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { defineConfig } from 'cypress'; + import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + + export default defineConfig({ + e2e: nxE2EPreset(__filename) + })" + `); + }); + + describe('nest cypress commands in should callback', () => { + beforeEach(() => { + tree.write( + 'should-callback.ts', + `describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + cy.server() + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + cy.should((b) => { + const a = 123; + // I'm not doing nested cy stuff + }); + cy.should(($s) => { + cy.task(""); + }) + cy.should(function($el) { + cy.task(""); + }) + }) +}) +` + ); + }); + it('should comment', () => { + shouldNotUseCyInShouldCB(tree, 'should-callback.ts'); + expect(tree.read('should-callback.ts', 'utf-8')).toMatchInlineSnapshot(` + "describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + cy.server() + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + cy.should((b) => { + const a = 123; + // I'm not doing nested cy stuff + }); + /** + * TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. + * You should use .then() to chain commands instead. + * More Info: https://docs.cypress.io/guides/references/migration-guide#-should + **/ + cy.should(($s) => { + cy.task(\\"\\"); + }) + /** + * TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. + * You should use .then() to chain commands instead. + * More Info: https://docs.cypress.io/guides/references/migration-guide#-should + **/ + cy.should(function($el) { + cy.task(\\"\\"); + }) + }) + }) + " + `); + }); + + it('should be idempotent', () => { + const expected = `describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + cy.server() + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + cy.should((b) => { + const a = 123; + // I'm not doing nested cy stuff + }); + /** +* TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. +* You should use .then() to chain commands instead. +* More Info: https://docs.cypress.io/guides/references/migration-guide#-should +**/ +cy.should(($s) => { + cy.task(""); + }) + /** +* TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. +* You should use .then() to chain commands instead. +* More Info: https://docs.cypress.io/guides/references/migration-guide#-should +**/ +cy.should(function($el) { + cy.task(""); + }) + }) +}) +`; + shouldNotUseCyInShouldCB(tree, 'should-callback.ts'); + expect(tree.read('should-callback.ts', 'utf-8')).toEqual(expected); + shouldNotUseCyInShouldCB(tree, 'should-callback.ts'); + expect(tree.read('should-callback.ts', 'utf-8')).toEqual(expected); + }); + }); + describe('banned Cypres.Commands.overwrite', () => { + beforeEach(() => { + tree.write( + 'commands.ts', + `declare namespace Cypress { + interface Chainable { + login(email: string, password: string): void; + } +} +// +// -- This is a parent command -- +Cypress.Commands.add('login', (email, password) => { + console.log('Custom command example: Login', email, password); +}); +Cypress.Commands.overwrite('find', () => {}); +` + ); + }); + it('should comment', () => { + shouldNotOverrideCommands(tree, 'commands.ts'); + expect(tree.read('commands.ts', 'utf-8')).toMatchInlineSnapshot(` + "declare namespace Cypress { + interface Chainable { + login(email: string, password: string): void; + } + } + // + // -- This is a parent command -- + Cypress.Commands.add('login', (email, password) => { + console.log('Custom command example: Login', email, password); + }); + /** + * TODO(@nrwl/cypress): This command can no longer be overridden + * Consider using a different name like 'custom_find' + * More info: https://docs.cypress.io/guides/references/migration-guide#Cypress-Commands-overwrite + **/ + Cypress.Commands.overwrite('find', () => {}); + " + `); + }); + + it('should be idempotent', () => { + const expected = `declare namespace Cypress { + interface Chainable { + login(email: string, password: string): void; + } +} +// +// -- This is a parent command -- +Cypress.Commands.add('login', (email, password) => { + console.log('Custom command example: Login', email, password); +}); +/** +* TODO(@nrwl/cypress): This command can no longer be overridden +* Consider using a different name like 'custom_find' +* More info: https://docs.cypress.io/guides/references/migration-guide#Cypress-Commands-overwrite +**/ +Cypress.Commands.overwrite('find', () => {}); +`; + + shouldNotOverrideCommands(tree, 'commands.ts'); + expect(tree.read('commands.ts', 'utf-8')).toEqual(expected); + shouldNotOverrideCommands(tree, 'commands.ts'); + expect(tree.read('commands.ts', 'utf-8')).toEqual(expected); + }); + }); + describe('api removal', () => { + it('should be idempotent', () => { + tree.write( + 'my-cool-test.cy.ts', + ` +describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + cy.server() + cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) +}) +` + ); + const expected = stripIndents`describe('something', () => { + it('should do the thing', () => { + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes + Cypress.Cookies.defaults() + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults + cy.server() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults + cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) + })`; + shouldUseCyIntercept(tree, 'my-cool-test.cy.ts'); + shouldUseCySession(tree, 'my-cool-test.cy.ts'); + expect(stripIndents`${tree.read('my-cool-test.cy.ts', 'utf-8')}`).toEqual( + expected + ); + + shouldUseCyIntercept(tree, 'my-cool-test.cy.ts'); + shouldUseCySession(tree, 'my-cool-test.cy.ts'); + expect(stripIndents`${tree.read('my-cool-test.cy.ts', 'utf-8')}`).toEqual( + expected + ); + }); + it('comment on cy.route,cy.server, & Cypress.Server.defaults usage', () => { + tree.write( + 'my-cool-test.cy.ts', + ` +describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + cy.server() + cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) +}) +` + ); + shouldUseCyIntercept(tree, 'my-cool-test.cy.ts'); + expect(tree.read('my-cool-test.cy.ts', 'utf-8')).toMatchInlineSnapshot(` + " + describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults + cy.server() + // TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults + cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) + }) + " + `); + }); + + it('comment on Cypress.Cookies.defaults & Cypress.Cookies.preserveOnce', () => { + tree.write( + 'my-cool-test.cy.ts', + ` +describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + cy.server() + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) +}) +` + ); + shouldUseCySession(tree, 'my-cool-test.cy.ts'); + expect(tree.read('my-cool-test.cy.ts', 'utf-8')).toMatchInlineSnapshot(` + " + describe('something', () => { + it('should do the thing', () => { + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes + Cypress.Cookies.defaults() + // TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + cy.server() + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) + }) + " + `); + }); + }); + describe('testIsolation', () => { + it('should be idempotent', () => { + const content = ` +import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename), + testIsolation: true, +}) +`; + tree.write('my-cypress.config.ts', content); + turnOffTestIsolation(tree, 'my-cypress.config.ts'); + + expect(tree.read('my-cypress.config.ts', 'utf-8')).toEqual(content); + }); + it('should add testIsolation: false to the default e2e config', () => { + tree.write( + 'my-cypress.config.ts', + ` +import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: nxE2EPreset(__filename), +}) +` + ); + turnOffTestIsolation(tree, 'my-cypress.config.ts'); + expect(tree.read('my-cypress.config.ts', 'utf-8')).toMatchInlineSnapshot(` + " + import { defineConfig } from 'cypress'; + import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename), + /** + * TODO(@nrwl/cypress): In Cypress v12,the testIsolation option is turned on by default. + * This can cause tests to start breaking where not indended. + * You should consider enabling this once you verify tests do not depend on each other + * More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation + **/ + testIsolation: false, + }, + }) + " + `); + }); + + it('should add testIsolation: false to inline object e2e config', () => { + tree.write( + 'my-cypress.config.ts', + ` +import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename), + video: false + } +}) +` + ); + turnOffTestIsolation(tree, 'my-cypress.config.ts'); + expect(tree.read('my-cypress.config.ts', 'utf-8')).toMatchInlineSnapshot(` + " + import { defineConfig } from 'cypress'; + import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename), + video: false, + /** + * TODO(@nrwl/cypress): In Cypress v12,the testIsolation option is turned on by default. + * This can cause tests to start breaking where not indended. + * You should consider enabling this once you verify tests do not depend on each other + * More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation + **/ + testIsolation: false, + } + }) + " + `); + }); + + it('should add testIsolation: false for a variable e2e config', () => { + tree.write( + 'my-cypress.config.ts', + ` +import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; +const myConfig = { + ...nxE2EPreset(__filename), + video: false + } + +export default defineConfig({ + e2e: myConfig, +}) +` + ); + turnOffTestIsolation(tree, 'my-cypress.config.ts'); + expect(tree.read('my-cypress.config.ts', 'utf-8')).toMatchInlineSnapshot(` + " + import { defineConfig } from 'cypress'; + import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + const myConfig = { + ...nxE2EPreset(__filename), + video: false + } + + export default defineConfig({ + e2e: { + ...myConfig, + /** + * TODO(@nrwl/cypress): In Cypress v12,the testIsolation option is turned on by default. + * This can cause tests to start breaking where not indended. + * You should consider enabling this once you verify tests do not depend on each other + * More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation + **/ + testIsolation: false, + }, + }) + " + `); + }); + }); +}); + +function addCypressProject(tree: Tree, name: string) { + const targets = { + e2e: { + executor: '@nrwl/cypress:cypress', + options: { + tsConfig: `apps/${name}/tsconfig.e2e.json`, + testingType: 'e2e', + browser: 'chrome', + }, + configurations: { + dev: { + cypressConfig: `apps/${name}/cypress.config.ts`, + devServerTarget: 'client:serve:dev', + baseUrl: 'http://localhost:4206', + }, + watch: { + cypressConfig: 'apps/client-e2e/cypress-custom.config.ts', + devServerTarget: 'client:serve:watch', + baseUrl: 'http://localhost:4204', + }, + }, + defaultConfiguration: 'dev', + }, + }; + addProjectConfiguration(tree, name, { + root: `apps/${name}`, + sourceRoot: `apps/${name}/src`, + projectType: 'application', + targets, + }); + // testIsolation + tree.write( + `apps/${name}/cypress.config.ts`, + `import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nrwl/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: nxE2EPreset(__filename) +})` + ); + // test Cypress.Commands.Override + tree.write( + `apps/${name}/src/support/commands.ts`, + `declare namespace Cypress { + interface Chainable { + login(email: string, password: string): void; + } +} +// +// -- This is a parent command -- +Cypress.Commands.add('login', (email, password) => { + console.log('Custom command example: Login', email, password); +}); +Cypress.Commands.overwrite('find', () => {}); +` + ); + // test .should(() => cy.) + tree.write( + `apps/${name}/src/e2e/callback.spec.ts`, + `describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + cy.server() + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + cy.should((b) => { + const a = 123; + // I'm not doing nested cy stuff + }); + cy.should(($s) => { + cy.task(""); + }) + cy.should(function($el) { + cy.task(""); + }) + }) +})` + ); + tree.write( + `apps/${name}/src/e2e/intercept-session.spec.ts`, + `describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + cy.server() + cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + }) +})` + ); + tree.write( + `apps/${name}/src/e2e/combo.spec.ts`, + `describe('something', () => { + it('should do the thing', () => { + Cypress.Cookies.defaults() + Cypress.Cookies.preserveOnce('seesion_id', 'remember-token'); + Cypress.blah.abc() + Cypress.Server.defaults({ + delay: 500, + method: 'GET', + }) + cy.server() + cy.route(/api/, () => { + return { + 'test': 'We’ll', + } + }).as('getApi') + + cy.visit('/index.html') + cy.window().then((win) => { + const xhr = new win.XMLHttpRequest + xhr.open('GET', '/api/v1/foo/bar?a=42') + xhr.send() + }) + + cy.wait('@getApi') + .its('url').should('include', 'api/v1') + cy.should(($s) => { + cy.get('@table').find('tr').should('have.length', 3) +}) + }) +})` + ); +} + +function assertMigration(tree: Tree, name: string) { + expect(tree.read(`apps/${name}/cypress.config.ts`, 'utf-8')).toContain( + 'testIsolation: false' + ); + // command overrides + expect(tree.read(`apps/${name}/src/support/commands.ts`, 'utf-8')).toContain( + 'TODO(@nrwl/cypress): This command can no longer be overridden' + ); + // test .should(() => cy.) + expect(tree.read(`apps/${name}/src/e2e/callback.spec.ts`, 'utf-8')).toContain( + 'TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws.' + ); + // use cy.intercept, cy.session + const interceptSessionSpec = tree.read( + `apps/${name}/src/e2e/intercept-session.spec.ts`, + 'utf-8' + ); + expect(interceptSessionSpec).toContain( + '// TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes' + ); + expect(interceptSessionSpec).toContain( + '// TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults' + ); + // intercept,session & callback + expect( + tree.read(`apps/${name}/src/e2e/combo.spec.ts`, 'utf-8') + ).toMatchSnapshot(); +} diff --git a/packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.ts b/packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.ts new file mode 100644 index 0000000000000..d8abda8218548 --- /dev/null +++ b/packages/cypress/src/migrations/update-15-5-0/update-to-cypress-12.ts @@ -0,0 +1,245 @@ +import { + getProjects, + stripIndents, + Tree, + updateJson, + visitNotIgnoredFiles, + installPackagesTask, + GeneratorCallback, +} from '@nrwl/devkit'; +import { forEachExecutorOptions } from '@nrwl/workspace/src/utilities/executor-options-utils'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { + CallExpression, + isArrowFunction, + isCallExpression, + isFunctionExpression, + isObjectLiteralExpression, + PropertyAccessExpression, + PropertyAssignment, +} from 'typescript'; +import { CypressExecutorOptions } from '../../executors/cypress/cypress.impl'; +import { installedCypressVersion } from '../../utils/cypress-version'; +import { BANNED_COMMANDS, isAlreadyCommented } from './helpers'; + +const JS_TS_FILE_MATCHER = /\.[jt]sx?$/; + +export function updateToCypress12(tree: Tree): GeneratorCallback { + if (installedCypressVersion() < 11) { + return; + } + const projects = getProjects(tree); + + forEachExecutorOptions( + tree, + '@nrwl/cypress:cypress', + (options, projectName, targetName, configName) => { + if (!(options.cypressConfig && tree.exists(options.cypressConfig))) { + return; + } + const projectConfig = projects.get(projectName); + turnOffTestIsolation(tree, options.cypressConfig); + + visitNotIgnoredFiles(tree, projectConfig.root, (filePath) => { + if (!JS_TS_FILE_MATCHER.test(filePath)) { + return; + } + shouldUseCyIntercept(tree, filePath); + shouldUseCySession(tree, filePath); + shouldNotUseCyInShouldCB(tree, filePath); + shouldNotOverrideCommands(tree, filePath); + }); + } + ); + + console.warn(stripIndents`Cypress 12 has lots of breaking changes that might subility break your tests. +This migration marked known issues that need to be manually migrated, +but there can still be runtime based errors that were not detected. +Please consult the offical Cypress v12 migration guide for more info on these changes and the next steps. +https://docs.cypress.io/guides/references/migration-guide + `); + + updateJson(tree, 'package.json', (json) => { + json.devDependencies.cypress = '^12.2.0'; + return json; + }); + + return () => { + installPackagesTask(tree); + }; +} + +export function turnOffTestIsolation(tree: Tree, configPath: string) { + const config = tree.read(configPath, 'utf-8'); + const isTestIsolationSet = tsquery.query( + config, + 'ExportAssignment ObjectLiteralExpression > PropertyAssignment:has(Identifier[name="testIsolation"])' + ); + + if (isTestIsolationSet.length > 0) { + return; + } + + const testIsolationProperty = `/** + * TODO(@nrwl/cypress): In Cypress v12,the testIsolation option is turned on by default. + * This can cause tests to start breaking where not indended. + * You should consider enabling this once you verify tests do not depend on each other + * More Info: https://docs.cypress.io/guides/references/migration-guide#Test-Isolation + **/ + testIsolation: false,`; + const updated = tsquery.replace( + config, + 'ExportAssignment ObjectLiteralExpression > PropertyAssignment:has(Identifier[name="e2e"])', + (node: PropertyAssignment) => { + if (isObjectLiteralExpression(node.initializer)) { + const listOfProperties = node.initializer.properties + .map((j) => j.getText()) + .join(',\n '); + return `e2e: { + ${listOfProperties}, + ${testIsolationProperty} + }`; + } + return `e2e: { + ...${node.initializer.getText()}, + ${testIsolationProperty} + }`; + } + ); + + tree.write(configPath, updated); +} + +/** + * Leave a comment on all apis that have been removed andsuperseded by cy.intercept + * stating they these API are now removed and need to update. + * cy.route, cy.server, Cypress.Server.defaults + **/ +export function shouldUseCyIntercept(tree: Tree, filePath: string) { + const content = tree.read(filePath, 'utf-8'); + const markedRemovedCommands = tsquery.replace( + content, + ':matches(PropertyAccessExpression:has(Identifier[name="cy"]):has(Identifier[name="server"], Identifier[name="route"]), PropertyAccessExpression:has(Identifier[name="defaults"]):has(Identifier[name="Cypress"], Identifier[name="Server"]))', + (node: PropertyAccessExpression) => { + if (isAlreadyCommented(node)) { + return; + } + const expression = node.expression.getText().trim(); + // prevent extra chaining i.e. cy.route().as() will return 2 results + // cy.route and cy.route().as + // only need the first 1 so skip any extra chaining + if (expression === 'cy' || expression === 'Cypress.Server') { + return `// TODO(@nrwl/cypress): this command has been removed, use cy.intercept instead. https://docs.cypress.io/guides/references/migration-guide#cy-server-cy-route-and-Cypress-Server-defaults +${node.getText()}`; + } + } + ); + + tree.write(filePath, markedRemovedCommands); +} + +/** + * Leave a comment on all apis that have been removed and superseded by cy.session + * stating they these API are now removed and need to update. + * Cypress.Cookies.defaults & Cypress.Cookies.preserveOnce + **/ +export function shouldUseCySession(tree: Tree, filePath: string) { + const content = tree.read(filePath, 'utf-8'); + const markedRemovedCommands = tsquery.replace( + content, + ':matches(PropertyAccessExpression:has(Identifier[name="defaults"]):has(Identifier[name="Cypress"], Identifier[name="Cookies"]), PropertyAccessExpression:has(Identifier[name="preserveOnce"]):has(Identifier[name="Cypress"], Identifier[name="Cookies"]))', + (node: PropertyAccessExpression) => { + if (isAlreadyCommented(node)) { + return; + } + const expression = node.expression.getText().trim(); + // prevent grabbing other Cypress..defaults + if (expression === 'Cypress.Cookies') { + return `// TODO(@nrwl/cypress): this command has been removed, use cy.session instead. https://docs.cypress.io/guides/references/migration-guide#Command-Cypress-API-Changes +${node.getText()}`; + } + } + ); + + tree.write(filePath, markedRemovedCommands); +} + +/** + * leave a comment about nested cy commands in a cy.should callback + * */ +export function shouldNotUseCyInShouldCB(tree: Tree, filePath: string) { + const content = tree.read(filePath, 'utf-8'); + const markedNestedCyCommands = tsquery.replace( + content, + 'CallExpression > PropertyAccessExpression:has(Identifier[name="cy"]):has(Identifier[name="should"])', + (node: PropertyAccessExpression) => { + if ( + isAlreadyCommented(node) || + (node.parent && !isCallExpression(node.parent)) + ) { + return; + } + const parentExpression = node.parent as CallExpression; + if ( + parentExpression?.arguments?.[0] && + (isArrowFunction(parentExpression.arguments[0]) || + isFunctionExpression(parentExpression.arguments[0])) + ) { + const isUsingNestedCyCommand = + tsquery.query( + parentExpression.arguments[0], + 'CallExpression > PropertyAccessExpression:has(Identifier[name="cy"])' + )?.length > 0; + if (isUsingNestedCyCommand) { + return `/** +* TODO(@nrwl/cypress): Nesting Cypress commands in a should assertion now throws. +* You should use .then() to chain commands instead. +* More Info: https://docs.cypress.io/guides/references/migration-guide#-should +**/ +${node.getText()}`; + } + return node.getText(); + } + } + ); + + tree.write(filePath, markedNestedCyCommands); +} + +/** + * leave a comment on all usages of overriding built-ins that are now banned + * */ +export function shouldNotOverrideCommands(tree: Tree, filePath: string) { + const content = tree.read(filePath, 'utf-8'); + const markedOverrideUsage = tsquery.replace( + content, + 'PropertyAccessExpression:has(Identifier[name="overwrite"]):has(Identifier[name="Cypress"])', + (node: PropertyAccessExpression) => { + if (isAlreadyCommented(node)) { + return; + } + const expression = node.expression.getText().trim(); + // prevent grabbing other Cypress..defaults + + if (expression === 'Cypress.Commands') { + // get value. + const overwriteExpression = node.parent as CallExpression; + + const command = (overwriteExpression.arguments?.[0] as any)?.text; // need string without quotes + if (BANNED_COMMANDS.includes(command)) { + // overwrite + return `/** +* TODO(@nrwl/cypress): This command can no longer be overridden +* Consider using a different name like 'custom_${command}' +* More info: https://docs.cypress.io/guides/references/migration-guide#Cypress-Commands-overwrite +**/ +${node.getText()}`; + } + } + } + ); + + tree.write(filePath, markedOverrideUsage); +} + +export default updateToCypress12; diff --git a/packages/cypress/src/utils/versions.ts b/packages/cypress/src/utils/versions.ts index 2e583bd757c39..34d3344b5ab4c 100644 --- a/packages/cypress/src/utils/versions.ts +++ b/packages/cypress/src/utils/versions.ts @@ -1,8 +1,8 @@ export const nxVersion = require('../../package.json').version; export const eslintPluginCypressVersion = '^2.10.3'; export const typesNodeVersion = '16.11.7'; -export const cypressVersion = '^11.0.0'; export const cypressViteDevServerVersion = '^2.2.1'; +export const cypressVersion = '^12.2.0'; export const cypressWebpackVersion = '^2.0.0'; export const webpackHttpPluginVersion = '^5.5.0'; export const viteVersion = '^4.0.1';