From 193206ac86e06d24cf8bd398027d37ed21411e0a Mon Sep 17 00:00:00 2001 From: Caleb Ukle Date: Fri, 1 Sep 2023 10:55:13 -0500 Subject: [PATCH] feat(testing): support cypress v13 (#18899) --- .../packages/cypress/executors/cypress.json | 4 + packages/cypress/migrations.json | 6 + packages/cypress/package.json | 2 +- packages/cypress/plugins/cypress-preset.ts | 2 - .../src/executors/cypress/cypress.impl.ts | 2 + .../cypress/src/executors/cypress/schema.json | 4 + .../update-16-8-0/cypress-13.spec.ts | 243 +++++++++++++++++ .../migrations/update-16-8-0/cypress-13.ts | 246 ++++++++++++++++++ packages/cypress/src/utils/versions.ts | 2 +- .../react/plugins/component-testing/index.ts | 1 - 10 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 packages/cypress/src/migrations/update-16-8-0/cypress-13.spec.ts create mode 100644 packages/cypress/src/migrations/update-16-8-0/cypress-13.ts diff --git a/docs/generated/packages/cypress/executors/cypress.json b/docs/generated/packages/cypress/executors/cypress.json index b4e2003f1e19d..82dbf95fbaac6 100644 --- a/docs/generated/packages/cypress/executors/cypress.json +++ b/docs/generated/packages/cypress/executors/cypress.json @@ -145,6 +145,10 @@ "type": "boolean", "description": "If passed, Cypress output will not be printed to stdout. Only output from the configured Mocha reporter will print.", "default": false + }, + "runnerUi": { + "type": "boolean", + "description": "Displays the Cypress Runner UI. Useful for when Test Replay is enabled and you would still like the Cypress Runner UI to be displayed for screenshots and video." } }, "additionalProperties": true, diff --git a/packages/cypress/migrations.json b/packages/cypress/migrations.json index 1f47185db9f53..ffd13a3e40983 100644 --- a/packages/cypress/migrations.json +++ b/packages/cypress/migrations.json @@ -59,6 +59,12 @@ "version": "16.4.0-beta.10", "description": "Remove tsconfig.e2e.json and add settings to project tsconfig.json. tsConfigs executor option is now deprecated. The project level tsconfig.json file should be used instead.", "implementation": "./src/migrations/update-16-4-0/tsconfig-sourcemaps" + }, + "update-16-8-0-cypress-13": { + "cli": "nx", + "version": "16.8.0-beta.4", + "description": "Update to Cypress v13. Most noteable change is video recording is off by default. This migration will only update if the workspace is already on Cypress v12. https://docs.cypress.io/guides/references/migration-guide#Migrating-to-Cypress-130", + "implementation": "./src/migrations/update-16-8-0/cypress-13" } }, "packageJsonUpdates": { diff --git a/packages/cypress/package.json b/packages/cypress/package.json index 396222be65601..892ea88470f0c 100644 --- a/packages/cypress/package.json +++ b/packages/cypress/package.json @@ -43,7 +43,7 @@ "@nx/linter": "file:../linter" }, "peerDependencies": { - "cypress": ">= 3 < 13" + "cypress": ">= 3 < 14" }, "peerDependenciesMeta": { "cypress": { diff --git a/packages/cypress/plugins/cypress-preset.ts b/packages/cypress/plugins/cypress-preset.ts index cfca88ded0ce5..6e3ed1a87e141 100644 --- a/packages/cypress/plugins/cypress-preset.ts +++ b/packages/cypress/plugins/cypress-preset.ts @@ -7,7 +7,6 @@ import vitePreprocessor from '../src/plugins/preprocessor-vite'; interface BaseCypressPreset { videosFolder: string; screenshotsFolder: string; - video: boolean; chromeWebSecurity: boolean; } @@ -47,7 +46,6 @@ export function nxBaseCypressPreset( return { videosFolder, screenshotsFolder, - video: true, chromeWebSecurity: false, }; } diff --git a/packages/cypress/src/executors/cypress/cypress.impl.ts b/packages/cypress/src/executors/cypress/cypress.impl.ts index 7a86df95b3f65..f351df2080599 100644 --- a/packages/cypress/src/executors/cypress/cypress.impl.ts +++ b/packages/cypress/src/executors/cypress/cypress.impl.ts @@ -51,6 +51,7 @@ export interface CypressExecutorOptions extends Json { tag?: string; port?: number | 'cypress-auto'; quiet?: boolean; + runnerUi?: boolean; } interface NormalizedCypressExecutorOptions extends CypressExecutorOptions { @@ -258,6 +259,7 @@ async function runCypress( options.tag = opts.tag; options.exit = opts.exit; options.headed = opts.headed; + options.runnerUi = opts.runnerUi; if (opts.headless) { options.headless = opts.headless; diff --git a/packages/cypress/src/executors/cypress/schema.json b/packages/cypress/src/executors/cypress/schema.json index 50917c806a400..370bfc61cb66d 100644 --- a/packages/cypress/src/executors/cypress/schema.json +++ b/packages/cypress/src/executors/cypress/schema.json @@ -164,6 +164,10 @@ "type": "boolean", "description": "If passed, Cypress output will not be printed to stdout. Only output from the configured Mocha reporter will print.", "default": false + }, + "runnerUi": { + "type": "boolean", + "description": "Displays the Cypress Runner UI. Useful for when Test Replay is enabled and you would still like the Cypress Runner UI to be displayed for screenshots and video." } }, "additionalProperties": true, diff --git a/packages/cypress/src/migrations/update-16-8-0/cypress-13.spec.ts b/packages/cypress/src/migrations/update-16-8-0/cypress-13.spec.ts new file mode 100644 index 0000000000000..75f90593f0e7e --- /dev/null +++ b/packages/cypress/src/migrations/update-16-8-0/cypress-13.spec.ts @@ -0,0 +1,243 @@ +import { Tree, addProjectConfiguration, readJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports'; +import { updateToCypress13 } from './cypress-13'; + +describe('Cypress 13', () => { + let tree: Tree; + beforeEach(() => { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + }); + + it('should update deps to cypress v13', async () => { + setup(tree, { name: 'my-app' }); + + await updateToCypress13(tree); + expect(readJson(tree, 'package.json').devDependencies.cypress).toEqual( + '^13.0.0' + ); + }); + + it('should update videoUploadOnPasses from config w/setupNodeEvents', async () => { + setup(tree, { name: 'my-app-video-upload-on-passes' }); + await updateToCypress13(tree); + expect( + tree.read('apps/my-app-video-upload-on-passes/cypress.config.ts', 'utf-8') + ).toMatchInlineSnapshot(` + "import fs from 'fs'; + + import { defineConfig } from 'cypress'; + import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing'; + + export default defineConfig({ + something: 'blah', + // nodeVersion: 'system', + // videoUploadOnPasses: false , + e2e: { + ...nxE2EPreset(__filename), + setupNodeEvents(on, config) { + const a = ''; + removePassedSpecs(on); + }, + }, + component: { + ...nxComponentTestingPreset(__filename), + setupNodeEvents: (on, config) => { + const b = ''; + removePassedSpecs(on); + }, + }, + }); + + /** + * Delete videos for specs that do not contain failing or retried tests. + * This function is to be used in the 'setupNodeEvents' configuration option as a replacement to + * 'videoUploadOnPasses' which has been removed. + * + * https://docs.cypress.io/guides/guides/screenshots-and-videos#Delete-videos-for-specs-without-failing-or-retried-tests + **/ + function removePassedSpecs(on) { + on('after:spec', (spec, results) => { + if (results && results.vide) { + const hasFailures = results.tests.some((t) => + t.attempts.some((a) => a.state === 'failed') + ); + + if (!hasFailures) { + fs.unlinkSync(results.video); + } + } + }); + } + " + `); + }); + it('should remove nodeVersion from config', async () => { + setup(tree, { name: 'my-app-node-version' }); + await updateToCypress13(tree); + expect(tree.read('apps/my-app-node-version/cypress.config.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import fs from 'fs'; + + import { defineConfig } from 'cypress'; + import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing'; + + export default defineConfig({ + something: 'blah', + // nodeVersion: 'system', + // videoUploadOnPasses: false , + e2e: { + ...nxE2EPreset(__filename), + setupNodeEvents(on, config) { + const a = ''; + removePassedSpecs(on); + }, + }, + component: { + ...nxComponentTestingPreset(__filename), + setupNodeEvents: (on, config) => { + const b = ''; + removePassedSpecs(on); + }, + }, + }); + + /** + * Delete videos for specs that do not contain failing or retried tests. + * This function is to be used in the 'setupNodeEvents' configuration option as a replacement to + * 'videoUploadOnPasses' which has been removed. + * + * https://docs.cypress.io/guides/guides/screenshots-and-videos#Delete-videos-for-specs-without-failing-or-retried-tests + **/ + function removePassedSpecs(on) { + on('after:spec', (spec, results) => { + if (results && results.vide) { + const hasFailures = results.tests.some((t) => + t.attempts.some((a) => a.state === 'failed') + ); + + if (!hasFailures) { + fs.unlinkSync(results.video); + } + } + }); + } + " + `); + }); + + it('should comment about overriding readFile command', async () => { + setup(tree, { name: 'my-app-read-file' }); + const testContent = `describe('something', () => { + it('should do the thing', () => { + cy.readFile('my-data.json').its('name').should('eq', 'Nx'); + }); +}); +`; + tree.write('apps/my-app-read-file/src/something.cy.ts', testContent); + + tree.write( + 'apps/my-app-read-file/cypress/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('readFile', () => {}); + +` + ); + await updateToCypress13(tree); + + expect( + tree.read('apps/my-app-read-file/src/something.cy.ts', 'utf-8') + ).toEqual(testContent); + expect( + tree.read('apps/my-app-read-file/cypress/support/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(@nx/cypress): This command can no longer be overridden + * Consider using a different name like 'custom_readFile' + * More info: https://docs.cypress.io/guides/references/migration-guide#readFile-can-no-longer-be-overwritten-with-CypressCommandsoverwrite + **/ + Cypress.Commands.overwrite('readFile', () => {}); + " + `); + }); +}); + +function setup(tree: Tree, options: { name: string }) { + tree.write( + `apps/${options.name}/cypress.config.ts`, + ` +import { defineConfig} from 'cypress'; +import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; +import { nxComponentTestingPreset } from '@nx/react/plugins/component-testing'; + +export default defineConfig({ + something: 'blah', + nodeVersion: 'system', + videoUploadOnPasses: false, + e2e: { + ...nxE2EPreset(__filename), + videoUploadOnPasses: false, + nodeVersion: 'bundled', + setupNodeEvents(on, config) { + const a = ''; + }, + }, + component: { + ...nxComponentTestingPreset(__filename), + videoUploadOnPasses: false, + nodeVersion: 'something', + setupNodeEvents: (on, config) => { + const b = ''; + } + }, +}) +` + ); + tree.write( + 'package.json', + JSON.stringify({ devDependencies: { cypress: '^12.16.0' } }) + ); + addProjectConfiguration(tree, options.name, { + root: `apps/${options.name}`, + sourceRoot: `apps/${options.name}/src`, + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: { + testingType: 'e2e', + cypressConfig: `apps/${options.name}/cypress.config.ts`, + devServerTarget: 'app:serve', + }, + }, + 'component-test': { + executor: '@nx/cypress:cypress', + options: { + testingType: 'component', + cypressConfig: `apps/${options.name}/ct-cypress.config.ts`, + skipServe: true, + devServerTarget: 'app:build', + }, + }, + }, + }); +} diff --git a/packages/cypress/src/migrations/update-16-8-0/cypress-13.ts b/packages/cypress/src/migrations/update-16-8-0/cypress-13.ts new file mode 100644 index 0000000000000..2320473555e76 --- /dev/null +++ b/packages/cypress/src/migrations/update-16-8-0/cypress-13.ts @@ -0,0 +1,246 @@ +import { + updateJson, + installPackagesTask, + getProjects, + visitNotIgnoredFiles, + formatFiles, + type Tree, +} from '@nx/devkit'; +import { installedCypressVersion } from '../../utils/cypress-version'; +import { + isObjectLiteralExpression, + isCallExpression, + type CallExpression, + type MethodDeclaration, + type PropertyAccessExpression, + type PropertyAssignment, +} from 'typescript'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { forEachExecutorOptions } from '@nx/devkit/src/generators/executor-options-utils'; +import type { CypressExecutorOptions } from '../../executors/cypress/cypress.impl'; + +const JS_TS_FILE_MATCHER = /\.[jt]sx?$/; + +export async function updateToCypress13(tree: Tree) { + if (installedCypressVersion() < 12) { + return; + } + + const projects = getProjects(tree); + + forEachExecutorOptions( + tree, + '@nx/cypress:cypress', + (options, projectName) => { + if (!options.cypressConfig || !tree.exists(options.cypressConfig)) { + return; + } + const projectConfig = projects.get(projectName); + + removeNodeVersionOption(tree, options.cypressConfig); + removeVideoUploadOnPassesOption(tree, options.cypressConfig); + + visitNotIgnoredFiles(tree, projectConfig.root, (filePath) => { + if (!JS_TS_FILE_MATCHER.test(filePath)) { + return; + } + shouldNotOverrideReadFile(tree, filePath); + }); + } + ); + + updateJson(tree, 'package.json', (json) => { + json.devDependencies ??= {}; + json.devDependencies.cypress = '^13.0.0'; + return json; + }); + + await formatFiles(tree); +} + +function removeVideoUploadOnPassesOption(tree: Tree, configPath: string) { + const config = tree.read(configPath, 'utf-8'); + const isUsingDeprecatedOption = + tsquery.query(config, getPropertyQuery('videoUploadOnPasses'))?.length > 0; + + if (!isUsingDeprecatedOption) { + return; + } + + const importStatement = configPath.endsWith('.ts') + ? "import fs from 'fs';" + : "const fs = require('fs');"; + + const replacementFunction = `/** +* Delete videos for specs that do not contain failing or retried tests. +* This function is to be used in the 'setupNodeEvents' configuration option as a replacement to +* 'videoUploadOnPasses' which has been removed. +* +* https://docs.cypress.io/guides/guides/screenshots-and-videos#Delete-videos-for-specs-without-failing-or-retried-tests +**/ +function removePassedSpecs(on) { + on('after:spec', (spec, results) => { + if(results && results.vide) { + const hasFailures = results.tests.some(t => t.attempts.some(a => a.state === 'failed')); + + if(!hasFailures) { + fs.unlinkSync(results.video); + } + } + }) +}`; + + const withReplacementFn = `${importStatement}\n${config}\n${replacementFunction}`; + + // setupNodeEvents can be a property or method. + const setupNodeEventsQuery = + 'ExportAssignment ObjectLiteralExpression > :matches(PropertyAssignment:has(Identifier[name="setupNodeEvents"]), MethodDeclaration:has(Identifier[name="setupNodeEvents"]))'; + + const hasSetupNodeEvents = + tsquery.query(withReplacementFn, setupNodeEventsQuery)?.length > 0; + + let updatedWithSetupNodeEvents = withReplacementFn; + if (hasSetupNodeEvents) { + // if have setupNodeEvents, update existing fn to use removePassedSpecs helper and remove videoUploadOnPasses + const noVideoUploadOption = tsquery.replace( + withReplacementFn, + getPropertyQuery('videoUploadOnPasses'), + (node: PropertyAssignment) => { + if (isObjectLiteralExpression(node.initializer)) { + // is a nested config object + const key = node.name.getText().trim(); + const listOfProperties = node.initializer.properties + .map((j) => j.getText()) + .filter((j) => !j.includes('videoUploadOnPasses')) + .join(',\n'); + + return `${key}: { + ${listOfProperties} +} + `; + } else { + if (isPropertyTopLevel(node)) { + return `// ${node.getText()} `; + } + } + } + ); + + updatedWithSetupNodeEvents = tsquery.replace( + noVideoUploadOption, + `${setupNodeEventsQuery} Block`, + (node: PropertyAssignment | MethodDeclaration) => { + const blockWithoutBraces = node + .getFullText() + .trim() + .slice(1, -1) + .trim(); + return `{ + ${blockWithoutBraces} + removePassedSpecs(on); +} +`; + }, + { visitAllChildren: false } + ); + } else { + // if don't have setupNodeEvents, replace videoUploadOnPasses with setupNodeEvents method + updatedWithSetupNodeEvents = tsquery.replace( + withReplacementFn, + getPropertyQuery('videoUploadOnPasses'), + () => { + return `setupNodeEvents(on, config) { + removePassedSpecs(on); +}`; + } + ); + } + + tree.write(configPath, updatedWithSetupNodeEvents); +} + +/** + * remove the nodeVersion option from the config file + **/ +function removeNodeVersionOption(tree: Tree, configPath: string) { + const config = tree.read(configPath, 'utf-8'); + + const updated = tsquery.replace( + config, + getPropertyQuery('nodeVersion'), + (node: PropertyAssignment) => { + if (isObjectLiteralExpression(node.initializer)) { + // is a nested config object + const key = node.name.getText().trim(); + const listOfProperties = node.initializer.properties + .map((j) => j.getFullText()) + .filter((j) => !j.includes('nodeVersion')) + .join(', '); + return `${key}: { + ${listOfProperties} + }`; + } else { + if (isPropertyTopLevel(node)) { + return `// ${node.getText()}`; + } + } + } + ); + + if (updated !== config) { + tree.write(configPath, updated); + } +} + +/** + * leave a comment on all usages of overriding built-ins that are now banned + **/ +export function shouldNotOverrideReadFile(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 (command === 'readFile') { + // overwrite + return `/** +* TODO(@nx/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#readFile-can-no-longer-be-overwritten-with-CypressCommandsoverwrite +**/ +${node.getText()}`; + } + } + } + ); + tree.write(filePath, markedOverrideUsage); +} + +function isAlreadyCommented(node: PropertyAccessExpression) { + return node.getFullText().includes('TODO(@nx/cypress)'); +} + +function isPropertyTopLevel(node: PropertyAssignment) { + return ( + node.parent && + isObjectLiteralExpression(node.parent) && + node.parent.parent && + isCallExpression(node.parent.parent) + ); +} + +const getPropertyQuery = (propertyName: string) => + `ExportAssignment ObjectLiteralExpression > PropertyAssignment:has(Identifier[name="${propertyName}"])`; + +export default updateToCypress13; diff --git a/packages/cypress/src/utils/versions.ts b/packages/cypress/src/utils/versions.ts index 519f73b1374f5..b45c976ba25ca 100644 --- a/packages/cypress/src/utils/versions.ts +++ b/packages/cypress/src/utils/versions.ts @@ -2,7 +2,7 @@ export const nxVersion = require('../../package.json').version; export const eslintPluginCypressVersion = '^2.13.4'; export const typesNodeVersion = '16.11.7'; export const cypressViteDevServerVersion = '^2.2.1'; -export const cypressVersion = '^12.16.0'; +export const cypressVersion = '^13.0.0'; export const cypressWebpackVersion = '^2.0.0'; export const webpackHttpPluginVersion = '^5.5.0'; export const viteVersion = '~4.3.9'; diff --git a/packages/react/plugins/component-testing/index.ts b/packages/react/plugins/component-testing/index.ts index a58b0fdd9f614..3f27caef7421c 100644 --- a/packages/react/plugins/component-testing/index.ts +++ b/packages/react/plugins/component-testing/index.ts @@ -61,7 +61,6 @@ export function nxComponentTestingPreset( devServer: ViteDevServer | WebpackDevServer; videosFolder: string; screenshotsFolder: string; - video: boolean; chromeWebSecurity: boolean; } { const normalizedProjectRootPath = ['.ts', '.js'].some((ext) =>