@nx/cypress
Version:
227 lines (226 loc) • 11.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.JIT_COMPILE_DISABLE_COMMENT = exports.CYPRESS_CONFIG_FILE_NAME_PATTERN = void 0;
exports.addDefaultE2EConfig = addDefaultE2EConfig;
exports.addDefaultCTConfig = addDefaultCTConfig;
exports.addMountDefinition = addMountDefinition;
exports.getProjectCypressConfigPath = getProjectCypressConfigPath;
exports.resolveCypressConfigObject = resolveCypressConfigObject;
const devkit_1 = require("@nx/devkit");
const internal_1 = require("@nx/js/internal");
exports.CYPRESS_CONFIG_FILE_NAME_PATTERN = 'cypress.config.{js,ts,mjs,cjs}';
const TS_QUERY_COMMON_JS_EXPORT_SELECTOR = 'BinaryExpression:has(Identifier[name="module"]):has(Identifier[name="exports"])';
const TS_QUERY_EXPORT_CONFIG_PREFIX = `:matches(ExportAssignment, ${TS_QUERY_COMMON_JS_EXPORT_SELECTOR}) `;
// Shared so the CT generator (addDefaultCTConfig) and the
// disable-webpack-ct-just-in-time-compile migration emit the identical note.
exports.JIT_COMPILE_DISABLE_COMMENT = [
'// Cypress 14+ defaults justInTimeCompile to true (webpack only), which can',
'// intermittently run 0 tests in CI. Remove this line to opt back in.',
];
async function addDefaultE2EConfig(cyConfigContents, options, baseUrl) {
if (!cyConfigContents) {
throw new Error('The passed in cypress config file is empty!');
}
const { tsquery } = await import('@phenomnomnominal/tsquery');
const isCommonJS = tsquery.query(cyConfigContents, TS_QUERY_COMMON_JS_EXPORT_SELECTOR).length >
0;
const testingTypeConfig = tsquery.query(cyConfigContents, `${TS_QUERY_EXPORT_CONFIG_PREFIX} PropertyAssignment:has(Identifier[name="e2e"])`);
let updatedConfigContents = cyConfigContents;
if (testingTypeConfig.length === 0) {
// ESM-shape configs use `import.meta.url` (a `file://...` URL string)
// because it's the most universally available `import.meta` field:
// Node's native TS strip exposes it in ESM scope, and Cypress's bundled
// tsx CJS loader provides it too (unlike `import.meta.dirname`, which
// older tsx versions don't shim). CJS-shape configs use `__filename`
// since `import.meta` isn't defined in plain CJS scope. The base
// template is selected to match the workspace's `type` field, so the
// shape detected here is consistent with how the file will actually be
// evaluated at runtime. `nxBaseCypressPreset` normalizes either form.
const pathToConfig = isCommonJS ? '__filename' : 'import.meta.url';
const configValue = `nxE2EPreset(${pathToConfig}, ${JSON.stringify(options, null, 2)
.split('\n')
.join('\n ')})`;
updatedConfigContents = tsquery.replace(cyConfigContents, `${TS_QUERY_EXPORT_CONFIG_PREFIX} ObjectLiteralExpression:first-child`, (node) => {
let baseUrlContents = baseUrl ? `,\n baseUrl: '${baseUrl}'` : '';
if (node.properties.length > 0) {
return `{
${node.properties.map((p) => p.getText()).join(',\n')},
e2e: {
...${configValue}${baseUrlContents}
}
}`;
}
return `{
e2e: {
...${configValue}${baseUrlContents}
}
}`;
});
// @nx/cypress's package exports cover both bare and `.js`-suffixed
// subpath forms, so emit the bare path - matches addDefaultCTConfig and
// keeps generated configs free of incidental `.js` noise.
return isCommonJS
? `const { nxE2EPreset } = require('@nx/cypress/plugins/cypress-preset');
${updatedConfigContents}`
: `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
${updatedConfigContents}`;
}
return updatedConfigContents;
}
/**
* Adds the nxComponentTestingPreset to the cypress config file.
*
* Pass `presetImportPath` (e.g. `@nx/angular/plugins/component-testing`) to
* have the matching `import` (ESM) or `const { ... } = require(...)` (CJS)
* statement prepended automatically based on the detected module shape.
* Without it, the caller is responsible for prepending the import - but
* doing so unconditionally produces mixed-syntax files in CJS workspaces
* (an ESM `import` followed by a CJS `module.exports`), so prefer passing
* `presetImportPath`.
*
* Pass `cypressMajorVersion` to opt webpack setups out of `justInTimeCompile`
* on Cypress 14+, where it defaults to `true` and can intermittently run 0
* tests in CI. The opt-out is emitted as an explicit `justInTimeCompile: false`
* so it is visible and reversible.
**/
async function addDefaultCTConfig(cyConfigContents, options = {}, presetImportPath, cypressMajorVersion) {
if (!cyConfigContents) {
throw new Error('The passed in cypress config file is empty!');
}
const { tsquery } = await import('@phenomnomnominal/tsquery');
const isCommonJS = tsquery.query(cyConfigContents, TS_QUERY_COMMON_JS_EXPORT_SELECTOR).length >
0;
const testingTypeConfig = tsquery.query(cyConfigContents, `${TS_QUERY_EXPORT_CONFIG_PREFIX} PropertyAssignment:has(Identifier[name="component"])`);
let updatedConfigContents = cyConfigContents;
if (testingTypeConfig.length === 0) {
// See addDefaultE2EConfig for the rationale on __filename vs
// import.meta.url.
const pathToConfig = isCommonJS ? '__filename' : 'import.meta.url';
// justInTimeCompile only applies to the webpack dev server and only exists
// on Cypress 14+, where it defaults to true.
const disableJustInTimeCompile = options.bundler !== 'vite' &&
cypressMajorVersion != null &&
cypressMajorVersion >= 14;
let configValue = `nxComponentTestingPreset(${pathToConfig})`;
if (options) {
if (options.bundler !== 'vite') {
// vite is the default bundler, so we don't need to set it
delete options.bundler;
}
if (Object.keys(options).length) {
configValue = `nxComponentTestingPreset(${pathToConfig}, ${JSON.stringify(options)})`;
}
}
const jitComment = exports.JIT_COMPILE_DISABLE_COMMENT.map((line) => ` ${line}`).join('\n');
const componentValue = disableJustInTimeCompile
? `{
...${configValue},
${jitComment}
justInTimeCompile: false,
}`
: configValue;
updatedConfigContents = tsquery.replace(cyConfigContents, `${TS_QUERY_EXPORT_CONFIG_PREFIX} ObjectLiteralExpression:first-child`, (node) => {
if (node.properties.length > 0) {
return `{
${node.properties.map((p) => p.getText()).join(',\n')},
component: ${componentValue}
}`;
}
return `{
component: ${componentValue}
}`;
});
}
if (presetImportPath) {
// Use the path verbatim - callers pass the public exported subpath. Don't
// append `.js`: @nx/react and @nx/angular's package exports only declare
// the bare `./plugins/component-testing` subpath, so a `.js` suffix
// breaks strict ESM resolution with ERR_PACKAGE_PATH_NOT_EXPORTED.
// (addDefaultE2EConfig hard-codes `.js` because @nx/cypress has no
// exports map - it can get away with the suffix; subpath-exporting
// packages cannot.)
const prefix = isCommonJS
? `const { nxComponentTestingPreset } = require('${presetImportPath}');\n`
: `import { nxComponentTestingPreset } from '${presetImportPath}';\n`;
return `${prefix}${updatedConfigContents}`;
}
return updatedConfigContents;
}
/**
* Adds the mount command for Cypress
* Make sure after calling this the correct import statement is added
* to bring in the correct mount from cypress.
**/
async function addMountDefinition(cmpCommandFileContents) {
if (!cmpCommandFileContents) {
throw new Error('The passed in cypress component file is empty!');
}
const { tsquery } = await import('@phenomnomnominal/tsquery');
const hasMountCommand = tsquery.query(cmpCommandFileContents, 'CallExpression StringLiteral[value="mount"]')?.length > 0;
if (hasMountCommand) {
return cmpCommandFileContents;
}
const mountCommand = `Cypress.Commands.add('mount', mount);`;
const updatedInterface = tsquery.replace(cmpCommandFileContents, 'InterfaceDeclaration', (node) => {
return `interface ${node.name.getText()}${node.typeParameters
? `<${node.typeParameters.map((p) => p.getText()).join(', ')}>`
: ''} {
${node.members.map((m) => m.getText()).join('\n ')}
mount: typeof mount;
}`;
});
return `${updatedInterface}\n${mountCommand}`;
}
function getProjectCypressConfigPath(tree, projectRoot) {
const cypressConfigPaths = (0, devkit_1.glob)(tree, [
(0, devkit_1.joinPathFragments)(projectRoot, exports.CYPRESS_CONFIG_FILE_NAME_PATTERN),
]);
if (cypressConfigPaths.length === 0) {
throw new Error(`Could not find a cypress config file in ${projectRoot}.`);
}
return cypressConfigPaths[0];
}
function resolveCypressConfigObject(cypressConfigContents) {
const ts = (0, internal_1.ensureTypescript)();
const { tsquery } = (require('@phenomnomnominal/tsquery'));
const sourceFile = tsquery.ast(cypressConfigContents);
const exportDefaultStatement = sourceFile.statements.find((statement) => ts.isExportAssignment(statement));
if (exportDefaultStatement) {
return resolveCypressConfigObjectFromExportExpression(exportDefaultStatement.expression, sourceFile);
}
const moduleExportsStatement = sourceFile.statements.find((statement) => ts.isExpressionStatement(statement) &&
ts.isBinaryExpression(statement.expression) &&
statement.expression.left.getText() === 'module.exports');
if (moduleExportsStatement) {
return resolveCypressConfigObjectFromExportExpression(moduleExportsStatement.expression.right, sourceFile);
}
return null;
}
function resolveCypressConfigObjectFromExportExpression(exportExpression, sourceFile) {
const ts = (0, internal_1.ensureTypescript)();
if (ts.isObjectLiteralExpression(exportExpression)) {
return exportExpression;
}
if (ts.isIdentifier(exportExpression)) {
// try to locate the identifier in the source file
const variableStatements = sourceFile.statements.filter((statement) => ts.isVariableStatement(statement));
for (const variableStatement of variableStatements) {
for (const declaration of variableStatement.declarationList
.declarations) {
if (ts.isIdentifier(declaration.name) &&
declaration.name.getText() === exportExpression.getText() &&
ts.isObjectLiteralExpression(declaration.initializer)) {
return declaration.initializer;
}
}
}
return null;
}
if (ts.isCallExpression(exportExpression) &&
ts.isIdentifier(exportExpression.expression) &&
exportExpression.expression.getText() === 'defineConfig' &&
ts.isObjectLiteralExpression(exportExpression.arguments[0])) {
return exportExpression.arguments[0];
}
return null;
}