import { nxBaseCypressPreset, NxComponentTestingOptions, } from '@nx/cypress/plugins/cypress-preset'; import { createExecutorContext, type CypressExecutorOptions, getProjectConfigByPath, } from '@nx/cypress/internal'; import { ExecutorContext, joinPathFragments, logger, parseTargetString, readCachedProjectGraph, readTargetOptions, stripIndents, Target, workspaceRoot, } from '@nx/devkit'; import { getProjectSourceRoot } from '@nx/js/internal'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; type ViteDevServer = { framework: 'react'; bundler: 'vite'; viteConfig?: any; }; type WebpackDevServer = { framework: 'react'; bundler: 'webpack'; webpackConfig?: any; }; /** * React nx preset for Cypress Component Testing * * This preset contains the base configuration * for your component tests that nx recommends. * including a devServer that supports nx workspaces. * you can easily extend this within your cypress config via spreading the preset * @example * export default defineConfig({ * component: { * ...nxComponentTestingPreset(__dirname) * // add your own config here * } * }) * * @param pathToConfig will be used for loading project options and to construct the output paths for videos and screenshots * @param options override options */ export function nxComponentTestingPreset( pathToConfig: string, options?: NxComponentTestingOptions ): { specPattern: string; devServer: ViteDevServer | WebpackDevServer; videosFolder: string; screenshotsFolder: string; chromeWebSecurity: boolean; } { const basePresetSettings = nxBaseCypressPreset(pathToConfig, { testingType: 'component', }); if (global.NX_GRAPH_CREATION) { // this is only used by plugins, so we don't need the component testing // options, cast to any to avoid type errors return basePresetSettings as any; } const normalizedProjectRootPath = ['.ts', '.js'].some((ext) => pathToConfig.endsWith(ext) ) ? pathToConfig : dirname(pathToConfig); if (options?.bundler === 'vite') { return { ...basePresetSettings, specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', devServer: { ...({ framework: 'react', bundler: 'vite' } as const), viteConfig: async () => { const viteConfigPath = findViteConfig(normalizedProjectRootPath); // TODO(jack): Remove this cast when @nx/react switches to // moduleResolution: "nodenext". Vite 8 ships ESM-only type // declarations (.d.mts) not resolvable under moduleResolution: "node". const { mergeConfig, loadConfigFromFile, searchForWorkspaceRoot } = await (Function('return import("vite")')() as Promise); const resolved = await loadConfigFromFile( { mode: 'watch', command: 'serve', }, viteConfigPath ); return mergeConfig(resolved.config, { server: { fs: { allow: [ searchForWorkspaceRoot(normalizedProjectRootPath), workspaceRoot, joinPathFragments(workspaceRoot, 'node_modules/vite'), ], }, }, }); }, }, }; } let webpackConfig: any = null; try { const graph = readCachedProjectGraph(); const { targets: ctTargets, name: ctProjectName } = getProjectConfigByPath( graph, pathToConfig ); const ctTargetName = options?.ctTargetName || 'component-test'; const ctConfigurationName = process.env.NX_CYPRESS_TARGET_CONFIGURATION; const ctExecutorContext = createExecutorContext( graph, ctTargets, ctProjectName, ctTargetName, ctConfigurationName ); let buildTarget: string = options?.buildTarget; if (!buildTarget) { const ctExecutorOptions = readTargetOptions( { project: ctProjectName, target: ctTargetName, configuration: ctConfigurationName, }, ctExecutorContext ); buildTarget = ctExecutorOptions.devServerTarget; } if (!buildTarget) { throw new Error( `Unable to find the 'devServerTarget' executor option in the '${ctTargetName}' target of the '${ctProjectName}' project` ); } webpackConfig = buildTargetWebpack( ctExecutorContext, buildTarget, ctProjectName ); } catch (e) { if (e instanceof InvalidExecutorError) { throw e; } logger.warn( stripIndents`Unable to build a webpack config with the project graph. Falling back to default webpack config.` ); logger.warn(e); } // Fallback config if (!webpackConfig) { const { buildBaseWebpackConfig } = require('./webpack-fallback'); webpackConfig = buildBaseWebpackConfig({ tsConfigPath: findTsConfig(normalizedProjectRootPath), compiler: options?.compiler || 'babel', }); } return { ...basePresetSettings, specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', devServer: { // cypress uses string union type, // need to use const to prevent typing to string // but don't want to use as const on webpackConfig // so it is still user modifiable ...({ framework: 'react', bundler: 'webpack' } as const), webpackConfig, }, }; } /** * apply the schema.json defaults from the @nx/web:webpack executor to the target options */ function withSchemaDefaults(target: Target, context: ExecutorContext) { const options = readTargetOptions(target, context); options.compiler ??= 'babel'; options.deleteOutputPath ??= true; options.vendorChunk ??= true; options.commonChunk ??= true; options.runtimeChunk ??= true; options.sourceMap ??= true; options.assets ??= []; options.scripts ??= []; options.styles ??= []; options.budgets ??= []; options.namedChunks ??= true; options.outputHashing ??= 'none'; options.extractCss ??= true; options.memoryLimit ??= 2048; options.maxWorkers ??= 2; options.fileReplacements ??= []; options.buildLibsFromSource ??= true; return options; } function buildTargetWebpack( ctx: ExecutorContext, buildTarget: string, componentTestingProjectName: string ) { const graph = ctx.projectGraph; const parsed = parseTargetString(buildTarget, graph); const buildableProjectConfig = graph.nodes[parsed.project]?.data; const ctProjectConfig = graph.nodes[componentTestingProjectName]?.data; if (!buildableProjectConfig || !ctProjectConfig) { throw new Error(stripIndents`Unable to load project configs from graph. Using build target '${buildTarget}' Has build config? ${!!buildableProjectConfig} Has component config? ${!!ctProjectConfig} `); } if ( buildableProjectConfig.targets[parsed.target].executor !== '@nx/webpack:webpack' && buildableProjectConfig.targets[parsed.target].executor !== '@nx/rspack:rspack' ) { throw new InvalidExecutorError( `The '${parsed.target}' target of the '${parsed.project}' project is not using the '@nx/webpack:webpack' or '@nx/rspack:rspack' executor. ` + `Please make sure to use '@nx/webpack:webpack' or '@nx/rspack:rspack' executor in that target to use Cypress Component Testing.` ); } const context = createExecutorContext( graph, buildableProjectConfig.targets, parsed.project, parsed.target, parsed.target ); const { resolveUserDefinedWebpackConfig } = require('@nx/webpack/internal'); const { normalizeOptions, composePluginsSync, withNx, withWeb, } = require('@nx/webpack'); const options = normalizeOptions( withSchemaDefaults(parsed, context), workspaceRoot, buildableProjectConfig.root!, getProjectSourceRoot(buildableProjectConfig) ); let customWebpack: any; if (options.webpackConfig) { customWebpack = resolveUserDefinedWebpackConfig( options.webpackConfig, options.tsConfig.startsWith(context.root) ? options.tsConfig : join(context.root, options.tsConfig) ); } return async () => { customWebpack = await customWebpack; // For legacy `composePlugins(...)` setup, we need change some options to make Cypress CT work properly. if ( customWebpack && require('@nx/webpack').isNxWebpackComposablePlugin(customWebpack) // using inline since @nx/webpack may not be installed when using vite so top-level import would error ) { return await customWebpack( {}, { options: { ...options, // cypress will generate its own index.html from component-index.html generateIndexHtml: false, // causes issues with buildable libraries with ENOENT: no such file or directory, scandir error extractLicenses: false, root: workspaceRoot, projectRoot: ctProjectConfig.root, sourceRoot: getProjectSourceRoot(ctProjectConfig), }, context, configuration: parsed.configuration, } ); } else if ( typeof customWebpack === 'object' || typeof customWebpack === 'function' ) { // If this is a standard webpack config object or function, just return. it return customWebpack; } return null; // return null to use fallback config }; } function findViteConfig(projectRootFullPath: string): string { const allowsExt = ['js', 'mjs', 'ts', 'cjs', 'mts', 'cts']; for (const ext of allowsExt) { if (existsSync(join(projectRootFullPath, `vite.config.${ext}`))) { return join(projectRootFullPath, `vite.config.${ext}`); } } } function findTsConfig(projectRoot: string) { const potentialConfigs = [ 'cypress/tsconfig.json', 'cypress/tsconfig.cy.json', 'tsconfig.cy.json', ]; for (const config of potentialConfigs) { if (existsSync(join(projectRoot, config))) { return config; } } } class InvalidExecutorError extends Error { constructor(public message: string) { super(message); this.name = 'InvalidExecutorError'; } }