Files
nrwl--nx/packages/react/plugins/component-testing/index.ts
T
2026-07-13 12:38:36 +08:00

355 lines
10 KiB
TypeScript

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<any>);
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<CypressExecutorOptions>(
{
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';
}
}