@nx/angular
Version:
549 lines (548 loc) • 27.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.E2eMigrator = void 0;
const devkit_1 = require("@nx/devkit");
const eslint_1 = require("@nx/eslint");
const js_1 = require("@nx/js");
const ensure_typescript_1 = require("@nx/js/src/utils/typescript/ensure-typescript");
const path_1 = require("path");
const file_change_recorder_1 = require("../../../../utils/file-change-recorder");
const versions_1 = require("../../../../utils/versions");
const project_migrator_1 = require("./project.migrator");
const supportedTargets = {
e2e: {
acceptMultipleDefinitions: true,
builders: [
'@angular-devkit/build-angular:protractor',
'@cypress/schematic:cypress',
],
},
};
const cypressConfig = {
srcPaths: ['supportFile', 'supportFolder', 'fixturesFolder'],
distPaths: ['videosFolder', 'screenshotsFolder', 'downloadsFolder'],
globPatterns: ['excludeSpecPattern', 'specPattern'],
};
class E2eMigrator extends project_migrator_1.ProjectMigrator {
constructor(tree, options, project, lintTargetName, logger) {
super(tree, options, supportedTargets, project, 'apps', logger);
this.lintTargetName = lintTargetName;
this.appConfig = project.config;
this.appName = this.project.name;
// TODO(leo): temporary keep restriction to support projects with an "e2e" target,
// will be lifted soon when the migration is split per-builder and proper support
// for multiple targets for the same builder is added
this.targetNames.e2e = this.appConfig.targets?.e2e ? 'e2e' : undefined;
this.initialize();
}
async migrate() {
if (!this.targetNames.e2e) {
this.logger.info('No e2e project was migrated because there was no "e2e" target declared in the "angular.json".');
return;
}
if (this.shouldSkipTargetTypeMigration('e2e')) {
return;
}
if (this.isProtractorE2eProject()) {
await this.migrateProtractorE2eProject();
}
else if (this.isCypressE2eProject()) {
await this.migrateCypressE2eProject();
}
const tsConfig = (0, devkit_1.joinPathFragments)(this.projectConfig.root, 'tsconfig.json');
if (!this.tree.exists(tsConfig)) {
this.logger.warn('A "tsconfig.json" file could not be found for the e2e project. Skipping updating the tsConfig file.');
return;
}
const rootOffset = (0, devkit_1.offsetFromRoot)(this.project.newRoot);
(0, devkit_1.updateJson)(this.tree, tsConfig, (json) => {
json.extends = `${rootOffset}${(0, js_1.getRootTsConfigPathInTree)(this.tree)}`;
json.compilerOptions = {
...json.compilerOptions,
outDir: `${rootOffset}dist/out-tsc`,
};
return json;
});
}
validate() {
if (!this.targetNames.e2e) {
return null;
}
const e2eTarget = this.projectConfig.targets[this.targetNames.e2e];
if (!e2eTarget.options) {
return [
{
message: `The "${this.targetNames.e2e}" target is not specifying any options. The target will be skipped.`,
hint: `Make sure to manually migrate the target configuration and any possible associated files. Alternatively, you could ` +
`revert the migration, ensure the "${this.appName}.architect.e2e.options" is correctly set or remove the target if ` +
`it is not valid, and run the migration again.`,
},
];
}
if (this.isProtractorE2eProject()) {
if (!e2eTarget.options.protractorConfig) {
return [
{
message: 'The "e2e" target is using the "@angular-devkit/build-angular:protractor" builder but the Protractor config file is not specified. The target will be skipped.',
hint: `Make sure to manually migrate the target configuration and any possible associated files. Alternatively, you could ` +
`revert the migration, ensure the "${this.appName}.architect.e2e.options.protractorConfig" is correctly set ` +
`or remove the "${this.appName}.architect.e2e" target if it is not valid, and run the migration again.`,
},
];
}
if (!this.tree.exists(e2eTarget.options.protractorConfig)) {
return [
{
message: `The specified Protractor config file "${e2eTarget.options.protractorConfig}" in the "e2e" target could not be found. The target will be skipped.`,
hint: `Make sure to manually migrate the target configuration and any possible associated files. Alternatively, you could ` +
`revert the migration, ensure the "${this.appName}.architect.e2e.options.protractorConfig" is set to a valid path ` +
`or remove the "${this.appName}.architect.e2e" target if it is not valid, and run the migration again.`,
},
];
}
return null;
}
if (this.isCypressE2eProject()) {
const configFile = this.projectConfig.targets[this.targetNames.e2e].options?.configFile;
if (configFile === undefined && !this.getOldCypressConfigFilePath()) {
const expectedConfigFile = 'cypress.config.{ts,js,mjs,cjs}';
return [
{
message: `The "e2e" target is using the "@cypress/schematic:cypress" builder but the "configFile" option is not specified ` +
`and a "${expectedConfigFile}" file could not be found at the project root. The target will be skipped.`,
hint: `Make sure to manually migrate the target configuration and any possible associated files. Alternatively, you could ` +
`revert the migration, ensure the "${this.appName}.architect.e2e.options.configFile" option is set to a valid path ` +
`or that a "${expectedConfigFile}" file exists at the project root or remove the "${this.appName}.architect.e2e" target ` +
`if it is not valid, and run the migration again.`,
},
];
}
else if (configFile && !this.tree.exists(configFile)) {
return [
{
message: `The specified Cypress config file "${configFile}" in the "e2e" target could not be found. The target will be skipped.`,
hint: `Make sure to manually migrate the target configuration and any possible associated files. Alternatively, you could ` +
`revert the migration, ensure the "${this.appName}.architect.e2e.options.configFile" option is set to a valid path ` +
`or remove the "${this.appName}.architect.e2e" target if it is not valid, and run the migration again.`,
},
];
}
if (!this.tree.exists((0, devkit_1.joinPathFragments)(this.project.oldRoot, 'cypress'))) {
return [
{
message: `The "e2e" target is using the "@cypress/schematic:cypress" builder but the "cypress" directory could not be found at the project root. The target will be skipped.`,
hint: `Make sure to manually migrate the target configuration and any possible associated files. Alternatively, you could ` +
`revert the migration, ensure the "cypress" directory exists in the project root or remove the "e2e" target if it is not ` +
`valid, and run the migration again.`,
},
];
}
return null;
}
return null;
}
initialize() {
if (!this.targetNames.e2e) {
return;
}
this.isProjectUsingEsLint =
Boolean(this.lintTargetName) ||
this.tree.exists((0, devkit_1.joinPathFragments)(this.appConfig.root, '.eslintrc.json'));
const name = this.project.name.endsWith('-e2e')
? this.project.name
: `${this.project.name}-e2e`;
const newRoot = (0, devkit_1.joinPathFragments)('apps', name);
const newSourceRoot = (0, devkit_1.joinPathFragments)('apps', name, 'src');
if (this.isProtractorE2eProject()) {
this.project = {
...this.project,
name,
oldRoot: (0, devkit_1.joinPathFragments)(this.project.oldRoot, 'e2e'),
newRoot,
newSourceRoot,
};
}
else if (this.isCypressE2eProject()) {
(0, devkit_1.ensurePackage)('@nx/cypress', versions_1.nxVersion);
this.project = {
...this.project,
name,
oldSourceRoot: (0, devkit_1.joinPathFragments)(this.project.oldRoot, 'cypress'),
newRoot,
newSourceRoot,
};
}
}
async migrateProtractorE2eProject() {
this.moveDir(this.project.oldRoot, this.project.newRoot);
this.projectConfig = {
root: this.project.newRoot,
sourceRoot: this.project.newSourceRoot,
projectType: 'application',
targets: {
e2e: {
...this.projectConfig.targets[this.targetNames.e2e],
options: {
...this.projectConfig.targets[this.targetNames.e2e].options,
protractorConfig: this.convertRootPath(this.projectConfig.targets[this.targetNames.e2e].options
.protractorConfig),
},
},
},
implicitDependencies: [this.appName],
tags: [],
};
// remove e2e target from the app config
delete this.appConfig.targets[this.targetNames.e2e];
(0, devkit_1.updateProjectConfiguration)(this.tree, this.appName, {
...this.appConfig,
});
// add e2e project config
(0, devkit_1.addProjectConfiguration)(this.tree, this.project.name, {
...this.projectConfig,
}, true);
if (this.isProjectUsingEsLint) {
await (0, eslint_1.lintProjectGenerator)(this.tree, {
project: this.project.name,
linter: 'eslint',
unitTestRunner: this.options.unitTestRunner,
tsConfigPaths: [
(0, devkit_1.joinPathFragments)(this.project.newRoot, 'tsconfig.json'),
],
skipFormat: true,
});
}
}
async migrateCypressE2eProject() {
const oldCypressConfigFilePath = this.getOldCypressConfigFilePath();
(0, devkit_1.addProjectConfiguration)(this.tree, this.project.name, {
projectType: 'application',
root: this.project.newRoot,
sourceRoot: this.project.newSourceRoot,
targets: {},
tags: [],
implicitDependencies: [this.appName],
});
const nxJson = (0, devkit_1.readNxJson)(this.tree) ?? {};
const addPlugin = process.env.NX_ADD_PLUGINS !== 'false' &&
nxJson.useInferencePlugins !== false;
const { configurationGenerator } = (require('@nx/cypress'));
await configurationGenerator(this.tree, {
project: this.project.name,
linter: this.isProjectUsingEsLint ? 'eslint' : 'none',
skipFormat: true,
// any target would do, we replace it later with the target existing in the project being migrated
devServerTarget: `${this.appName}:serve`,
baseUrl: 'http://localhost:4200',
addPlugin,
});
const cypressConfigFilePath = this.updateOrCreateCypressConfigFile(oldCypressConfigFilePath);
this.updateCypressProjectConfiguration(cypressConfigFilePath);
// replace the generated tsconfig.json with the project one
const newTsConfigPath = (0, devkit_1.joinPathFragments)(this.project.newRoot, 'tsconfig.json');
this.tree.delete(newTsConfigPath);
this.moveFile((0, devkit_1.joinPathFragments)(this.project.oldSourceRoot, 'tsconfig.json'), newTsConfigPath);
// replace the generated source with the project source
this.visitFiles(this.project.newSourceRoot, (filePath) => {
this.tree.delete(filePath);
});
this.moveDir(this.project.oldSourceRoot, (0, devkit_1.joinPathFragments)(this.project.newSourceRoot));
}
updateOrCreateCypressConfigFile(configFile) {
if (!configFile) {
return this.getDefaultCypressConfigFilePath();
}
const cypressConfigFilePath = (0, devkit_1.joinPathFragments)(this.project.newRoot, (0, path_1.basename)(configFile));
this.updateCypressConfigFile(configFile);
this.tree.delete(cypressConfigFilePath);
this.moveFile(configFile, cypressConfigFilePath);
return cypressConfigFilePath;
}
updateCypressProjectConfiguration(cypressConfigPath) {
this.projectConfig = (0, devkit_1.readProjectConfiguration)(this.tree, this.project.name);
if (this.isProjectUsingEsLint) {
// the generated cypress project always generates a "lint" target,
// in case the app was using a different name for it, we use it
if (this.lintTargetName && this.lintTargetName !== 'lint') {
this.projectConfig.targets[this.lintTargetName] =
this.projectConfig.targets.lint;
}
}
[this.targetNames.e2e, 'cypress-run', 'cypress-open'].forEach((target) => {
if (this.appConfig.targets[target]) {
this.projectConfig.targets[target] = this.updateE2eCypressTarget(this.appConfig.targets[target], cypressConfigPath);
}
});
(0, devkit_1.updateProjectConfiguration)(this.tree, this.project.name, {
...this.projectConfig,
});
delete this.appConfig.targets['cypress-run'];
delete this.appConfig.targets['cypress-open'];
delete this.appConfig.targets[this.targetNames.e2e];
(0, devkit_1.updateProjectConfiguration)(this.tree, this.appName, {
...this.appConfig,
});
}
updateE2eCypressTarget(existingTarget, cypressConfig) {
const updatedTarget = {
...existingTarget,
executor: '@nx/cypress:cypress',
options: {
...existingTarget.options,
cypressConfig,
},
};
delete updatedTarget.options.configFile;
if (updatedTarget.options.tsConfig) {
updatedTarget.options.tsConfig = (0, devkit_1.joinPathFragments)(this.project.newRoot, 'tsconfig.json');
}
else {
delete updatedTarget.options.tsConfig;
}
return updatedTarget;
}
cypressConfigGlobToNewGlob(globPattern) {
return globPattern
? globPattern.replace(new RegExp(`^(\\.\\/|\\/)?${(0, path_1.relative)(this.project.oldRoot, this.project.oldSourceRoot)}\\/`), 'src/')
: undefined;
}
cypressConfigSrcPathToNewPath(path) {
return path
? (0, devkit_1.joinPathFragments)('src', (0, path_1.relative)(this.project.oldSourceRoot, (0, devkit_1.joinPathFragments)(this.project.oldRoot, path)))
: undefined;
}
cypressConfigDistPathToNewPath(path) {
return path
? (0, devkit_1.joinPathFragments)('../../dist/cypress/', this.project.newRoot, (0, path_1.relative)(this.project.oldSourceRoot, (0, devkit_1.joinPathFragments)(this.project.oldRoot, path)))
: undefined;
}
updateCypressConfigFile(configFilePath) {
const { isPropertyAssignment } = (0, ensure_typescript_1.ensureTypescript)();
const { tsquery } = require('@phenomnomnominal/tsquery');
const { nxE2EPreset } = require('@nx/cypress/plugins/cypress-preset');
this.cypressPreset = nxE2EPreset(configFilePath);
const fileContent = this.tree.read(configFilePath, 'utf-8');
let sourceFile = tsquery.ast(fileContent);
const recorder = new file_change_recorder_1.FileChangeRecorder(this.tree, configFilePath);
const defineConfigExpression = tsquery.query(sourceFile, 'CallExpression:has(Identifier[name=defineConfig]) > ObjectLiteralExpression')[0];
if (!defineConfigExpression) {
this.logger.warn(`Could not find a "defineConfig" expression in "${configFilePath}". Skipping updating the Cypress configuration.`);
return;
}
let e2eNode;
let componentNode;
const globalConfig = {};
defineConfigExpression.forEachChild((node) => {
if (isPropertyAssignment(node) && node.name.getText() === 'component') {
componentNode = node;
return;
}
if (isPropertyAssignment(node) && node.name.getText() === 'e2e') {
e2eNode = node;
return;
}
if (isPropertyAssignment(node)) {
this.updateCypressConfigNodeValue(recorder, node, globalConfig);
}
});
this.updateCypressComponentConfig(componentNode, recorder);
this.updateCypressE2EConfig(configFilePath, defineConfigExpression, e2eNode, recorder, globalConfig);
recorder.applyChanges();
}
updateCypressComponentConfig(componentNode, recorder) {
if (!componentNode) {
return;
}
const { isObjectLiteralExpression, isPropertyAssignment } = (0, ensure_typescript_1.ensureTypescript)();
if (!isObjectLiteralExpression(componentNode.initializer)) {
this.logger.warn('The automatic migration only supports having an object literal in the "component" option of the Cypress configuration. ' +
`The configuration won't be updated. Please make sure to update any paths you may have in the "component" option ` +
'manually to point to the new location.');
return;
}
componentNode.initializer.properties.forEach((node) => {
if (isPropertyAssignment(node)) {
this.updateCypressConfigNodeValue(recorder, node);
}
});
}
updateCypressE2EConfig(configFilePath, defineConfigNode, e2eNode, recorder, { ...globalConfig }) {
(0, ensure_typescript_1.ensureTypescript)();
const { tsquery } = require('@phenomnomnominal/tsquery');
const e2eConfig = {};
const presetSpreadAssignment = `...nxE2EPreset(__dirname),`;
if (!e2eNode) {
// add the e2e node with the preset and properties that need to overwrite
// the preset
const e2eAssignment = (0, devkit_1.stripIndents) `e2e: {
${presetSpreadAssignment}
${Object.entries(globalConfig)
.filter(([key, value]) => !e2eConfig[key] && value !== this.cypressPreset[key])
.map(([key, value]) => `${key}: '${value}'`)
.join(',\n')}
},`;
recorder.insertRight(defineConfigNode.getStart() + 1, e2eAssignment);
}
else {
const { isObjectLiteralExpression, isPropertyAssignment } = (0, ensure_typescript_1.ensureTypescript)();
if (!isObjectLiteralExpression(e2eNode.initializer)) {
this.logger.warn('The automatic migration only supports having an object literal in the "e2e" option of the Cypress configuration. ' +
`The configuration won't be updated. Please make sure to update any paths you might have in the "e2e" option ` +
'manually to point to the new location.');
return;
}
recorder.insertRight(e2eNode.initializer.getStart() + 1, presetSpreadAssignment);
e2eNode.initializer.properties.forEach((node) => {
if (!isPropertyAssignment(node)) {
return;
}
let change = { type: 'ignore' };
const property = this.normalizeNodeText(node.name.getText());
const oldValue = this.normalizeNodeText(node.initializer.getText());
e2eConfig[property] = oldValue;
const createChange = (newValue) => {
if (newValue === this.cypressPreset[property]) {
return { type: 'remove' };
}
return { type: 'replace', value: newValue };
};
if (this.isValidPathLikePropertyWithStringLiteralValue(node, cypressConfig.srcPaths)) {
const newValue = this.cypressConfigSrcPathToNewPath(oldValue);
change = createChange(newValue);
}
else if (this.isValidPathLikePropertyWithStringLiteralValue(node, cypressConfig.distPaths)) {
const newValue = this.cypressConfigDistPathToNewPath(oldValue);
change = createChange(newValue);
}
else if (this.isValidPathLikePropertyWithStringLiteralValue(node, cypressConfig.globPatterns)) {
const newValue = this.cypressConfigGlobToNewGlob(oldValue);
change = createChange(newValue);
}
if (change.type === 'replace') {
recorder.replace(node.initializer, `'${change.value}'`);
e2eConfig[property] = change.value;
}
else if (change.type === 'remove') {
const trailingCommaMatch = recorder.originalContent
.slice(node.getEnd())
.match(/^\s*,/);
if (trailingCommaMatch) {
recorder.remove(node.getFullStart(), node.getEnd() + trailingCommaMatch[0].length);
}
else {
recorder.remove(node.getFullStart(), node.getEnd());
}
delete e2eConfig[property];
delete globalConfig[property];
}
});
// add any global config that was present and that would be overwritten
// by the preset
Object.entries(globalConfig).forEach(([key, value]) => {
if (e2eConfig[key] || value === this.cypressPreset[key]) {
return;
}
recorder.insertRight(e2eNode.initializer.getStart() + 1, `${key}: '${value}',`);
});
}
// apply changes so we can apply AST transformations
recorder.applyChanges();
const sourceFile = tsquery.ast(recorder.content);
(0, js_1.insertImport)(this.tree, sourceFile, configFilePath, 'nxE2EPreset', '@nx/cypress/plugins/cypress-preset');
// update recorder with the new content from the file
recorder.setContentToFileContent();
}
updateCypressConfigNodeValue(recorder, node, configCollected) {
let newValue;
const oldValue = this.normalizeNodeText(node.initializer.getText());
if (this.isValidPathLikePropertyWithStringLiteralValue(node, cypressConfig.srcPaths)) {
newValue = this.cypressConfigSrcPathToNewPath(oldValue);
}
else if (this.isValidPathLikePropertyWithStringLiteralValue(node, cypressConfig.distPaths)) {
newValue = this.cypressConfigDistPathToNewPath(oldValue);
}
else if (this.isValidPathLikePropertyWithStringLiteralValue(node, cypressConfig.globPatterns)) {
newValue = this.cypressConfigGlobToNewGlob(oldValue);
}
if (newValue) {
recorder.replace(node.initializer, `'${newValue}'`);
if (configCollected) {
configCollected[node.name.getText()] = newValue;
}
}
}
isValidPathLikePropertyWithStringLiteralValue(node, properties) {
const { isPropertyAssignment, isStringLiteralLike, isTemplateExpression, SyntaxKind, } = (0, ensure_typescript_1.ensureTypescript)();
if (!isPropertyAssignment(node)) {
// TODO(leo): handle more scenarios (spread assignments, etc)
return false;
}
const property = properties.find((p) => p === node.name.getText());
if (!property) {
return false;
}
if (node.initializer.kind === SyntaxKind.UndefinedKeyword ||
node.initializer.kind === SyntaxKind.NullKeyword ||
node.initializer.kind === SyntaxKind.FalseKeyword) {
return false;
}
if (!isStringLiteralLike(node.initializer)) {
if (isTemplateExpression(node.initializer)) {
this.logger.warn(`The "${node.name.getText()}" in the Cypress configuration file is set to a template expression ("${node.initializer.getText()}"). ` +
`This is not supported by the automatic migration and its value won't be automatically migrated. ` +
`Please make sure to update its value to match the new location if needed.`);
}
else {
this.logger.warn(`The "${node.name.getText()}" in the Cypress configuration file is not set to a string literal ("${node.initializer.getText()}"). ` +
`This is not supported by the automatic migration and its value won't be automatically migrated. ` +
`Please make sure to update its value to match the new location if needed.`);
}
return false;
}
return true;
}
normalizeNodeText(value) {
return value.replace(/['"`]/g, '');
}
getOldCypressConfigFilePath() {
let cypressConfig;
const configFileOption = this.projectConfig.targets.e2e.options.configFile;
if (configFileOption === false) {
cypressConfig = null;
}
else if (typeof configFileOption === 'string') {
cypressConfig = (0, path_1.basename)(configFileOption);
}
else {
cypressConfig = this.findCypressConfigFilePath(this.project.oldRoot);
}
return cypressConfig;
}
getDefaultCypressConfigFilePath() {
return (0, devkit_1.joinPathFragments)(this.project.newRoot, 'cypress.config.ts');
}
findCypressConfigFilePath(dir) {
// https://docs.cypress.io/guides/references/configuration#Configuration-File
const possibleFiles = [
(0, devkit_1.joinPathFragments)(dir, 'cypress.config.ts'),
(0, devkit_1.joinPathFragments)(dir, 'cypress.config.js'),
(0, devkit_1.joinPathFragments)(dir, 'cypress.config.mjs'),
(0, devkit_1.joinPathFragments)(dir, 'cypress.config.cjs'),
];
for (const file of possibleFiles) {
if (this.tree.exists(file)) {
return file;
}
}
return null;
}
isCypressE2eProject() {
return (this.projectConfig.targets[this.targetNames.e2e].executor ===
'@cypress/schematic:cypress');
}
isProtractorE2eProject() {
return (this.projectConfig.targets[this.targetNames.e2e].executor ===
'@angular-devkit/build-angular:protractor');
}
}
exports.E2eMigrator = E2eMigrator;