UNPKG

@nx/cypress

Version:

The Nx Plugin for Cypress contains executors and generators allowing your workspace to use the powerful Cypress integration testing capabilities.

484 lines (483 loc) 22.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createNodesV2 = exports.createNodes = void 0; const devkit_1 = require("@nx/devkit"); const calculate_hash_for_create_nodes_1 = require("@nx/devkit/src/utils/calculate-hash-for-create-nodes"); const config_utils_1 = require("@nx/devkit/src/utils/config-utils"); const get_named_inputs_1 = require("@nx/devkit/src/utils/get-named-inputs"); const js_1 = require("@nx/js"); const fs_1 = require("fs"); const devkit_internals_1 = require("nx/src/devkit-internals"); const cache_directory_1 = require("nx/src/utils/cache-directory"); const plugin_cache_utils_1 = require("nx/src/utils/plugin-cache-utils"); const workspace_context_1 = require("nx/src/utils/workspace-context"); const path_1 = require("path"); const constants_1 = require("../utils/constants"); const cypressConfigGlob = '**/cypress.config.{js,ts,mjs,cjs}'; const defaultPatterns = { e2e: { specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', excludeSpecPattern: '*.hot-update.js', }, component: { specPattern: '**/*.cy.{js,jsx,ts,tsx}', excludeSpecPattern: ['/snapshots/*', '/image_snapshots/*'], }, }; exports.createNodes = [ cypressConfigGlob, async (configFiles, options, context) => { const optionsHash = (0, devkit_internals_1.hashObject)(options); const cachePath = (0, path_1.join)(cache_directory_1.workspaceDataDirectory, `cypress-${optionsHash}.hash`); const pluginCache = new plugin_cache_utils_1.PluginCache(cachePath); const pmc = (0, devkit_1.getPackageManagerCommand)((0, devkit_1.detectPackageManager)(context.workspaceRoot)); try { return await (0, devkit_1.createNodesFromFiles)((configFile, options, context) => createNodesInternal(configFile, options, context, pluginCache, pmc), configFiles, options, context); } finally { pluginCache.writeToDisk(cachePath); } }, ]; exports.createNodesV2 = exports.createNodes; async function createNodesInternal(configFilePath, options, context, pluginCache, pmc) { options = normalizeOptions(options); const projectRoot = (0, path_1.dirname)(configFilePath); // Do not create a project if package.json and project.json isn't there. const siblingFiles = (0, fs_1.readdirSync)((0, path_1.join)(context.workspaceRoot, projectRoot)); if (!siblingFiles.includes('package.json') && !siblingFiles.includes('project.json')) { return {}; } const hash = (await (0, calculate_hash_for_create_nodes_1.calculateHashForCreateNodes)(projectRoot, options, context, [ (0, js_1.getLockFileName)((0, devkit_1.detectPackageManager)(context.workspaceRoot)), ])) + configFilePath; if (!pluginCache.has(hash)) { pluginCache.set(hash, await buildCypressTargets(configFilePath, projectRoot, options, context, pmc)); } const { targets, metadata } = pluginCache.get(hash); const project = { projectType: 'application', targets, metadata, }; return { projects: { [projectRoot]: project, }, }; } function getTargetOutputs(outputs, subfolder) { return outputs.map((output) => subfolder ? (0, path_1.join)(output, subfolder) : output); } // Normalize a Cypress config folder (e.g. videosFolder, screenshotsFolder) to a // project-root-relative path, then append the per-spec subfolder. Cypress // resolves relative folders against the project root (cwd), so this keeps the // path Cypress writes to in lockstep with what Nx declares as the target's // outputs — regardless of whether the user wrote a relative path or computed // an absolute one (e.g. via `path.resolve` / `__dirname`). function serializeConfigPath(configPath, projectRoot, workspaceRoot, outputSubfolder) { if (!configPath) { return configPath; } const fullProjectRoot = (0, path_1.resolve)(workspaceRoot, projectRoot); const fullConfigPath = (0, path_1.resolve)(fullProjectRoot, configPath); const relativeConfigPath = (0, devkit_1.normalizePath)((0, path_1.relative)(fullProjectRoot, fullConfigPath)); return (0, devkit_1.normalizePath)((0, path_1.join)(relativeConfigPath, outputSubfolder)); } function getTargetConfig(cypressConfig, projectRoot, workspaceRoot, outputSubfolder, ciBaseUrl) { const config = {}; if (ciBaseUrl) { config['baseUrl'] = ciBaseUrl; } const { screenshotsFolder, videosFolder, e2e, component } = cypressConfig; if (videosFolder) { config['videosFolder'] = serializeConfigPath(videosFolder, projectRoot, workspaceRoot, outputSubfolder); } if (screenshotsFolder) { config['screenshotsFolder'] = serializeConfigPath(screenshotsFolder, projectRoot, workspaceRoot, outputSubfolder); } if (e2e) { config['e2e'] = {}; if (e2e.videosFolder) { config['e2e']['videosFolder'] = serializeConfigPath(e2e.videosFolder, projectRoot, workspaceRoot, outputSubfolder); } if (e2e.screenshotsFolder) { config['e2e']['screenshotsFolder'] = serializeConfigPath(e2e.screenshotsFolder, projectRoot, workspaceRoot, outputSubfolder); } } if (component) { config['component'] = {}; if (component.videosFolder) { config['component']['videosFolder'] = serializeConfigPath(component.videosFolder, projectRoot, workspaceRoot, outputSubfolder); } if (component.screenshotsFolder) { config['component']['screenshotsFolder'] = serializeConfigPath(component.screenshotsFolder, projectRoot, workspaceRoot, outputSubfolder); } } // Stringify twice to escape the quotes. return JSON.stringify(JSON.stringify(config)); } function getOutputs(projectRoot, cypressConfig, testingType, workspaceRoot) { const fullProjectRoot = (0, path_1.resolve)(workspaceRoot, projectRoot); function getOutput(outputPath) { const fullPath = (0, path_1.resolve)(fullProjectRoot, outputPath); const relativeToProjectRoot = (0, devkit_1.normalizePath)((0, path_1.relative)(fullProjectRoot, fullPath)); if (relativeToProjectRoot.startsWith('..')) { return (0, devkit_1.joinPathFragments)('{workspaceRoot}', (0, path_1.relative)(workspaceRoot, fullPath)); } return (0, devkit_1.joinPathFragments)('{projectRoot}', relativeToProjectRoot); } const { screenshotsFolder, videosFolder, e2e, component } = cypressConfig; const outputs = []; 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; } async function buildCypressTargets(configFilePath, projectRoot, options, context, pmc) { const cypressConfig = await (0, config_utils_1.loadConfigFile)((0, path_1.join)(context.workspaceRoot, configFilePath)); const pluginPresetOptions = { ...cypressConfig.e2e?.[constants_1.NX_PLUGIN_OPTIONS], ...cypressConfig.env, ...cypressConfig.e2e?.env, }; const webServerCommands = pluginPresetOptions?.webServerCommands; const shouldReuseExistingServer = pluginPresetOptions?.reuseExistingServer ?? true; const namedInputs = (0, get_named_inputs_1.getNamedInputs)(projectRoot, context); const targets = {}; let metadata; const tsNodeCompilerOptions = JSON.stringify({ customConditions: null }); if ('e2e' in cypressConfig) { targets[options.targetName] = { command: `cypress run`, options: { cwd: projectRoot, env: { TS_NODE_COMPILER_OPTIONS: tsNodeCompilerOptions }, }, cache: true, inputs: getInputs(namedInputs), outputs: getOutputs(projectRoot, cypressConfig, 'e2e', context.workspaceRoot), metadata: { technologies: ['cypress'], description: 'Runs Cypress Tests', help: { command: `${pmc.exec} cypress run --help`, example: { args: ['--dev', '--headed'], }, }, }, }; if (webServerCommands?.default) { const webServerCommandTask = shouldReuseExistingServer ? parseTaskFromCommand(webServerCommands.default) : null; if (webServerCommandTask) { targets[options.targetName].dependsOn = [ { projects: [webServerCommandTask.project], target: webServerCommandTask.target, }, ]; } else { targets[options.targetName].parallelism = false; } delete webServerCommands.default; } else { targets[options.targetName].parallelism = false; } if (Object.keys(webServerCommands ?? {}).length > 0) { targets[options.targetName].configurations ??= {}; for (const [configuration, webServerCommand] of Object.entries(webServerCommands ?? {})) { targets[options.targetName].configurations[configuration] = { command: `cypress run --env webServerCommand="${webServerCommand}"`, }; } } const ciWebServerCommand = pluginPresetOptions?.ciWebServerCommand; if (ciWebServerCommand) { const { specFiles, specPatterns, excludeSpecPatterns } = await getSpecFilesAndPatternsForTestType(cypressConfig, 'e2e', context.workspaceRoot, projectRoot); const ciBaseUrl = pluginPresetOptions?.ciBaseUrl; const dependsOn = []; const outputs = getOutputs(projectRoot, cypressConfig, 'e2e', context.workspaceRoot); const inputs = getInputs(namedInputs); const groupName = 'E2E (CI)'; metadata = { targetGroups: { [groupName]: [] } }; const ciTargetGroup = metadata.targetGroups[groupName]; const ciWebServerCommandTask = shouldReuseExistingServer ? parseTaskFromCommand(ciWebServerCommand) : null; for (const file of specFiles) { const relativeSpecFilePath = (0, devkit_1.normalizePath)((0, path_1.relative)(projectRoot, file)); if (relativeSpecFilePath.includes('../')) { throw new Error('@nx/cypress/plugin attempted to run tests outside of the project root. This is not supported and should not happen. Please open an issue at https://github.com/nrwl/nx/issues/new/choose with the following information:\n\n' + `\n\n${JSON.stringify({ projectRoot, relativeSpecFilePath, specFiles, context, excludeSpecPatterns, specPatterns, }, null, 2)}`); } const targetName = options.ciTargetName + '--' + relativeSpecFilePath; const outputSubfolder = relativeSpecFilePath .replace(/[\/\\]/g, '-') .replace(/\./g, '-'); ciTargetGroup.push(targetName); targets[targetName] = { outputs: getTargetOutputs(outputs, outputSubfolder), inputs, cache: true, command: `cypress run --env webServerCommand="${ciWebServerCommand}" --spec ${relativeSpecFilePath} --config=${getTargetConfig(cypressConfig, projectRoot, context.workspaceRoot, outputSubfolder, ciBaseUrl)}`, options: { cwd: projectRoot, env: { TS_NODE_COMPILER_OPTIONS: tsNodeCompilerOptions }, }, metadata: { technologies: ['cypress'], description: `Runs Cypress Tests in ${relativeSpecFilePath} in CI`, help: { command: `${pmc.exec} cypress run --help`, example: { args: ['--dev', '--headed'], }, }, }, }; dependsOn.push({ target: targetName, params: 'forward', options: 'forward', }); if (ciWebServerCommandTask) { targets[targetName].dependsOn = [ { target: ciWebServerCommandTask.target, projects: [ciWebServerCommandTask.project], }, ]; } else { targets[targetName].parallelism = false; } } targets[options.ciTargetName] = { executor: 'nx:noop', cache: true, inputs, outputs, dependsOn, metadata: { technologies: ['cypress'], description: 'Runs Cypress Tests in CI', nonAtomizedTarget: options.targetName, help: { command: `${pmc.exec} cypress run --help`, example: { args: ['--dev', '--headed'], }, }, }, }; if (!ciWebServerCommandTask) { targets[options.ciTargetName].parallelism = false; } ciTargetGroup.push(options.ciTargetName); } } if ('component' in cypressConfig) { const inputs = getInputs(namedInputs); const outputs = getOutputs(projectRoot, cypressConfig, 'component', context.workspaceRoot); // This will not override the e2e target if it is the same targets[options.componentTestingTargetName] ??= { command: `cypress run --component`, options: { cwd: projectRoot, env: { TS_NODE_COMPILER_OPTIONS: tsNodeCompilerOptions }, }, cache: true, inputs, outputs, metadata: { technologies: ['cypress'], description: 'Runs Cypress Component Tests', help: { command: `${pmc.exec} cypress run --help`, example: { args: ['--dev', '--headed'], }, }, }, }; if (options.ciComponentTestingTargetName) { const { specFiles, specPatterns, excludeSpecPatterns } = await getSpecFilesAndPatternsForTestType(cypressConfig, 'component', context.workspaceRoot, projectRoot); const dependsOn = []; const groupName = 'Component Testing (CI)'; metadata ??= {}; metadata.targetGroups ??= {}; metadata.targetGroups[groupName] ??= []; const ctCiTargetGroup = metadata.targetGroups[groupName]; for (const file of specFiles) { const relativeSpecFilePath = (0, devkit_1.normalizePath)((0, path_1.relative)(projectRoot, file)); if (relativeSpecFilePath.includes('../')) { throw new Error('@nx/cypress/plugin attempted to run tests outside of the project root. This is not supported and should not happen. Please open an issue at https://github.com/nrwl/nx/issues/new/choose with the following information:\n\n' + `\n\n${JSON.stringify({ projectRoot, relativeSpecFilePath, specFiles, context, excludeSpecPatterns, specPatterns, }, null, 2)}`); } const targetName = options.ciComponentTestingTargetName + '--' + relativeSpecFilePath; const outputSubfolder = relativeSpecFilePath .replace(/[\/\\]/g, '-') .replace(/\./g, '-'); ctCiTargetGroup.push(targetName); targets[targetName] = { outputs: getTargetOutputs(outputs, outputSubfolder), inputs, cache: true, command: `cypress run --component --spec ${relativeSpecFilePath} --config=${getTargetConfig(cypressConfig, projectRoot, context.workspaceRoot, outputSubfolder)}`, options: { cwd: projectRoot, env: { TS_NODE_COMPILER_OPTIONS: tsNodeCompilerOptions }, }, // Cypress handles starting the server, there's no separate server // target we can use as continuous, so we need to disable parallelism // to avoid port conflicts parallelism: false, metadata: { technologies: ['cypress'], description: `Runs Cypress Component Tests for ${relativeSpecFilePath} in CI`, help: { command: `${pmc.exec} cypress run --help`, example: { args: ['--dev', '--headed'], }, }, }, }; dependsOn.push({ target: targetName, params: 'forward', options: 'forward', }); } targets[options.ciComponentTestingTargetName] = { executor: 'nx:noop', cache: true, inputs, outputs, dependsOn, metadata: { technologies: ['cypress'], description: 'Runs Cypress Component Tests in CI', nonAtomizedTarget: options.componentTestingTargetName, help: { command: `${pmc.exec} cypress run --help`, example: { args: ['--dev', '--headed'], }, }, }, }; ctCiTargetGroup.push(options.ciComponentTestingTargetName); } } targets[options.openTargetName] = { command: `cypress open`, options: { cwd: projectRoot, env: { TS_NODE_COMPILER_OPTIONS: tsNodeCompilerOptions }, }, metadata: { technologies: ['cypress'], description: 'Opens Cypress', help: { command: `${pmc.exec} cypress open --help`, example: { args: ['--dev', '--e2e'], }, }, }, }; return { targets, metadata }; } function normalizeOptions(options) { options ??= {}; options.targetName ??= 'e2e'; options.openTargetName ??= 'open-cypress'; options.componentTestingTargetName ??= 'component-test'; options.ciTargetName ??= 'e2e-ci'; // must be explicitly provided to opt-in to atomized component testing options.ciComponentTestingTargetName; return options; } function getInputs(namedInputs) { return [ ...('production' in namedInputs ? ['default', '^production'] : ['default', '^default']), { externalDependencies: ['cypress'], }, ]; } function parseTaskFromCommand(command) { const nxRunRegex = /^(?:(?:npx|yarn|bun|pnpm|pnpm exec|pnpx) )?nx run (\S+:\S+)$/; const infixRegex = /^(?:(?:npx|yarn|bun|pnpm|pnpm exec|pnpx) )?nx (\S+ \S+)$/; const nxRunMatch = command.match(nxRunRegex); if (nxRunMatch) { const [project, target] = nxRunMatch[1].split(':'); return { project, target }; } const infixMatch = command.match(infixRegex); if (infixMatch) { const [target, project] = infixMatch[1].split(' '); return { project, target }; } return null; } async function getSpecFilesAndPatternsForTestType(cypressConfig, testType, workspaceRoot, projectRoot) { const specPattern = cypressConfig[testType].specPattern ?? defaultPatterns[testType].specPattern; const specPatterns = Array.isArray(specPattern) ? specPattern.map((p) => (0, path_1.join)(projectRoot, p)) : [(0, path_1.join)(projectRoot, specPattern)]; const excludeSpecPattern = cypressConfig[testType].excludeSpecPattern ?? defaultPatterns[testType].excludeSpecPattern; const excludeSpecPatterns = Array.isArray(excludeSpecPattern) ? excludeSpecPattern.map((p) => (0, path_1.join)(projectRoot, p)) : [(0, path_1.join)(projectRoot, excludeSpecPattern)]; const specFiles = await (0, workspace_context_1.globWithWorkspaceContext)(workspaceRoot, specPatterns, excludeSpecPatterns); return { specFiles, specPatterns, excludeSpecPatterns }; }