From a86d92661299380b0cda088463c36b459912ba84 Mon Sep 17 00:00:00 2001 From: Jason Jean Date: Thu, 9 Nov 2023 14:05:19 -0500 Subject: [PATCH] feat(testing): add cypress create nodes plugin (#19840) --- .../cypress-component-configuration.spec.ts | 4 - .../__snapshots__/e2e.migrator.spec.ts.snap | 6 +- .../migrators/projects/app.migrator.spec.ts | 3 +- .../migrators/projects/e2e.migrator.spec.ts | 4 +- .../migrators/projects/lib.migrator.spec.ts | 3 +- packages/cypress/migrations.json | 10 + packages/cypress/package.json | 8 +- packages/cypress/plugin.ts | 1 + packages/cypress/plugins/cypress-preset.ts | 70 ++++- .../src/executors/cypress/cypress.impl.ts | 230 +-------------- .../component-configuration.spec.ts | 34 +++ .../component-configuration.ts | 57 ++-- .../configuration/configuration.spec.ts | 92 +++++- .../generators/configuration/configuration.ts | 76 ++++- .../cypress/src/generators/init/init.spec.ts | 47 ++++ packages/cypress/src/generators/init/init.ts | 53 +++- .../cypress/src/generators/init/schema.d.ts | 2 + ...er-targets-to-cypress-configs.spec.ts.snap | 64 +++++ ...-server-targets-to-cypress-configs.spec.ts | 153 ++++++++++ ...d-dev-server-targets-to-cypress-configs.ts | 176 ++++++++++++ .../add-nx-cypress-plugin.spec.ts | 154 ++++++++++ .../update-17-2-0/add-nx-cypress-plugin.ts | 24 ++ packages/cypress/src/plugins/plugin.spec.ts | 200 +++++++++++++ packages/cypress/src/plugins/plugin.ts | 264 ++++++++++++++++++ packages/cypress/src/utils/config.spec.ts | 84 +++++- packages/cypress/src/utils/config.ts | 21 +- .../cypress/src/utils/start-dev-server.ts | 218 +++++++++++++++ .../create-tree-with-empty-workspace.ts | 3 - .../generators/configuration/configuration.ts | 1 + packages/react/src/generators/init/init.ts | 6 +- .../move-storybook-tsconfig.spec.ts.snap | 3 - .../executors/dev-server/dev-server.impl.ts | 4 + .../move/lib/update-cypress-config.spec.ts | 2 +- .../lib/update-project-root-files.spec.ts | 34 +++ .../move/lib/update-project-root-files.ts | 24 +- .../src/generators/move/move.spec.ts | 46 +++ .../new/__snapshots__/new.spec.ts.snap | 3 - .../new/generate-workspace-files.spec.ts | 9 - .../new/generate-workspace-files.ts | 3 - 39 files changed, 1851 insertions(+), 345 deletions(-) create mode 100644 packages/cypress/plugin.ts create mode 100644 packages/cypress/src/migrations/update-17-2-0/__snapshots__/add-dev-server-targets-to-cypress-configs.spec.ts.snap create mode 100644 packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.spec.ts create mode 100644 packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.ts create mode 100644 packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.spec.ts create mode 100644 packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.ts create mode 100644 packages/cypress/src/plugins/plugin.spec.ts create mode 100644 packages/cypress/src/plugins/plugin.ts create mode 100644 packages/cypress/src/utils/start-dev-server.ts diff --git a/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts b/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts index c13466ced2f5e..4b8c4b77cfd69 100644 --- a/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts +++ b/packages/angular/src/generators/cypress-component-configuration/cypress-component-configuration.spec.ts @@ -213,10 +213,6 @@ describe('Cypress Component Testing Configuration', () => { generateTests: false, }); }).resolves; - - expect( - require('@nx/devkit').createProjectGraphAsync - ).not.toHaveBeenCalled(); }); it('should use own project config', async () => { await generateTestApplication(tree, { diff --git a/packages/angular/src/generators/ng-add/migrators/projects/__snapshots__/e2e.migrator.spec.ts.snap b/packages/angular/src/generators/ng-add/migrators/projects/__snapshots__/e2e.migrator.spec.ts.snap index ed51da17f6db2..2688107945087 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/__snapshots__/e2e.migrator.spec.ts.snap +++ b/packages/angular/src/generators/ng-add/migrators/projects/__snapshots__/e2e.migrator.spec.ts.snap @@ -15,10 +15,11 @@ export default defineConfig({ exports[`e2e migrator cypress with project root at "" cypress version >=10 should create a cypress.config.ts file when it does not exist 1`] = ` "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; export default defineConfig({ - e2e: nxE2EPreset(__filename, { cypressDir: 'src' }) + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }) }, }); " `; @@ -91,10 +92,11 @@ export default defineConfig({ exports[`e2e migrator cypress with project root at "projects/app1" cypress version >=10 should create a cypress.config.ts file when it does not exist 1`] = ` "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; export default defineConfig({ - e2e: nxE2EPreset(__filename, { cypressDir: 'src' }) + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }) }, }); " `; diff --git a/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts b/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts index 6fb91bf392228..75f9d4f52739b 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/app.migrator.spec.ts @@ -1710,7 +1710,6 @@ describe('app migrator', () => { ).toStrictEqual([ 'build', 'lint', - 'e2e', 'myCustomTest', 'myCustomLint', 'myCustomBuild', @@ -1742,7 +1741,7 @@ describe('app migrator', () => { const { targetDefaults } = readNxJson(tree); expect( Object.keys(targetDefaults).filter((f) => targetDefaults[f].cache) - ).toStrictEqual(['build', 'lint', 'e2e', 'myCustomTest']); + ).toStrictEqual(['build', 'lint', 'myCustomTest', 'e2e']); }); }); }); diff --git a/packages/angular/src/generators/ng-add/migrators/projects/e2e.migrator.spec.ts b/packages/angular/src/generators/ng-add/migrators/projects/e2e.migrator.spec.ts index 2103b73204f11..df84c4ab2716d 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/e2e.migrator.spec.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/e2e.migrator.spec.ts @@ -11,7 +11,7 @@ jest.mock('fs', () => { }); import { installedCypressVersion } from '@nx/cypress/src/utils/cypress-version'; -import type { ProjectConfiguration, Tree } from '@nx/devkit'; +import { formatFiles, ProjectConfiguration, Tree } from '@nx/devkit'; import { joinPathFragments, offsetFromRoot, @@ -826,6 +826,8 @@ describe('e2e migrator', () => { await migrator.migrate(); + await formatFiles(tree); + expect(tree.exists('apps/app1-e2e/cypress.config.ts')).toBe(true); const cypressConfig = tree.read( 'apps/app1-e2e/cypress.config.ts', diff --git a/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts b/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts index 68d1ebf7c952c..64cfa4278d746 100644 --- a/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts +++ b/packages/angular/src/generators/ng-add/migrators/projects/lib.migrator.spec.ts @@ -1280,7 +1280,6 @@ describe('lib migrator', () => { ).toStrictEqual([ 'build', 'lint', - 'e2e', 'myCustomBuild', 'myCustomTest', 'myCustomLint', @@ -1306,7 +1305,7 @@ describe('lib migrator', () => { const { targetDefaults } = readNxJson(tree); expect( Object.keys(targetDefaults).filter((f) => targetDefaults[f].cache) - ).toStrictEqual(['build', 'lint', 'e2e', 'myCustomTest']); + ).toStrictEqual(['build', 'lint', 'myCustomTest']); }); }); }); diff --git a/packages/cypress/migrations.json b/packages/cypress/migrations.json index 05dfff1a369c6..e502df40c652b 100644 --- a/packages/cypress/migrations.json +++ b/packages/cypress/migrations.json @@ -47,6 +47,16 @@ "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" + }, + "add-nx-metadata": { + "version": "17.2.0-beta.0", + "description": "Add devServerTargets into cypress.config.ts files for @nx/cypress/plugin", + "implementation": "./src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs" + }, + "add-nx-cypress-plugin": { + "version": "17.2.0-beta.0", + "description": "Add the @nx/cypress/plugin to nx.json plugins", + "implementation": "./src/migrations/update-17-2-0/add-nx-cypress-plugin" } }, "packageJsonUpdates": { diff --git a/packages/cypress/package.json b/packages/cypress/package.json index ab61b22396e79..397a4dfdeb84e 100644 --- a/packages/cypress/package.json +++ b/packages/cypress/package.json @@ -34,13 +34,13 @@ "migrations": "./migrations.json" }, "dependencies": { + "@nx/devkit": "file:../devkit", + "@nx/eslint": "file:../eslint", + "@nx/js": "file:../js", "@phenomnomnominal/tsquery": "~5.0.1", "detect-port": "^1.5.1", "semver": "7.5.3", - "tslib": "^2.3.0", - "@nx/devkit": "file:../devkit", - "@nx/js": "file:../js", - "@nx/eslint": "file:../eslint" + "tslib": "^2.3.0" }, "peerDependencies": { "cypress": ">= 3 < 14" diff --git a/packages/cypress/plugin.ts b/packages/cypress/plugin.ts new file mode 100644 index 0000000000000..f8ca64185f7ae --- /dev/null +++ b/packages/cypress/plugin.ts @@ -0,0 +1 @@ +export { createNodes } from './src/plugins/plugin'; diff --git a/packages/cypress/plugins/cypress-preset.ts b/packages/cypress/plugins/cypress-preset.ts index a3f58e1d7149a..95b7e1e461a14 100644 --- a/packages/cypress/plugins/cypress-preset.ts +++ b/packages/cypress/plugins/cypress-preset.ts @@ -1,8 +1,16 @@ -import { workspaceRoot } from '@nx/devkit'; +import { + createProjectGraphAsync, + logger, + parseTargetString, + workspaceRoot, +} from '@nx/devkit'; import { dirname, join, relative } from 'path'; import { lstatSync } from 'fs'; import vitePreprocessor from '../src/plugins/preprocessor-vite'; +import { ChildProcess, fork } from 'node:child_process'; +import { createExecutorContext } from '../src/utils/ct-helpers'; +import { startDevServer } from '../src/utils/start-dev-server'; interface BaseCypressPreset { videosFolder: string; @@ -65,29 +73,66 @@ export function nxBaseCypressPreset( * } * }) * - * @param pathToConfig will be used to construct the output paths for videos and screenshots */ export function nxE2EPreset( pathToConfig: string, options?: NxCypressE2EPresetOptions ) { const basePath = options?.cypressDir || 'src'; - const baseConfig = { + const baseConfig: any /** Cypress.EndToEndConfigOptions */ = { ...nxBaseCypressPreset(pathToConfig), fileServerFolder: '.', supportFile: `${basePath}/support/e2e.ts`, specPattern: `${basePath}/**/*.cy.{js,jsx,ts,tsx}`, fixturesFolder: `${basePath}/fixtures`, + env: { + devServerTargets: options?.devServerTargets, + devServerTargetOptions: {}, + ciDevServerTarget: options?.ciDevServerTarget, + }, + async setupNodeEvents(on, config) { + if (options?.bundler === 'vite') { + on('file:preprocessor', vitePreprocessor()); + } + if (!config.env.devServerTargets) { + return; + } + const devServerTarget = + config.env.devServerTarget ?? config.env.devServerTargets['default']; + + if (!devServerTarget) { + return; + } + if (!config.baseUrl && devServerTarget) { + const graph = await createProjectGraphAsync(); + const target = parseTargetString(devServerTarget, graph); + const context = createExecutorContext( + graph, + graph.nodes[target.project].data?.targets, + target.project, + target.target, + target.configuration + ); + + const devServer = startDevServer( + { + devServerTarget, + ...config.env.devServerTargetOptions, + }, + context + ); + on('after:run', () => { + devServer.return(); + }); + const devServerValue = (await devServer.next()).value; + if (!devServerValue) { + return; + } + return { ...config, baseUrl: devServerValue.baseUrl }; + } + }, }; - if (options?.bundler === 'vite') { - return { - ...baseConfig, - setupNodeEvents(on) { - on('file:preprocessor', vitePreprocessor()); - }, - }; - } return baseConfig; } @@ -99,4 +144,7 @@ export type NxCypressE2EPresetOptions = { * default is 'src' **/ cypressDir?: string; + + devServerTargets?: Record; + ciDevServerTarget?: string; }; diff --git a/packages/cypress/src/executors/cypress/cypress.impl.ts b/packages/cypress/src/executors/cypress/cypress.impl.ts index 07f4c3ce271d2..af86098e22d6f 100644 --- a/packages/cypress/src/executors/cypress/cypress.impl.ts +++ b/packages/cypress/src/executors/cypress/cypress.impl.ts @@ -1,20 +1,9 @@ -import { - ExecutorContext, - logger, - parseTargetString, - readTargetOptions, - runExecutor, - stripIndents, - Target, - targetToTargetString, - output, -} from '@nx/devkit'; -import { getExecutorInformation } from 'nx/src/command-line/run/executor-utils'; -import { existsSync, readdirSync, unlinkSync, writeFileSync } from 'fs'; -import { basename, dirname, join } from 'path'; +import { ExecutorContext, logger, stripIndents } from '@nx/devkit'; +import { existsSync, readdirSync, unlinkSync } from 'fs'; +import { basename, dirname } from 'path'; import { getTempTailwindPath } from '../../utils/ct-helpers'; import { installedCypressVersion } from '../../utils/cypress-version'; -import * as detectPort from 'detect-port'; +import { startDevServer } from '../../utils/start-dev-server'; const Cypress = require('cypress'); // @NOTE: Importing via ES6 messes the whole test dependencies. @@ -156,75 +145,6 @@ A generator to migrate from v8 to v10 is provided. See https://nx.dev/cypress/v1 } } -async function* startDevServer( - opts: CypressExecutorOptions, - context: ExecutorContext -) { - // no dev server, return the provisioned base url - if (!opts.devServerTarget || opts.skipServe) { - yield { baseUrl: opts.baseUrl }; - return; - } - - const parsedDevServerTarget = parseTargetString( - opts.devServerTarget, - context - ); - - const [targetSupportsWatchOpt] = getValueFromSchema( - context, - parsedDevServerTarget, - 'watch' - ); - - const overrides: Record = { - // @NOTE: Do not forward watch option if not supported by the target dev server, - // this is relevant for running Cypress against dev server target that does not support this option, - // for instance @nguniversal/builders:ssr-dev-server. - ...(targetSupportsWatchOpt ? { watch: opts.watch } : {}), - }; - - if (opts.port === 'cypress-auto') { - const freePort = await getPortForProject(context, parsedDevServerTarget); - overrides['port'] = freePort; - } else if (opts.port !== undefined) { - overrides['port'] = opts.port; - // zero is a special case that means any valid port so there is no reason to try to 'lock it' - if (opts.port !== 0) { - const didLock = attemptToLockPort(opts.port); - if (!didLock) { - logger.warn( - stripIndents`${opts.port} is potentially already in use by another cypress run. -If the port is in use, try using a different port value or passing --port='cypress-auto' to find a free port.` - ); - } - } - } - - for await (const output of await runExecutor<{ - success: boolean; - baseUrl?: string; - port?: string; - info?: { port?: number; baseUrl?: string }; - }>(parsedDevServerTarget, overrides, context)) { - if (!output.success && !opts.watch) - throw new Error('Could not compile application files'); - if ( - !opts.baseUrl && - !output.baseUrl && - !output.info?.baseUrl && - (output.port || output.info?.port) - ) { - output.baseUrl = `http://localhost:${output.port ?? output.info?.port}`; - } - yield { - baseUrl: opts.baseUrl || output.baseUrl || output.info?.baseUrl, - portLockFilePath: - overrides.port && join(__dirname, `${overrides.port}.txt`), - }; - } -} - /** * @whatItDoes Initialize the Cypress test runner with the provided project configuration. * By default, Cypress will run tests from the CLI without the GUI and provide directly the results in the console output. @@ -250,8 +170,15 @@ async function runCypress( options.browser = opts.browser; } + options.env = { + devServerTarget: opts.devServerTarget, + }; + if (opts.env) { - options.env = opts.env; + options.env = { + ...options.env, + ...opts.env, + }; } if (opts.spec) { options.spec = opts.spec; @@ -317,139 +244,6 @@ async function runCypress( return !result.totalFailed && !result.failures; } -/** - * try to find a free port for the project to run on - * will return undefined if no port is found or the project doesn't have a port option - **/ -async function getPortForProject( - context: ExecutorContext, - target: Target, - defaultPort = 4200 -) { - const fmtTarget = targetToTargetString(target); - const [hasPortOpt, schemaPortValue] = getValueFromSchema( - context, - target, - 'port' - ); - - let freePort: number | undefined; - - if (hasPortOpt) { - let normalizedPortValue: number; - if (!schemaPortValue) { - logger.info( - `NX ${fmtTarget} did not have a defined port value, checking for free port with the default value of ${defaultPort}` - ); - normalizedPortValue = defaultPort; - } else { - normalizedPortValue = Number(schemaPortValue); - } - - if (isNaN(normalizedPortValue)) { - output.warn({ - title: `Port Not a Number`, - bodyLines: [ - `The port value found was not a number or can't be parsed to a number`, - `When reading the devServerTarget (${fmtTarget}) schema, expected ${schemaPortValue} to be a number but got NaN.`, - `Nx will use the default value of ${defaultPort} instead.`, - `You can manually specify a port by setting the 'port' option`, - ], - }); - normalizedPortValue = defaultPort; - } - try { - let attempts = 0; - // make sure when this check happens in parallel, - // we don't let the same port be used by multiple projects - do { - freePort = await detectPort(freePort || normalizedPortValue); - if (attemptToLockPort(freePort)) { - break; - } - attempts++; - // increment port in case the lock file isn't cleaned up - freePort++; - } while (attempts < 20); - - logger.info(`NX Using port ${freePort} for ${fmtTarget}`); - } catch (err) { - throw new Error( - stripIndents`Unable to find a free port for the dev server, ${fmtTarget}. -You can disable auto port detection by specifing a port or not passing a value to --port` - ); - } - } else { - output.warn({ - title: `No Port Option Found`, - bodyLines: [ - `The 'port' option is set to 'cypress-auto', but the devServerTarget (${fmtTarget}) does not have a port option.`, - `Because of this, Nx is unable to verify the port is free before starting the dev server.`, - `This might cause issues if the devServerTarget is trying to use a port that is already in use.`, - ], - }); - } - - return freePort; -} - -/** - * Check if the given target has the given property in it's options. - * if the property is does not have a default value or is not in the actual executor options, - * the value will be undefined even if it's in the executor schema. - **/ -function getValueFromSchema( - context: ExecutorContext, - target: Target, - property: string -): [hasPropertyOpt: boolean, value?: unknown] { - let targetOpts: any; - try { - targetOpts = readTargetOptions(target, context); - } catch (e) { - throw new Error(`Unable to read the target options for ${targetToTargetString( - target - )}. -Are you sure this is a valid target? -Was trying to read the target for the property: '${property}', but got the following error: -${e.message || e}`); - } - let targetHasOpt = Object.keys(targetOpts).includes(property); - - if (!targetHasOpt) { - // NOTE: readTargetOptions doesn't apply non defaulted values, i.e. @nx/vite has a port options but is optional - // so we double check the schema if readTargetOptions didn't return a value for the property - const projectConfig = - context.projectsConfigurations?.projects?.[target.project]; - const targetConfig = projectConfig.targets[target.target]; - - const [collection, executor] = targetConfig.executor.split(':'); - const { schema } = getExecutorInformation( - collection, - executor, - context.root - ); - - // NOTE: schema won't have a default since readTargetOptions would have - // already set that and this check wouldn't need to be made - targetHasOpt = Object.keys(schema.properties).includes(property); - } - return [targetHasOpt, targetOpts[property]]; -} - -function attemptToLockPort(port: number): boolean { - const portLockFilePath = join(__dirname, `${port}.txt`); - try { - if (existsSync(portLockFilePath)) { - return false; - } - writeFileSync(portLockFilePath, 'locked'); - return true; - } catch (err) { - return false; - } -} - function cleanupTmpFile(path: string) { try { if (path && existsSync(path)) { diff --git a/packages/cypress/src/generators/component-configuration/component-configuration.spec.ts b/packages/cypress/src/generators/component-configuration/component-configuration.spec.ts index da062d1dd58a4..1cbe90f6359cb 100644 --- a/packages/cypress/src/generators/component-configuration/component-configuration.spec.ts +++ b/packages/cypress/src/generators/component-configuration/component-configuration.spec.ts @@ -2,14 +2,17 @@ import { addProjectConfiguration, ProjectConfiguration, readJson, + readNxJson, readProjectConfiguration, Tree, updateJson, + updateNxJson, updateProjectConfiguration, } from '@nx/devkit'; import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import { installedCypressVersion } from '../../utils/cypress-version'; import { componentConfigurationGenerator } from './component-configuration'; +import { cypressInitGenerator } from '../init/init'; jest.mock('../../utils/cypress-version'); let projectConfig: ProjectConfiguration = { @@ -95,6 +98,37 @@ describe('Cypress Component Configuration', () => { jest.clearAllMocks(); }); + it('should not add the target when @nx/cypress/plugin is registered', async () => { + process.env.NX_PCV3 = 'true'; + await cypressInitGenerator(tree, {}); + const nxJson = readNxJson(tree); + nxJson.namedInputs = { + default: ['{projectRoot}/**/*'], + production: ['default'], + }; + updateNxJson(tree, nxJson); + + await componentConfigurationGenerator(tree, { + project: 'cool-lib', + skipFormat: false, + }); + + expect( + readProjectConfiguration(tree, 'cool-lib').targets['component-test'] + ).toBeUndefined(); + + expect(readNxJson(tree).namedInputs.production).toMatchInlineSnapshot(` + [ + "default", + "!{projectRoot}/cypress/**/*", + "!{projectRoot}/**/*.cy.[jt]s?(x)", + "!{projectRoot}/cypress.config.[jt]s", + ] + `); + + delete process.env.NX_PCV3; + }); + it('should add base cypress component testing config', async () => { mockedInstalledCypressVersion.mockReturnValue(10); await componentConfigurationGenerator(tree, { diff --git a/packages/cypress/src/generators/component-configuration/component-configuration.ts b/packages/cypress/src/generators/component-configuration/component-configuration.ts index ad1a65da4fb8e..997516d152035 100644 --- a/packages/cypress/src/generators/component-configuration/component-configuration.ts +++ b/packages/cypress/src/generators/component-configuration/component-configuration.ts @@ -31,13 +31,23 @@ export async function componentConfigurationGenerator( ) { const opts = normalizeOptions(options); + const nxJson = readNxJson(tree); + const hasPlugin = nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ); + const projectConfig = readProjectConfiguration(tree, opts.project); const installDepsTask = updateDeps(tree, opts); addProjectFiles(tree, projectConfig, opts); - addTargetToProject(tree, projectConfig, opts); - updateNxJsonConfiguration(tree); + if (!hasPlugin) { + addTargetToProject(tree, projectConfig, opts); + } + updateNxJsonConfiguration(tree, hasPlugin); + updateTsConfigForComponentTesting(tree, projectConfig); if (!opts.skipFormat) { @@ -117,30 +127,33 @@ function addTargetToProject( updateProjectConfiguration(tree, opts.project, projectConfig); } -function updateNxJsonConfiguration(tree: Tree) { +function updateNxJsonConfiguration(tree: Tree, hasPlugin: boolean) { const nxJson = readNxJson(tree); - const cacheableOperations: string[] | null = - nxJson.tasksRunnerOptions?.default?.options?.cacheableOperations; - if (cacheableOperations && !cacheableOperations.includes('component-test')) { - cacheableOperations.push('component-test'); + const productionFileSet = nxJson.namedInputs?.production; + if (productionFileSet) { + nxJson.namedInputs.production = Array.from( + new Set([ + ...productionFileSet, + '!{projectRoot}/cypress/**/*', + '!{projectRoot}/**/*.cy.[jt]s?(x)', + '!{projectRoot}/cypress.config.[jt]s', + ]) + ); } - nxJson.targetDefaults ??= {}; - nxJson.targetDefaults['component-test'] ??= {}; - nxJson.targetDefaults['component-test'].cache ??= true; - - if (nxJson.namedInputs) { - const productionFileSet = nxJson.namedInputs?.production; - if (productionFileSet) { - nxJson.namedInputs.production = Array.from( - new Set([ - ...productionFileSet, - '!{projectRoot}/cypress/**/*', - '!{projectRoot}/**/*.cy.[jt]s?(x)', - '!{projectRoot}/cypress.config.[jt]s', - ]) - ); + if (!hasPlugin) { + const cacheableOperations: string[] | null = + nxJson.tasksRunnerOptions?.default?.options?.cacheableOperations; + if ( + cacheableOperations && + !cacheableOperations.includes('component-test') + ) { + cacheableOperations.push('component-test'); } + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['component-test'] ??= {}; + nxJson.targetDefaults['component-test'].cache ??= true; + nxJson.targetDefaults['component-test'] ??= {}; nxJson.targetDefaults['component-test'].inputs ??= [ 'default', diff --git a/packages/cypress/src/generators/configuration/configuration.spec.ts b/packages/cypress/src/generators/configuration/configuration.spec.ts index 8d9ce68233730..f106d7cad0df9 100644 --- a/packages/cypress/src/generators/configuration/configuration.spec.ts +++ b/packages/cypress/src/generators/configuration/configuration.spec.ts @@ -10,6 +10,7 @@ import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; import cypressE2EConfigurationGenerator from './configuration'; import { installedCypressVersion } from '../../utils/cypress-version'; +import { cypressInitGenerator } from '../init/init'; jest.mock('../../utils/cypress-version'); @@ -33,6 +34,67 @@ describe('Cypress e2e configuration', () => { mockedInstalledCypressVersion.mockReturnValue(10); }); + it('should add dev server targets to the cypress config when the @nx/cypress/plugin is present', async () => { + process.env.NX_PCV3 = 'true'; + await cypressInitGenerator(tree, {}); + + addProject(tree, { name: 'my-app', type: 'apps' }); + + await cypressE2EConfigurationGenerator(tree, { + project: 'my-app', + }); + expect(tree.read('apps/my-app/cypress.config.ts', 'utf-8')) + .toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { + cypressDir: 'src', + devServerTargets: { + default: 'my-app:serve', + production: 'my-app:serve:production', + }, + ciDevServerTarget: 'my-app:serve-static', + }), + }, + }); + " + `); + expect( + readProjectConfiguration(tree, 'my-app').targets.e2e + ).toMatchInlineSnapshot(`undefined`); + + expect(readJson(tree, 'apps/my-app/tsconfig.json')) + .toMatchInlineSnapshot(` + { + "compilerOptions": { + "allowJs": true, + "module": "commonjs", + "outDir": "../../dist/out-tsc", + "sourceMap": false, + "types": [ + "cypress", + "node", + ], + }, + "extends": "../../tsconfig.base.json", + "include": [ + "**/*.ts", + "**/*.js", + "cypress.config.ts", + "**/*.cy.ts", + "**/*.cy.js", + "**/*.d.ts", + ], + } + `); + assertCypressFiles(tree, 'apps/my-app/src'); + delete process.env.NX_PCV3; + }); + it('should add e2e target to existing app', async () => { addProject(tree, { name: 'my-app', type: 'apps' }); @@ -42,16 +104,25 @@ describe('Cypress e2e configuration', () => { expect(tree.read('apps/my-app/cypress.config.ts', 'utf-8')) .toMatchInlineSnapshot(` "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; export default defineConfig({ - e2e: nxE2EPreset(__filename, { cypressDir: 'src' }), + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }) }, }); " `); expect(readProjectConfiguration(tree, 'my-app').targets.e2e) .toMatchInlineSnapshot(` { + "configurations": { + "ci": { + "devServerTarget": "my-app:serve-static", + }, + "production": { + "devServerTarget": "my-app:serve:production", + }, + }, "executor": "@nx/cypress:cypress", "options": { "cypressConfig": "apps/my-app/cypress.config.ts", @@ -99,10 +170,11 @@ describe('Cypress e2e configuration', () => { expect(tree.read('libs/my-lib/cypress.config.ts', 'utf-8')) .toMatchInlineSnapshot(` "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; export default defineConfig({ - e2e: nxE2EPreset(__filename, { cypressDir: 'cypress' }), + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'cypress' }) }, }); " `); @@ -337,12 +409,16 @@ export default defineConfig({ expect(tree.read('libs/my-lib/cypress.config.ts', 'utf-8')) .toMatchInlineSnapshot(` "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + import { defineConfig } from 'cypress'; import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; export default defineConfig({ component: nxComponentTestingPreset(__filename), - e2e: nxE2EPreset(__filename, { cypressDir: 'src' }), + e2e: { + ...nxE2EPreset(__filename, { cypressDir: 'src' }), + baseUrl: 'http://localhost:4200', + }, }); " `); @@ -394,7 +470,15 @@ function addProject( root: `${opts.type}/${opts.name}`, sourceRoot: `${opts.type}/${opts.name}`, targets: { - serve: opts.type === 'apps' ? {} : undefined, + serve: + opts.type === 'apps' + ? { + configurations: { + production: {}, + }, + } + : undefined, + 'serve-static': opts.type === 'apps' ? {} : undefined, }, }; diff --git a/packages/cypress/src/generators/configuration/configuration.ts b/packages/cypress/src/generators/configuration/configuration.ts index fb203b2784957..27658e5ee608d 100644 --- a/packages/cypress/src/generators/configuration/configuration.ts +++ b/packages/cypress/src/generators/configuration/configuration.ts @@ -1,11 +1,14 @@ import { addDependenciesToPackageJson, + createProjectGraphAsync, formatFiles, generateFiles, GeneratorCallback, joinPathFragments, offsetFromRoot, parseTargetString, + ProjectGraph, + readNxJson, readProjectConfiguration, runTasksInSerial, toJS, @@ -52,11 +55,20 @@ export async function configurationGenerator( if (!installedCypressVersion()) { tasks.push(await cypressInitGenerator(tree, opts)); } + const projectGraph = await createProjectGraphAsync(); + const nxJson = readNxJson(tree); + const hasPlugin = nxJson.plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/cypress/plugin' + : p.plugin === '@nx/cypress/plugin' + ); if (opts.bundler === 'vite') { tasks.push(addDependenciesToPackageJson(tree, {}, { vite: viteVersion })); } - await addFiles(tree, opts); - addTarget(tree, opts); + await addFiles(tree, opts, projectGraph, hasPlugin); + if (!hasPlugin) { + addTarget(tree, opts); + } const linterTask = await addLinterToCyProject(tree, { ...opts, @@ -89,18 +101,29 @@ In this case you need to provide a devServerTarget,':[: options.directory ??= 'src'; + const devServerTarget = + options.devServerTarget ?? + (projectConfig.targets.serve ? `${options.project}:serve` : undefined); + + if (!options.baseUrl && !devServerTarget) { + throw new Error('Either baseUrl or devServerTarget must be provided'); + } + return { ...options, bundler: options.bundler ?? 'webpack', rootProject: options.rootProject ?? projectConfig.root === '.', linter: options.linter ?? Linter.EsLint, - devServerTarget: - options.devServerTarget ?? - (projectConfig.targets.serve ? `${options.project}:serve` : undefined), + devServerTarget, }; } -async function addFiles(tree: Tree, options: NormalizedSchema) { +async function addFiles( + tree: Tree, + options: NormalizedSchema, + projectGraph: ProjectGraph, + hasPlugin: boolean +) { const projectConfig = readProjectConfiguration(tree, options.project); const cyVersion = installedCypressVersion(); const filesToUse = cyVersion && cyVersion < 10 ? 'v9' : 'v10'; @@ -141,13 +164,46 @@ async function addFiles(tree: Tree, options: NormalizedSchema) { }); const cyFile = joinPathFragments(projectConfig.root, 'cypress.config.ts'); + let devServerTargets: Record; + let ciDevServerTarget: string; + + if (hasPlugin && !options.baseUrl && options.devServerTarget) { + devServerTargets = {}; + + devServerTargets.default = options.devServerTarget; + const parsedTarget = parseTargetString( + options.devServerTarget, + projectGraph + ); + + const devServerProjectConfig = readProjectConfiguration( + tree, + parsedTarget.project + ); + // Add production e2e target if serve target is found + if ( + parsedTarget.configuration !== 'production' && + devServerProjectConfig.targets[parsedTarget.target]?.configurations?.[ + 'production' + ] + ) { + devServerTargets.production = `${parsedTarget.project}:${parsedTarget.target}:production`; + } + // Add ci/static e2e target if serve target is found + if (devServerProjectConfig.targets?.['serve-static']) { + ciDevServerTarget = `${parsedTarget.project}:serve-static`; + } + } const updatedCyConfig = await addDefaultE2EConfig( tree.read(cyFile, 'utf-8'), { - directory: options.directory, - bundler: options.bundler, - } + cypressDir: options.directory, + bundler: options.bundler === 'vite' ? 'vite' : undefined, + devServerTargets, + ciDevServerTarget: ciDevServerTarget, + }, + options.baseUrl ); tree.write(cyFile, updatedCyConfig); @@ -226,8 +282,6 @@ function addTarget(tree: Tree, opts: NormalizedSchema) { devServerTarget: `${parsedTarget.project}:serve-static`, }; } - } else { - throw new Error('Either baseUrl or devServerTarget must be provided'); } updateProjectConfiguration(tree, opts.project, projectConfig); diff --git a/packages/cypress/src/generators/init/init.spec.ts b/packages/cypress/src/generators/init/init.spec.ts index cd9a82aedd8ef..b16cb79799062 100644 --- a/packages/cypress/src/generators/init/init.spec.ts +++ b/packages/cypress/src/generators/init/init.spec.ts @@ -48,4 +48,51 @@ describe('init', () => { inputs: ['default', '^production'], }); }); + + it('should setup @nx/cypress/plugin', async () => { + process.env.NX_PCV3 = 'true'; + updateJson(tree, 'nx.json', (json) => { + json.namedInputs ??= {}; + json.namedInputs.production = ['default']; + return json; + }); + + await cypressInitGenerator(tree, {}); + + expect(readJson(tree, 'nx.json')) + .toMatchInlineSnapshot(` + { + "affected": { + "defaultBase": "main", + }, + "namedInputs": { + "production": [ + "default", + "!{projectRoot}/cypress/**/*", + "!{projectRoot}/**/*.cy.[jt]s?(x)", + "!{projectRoot}/cypress.config.[jt]s", + ], + }, + "plugins": [ + { + "options": { + "componentTestingTargetName": "component-test", + "targetName": "e2e", + }, + "plugin": "@nx/cypress/plugin", + }, + ], + "targetDefaults": { + "build": { + "cache": true, + }, + "lint": { + "cache": true, + }, + }, + } + `); + + delete process.env.NX_PCV3; + }); }); diff --git a/packages/cypress/src/generators/init/init.ts b/packages/cypress/src/generators/init/init.ts index 688c28a2704d6..db534eff08006 100644 --- a/packages/cypress/src/generators/init/init.ts +++ b/packages/cypress/src/generators/init/init.ts @@ -14,6 +14,7 @@ import { } from '../../utils/versions'; import { Schema } from './schema'; import { initGenerator } from '@nx/js'; +import { CypressPluginOptions } from '../../plugins/plugin'; function setupE2ETargetDefaults(tree: Tree) { const nxJson = readNxJson(tree); @@ -27,6 +28,7 @@ function setupE2ETargetDefaults(tree: Tree) { const productionFileSet = !!nxJson.namedInputs?.production; nxJson.targetDefaults.e2e ??= {}; + nxJson.targetDefaults.e2e.cache ??= true; nxJson.targetDefaults.e2e.inputs ??= [ 'default', productionFileSet ? '^production' : '^default', @@ -49,8 +51,53 @@ function updateDependencies(tree: Tree) { ); } +function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + + for (const plugin of nxJson.plugins) { + if ( + typeof plugin === 'string' + ? plugin === '@nx/cypress/plugin' + : plugin.plugin === '@nx/cypress/plugin' + ) { + return; + } + } + + nxJson.plugins.push({ + plugin: '@nx/cypress/plugin', + options: { + targetName: 'e2e', + componentTestingTargetName: 'component-test', + } as CypressPluginOptions, + }); + updateNxJson(tree, nxJson); +} + +function updateProductionFileset(tree: Tree) { + const nxJson = readNxJson(tree); + + const productionFileset = nxJson.namedInputs?.production; + if (productionFileset) { + nxJson.namedInputs.production = Array.from( + new Set([ + ...productionFileset, + '!{projectRoot}/cypress/**/*', + '!{projectRoot}/**/*.cy.[jt]s?(x)', + '!{projectRoot}/cypress.config.[jt]s', + ]) + ); + } + updateNxJson(tree, nxJson); +} + export async function cypressInitGenerator(tree: Tree, options: Schema) { - setupE2ETargetDefaults(tree); + const addPlugins = process.env.NX_PCV3 === 'true'; + updateProductionFileset(tree); + if (!addPlugins) { + setupE2ETargetDefaults(tree); + } const tasks: GeneratorCallback[] = []; @@ -61,6 +108,10 @@ export async function cypressInitGenerator(tree: Tree, options: Schema) { }) ); + if (addPlugins) { + addPlugin(tree); + } + if (!options.skipPackageJson) { tasks.push(updateDependencies(tree)); } diff --git a/packages/cypress/src/generators/init/schema.d.ts b/packages/cypress/src/generators/init/schema.d.ts index 44c34f897f10a..7efd4abb28389 100644 --- a/packages/cypress/src/generators/init/schema.d.ts +++ b/packages/cypress/src/generators/init/schema.d.ts @@ -2,4 +2,6 @@ export interface Schema { skipPackageJson?: boolean; skipFormat?: boolean; + + addPlugins?: boolean; } diff --git a/packages/cypress/src/migrations/update-17-2-0/__snapshots__/add-dev-server-targets-to-cypress-configs.spec.ts.snap b/packages/cypress/src/migrations/update-17-2-0/__snapshots__/add-dev-server-targets-to-cypress-configs.spec.ts.snap new file mode 100644 index 0000000000000..7de9fed306523 --- /dev/null +++ b/packages/cypress/src/migrations/update-17-2-0/__snapshots__/add-dev-server-targets-to-cypress-configs.spec.ts.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`add-dev-server-targets-to-cypress-configs migration should add dev server targets for default, production, and ci 1`] = ` +"import { defineConfig } from "cypress"; +import { nxE2EPreset } from "@nx/cypress/plugins/cypress-preset"; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { + devServerTargets: { + default: "my-app:serve", + production: "my-app:serve:production", + }, + ciDevServerTarget: "my-app:serve-static", + }), + }, +}); +" +`; + +exports[`add-dev-server-targets-to-cypress-configs migration should add devServerTargets to cypress.config.ts 1`] = ` +"import { defineConfig } from "cypress"; +import { nxE2EPreset } from "@nx/cypress/plugins/cypress-preset"; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { + devServerTargets: { default: "my-app:serve" }, + }), + }, +}); +" +`; + +exports[`add-dev-server-targets-to-cypress-configs migration should not add nx metadata for if there are none to add 1`] = ` +"import { defineConfig } from "cypress"; +import { nxE2EPreset } from "@nx/cypress/plugins/cypress-preset"; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename), + }, +}); +" +`; + +exports[`add-dev-server-targets-to-cypress-configs migration should update existing options with dev server targets for default, production, and ci 1`] = ` +"import { defineConfig } from "cypress"; +import { nxE2EPreset } from "@nx/cypress/plugins/cypress-preset"; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { + bundler: "vite", + devServerTargets: { + default: "my-app:serve", + production: "my-app:serve:production", + }, + ciDevServerTarget: "my-app:serve-static", + }), + }, +}); +" +`; diff --git a/packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.spec.ts b/packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.spec.ts new file mode 100644 index 0000000000000..24979919667a7 --- /dev/null +++ b/packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.spec.ts @@ -0,0 +1,153 @@ +import { createTree } from '@nx/devkit/testing'; +import { + addProjectConfiguration as _addProjectConfiguration, + ProjectGraph, + Tree, +} from '@nx/devkit'; +import update from './add-dev-server-targets-to-cypress-configs'; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest.fn().mockImplementation(async () => { + return projectGraph; + }), +})); + +function addProjectConfiguration(tree, name, project) { + _addProjectConfiguration(tree, name, project); + projectGraph.nodes[name] = { + name: name, + type: 'lib', + data: { + root: project.root, + targets: project.targets, + }, + }; +} + +describe('add-dev-server-targets-to-cypress-configs migration', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTree(); + + tree.write( + 'e2e/cypress.config.ts', + ` + import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename), + }, +}); + ` + ); + + projectGraph = { + nodes: {}, + dependencies: {}, + }; + }); + + it('should add devServerTargets to cypress.config.ts', async () => { + addProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: { + cypressConfig: 'e2e/cypress.config.ts', + devServerTarget: 'my-app:serve', + }, + }, + }, + }); + + await update(tree); + + expect(tree.read('e2e/cypress.config.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should add dev server targets for default, production, and ci', async () => { + addProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: { + cypressConfig: 'e2e/cypress.config.ts', + devServerTarget: 'my-app:serve', + }, + configurations: { + production: { + devServerTarget: 'my-app:serve:production', + }, + ci: { + devServerTarget: 'my-app:serve-static', + }, + }, + }, + }, + }); + await update(tree); + + expect(tree.read('e2e/cypress.config.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should update existing options with dev server targets for default, production, and ci', async () => { + tree.write( + 'e2e/cypress.config.ts', + ` + import { defineConfig } from 'cypress'; +import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +export default defineConfig({ + e2e: { + ...nxE2EPreset(__filename, { bundler: 'vite' }), + }, +}); + ` + ); + addProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: { + cypressConfig: 'e2e/cypress.config.ts', + devServerTarget: 'my-app:serve', + }, + configurations: { + production: { + devServerTarget: 'my-app:serve:production', + }, + ci: { + devServerTarget: 'my-app:serve-static', + }, + }, + }, + }, + }); + await update(tree); + + expect(tree.read('e2e/cypress.config.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should not add nx metadata for if there are none to add', async () => { + addProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: {}, + }, + }, + }); + await update(tree); + + expect(tree.read('e2e/cypress.config.ts', 'utf-8')).toMatchSnapshot(); + }); +}); diff --git a/packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.ts b/packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.ts new file mode 100644 index 0000000000000..2ae5d0777891d --- /dev/null +++ b/packages/cypress/src/migrations/update-17-2-0/add-dev-server-targets-to-cypress-configs.ts @@ -0,0 +1,176 @@ +import { + applyChangesToString, + ChangeType, + createProjectGraphAsync, + formatFiles, + getProjects, + StringChange, + Tree, +} from '@nx/devkit'; +import { CypressExecutorOptions } from '../../executors/cypress/cypress.impl'; +import { forEachExecutorOptionsInGraph } from '@nx/devkit/src/generators/executor-options-utils'; +import { + createSourceFile, + forEachChild, + isCallExpression, + isIdentifier, + isObjectLiteralExpression, + Node, + ScriptTarget, +} from 'typescript'; + +function addDevServerTargets( + tree: Tree, + cypressConfig: string, + devServerTargets: Record +) { + const contents = tree.read(cypressConfig, 'utf-8'); + + const sourceFile = createSourceFile( + cypressConfig, + contents, + ScriptTarget.ESNext + ); + + const ciDevServerTarget = devServerTargets.ci; + delete devServerTargets.ci; + + const changes: StringChange[] = []; + + const visit = (node: Node) => { + if ( + isCallExpression(node) && + isIdentifier(node.expression) && + node.expression.text === 'nxE2EPreset' + ) { + const argumentContents = + ', ' + + JSON.stringify({ + devServerTargets, + ciDevServerTarget, + }); + if (node.arguments.length === 1) { + changes.push({ + type: ChangeType.Insert, + index: node.arguments[0].getEnd(), + text: argumentContents, + }); + } else { + const lastArgument = node.arguments[node.arguments.length - 1]; + + if (isObjectLiteralExpression(lastArgument)) { + const lastProperty = + lastArgument.properties[lastArgument.properties.length - 1]; + + const trailingComma = lastArgument.properties.hasTrailingComma; + + changes.push({ + type: ChangeType.Insert, + index: lastProperty.getEnd(), + text: + (trailingComma ? '' : ', ') + + 'devServerTargets: ' + + JSON.stringify(devServerTargets) + + (ciDevServerTarget + ? `, ciDevServerTarget: '${ciDevServerTarget}'` + : ''), + }); + } + } + } else { + forEachChild(node, visit); + } + }; + + forEachChild(sourceFile, visit); + + tree.write(cypressConfig, applyChangesToString(contents, changes)); +} + +export default async function update(tree: Tree) { + const projects = getProjects(tree); + const graph = await createProjectGraphAsync(); + + const devServerTargetsMap = new Map>(); + forEachExecutorOptionsInGraph( + graph, + '@nx/cypress:cypress', + (options, project, target, configuration) => { + const targetConfig = projects.get(project).targets?.[target]; + + if (!targetConfig) { + return; + } + + const cypressConfig = + options.cypressConfig ?? targetConfig.options?.cypressConfig; + if (!cypressConfig) { + return; + } + const devServerTargets: Record = {}; + + devServerTargets.default = targetConfig.options?.devServerTarget; + for (const [configuration, configurationOptions] of Object.entries( + targetConfig.configurations ?? {} + )) { + devServerTargets[configuration] = configurationOptions.devServerTarget; + } + + devServerTargetsMap.set(cypressConfig, devServerTargets); + } + ); + + for (const [cypressConfig, devServerTargets] of devServerTargetsMap) { + addDevServerTargets(tree, cypressConfig, devServerTargets); + } + + // const configFiles = glob(tree, [createNodes[0]]); + // + // const proj = Object.fromEntries(getProjects(tree).entries()); + // + // const rootMappings = createProjectRootMappingsFromProjectConfigurations(proj); + // + // for (const configFile of configFiles) { + // const siblings = tree.children(dirname(configFile)); + // if (!siblings.includes('project.json')) { + // continue; + // } + // + // const projectName = findProjectForPath(configFile, rootMappings); + // const projectConfig = readProjectConfiguration(tree, projectName); + // const e2eTarget: TargetConfiguration = + // projectConfig.targets?.e2e; + // + // if (!e2eTarget || e2eTarget.executor !== '@nx/cypress:cypress') { + // continue; + // } + // + // nxMetadata.devServerTarget = e2eTarget.options?.devServerTarget; + // nxMetadata.productionDevServerTarget = + // e2eTarget.configurations?.production?.devServerTarget; + // nxMetadata.ciDevServerTarget = + // e2eTarget.configurations?.ci?.devServerTarget; + // + // if (Object.values(nxMetadata).filter((v) => !!v).length > 0) { + // let contents = tree.read(configFile, 'utf-8'); + // + // contents = + // `import type { NxCypressMetadata } from '@nx/cypress/plugin';\n` + + // contents + + // ` + // + // /** + // * This is metadata for the @nx/cypress/plugin + // */ + // export const nx: NxCypressMetadata = ${JSON.stringify( + // nxMetadata, + // null, + // 2 + // )};`; + // + // tree.write(configFile, contents); + // } + // } + + await formatFiles(tree); +} diff --git a/packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.spec.ts b/packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.spec.ts new file mode 100644 index 0000000000000..68982ea551520 --- /dev/null +++ b/packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.spec.ts @@ -0,0 +1,154 @@ +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { + readProjectConfiguration, + Tree, + updateProjectConfiguration, +} from '@nx/devkit'; + +import update from './add-nx-cypress-plugin'; +import { defineConfig } from 'cypress'; +import { TempFs } from 'nx/src/internal-testing-utils/temp-fs'; +import { join } from 'path'; + +describe('add-nx-cypress-plugin migration', () => { + let tree: Tree; + let tempFs: TempFs; + + function mockCypressConfig(cypressConfig: Cypress.ConfigOptions) { + jest.mock( + join(tempFs.tempDir, 'e2e/cypress.config.ts'), + () => ({ + default: cypressConfig, + }), + { + virtual: true, + } + ); + } + + beforeEach(async () => { + tempFs = new TempFs('test'); + tree = createTreeWithEmptyWorkspace(); + tree.root = tempFs.tempDir; + await tempFs.createFiles({ + 'e2e/cypress.config.ts': '', + 'e2e/project.json': '{ "name": "e2e" }', + }); + tree.write('e2e/cypress.config.ts', `console.log('hi');`); + }); + + afterEach(() => { + jest.resetModules(); + tempFs.cleanup(); + }); + + it('should remove the e2e target when there are no other options', async () => { + mockCypressConfig( + defineConfig({ + env: { + devServerTargets: { + default: 'my-app:serve', + production: 'my-app:serve:production', + }, + ciDevServerTarget: 'my-app:serve-static', + }, + e2e: {}, + }) + ); + updateProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: { + devServerTarget: 'my-app:serve', + }, + configurations: { + production: { + devServerTarget: 'my-app:serve:production', + }, + ci: { + devServerTarget: 'my-app:serve-static', + }, + }, + }, + }, + }); + + await update(tree); + + expect(readProjectConfiguration(tree, 'e2e').targets.e2e).toBeUndefined(); + }); + + it('should not the e2e target when it uses a different executor', async () => { + const e2eTarget = { + executor: '@nx/playwright:playwright', + options: { + devServerTarget: 'my-app:serve', + }, + configurations: { + production: { + devServerTarget: 'my-app:serve:production', + }, + ci: { + devServerTarget: 'my-app:serve-static', + }, + }, + }; + updateProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: e2eTarget, + }, + }); + + await update(tree); + + expect(readProjectConfiguration(tree, 'e2e').targets.e2e).toEqual( + e2eTarget + ); + }); + + it('should leave the e2e target with other options', async () => { + mockCypressConfig( + defineConfig({ + env: { + devServerTargets: { + default: 'my-app:serve', + production: 'my-app:serve:production', + }, + ciDevServerTarget: 'my-app:serve-static', + }, + e2e: {}, + }) + ); + updateProjectConfiguration(tree, 'e2e', { + root: 'e2e', + targets: { + e2e: { + executor: '@nx/cypress:cypress', + options: { + devServerTarget: 'my-app:serve', + watch: false, + }, + configurations: { + production: { + devServerTarget: 'my-app:serve:production', + }, + ci: { + devServerTarget: 'my-app:serve-static', + }, + }, + }, + }, + }); + + await update(tree); + + expect(readProjectConfiguration(tree, 'e2e').targets.e2e).toEqual({ + options: { + watch: false, + }, + }); + }); +}); diff --git a/packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.ts b/packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.ts new file mode 100644 index 0000000000000..43ede38c70019 --- /dev/null +++ b/packages/cypress/src/migrations/update-17-2-0/add-nx-cypress-plugin.ts @@ -0,0 +1,24 @@ +import { formatFiles, getProjects, Tree } from '@nx/devkit'; +import { createNodes } from '../../plugins/plugin'; + +import { createProjectRootMappingsFromProjectConfigurations } from 'nx/src/project-graph/utils/find-project-for-path'; +import { replaceProjectConfigurationsWithPlugin } from '@nx/devkit/src/utils/replace-project-configuration-with-plugin'; + +export default async function update(tree: Tree) { + const proj = Object.fromEntries(getProjects(tree).entries()); + + const rootMappings = createProjectRootMappingsFromProjectConfigurations(proj); + + replaceProjectConfigurationsWithPlugin( + tree, + rootMappings, + '@nx/cypress/plugin', + createNodes, + { + targetName: 'e2e', + componentTestingTargetName: 'component-test', + } + ); + + await formatFiles(tree); +} diff --git a/packages/cypress/src/plugins/plugin.spec.ts b/packages/cypress/src/plugins/plugin.spec.ts new file mode 100644 index 0000000000000..ccdd3f6fe31fd --- /dev/null +++ b/packages/cypress/src/plugins/plugin.spec.ts @@ -0,0 +1,200 @@ +import { CreateNodesContext } from '@nx/devkit'; +import { defineConfig } from 'cypress'; + +import { createNodes } from './plugin'; + +describe('@nx/cypress/plugin', () => { + let createNodesFunction = createNodes[1]; + let context: CreateNodesContext; + + beforeEach(async () => { + context = { + nxJsonConfiguration: { + namedInputs: { + default: ['{projectRoot}/**/*'], + production: ['!{projectRoot}/**/*.spec.ts'], + }, + }, + workspaceRoot: '', + }; + }); + + afterEach(() => { + jest.resetModules(); + }); + + it('should add a target for e2e', () => { + mockCypressConfig( + defineConfig({ + e2e: { + videosFolder: './dist/videos', + screenshotsFolder: './dist/screenshots', + }, + }) + ); + const nodes = createNodesFunction( + 'cypress.config.js', + { + targetName: 'e2e', + }, + context + ); + + expect(nodes).toMatchInlineSnapshot(` + { + "projects": { + ".": { + "projectType": "application", + "root": ".", + "targets": { + "e2e": { + "cache": true, + "executor": "@nx/cypress:cypress", + "inputs": [ + "default", + "^production", + ], + "options": { + "cypressConfig": "cypress.config.js", + "testingType": "e2e", + }, + "outputs": [ + "{options.videosFolder}", + "{options.screenshotsFolder}", + "{projectRoot}/dist/videos", + "{projectRoot}/dist/screenshots", + ], + }, + }, + }, + }, + } + `); + }); + + it('should add a target for component testing', () => { + mockCypressConfig( + defineConfig({ + component: { + videosFolder: './dist/videos', + screenshotsFolder: './dist/screenshots', + devServer: { + framework: 'create-react-app', + bundler: 'webpack', + }, + }, + }) + ); + const nodes = createNodesFunction( + 'cypress.config.js', + { + componentTestingTargetName: 'component-test', + }, + context + ); + + expect(nodes).toMatchInlineSnapshot(` + { + "projects": { + ".": { + "projectType": "application", + "root": ".", + "targets": { + "component-test": { + "cache": true, + "executor": "@nx/cypress:cypress", + "inputs": [ + "default", + "^production", + ], + "options": { + "cypressConfig": "cypress.config.js", + "testingType": "component", + }, + "outputs": [ + "{options.videosFolder}", + "{options.screenshotsFolder}", + "{projectRoot}/dist/videos", + "{projectRoot}/dist/screenshots", + ], + }, + }, + }, + }, + } + `); + }); + + it('should use nxMetadata to create additional configurations', () => { + mockCypressConfig( + defineConfig({ + e2e: { + env: { + devServerTargets: { + default: 'my-app:serve', + production: 'my-app:serve:production', + }, + ciDevServerTarget: 'my-app:serve-static', + }, + }, + }) + ); + const nodes = createNodesFunction( + 'cypress.config.js', + { + componentTestingTargetName: 'component-test', + }, + context + ); + + expect(nodes).toMatchInlineSnapshot(` + { + "projects": { + ".": { + "projectType": "application", + "root": ".", + "targets": { + "e2e": { + "cache": true, + "configurations": { + "ci": { + "devServerTarget": "my-app:serve-static", + }, + "production": { + "devServerTarget": "my-app:serve:production", + }, + }, + "executor": "@nx/cypress:cypress", + "inputs": [ + "default", + "^production", + ], + "options": { + "cypressConfig": "cypress.config.js", + "devServerTarget": "my-app:serve", + "testingType": "e2e", + }, + "outputs": [ + "{options.videosFolder}", + "{options.screenshotsFolder}", + ], + }, + }, + }, + }, + } + `); + }); +}); + +function mockCypressConfig(cypressConfig: Cypress.ConfigOptions) { + jest.mock( + 'cypress.config.js', + () => ({ + default: cypressConfig, + }), + { + virtual: true, + } + ); +} diff --git a/packages/cypress/src/plugins/plugin.ts b/packages/cypress/src/plugins/plugin.ts new file mode 100644 index 0000000000000..d6319b38a1891 --- /dev/null +++ b/packages/cypress/src/plugins/plugin.ts @@ -0,0 +1,264 @@ +import { + CreateNodes, + CreateNodesContext, + TargetConfiguration, +} from '@nx/devkit'; +import { basename, dirname, extname, join } from 'path'; +import { registerTsProject } from '@nx/js/src/internal'; + +import { getRootTsConfigPath } from '@nx/js'; + +import { CypressExecutorOptions } from '../executors/cypress/cypress.impl'; +import { readTargetDefaultsForTarget } from 'nx/src/project-graph/utils/project-configuration-utils'; +import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; +import { readdirSync } from 'fs'; + +export interface CypressPluginOptions { + targetName?: string; + componentTestingTargetName?: string; +} + +export const createNodes: CreateNodes = [ + '**/cypress.config.{js,ts,mjs,mts,cjs,cts}', + (configFilePath, options, context) => { + options = normalizeOptions(options); + const projectRoot = dirname(configFilePath); + + // Do not create a project if package.json and project.json isn't there. + const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot)); + if ( + !siblingFiles.includes('package.json') && + !siblingFiles.includes('project.json') + ) { + return {}; + } + + const projectName = basename(projectRoot); + + return { + projects: { + [projectName]: { + root: projectRoot, + projectType: 'application', + targets: buildCypressTargets( + configFilePath, + projectRoot, + options, + context + ), + }, + }, + }; + }, +]; + +function getOutputs( + projectRoot: string, + cypressConfig: any, + testingType: 'e2e' | 'component' +): string[] { + function getOutput(path: string): string { + if (path.startsWith('..')) { + return join('{workspaceRoot}', join(projectRoot, path)); + } else { + return join('{projectRoot}', path); + } + } + + const { screenshotsFolder, videosFolder, e2e, component } = cypressConfig; + const outputs = ['{options.videosFolder}', '{options.screenshotsFolder}']; + + if (videosFolder) { + outputs.push(getOutput(videosFolder)); + } + + if (screenshotsFolder) { + outputs.push(getOutput(screenshotsFolder)); + } + + switch (testingType) { + case 'e2e': { + if (e2e.videosFolder) { + outputs.push(getOutput(e2e.videosFolder)); + } + if (e2e.screenshotsFolder) { + outputs.push(getOutput(e2e.screenshotsFolder)); + } + break; + } + case 'component': { + if (component.videosFolder) { + outputs.push(getOutput(component.videosFolder)); + } + if (component.screenshotsFolder) { + outputs.push(getOutput(component.screenshotsFolder)); + } + break; + } + } + + return outputs; +} + +function buildCypressTargets( + configFilePath: string, + projectRoot: string, + options: CypressPluginOptions, + context: CreateNodesContext +) { + const cypressConfig = getCypressConfig(configFilePath, context); + + const namedInputs = getNamedInputs(projectRoot, context); + + const baseTargetConfig: TargetConfiguration = { + executor: '@nx/cypress:cypress', + options: { + cypressConfig: configFilePath, + }, + }; + + const targets: Record< + string, + TargetConfiguration + > = {}; + + if ('e2e' in cypressConfig) { + const e2eTargetDefaults = readTargetDefaultsForTarget( + options.targetName, + context.nxJsonConfiguration.targetDefaults, + '@nx/cypress:cypress' + ); + + targets[options.targetName] = { + ...baseTargetConfig, + options: { + ...baseTargetConfig.options, + testingType: 'e2e', + }, + }; + + if (e2eTargetDefaults?.cache === undefined) { + targets[options.targetName].cache = true; + } + + if (e2eTargetDefaults?.inputs === undefined) { + targets[options.targetName].inputs = + 'production' in namedInputs + ? ['default', '^production'] + : ['default', '^default']; + } + + if (e2eTargetDefaults?.outputs === undefined) { + targets[options.targetName].outputs = getOutputs( + projectRoot, + cypressConfig, + 'e2e' + ); + } + + const cypressEnv = { + ...cypressConfig.env, + ...cypressConfig.e2e?.env, + }; + + const devServerTargets: Record = + cypressEnv?.devServerTargets; + + if (devServerTargets?.default) { + targets[options.targetName].options.devServerTarget = + devServerTargets.default; + delete devServerTargets.default; + } + + if (Object.keys(devServerTargets ?? {}).length > 0) { + targets[options.targetName].configurations ??= {}; + for (const [configuration, devServerTarget] of Object.entries( + devServerTargets ?? {} + )) { + targets[options.targetName].configurations[configuration] = { + devServerTarget, + }; + } + } + + const ciDevServerTarget: string = cypressEnv?.ciDevServerTarget; + if (ciDevServerTarget) { + targets[options.targetName].configurations ??= {}; + + targets[options.targetName].configurations['ci'] = { + devServerTarget: ciDevServerTarget, + }; + } + } + + if ('component' in cypressConfig) { + const componentTestingTargetDefaults = readTargetDefaultsForTarget( + options.componentTestingTargetName, + context.nxJsonConfiguration.targetDefaults, + '@nx/cypress:cypress' + ); + + // This will not override the e2e target if it is the same + targets[options.componentTestingTargetName] ??= { + ...baseTargetConfig, + options: { + ...baseTargetConfig.options, + testingType: 'component', + }, + }; + + if (componentTestingTargetDefaults?.cache === undefined) { + targets[options.componentTestingTargetName].cache = true; + } + + if (componentTestingTargetDefaults?.inputs === undefined) { + targets[options.componentTestingTargetName].inputs = + 'production' in namedInputs + ? ['default', '^production'] + : ['default', '^default']; + } + + if (componentTestingTargetDefaults?.outputs === undefined) { + targets[options.componentTestingTargetName].outputs = getOutputs( + projectRoot, + cypressConfig, + 'component' + ); + } + } + + return targets; +} + +function getCypressConfig( + configFilePath: string, + context: CreateNodesContext +): any { + const resolvedPath = join(context.workspaceRoot, configFilePath); + + let module: any; + if (['.ts', '.mts', '.cts'].includes(extname(configFilePath))) { + const tsConfigPath = getRootTsConfigPath(); + + if (tsConfigPath) { + const unregisterTsProject = registerTsProject(tsConfigPath); + try { + module = require(resolvedPath); + } finally { + unregisterTsProject(); + } + } else { + module = require(resolvedPath); + } + } else { + module = require(resolvedPath); + } + return module.default ?? module; +} + +function normalizeOptions(options: CypressPluginOptions): CypressPluginOptions { + options ??= {}; + options.targetName ??= 'e2e'; + options.componentTestingTargetName ??= 'component-test'; + return options; +} diff --git a/packages/cypress/src/utils/config.spec.ts b/packages/cypress/src/utils/config.spec.ts index 0b5fb06512d21..1833a3ab2931c 100644 --- a/packages/cypress/src/utils/config.spec.ts +++ b/packages/cypress/src/utils/config.spec.ts @@ -1,15 +1,9 @@ -import { Tree } from '@nx/devkit'; -import { createTree } from '@nx/devkit/testing'; import { addDefaultCTConfig, addDefaultE2EConfig, addMountDefinition, } from './config'; describe('Cypress Config parser', () => { - let tree: Tree; - beforeEach(() => { - tree = createTree(); - }); it('should add CT config to existing e2e config', async () => { const actual = await addDefaultCTConfig( `import { defineConfig } from 'cypress'; @@ -42,17 +36,19 @@ export default defineConfig({ }); `, { - directory: 'cypress', - } + cypressDir: 'cypress', + }, + undefined ); expect(actual).toMatchInlineSnapshot(` "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; - import { defineConfig } from 'cypress'; + + import { defineConfig } from 'cypress'; import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; export default defineConfig({ component: nxComponentTestingPreset(__filename), - e2e: nxE2EPreset(__filename, { cypressDir: 'cypress' }) + e2e: { ...nxE2EPreset(__filename, {"cypressDir":"cypress"}) } }); " `); @@ -69,8 +65,9 @@ export default defineConfig({ `, { - directory: 'cypress', - } + cypressDir: 'cypress', + }, + undefined ); expect(actual).toMatchInlineSnapshot(` @@ -101,8 +98,9 @@ export default defineConfig({ `, { - directory: 'cypress', - } + cypressDir: 'cypress', + }, + undefined ); expect(actual).toMatchInlineSnapshot(` "import { defineConfig } from 'cypress'; @@ -121,6 +119,64 @@ export default defineConfig({ `); }); + it('should add baseUrl config', async () => { + const actual = await addDefaultE2EConfig( + `import { defineConfig } from 'cypress'; +import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; + +export default defineConfig({ +}); +`, + { + cypressDir: 'cypress', + }, + 'https://example.com' + ); + expect(actual).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + + import { defineConfig } from 'cypress'; + import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; + + export default defineConfig({ + e2e: { ...nxE2EPreset(__filename, {"cypressDir":"cypress"}), + baseUrl: 'https://example.com' } + }); + " + `); + }); + + it('should add nx metadata for @nx/cypress/plugin', async () => { + const actual = await addDefaultE2EConfig( + `import { defineConfig } from 'cypress'; +import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; + +export default defineConfig({ +}); +`, + { + cypressDir: 'cypress', + devServerTargets: { + default: 'my-app:serve', + production: 'my-app:serve:production', + }, + ciDevServerTarget: 'my-app:serve-static', + }, + undefined + ); + expect(actual).toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + + import { defineConfig } from 'cypress'; + import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing'; + + export default defineConfig({ + e2e: { ...nxE2EPreset(__filename, {"cypressDir":"cypress","devServerTargets":{"default":"my-app:serve","production":"my-app:serve:production"},"ciDevServerTarget":"my-app:serve-static"}) } + }); + " + `); + }); + it('should add a mount config', async () => { const actual = await addMountDefinition( `/// diff --git a/packages/cypress/src/utils/config.ts b/packages/cypress/src/utils/config.ts index 7597a603e96f4..a62d0dbb691d0 100644 --- a/packages/cypress/src/utils/config.ts +++ b/packages/cypress/src/utils/config.ts @@ -1,4 +1,3 @@ -import type { Tree } from '@nx/devkit'; import type { InterfaceDeclaration, MethodSignature, @@ -6,13 +5,15 @@ import type { PropertyAssignment, PropertySignature, } from 'typescript'; +import { NxCypressE2EPresetOptions } from '../../plugins/cypress-preset'; const TS_QUERY_EXPORT_CONFIG_PREFIX = ':matches(ExportAssignment, BinaryExpression:has(Identifier[name="module"]):has(Identifier[name="exports"]))'; export async function addDefaultE2EConfig( cyConfigContents: string, - options: { directory: string; bundler?: string } + options: NxCypressE2EPresetOptions, + baseUrl: string ) { if (!cyConfigContents) { throw new Error('The passed in cypress config file is empty!'); @@ -27,29 +28,29 @@ export async function addDefaultE2EConfig( let updatedConfigContents = cyConfigContents; if (testingTypeConfig.length === 0) { - const configValue = - options.bundler === 'vite' - ? `nxE2EPreset(__filename, { cypressDir: '${options.directory}', bundler: 'vite' })` - : `nxE2EPreset(__filename, { cypressDir: '${options.directory}' })`; + const configValue = `nxE2EPreset(__filename, ${JSON.stringify(options)})`; updatedConfigContents = tsquery.replace( cyConfigContents, `${TS_QUERY_EXPORT_CONFIG_PREFIX} ObjectLiteralExpression:first-child`, (node: ObjectLiteralExpression) => { + let baseUrlContents = baseUrl ? `,\nbaseUrl: '${baseUrl}'` : ''; if (node.properties.length > 0) { return `{ ${node.properties.map((p) => p.getText()).join(',\n')}, - e2e: ${configValue} + e2e: { ...${configValue}${baseUrlContents} } }`; } return `{ - e2e: ${configValue} + e2e: { ...${configValue}${baseUrlContents} } }`; } ); - updatedConfigContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';\n${updatedConfigContents}`; - } + return `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + + ${updatedConfigContents}`; + } return updatedConfigContents; } diff --git a/packages/cypress/src/utils/start-dev-server.ts b/packages/cypress/src/utils/start-dev-server.ts new file mode 100644 index 0000000000000..ea6f2d5c44b71 --- /dev/null +++ b/packages/cypress/src/utils/start-dev-server.ts @@ -0,0 +1,218 @@ +import { + ExecutorContext, + logger, + output, + parseTargetString, + readTargetOptions, + runExecutor, + stripIndents, + Target, + targetToTargetString, +} from '@nx/devkit'; +import { join } from 'path'; +import { CypressExecutorOptions } from '../executors/cypress/cypress.impl'; +import * as detectPort from 'detect-port'; +import { getExecutorInformation } from 'nx/src/command-line/run/executor-utils'; +import { existsSync, writeFileSync } from 'fs'; + +export async function* startDevServer( + opts: Omit, + context: ExecutorContext +) { + // no dev server, return the provisioned base url + if (!opts.devServerTarget || opts.skipServe) { + yield { baseUrl: opts.baseUrl }; + return; + } + + const parsedDevServerTarget = parseTargetString( + opts.devServerTarget, + context + ); + + const [targetSupportsWatchOpt] = getValueFromSchema( + context, + parsedDevServerTarget, + 'watch' + ); + + const overrides: Record = { + // @NOTE: Do not forward watch option if not supported by the target dev server, + // this is relevant for running Cypress against dev server target that does not support this option, + // for instance @nguniversal/builders:ssr-dev-server. + ...(targetSupportsWatchOpt ? { watch: opts.watch } : {}), + }; + + if (opts.port === 'cypress-auto') { + const freePort = await getPortForProject(context, parsedDevServerTarget); + overrides['port'] = freePort; + } else if (opts.port !== undefined) { + overrides['port'] = opts.port; + // zero is a special case that means any valid port so there is no reason to try to 'lock it' + if (opts.port !== 0) { + const didLock = attemptToLockPort(opts.port); + if (!didLock) { + logger.warn( + stripIndents`${opts.port} is potentially already in use by another cypress run. +If the port is in use, try using a different port value or passing --port='cypress-auto' to find a free port.` + ); + } + } + } + + for await (const output of await runExecutor<{ + success: boolean; + baseUrl?: string; + port?: string; + info?: { port: number; baseUrl?: string }; + }>(parsedDevServerTarget, overrides, context)) { + if (!output.success && !opts.watch) + throw new Error('Could not compile application files'); + if ( + !opts.baseUrl && + !output.baseUrl && + !output.info?.baseUrl && + (output.port || output.info?.port) + ) { + output.baseUrl = `http://localhost:${output.info.port}`; + } + yield { + baseUrl: opts.baseUrl || output.baseUrl || output.info?.baseUrl, + portLockFilePath: + overrides.port && join(__dirname, `${overrides.port}.txt`), + }; + } +} + +/** + * try to find a free port for the project to run on + * will return undefined if no port is found or the project doesn't have a port option + **/ +async function getPortForProject( + context: ExecutorContext, + target: Target, + defaultPort = 4200 +) { + const fmtTarget = targetToTargetString(target); + const [hasPortOpt, schemaPortValue] = getValueFromSchema( + context, + target, + 'port' + ); + + let freePort: number | undefined; + + if (hasPortOpt) { + let normalizedPortValue: number; + if (!schemaPortValue) { + logger.info( + `NX ${fmtTarget} did not have a defined port value, checking for free port with the default value of ${defaultPort}` + ); + normalizedPortValue = defaultPort; + } else { + normalizedPortValue = Number(schemaPortValue); + } + + if (isNaN(normalizedPortValue)) { + output.warn({ + title: `Port Not a Number`, + bodyLines: [ + `The port value found was not a number or can't be parsed to a number`, + `When reading the devServerTarget (${fmtTarget}) schema, expected ${schemaPortValue} to be a number but got NaN.`, + `Nx will use the default value of ${defaultPort} instead.`, + `You can manually specify a port by setting the 'port' option`, + ], + }); + normalizedPortValue = defaultPort; + } + try { + let attempts = 0; + // make sure when this check happens in parallel, + // we don't let the same port be used by multiple projects + do { + freePort = await detectPort(freePort || normalizedPortValue); + if (attemptToLockPort(freePort)) { + break; + } + attempts++; + // increment port in case the lock file isn't cleaned up + freePort++; + } while (attempts < 20); + + logger.info(`NX Using port ${freePort} for ${fmtTarget}`); + } catch (err) { + throw new Error( + stripIndents`Unable to find a free port for the dev server, ${fmtTarget}. +You can disable auto port detection by specifing a port or not passing a value to --port` + ); + } + } else { + output.warn({ + title: `No Port Option Found`, + bodyLines: [ + `The 'port' option is set to 'cypress-auto', but the devServerTarget (${fmtTarget}) does not have a port option.`, + `Because of this, Nx is unable to verify the port is free before starting the dev server.`, + `This might cause issues if the devServerTarget is trying to use a port that is already in use.`, + ], + }); + } + + return freePort; +} + +/** + * Check if the given target has the given property in it's options. + * if the property is does not have a default value or is not in the actual executor options, + * the value will be undefined even if it's in the executor schema. + **/ +function getValueFromSchema( + context: ExecutorContext, + target: Target, + property: string +): [hasPropertyOpt: boolean, value?: unknown] { + let targetOpts: any; + try { + targetOpts = readTargetOptions(target, context); + } catch (e) { + throw new Error(`Unable to read the target options for ${targetToTargetString( + target + )}. +Are you sure this is a valid target? +Was trying to read the target for the property: '${property}', but got the following error: +${e.message || e}`); + } + let targetHasOpt = Object.keys(targetOpts).includes(property); + + if (!targetHasOpt) { + // NOTE: readTargetOptions doesn't apply non defaulted values, i.e. @nx/vite has a port options but is optional + // so we double check the schema if readTargetOptions didn't return a value for the property + const projectConfig = + context.projectsConfigurations?.projects?.[target.project]; + const targetConfig = projectConfig.targets[target.target]; + + const [collection, executor] = targetConfig.executor.split(':'); + const { schema } = getExecutorInformation( + collection, + executor, + context.root + ); + + // NOTE: schema won't have a default since readTargetOptions would have + // already set that and this check wouldn't need to be made + targetHasOpt = Object.keys(schema.properties).includes(property); + } + return [targetHasOpt, targetOpts[property]]; +} + +function attemptToLockPort(port: number): boolean { + const portLockFilePath = join(__dirname, `${port}.txt`); + try { + if (existsSync(portLockFilePath)) { + return false; + } + writeFileSync(portLockFilePath, 'locked'); + return true; + } catch (err) { + return false; + } +} diff --git a/packages/nx/src/generators/testing-utils/create-tree-with-empty-workspace.ts b/packages/nx/src/generators/testing-utils/create-tree-with-empty-workspace.ts index f6344adeff5e6..005fc37e25d9b 100644 --- a/packages/nx/src/generators/testing-utils/create-tree-with-empty-workspace.ts +++ b/packages/nx/src/generators/testing-utils/create-tree-with-empty-workspace.ts @@ -43,9 +43,6 @@ function addCommonFiles(tree: Tree, addAppsAndLibsFolders: boolean): Tree { lint: { cache: true, }, - e2e: { - cache: true, - }, }, }) ); diff --git a/packages/playwright/src/generators/configuration/configuration.ts b/packages/playwright/src/generators/configuration/configuration.ts index b2279bd122362..b197fb450a96b 100644 --- a/packages/playwright/src/generators/configuration/configuration.ts +++ b/packages/playwright/src/generators/configuration/configuration.ts @@ -72,6 +72,7 @@ function setupE2ETargetDefaults(tree: Tree) { const productionFileSet = !!nxJson.namedInputs?.production; nxJson.targetDefaults.e2e ??= {}; + nxJson.targetDefaults.e2e.cache ??= true; nxJson.targetDefaults.e2e.inputs ??= [ 'default', productionFileSet ? '^production' : '^default', diff --git a/packages/react/src/generators/init/init.ts b/packages/react/src/generators/init/init.ts index 0d749a532a24b..b1b71937c85e6 100755 --- a/packages/react/src/generators/init/init.ts +++ b/packages/react/src/generators/init/init.ts @@ -77,10 +77,10 @@ export async function reactInitGenerator(host: Tree, schema: InitSchema) { if (!schema.e2eTestRunner || schema.e2eTestRunner === 'cypress') { ensurePackage('@nx/cypress', nxVersion); - const { cypressInitGenerator } = await import( + const { cypressInitGenerator } = (await import( '@nx/cypress/src/generators/init/init' - ); - const cypressTask = await cypressInitGenerator(host, {}); + )) as typeof import('@nx/cypress/src/generators/init/init'); + const cypressTask = await cypressInitGenerator(host, schema); tasks.push(cypressTask); } diff --git a/packages/storybook/src/migrations/update-16-5-0/__snapshots__/move-storybook-tsconfig.spec.ts.snap b/packages/storybook/src/migrations/update-16-5-0/__snapshots__/move-storybook-tsconfig.spec.ts.snap index 8efb7dbd3f0f0..8fa23d4051066 100644 --- a/packages/storybook/src/migrations/update-16-5-0/__snapshots__/move-storybook-tsconfig.spec.ts.snap +++ b/packages/storybook/src/migrations/update-16-5-0/__snapshots__/move-storybook-tsconfig.spec.ts.snap @@ -115,9 +115,6 @@ exports[`Ignore @nx/react/plugins/storybook in Storybook eslint plugin should up "{projectRoot}/tsconfig.storybook.json", ], }, - "e2e": { - "cache": true, - }, "lint": { "cache": true, }, diff --git a/packages/vite/src/executors/dev-server/dev-server.impl.ts b/packages/vite/src/executors/dev-server/dev-server.impl.ts index f3960b8e6129a..17761ca0d0f69 100644 --- a/packages/vite/src/executors/dev-server/dev-server.impl.ts +++ b/packages/vite/src/executors/dev-server/dev-server.impl.ts @@ -77,6 +77,10 @@ export async function* viteDevServerExecutor( async function runViteDevServer(server: ViteDevServer): Promise { await server.listen(); + process.send({ + type: 'nx.server.ready', + baseUrl: server.resolvedUrls.local[0], + }); server.printUrls(); const processOnExit = async () => { diff --git a/packages/workspace/src/generators/move/lib/update-cypress-config.spec.ts b/packages/workspace/src/generators/move/lib/update-cypress-config.spec.ts index b00fec6fac3f8..b11a2f7a19654 100644 --- a/packages/workspace/src/generators/move/lib/update-cypress-config.spec.ts +++ b/packages/workspace/src/generators/move/lib/update-cypress-config.spec.ts @@ -90,7 +90,7 @@ import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; export default defineConfig({ e2e: { - nxE2EPreset(__dirname), + ...nxE2EPreset(__dirname), videosFolder: '../../dist/cypress/my-lib/videos', screenshotsFolder: '../../dist/cypress/my-lib/screenshots', } diff --git a/packages/workspace/src/generators/move/lib/update-project-root-files.spec.ts b/packages/workspace/src/generators/move/lib/update-project-root-files.spec.ts index 912105faba769..92a14d78b628e 100644 --- a/packages/workspace/src/generators/move/lib/update-project-root-files.spec.ts +++ b/packages/workspace/src/generators/move/lib/update-project-root-files.spec.ts @@ -47,4 +47,38 @@ describe('updateProjectRootFiles', () => { `coverageDirectory: '../../coverage/my-source'` ); }); + + it('should handle cypress configs correctly', async () => { + const cypressConfigContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + +import { defineConfig } from 'cypress'; + +export default defineConfig({ + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }) }, +}); +`; + const cypressConfigPath = 'apps/my-app-e2e/cypress.config.ts'; + await libraryGenerator(tree, { + name: 'e2e', + root: 'e2e', + projectNameAndRootFormat: 'as-provided', + }); + const projectConfig = readProjectConfiguration(tree, 'e2e'); + tree.write(cypressConfigPath, cypressConfigContents); + const schema: NormalizedSchema = { + projectName: 'e2e', + destination: 'apps/my-app-e2e', + importPath: '@proj/e2e', + updateImportPath: false, + newProjectName: 'my-app-e2e', + relativeToRootDestination: 'apps/my-app-e2e', + }; + + updateProjectRootFiles(tree, schema, projectConfig); + + const cypressConfigAfter = tree.read(cypressConfigPath, 'utf-8'); + expect(cypressConfigAfter).toContain( + `e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }) }` + ); + }); }); diff --git a/packages/workspace/src/generators/move/lib/update-project-root-files.ts b/packages/workspace/src/generators/move/lib/update-project-root-files.ts index d2c68ac287200..c516488cc0aa7 100644 --- a/packages/workspace/src/generators/move/lib/update-project-root-files.ts +++ b/packages/workspace/src/generators/move/lib/update-project-root-files.ts @@ -1,9 +1,4 @@ -import { - updateJson, - ProjectConfiguration, - Tree, - joinPathFragments, -} from '@nx/devkit'; +import { updateJson, ProjectConfiguration, Tree } from '@nx/devkit'; import { workspaceRoot } from '@nx/devkit'; import * as path from 'path'; import { extname, join } from 'path'; @@ -81,13 +76,14 @@ export function updateFilesForNonRootProjects( schema: NormalizedSchema, project: ProjectConfiguration ): void { - const newRelativeRoot = path - .relative( - path.join(workspaceRoot, schema.relativeToRootDestination), - workspaceRoot - ) - .split(path.sep) - .join('/'); + const newRelativeRoot = + path + .relative( + path.join(workspaceRoot, schema.relativeToRootDestination), + workspaceRoot + ) + .split(path.sep) + .join('/') + '/'; const oldRelativeRoot = path .relative(path.join(workspaceRoot, project.root), workspaceRoot) .split(path.sep) @@ -100,7 +96,7 @@ export function updateFilesForNonRootProjects( const dots = /\./g; const regex = new RegExp( - `(? { expect(tree.exists('.eslintrc.base.json')).toBeTruthy(); }); + it('should support moving standalone repos', async () => { + // Test that these are not moved + tree.write('.gitignore', ''); + tree.write('README.md', ''); + + await applicationGenerator(tree, { + name: 'react-app', + rootProject: true, + unitTestRunner: 'jest', + e2eTestRunner: 'cypress', + linter: 'eslint', + style: 'css', + projectNameAndRootFormat: 'as-provided', + }); + + // Test that this does not get moved + tree.write('other-lib/index.ts', ''); + + await moveGenerator(tree, { + projectName: 'react-app', + updateImportPath: false, + destination: 'apps/react-app', + projectNameAndRootFormat: 'as-provided', + }); + await moveGenerator(tree, { + projectName: 'e2e', + updateImportPath: false, + destination: 'apps/react-app-e2e', + projectNameAndRootFormat: 'as-provided', + }); + + expect(tree.read('apps/react-app-e2e/cypress.config.ts').toString()) + .toMatchInlineSnapshot(` + "import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; + + import { defineConfig } from 'cypress'; + + export default defineConfig({ + e2e: { ...nxE2EPreset(__filename, { cypressDir: 'src' }) }, + }); + " + `); + }); + it('should move project correctly when --project-name-and-root-format=derived', async () => { await libraryGenerator(tree, { name: 'my-lib', diff --git a/packages/workspace/src/generators/new/__snapshots__/new.spec.ts.snap b/packages/workspace/src/generators/new/__snapshots__/new.spec.ts.snap index 0750263e95360..bd36babcb5ed1 100644 --- a/packages/workspace/src/generators/new/__snapshots__/new.spec.ts.snap +++ b/packages/workspace/src/generators/new/__snapshots__/new.spec.ts.snap @@ -40,9 +40,6 @@ exports[`new should generate an empty nx.json 1`] = ` "^production", ], }, - "e2e": { - "cache": true, - }, "lint": { "cache": true, }, diff --git a/packages/workspace/src/generators/new/generate-workspace-files.spec.ts b/packages/workspace/src/generators/new/generate-workspace-files.spec.ts index 0bfedf740aaff..4ec00c959e7a2 100644 --- a/packages/workspace/src/generators/new/generate-workspace-files.spec.ts +++ b/packages/workspace/src/generators/new/generate-workspace-files.spec.ts @@ -113,9 +113,6 @@ describe('@nx/workspace:generateWorkspaceFiles', () => { "^production", ], }, - "e2e": { - "cache": true, - }, "lint": { "cache": true, }, @@ -159,9 +156,6 @@ describe('@nx/workspace:generateWorkspaceFiles', () => { "^production", ], }, - "e2e": { - "cache": true, - }, "lint": { "cache": true, }, @@ -224,9 +218,6 @@ describe('@nx/workspace:generateWorkspaceFiles', () => { "^build", ], }, - "e2e": { - "cache": true, - }, "lint": { "cache": true, }, diff --git a/packages/workspace/src/generators/new/generate-workspace-files.ts b/packages/workspace/src/generators/new/generate-workspace-files.ts index 64ffb63a21ada..22f12c547a38a 100644 --- a/packages/workspace/src/generators/new/generate-workspace-files.ts +++ b/packages/workspace/src/generators/new/generate-workspace-files.ts @@ -75,9 +75,6 @@ function createNxJson( lint: { cache: true, }, - e2e: { - cache: true, - }, }, };