feat(testing): add convert-to-inferred migration generator for cypress (#22884)

This commit is contained in:
Colum Ferry 2024-04-30 17:47:12 +01:00 committed by GitHub
parent 4ef832f4d5
commit 44820f2c4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1409 additions and 2 deletions

View File

@ -7048,6 +7048,14 @@
"children": [],
"isExternal": false,
"disableCollapsible": false
},
{
"id": "convert-to-inferred",
"path": "/nx-api/cypress/generators/convert-to-inferred",
"name": "convert-to-inferred",
"children": [],
"isExternal": false,
"disableCollapsible": false
}
],
"isExternal": false,

View File

@ -545,6 +545,15 @@
"originalFilePath": "/packages/cypress/src/generators/migrate-to-cypress-11/schema.json",
"path": "/nx-api/cypress/generators/migrate-to-cypress-11",
"type": "generator"
},
"/nx-api/cypress/generators/convert-to-inferred": {
"description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"file": "generated/packages/cypress/generators/convert-to-inferred.json",
"hidden": false,
"name": "convert-to-inferred",
"originalFilePath": "/packages/cypress/src/generators/convert-to-inferred/schema.json",
"path": "/nx-api/cypress/generators/convert-to-inferred",
"type": "generator"
}
},
"path": "/nx-api/cypress"

View File

@ -537,6 +537,15 @@
"originalFilePath": "/packages/cypress/src/generators/migrate-to-cypress-11/schema.json",
"path": "cypress/generators/migrate-to-cypress-11",
"type": "generator"
},
{
"description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"file": "generated/packages/cypress/generators/convert-to-inferred.json",
"hidden": false,
"name": "convert-to-inferred",
"originalFilePath": "/packages/cypress/src/generators/convert-to-inferred/schema.json",
"path": "cypress/generators/convert-to-inferred",
"type": "generator"
}
],
"githubRoot": "https://github.com/nrwl/nx/blob/master",

View File

@ -0,0 +1,30 @@
{
"name": "convert-to-inferred",
"factory": "./src/generators/convert-to-inferred/convert-to-inferred",
"schema": {
"$schema": "https://json-schema.org/schema",
"$id": "NxCypressConvertToInferred",
"description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"title": "Convert Cypress project from executor to plugin",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The project to convert from using the `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"x-priority": "important"
},
"skipFormat": {
"type": "boolean",
"description": "Whether to format files at the end of the migration.",
"default": false
}
},
"presets": []
},
"description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"implementation": "/packages/cypress/src/generators/convert-to-inferred/convert-to-inferred.ts",
"aliases": [],
"hidden": false,
"path": "/packages/cypress/src/generators/convert-to-inferred/schema.json",
"type": "generator"
}

View File

@ -372,6 +372,7 @@
- [configuration](/nx-api/cypress/generators/configuration)
- [component-configuration](/nx-api/cypress/generators/component-configuration)
- [migrate-to-cypress-11](/nx-api/cypress/generators/migrate-to-cypress-11)
- [convert-to-inferred](/nx-api/cypress/generators/convert-to-inferred)
- [detox](/nx-api/detox)
- [documents](/nx-api/detox/documents)
- [Overview](/nx-api/detox/documents/overview)

View File

@ -32,6 +32,11 @@
"factory": "./src/generators/migrate-to-cypress-11/migrate-to-cypress-11#migrateCypressProject",
"schema": "./src/generators/migrate-to-cypress-11/schema.json",
"description": "Migrate existing Cypress e2e projects to Cypress v11"
},
"convert-to-inferred": {
"factory": "./src/generators/convert-to-inferred/convert-to-inferred",
"schema": "./src/generators/convert-to-inferred/schema.json",
"description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`."
}
}
}

View File

@ -0,0 +1,465 @@
import {
getRelativeProjectJsonSchemaPath,
updateProjectConfiguration,
} from 'nx/src/generators/utils/project-configuration';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { convertToInferred } from './convert-to-inferred';
import {
addProjectConfiguration as _addProjectConfiguration,
type ExpandedPluginConfiguration,
joinPathFragments,
type ProjectConfiguration,
type ProjectGraph,
readNxJson,
readProjectConfiguration,
type Tree,
updateNxJson,
writeJson,
} from '@nx/devkit';
import { TempFs } from '@nx/devkit/internal-testing-utils';
import { join } from 'node:path';
let fs: TempFs;
let projectGraph: ProjectGraph;
jest.mock('@nx/devkit', () => ({
...jest.requireActual<any>('@nx/devkit'),
createProjectGraphAsync: jest.fn().mockImplementation(async () => {
return projectGraph;
}),
updateProjectConfiguration: jest
.fn()
.mockImplementation((tree, projectName, projectConfiguration) => {
function handleEmptyTargets(
projectName: string,
projectConfiguration: ProjectConfiguration
): void {
if (
projectConfiguration.targets &&
!Object.keys(projectConfiguration.targets).length
) {
// Re-order `targets` to appear after the `// target` comment.
delete projectConfiguration.targets;
projectConfiguration[
'// targets'
] = `to see all targets run: nx show project ${projectName} --web`;
projectConfiguration.targets = {};
} else {
delete projectConfiguration['// targets'];
}
}
const projectConfigFile = joinPathFragments(
projectConfiguration.root,
'project.json'
);
if (!tree.exists(projectConfigFile)) {
throw new Error(
`Cannot update Project ${projectName} at ${projectConfiguration.root}. It either doesn't exist yet, or may not use project.json for configuration. Use \`addProjectConfiguration()\` instead if you want to create a new project.`
);
}
handleEmptyTargets(projectName, projectConfiguration);
writeJson(tree, projectConfigFile, {
name: projectConfiguration.name ?? projectName,
$schema: getRelativeProjectJsonSchemaPath(tree, projectConfiguration),
...projectConfiguration,
root: undefined,
});
projectGraph.nodes[projectName].data = projectConfiguration;
}),
}));
function addProjectConfiguration(
tree: Tree,
name: string,
project: ProjectConfiguration
) {
_addProjectConfiguration(tree, name, project);
projectGraph.nodes[name] = {
name: name,
type: project.projectType === 'application' ? 'app' : 'lib',
data: {
projectType: project.projectType,
root: project.root,
targets: project.targets,
},
};
}
interface CreateCypressTestProjectOptions {
appName: string;
appRoot: string;
e2eTargetName: string;
}
const defaultCreateCypressTestProjectOptions: CreateCypressTestProjectOptions =
{
appName: 'myapp-e2e',
appRoot: 'myapp-e2e',
e2eTargetName: 'e2e',
};
function createTestProject(
tree: Tree,
opts: Partial<CreateCypressTestProjectOptions> = defaultCreateCypressTestProjectOptions
) {
let projectOpts = { ...defaultCreateCypressTestProjectOptions, ...opts };
const project: ProjectConfiguration = {
name: projectOpts.appName,
root: projectOpts.appRoot,
projectType: 'application',
targets: {
[projectOpts.e2eTargetName]: {
executor: '@nx/cypress:cypress',
options: {
cypressConfig: `${projectOpts.appRoot}/cypress.config.ts`,
testingType: `e2e`,
devServerTarget: 'myapp:serve',
},
},
},
};
const cypressConfigContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: 'http://localhost:4200',
},
});`;
tree.write(`${projectOpts.appRoot}/cypress.config.ts`, cypressConfigContents);
fs.createFileSync(
`${projectOpts.appRoot}/cypress.config.ts`,
cypressConfigContents
);
jest.doMock(
join(fs.tempDir, `${projectOpts.appRoot}/cypress.config.ts`),
() => ({
default: {
e2e: {
baseUrl: 'http://localhost:4200',
},
},
}),
{
virtual: true,
}
);
addProjectConfiguration(tree, project.name, project);
fs.createFileSync(
`${projectOpts.appRoot}/project.json`,
JSON.stringify(project)
);
return project;
}
describe('Cypress - Convert Executors To Plugin', () => {
let tree: Tree;
beforeEach(() => {
fs = new TempFs('cypress');
tree = createTreeWithEmptyWorkspace();
tree.root = fs.tempDir;
projectGraph = {
nodes: {},
dependencies: {},
externalNodes: {},
};
});
afterEach(() => {
fs.reset();
});
describe('--project', () => {
it('should setup a new Cypress plugin and only migrate one specific project', async () => {
// ARRANGE
const existingProject = createTestProject(tree, {
appRoot: 'existing',
appName: 'existing',
e2eTargetName: 'e2e',
});
const project = createTestProject(tree, {
e2eTargetName: 'test',
});
const secondProject = createTestProject(tree, {
appRoot: 'second',
appName: 'second',
e2eTargetName: 'test',
});
const thirdProject = createTestProject(tree, {
appRoot: 'third',
appName: 'third',
e2eTargetName: 'integration',
});
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'e2e-ci',
},
});
updateNxJson(tree, nxJson);
// ACT
await convertToInferred(tree, { project: 'myapp-e2e', skipFormat: true });
// ASSERT
// project.json modifications
const updatedProject = readProjectConfiguration(tree, project.name);
const targetKeys = Object.keys(updatedProject.targets);
['test'].forEach((key) => expect(targetKeys).not.toContain(key));
// nx.json modifications
const nxJsonPlugins = readNxJson(tree).plugins;
const addedTestCypressPlugin = nxJsonPlugins.find((plugin) => {
if (
typeof plugin !== 'string' &&
plugin.plugin === '@nx/cypress/plugin' &&
plugin.include?.length === 1
) {
return true;
}
});
expect(addedTestCypressPlugin).toBeTruthy();
expect(
(addedTestCypressPlugin as ExpandedPluginConfiguration).include
).toEqual(['myapp-e2e/**/*']);
});
});
describe('--all', () => {
it('should successfully migrate a project using Cypress executors to plugin', async () => {
const project = createTestProject(tree);
// ACT
await convertToInferred(tree, { skipFormat: true });
// ASSERT
// project.json modifications
const updatedProject = readProjectConfiguration(tree, project.name);
const targetKeys = Object.keys(updatedProject.targets);
expect(targetKeys).not.toContain('e2e');
// nx.json modifications
const nxJsonPlugins = readNxJson(tree).plugins;
const hasCypressPlugin = nxJsonPlugins.find((plugin) =>
typeof plugin === 'string'
? plugin === '@nx/cypress/plugin'
: plugin.plugin === '@nx/cypress/plugin'
);
expect(hasCypressPlugin).toBeTruthy();
if (typeof hasCypressPlugin !== 'string') {
[
['targetName', 'e2e'],
['ciTargetName', 'e2e-ci'],
].forEach(([targetOptionName, targetName]) => {
expect(hasCypressPlugin.options[targetOptionName]).toEqual(
targetName
);
});
}
});
it('should setup Cypress plugin to match projects', async () => {
// ARRANGE
const project = createTestProject(tree, {
e2eTargetName: 'test',
});
// ACT
await convertToInferred(tree, { skipFormat: true });
// ASSERT
// project.json modifications
const updatedProject = readProjectConfiguration(tree, project.name);
const targetKeys = Object.keys(updatedProject.targets);
['test'].forEach((key) => expect(targetKeys).not.toContain(key));
// nx.json modifications
const nxJsonPlugins = readNxJson(tree).plugins;
const hasCypressPlugin = nxJsonPlugins.find((plugin) =>
typeof plugin === 'string'
? plugin === '@nx/cypress/plugin'
: plugin.plugin === '@nx/cypress/plugin'
);
expect(hasCypressPlugin).toBeTruthy();
if (typeof hasCypressPlugin !== 'string') {
[
['targetName', 'test'],
['ciTargetName', 'e2e-ci'],
].forEach(([targetOptionName, targetName]) => {
expect(hasCypressPlugin.options[targetOptionName]).toEqual(
targetName
);
});
}
});
it('should setup a new Cypress plugin to match only projects migrated', async () => {
// ARRANGE
const existingProject = createTestProject(tree, {
appRoot: 'existing',
appName: 'existing',
e2eTargetName: 'e2e',
});
const project = createTestProject(tree, {
e2eTargetName: 'test',
});
const secondProject = createTestProject(tree, {
appRoot: 'second',
appName: 'second',
e2eTargetName: 'test',
});
const thirdProject = createTestProject(tree, {
appRoot: 'third',
appName: 'third',
e2eTargetName: 'integration',
});
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/cypress/plugin',
options: {
targetName: 'e2e',
ciTargetName: 'e2e-ci',
},
});
updateNxJson(tree, nxJson);
// ACT
await convertToInferred(tree, { skipFormat: true });
// ASSERT
// project.json modifications
const updatedProject = readProjectConfiguration(tree, project.name);
const targetKeys = Object.keys(updatedProject.targets);
['test'].forEach((key) => expect(targetKeys).not.toContain(key));
// nx.json modifications
const nxJsonPlugins = readNxJson(tree).plugins;
const addedTestCypressPlugin = nxJsonPlugins.find((plugin) => {
if (
typeof plugin !== 'string' &&
plugin.plugin === '@nx/cypress/plugin' &&
plugin.include?.length === 2
) {
return true;
}
});
expect(addedTestCypressPlugin).toBeTruthy();
expect(
(addedTestCypressPlugin as ExpandedPluginConfiguration).include
).toEqual(['myapp-e2e/**/*', 'second/**/*']);
const addedIntegrationCypressPlugin = nxJsonPlugins.find((plugin) => {
if (
typeof plugin !== 'string' &&
plugin.plugin === '@nx/cypress/plugin' &&
plugin.include?.length === 1
) {
return true;
}
});
expect(addedIntegrationCypressPlugin).toBeTruthy();
expect(
(addedIntegrationCypressPlugin as ExpandedPluginConfiguration).include
).toEqual(['third/**/*']);
});
it('should keep Cypress options in project.json', async () => {
// ARRANGE
const project = createTestProject(tree);
project.targets.e2e.options.runnerUi = true;
updateProjectConfiguration(tree, project.name, project);
// ACT
await convertToInferred(tree, { skipFormat: true });
// ASSERT
// project.json modifications
const updatedProject = readProjectConfiguration(tree, project.name);
expect(updatedProject.targets.e2e).toMatchInlineSnapshot(`
{
"options": {
"runner-ui": true,
},
}
`);
// nx.json modifications
const nxJsonPlugins = readNxJson(tree).plugins;
const hasCypressPlugin = nxJsonPlugins.find((plugin) =>
typeof plugin === 'string'
? plugin === '@nx/cypress/plugin'
: plugin.plugin === '@nx/cypress/plugin'
);
expect(hasCypressPlugin).toBeTruthy();
if (typeof hasCypressPlugin !== 'string') {
[
['targetName', 'e2e'],
['ciTargetName', 'e2e-ci'],
].forEach(([targetOptionName, targetName]) => {
expect(hasCypressPlugin.options[targetOptionName]).toEqual(
targetName
);
});
}
});
it('should add Cypress options found in targetDefaults for the executor to the project.json', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.targetDefaults ??= {};
nxJson.targetDefaults['@nx/cypress:cypress'] = {
options: {
exit: false,
},
};
updateNxJson(tree, nxJson);
const project = createTestProject(tree);
// ACT
await convertToInferred(tree, { skipFormat: true });
// ASSERT
// project.json modifications
const updatedProject = readProjectConfiguration(tree, project.name);
expect(updatedProject.targets.e2e).toMatchInlineSnapshot(`
{
"options": {
"no-exit": true,
},
}
`);
// nx.json modifications
const nxJsonPlugins = readNxJson(tree).plugins;
const hasCypressPlugin = nxJsonPlugins.find((plugin) =>
typeof plugin === 'string'
? plugin === '@nx/cypress/plugin'
: plugin.plugin === '@nx/cypress/plugin'
);
expect(hasCypressPlugin).toBeTruthy();
if (typeof hasCypressPlugin !== 'string') {
[
['targetName', 'e2e'],
['ciTargetName', 'e2e-ci'],
].forEach(([targetOptionName, targetName]) => {
expect(hasCypressPlugin.options[targetOptionName]).toEqual(
targetName
);
});
}
});
});
});

View File

@ -0,0 +1,136 @@
import {
CreateNodesContext,
createProjectGraphAsync,
formatFiles,
joinPathFragments,
type TargetConfiguration,
type Tree,
} from '@nx/devkit';
import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator';
import { createNodes } from '../../plugins/plugin';
import { targetOptionsToCliMap } from './lib/target-options-map';
import { upsertBaseUrl } from './lib/upsert-baseUrl';
import { addDevServerTargetToConfig } from './lib/add-dev-server-target-to-config';
import { addExcludeSpecPattern } from './lib/add-exclude-spec-pattern';
interface Schema {
project?: string;
all?: boolean;
skipFormat?: boolean;
}
export async function convertToInferred(tree: Tree, options: Schema) {
const projectGraph = await createProjectGraphAsync();
await migrateExecutorToPlugin(
tree,
projectGraph,
'@nx/cypress:cypress',
'@nx/cypress/plugin',
(targetName) => ({
targetName,
ciTargetName: 'e2e-ci',
}),
postTargetTransformer,
createNodes,
options.project
);
if (!options.skipFormat) {
await formatFiles(tree);
}
}
function postTargetTransformer(
target: TargetConfiguration,
tree: Tree
): TargetConfiguration {
if (target.options) {
const configFilePath = target.options.cypressConfig;
delete target.options.cypressConfig;
delete target.options.copyFiles;
delete target.options.skipServe;
for (const key in targetOptionsToCliMap) {
if (target.options[key]) {
target.options[targetOptionsToCliMap[key]] = target.options[key];
delete target.options[key];
}
}
if ('exit' in target.options && !target.options.exit) {
delete target.options.exit;
target.options['no-exit'] = true;
}
if (target.options.testingType) {
delete target.options.testingType;
}
if (target.options.watch) {
target.options.headed = true;
target.options['no-exit'] = true;
delete target.options.watch;
}
if (target.options.baseUrl) {
upsertBaseUrl(tree, configFilePath, target.options.baseUrl);
delete target.options.baseUrl;
}
if (target.options.devServerTarget) {
const webServerCommands: Record<string, string> = {
default: `npx nx run ${target.options.devServerTarget}`,
};
delete target.options.devServerTarget;
if (target.configurations) {
for (const configuration in target.configurations) {
if (target.configurations[configuration]?.devServerTarget) {
webServerCommands[
configuration
] = `npx nx run ${target.configurations[configuration].devServerTarget}`;
delete target.configurations[configuration].devServerTarget;
}
}
}
addDevServerTargetToConfig(
tree,
configFilePath,
webServerCommands,
target.configurations?.ci?.devServerTarget
);
}
if (target.options.ignoreTestFiles) {
addExcludeSpecPattern(
tree,
configFilePath,
target.options.ignoreTestFiles
);
delete target.options.ignoreTestFiles;
}
if (Object.keys(target.options).length === 0) {
delete target.options;
}
if (
target.configurations &&
Object.keys(target.configurations).length !== 0
) {
for (const configuration in target.configurations) {
if (Object.keys(target.configurations[configuration]).length === 0) {
delete target.configurations[configuration];
}
}
if (Object.keys(target.configurations).length === 0) {
delete target.configurations;
}
}
}
return target;
}
export default convertToInferred;

View File

@ -0,0 +1,211 @@
import type { Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import { addDevServerTargetToConfig } from './add-dev-server-target-to-config';
describe('addDevServerTargetToConfig', () => {
let tree: Tree;
const configFilePath = 'cypress.config.ts';
const configFileContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});`;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
tree.write(configFilePath, configFileContents);
});
describe('devServerTarget only', () => {
it('should add webServerCommands when it does not exist', () => {
// ACT
addDevServerTargetToConfig(tree, configFilePath, {
default: 'npx nx run myorg:serve',
});
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {webServerCommands: {"default":"npx nx run myorg:serve"}, cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
it('should do nothing if the webServerCommands exists and matches the devServerTarget', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"} }),
baseUrl: "http://localhost:4200",
},
});`
);
// ACT
addDevServerTargetToConfig(tree, configFilePath, {
default: 'npx nx run myorg:serve',
});
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {"default":"npx nx run myorg:serve"} }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
it('should update the webServerCommands if it does not match', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run test:serve"} }),
baseUrl: "http://localhost:4200",
},
});`
);
// ACT
addDevServerTargetToConfig(tree, configFilePath, {
default: 'npx nx run myorg:serve',
});
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {"default":"npx nx run myorg:serve"} }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
});
describe('devServerTarget and ci.devServerTarget', () => {
it('should add webServerCommands and ciWebServerCommand when it does not exist', () => {
// ACT
addDevServerTargetToConfig(
tree,
configFilePath,
{ default: 'npx nx run myorg:serve' },
'myorg:static-serve'
);
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, {ciWebServerCommand: "npx nx run myorg:static-serve",webServerCommands: {"default":"npx nx run myorg:serve"}, cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
it('should do nothing if the webServerCommands and ciWebServerCommand exists and matches the devServerTarget', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run myorg:serve"}, ciWebServerCommand: "npx nx run myorg:static-serve" }),
baseUrl: "http://localhost:4200",
},
});`
);
// ACT
addDevServerTargetToConfig(
tree,
configFilePath,
{ default: 'npx nx run myorg:serve' },
'myorg:static-serve'
);
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {"default":"npx nx run myorg:serve"}, ciWebServerCommand: "npx nx run myorg:static-serve" }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
it('should update the webServerCommands and ciWebServerCommand if it does not match', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {default: "npx nx run test:serve"}, ciWebServerCommand: "npx nx run test:static-serve" }),
baseUrl: "http://localhost:4200",
},
});`
);
// ACT
addDevServerTargetToConfig(
tree,
configFilePath,
{
default: 'npx nx run myorg:serve',
production: 'npx nx run myorg:serve:production',
ci: 'npx nx run myorg-static-serve',
},
'myorg:static-serve'
);
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src', webServerCommands: {"default":"npx nx run myorg:serve","production":"npx nx run myorg:serve:production","ci":"npx nx run myorg-static-serve"}, ciWebServerCommand: "npx nx run myorg:static-serve" }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
});
});

View File

@ -0,0 +1,106 @@
import type { Tree } from '@nx/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
/**
* Add or update the webServerCommands and ciWebServerCommand options in the Cypress Config
* Scenarios Covered:
* 1. Only devServerTarget Exists
* 2. devServerTarget and configuration.ci.devServerTarget Exists
*
* For each, the following scenarios are covered:
* a. The command is not listed in the config, so it is added
* b. Replace the existing webServerCommands with the value passed in
*/
export function addDevServerTargetToConfig(
tree: Tree,
configFilePath: string,
webServerCommands: Record<string, string>,
ciDevServerTarget?: string
) {
let configFileContents = tree.read(configFilePath, 'utf-8');
let ast = tsquery.ast(configFileContents);
const NX_E2E_PRESET_OPTIONS_SELECTOR =
'PropertyAssignment:has(Identifier[name=e2e]) CallExpression:has(Identifier[name=nxE2EPreset]) > ObjectLiteralExpression';
const nxE2ePresetOptionsNodes = tsquery(ast, NX_E2E_PRESET_OPTIONS_SELECTOR, {
visitAllChildren: true,
});
if (nxE2ePresetOptionsNodes.length !== 0) {
let nxE2ePresetOptionsNode = nxE2ePresetOptionsNodes[0];
const WEB_SERVER_COMMANDS_SELECTOR =
'PropertyAssignment:has(Identifier[name=webServerCommands])';
const webServerCommandsNodes = tsquery(
nxE2ePresetOptionsNode,
WEB_SERVER_COMMANDS_SELECTOR,
{ visitAllChildren: true }
);
if (webServerCommandsNodes.length !== 0) {
// Already exists, replace it
tree.write(
configFilePath,
`${configFileContents.slice(
0,
webServerCommandsNodes[0].getStart()
)}webServerCommands: ${JSON.stringify(
webServerCommands
)}${configFileContents.slice(webServerCommandsNodes[0].getEnd())}`
);
} else {
tree.write(
configFilePath,
`${configFileContents.slice(
0,
nxE2ePresetOptionsNode.getStart() + 1
)}webServerCommands: ${JSON.stringify(
webServerCommands
)},${configFileContents.slice(nxE2ePresetOptionsNode.getStart() + 1)}`
);
}
if (ciDevServerTarget) {
configFileContents = tree.read(configFilePath, 'utf-8');
ast = tsquery.ast(configFileContents);
nxE2ePresetOptionsNode = tsquery(ast, NX_E2E_PRESET_OPTIONS_SELECTOR, {
visitAllChildren: true,
})[0];
const CI_WEB_SERVER_COMMANDS_SELECTOR =
'PropertyAssignment:has(Identifier[name=ciWebServerCommand])';
const ciWebServerCommandsNodes = tsquery(
nxE2ePresetOptionsNode,
CI_WEB_SERVER_COMMANDS_SELECTOR,
{ visitAllChildren: true }
);
if (ciWebServerCommandsNodes.length !== 0) {
const ciWebServerCommandNode =
ciWebServerCommandsNodes[0].getChildAt(2);
const ciWebServerCommand = ciWebServerCommandNode
.getText()
.replace(/["']/g, '');
if (!ciWebServerCommand.includes(ciDevServerTarget)) {
tree.write(
configFilePath,
`${configFileContents.slice(
0,
ciWebServerCommandNode.getStart()
)}"npx nx run ${ciDevServerTarget}"${configFileContents.slice(
ciWebServerCommandNode.getEnd()
)}`
);
}
} else {
tree.write(
configFilePath,
`${configFileContents.slice(
0,
nxE2ePresetOptionsNode.getStart() + 1
)}ciWebServerCommand: "npx nx run ${ciDevServerTarget}",${configFileContents.slice(
nxE2ePresetOptionsNode.getStart() + 1
)}`
);
}
}
}
}

View File

@ -0,0 +1,200 @@
import type { Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import { addExcludeSpecPattern } from './add-exclude-spec-pattern';
describe('addExcludeSpecPattern', () => {
let tree: Tree;
const configFilePath = 'cypress.config.ts';
const configFileContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});`;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
tree.write(configFilePath, configFileContents);
});
it('should add excludeSpecPattern string if it does not exist', () => {
// ACT
addExcludeSpecPattern(tree, configFilePath, 'mytests/**/*.spec.ts');
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {excludeSpecPattern: "mytests/**/*.spec.ts",
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
it('should add excludeSpecPattern array if it does not exist', () => {
// ACT
addExcludeSpecPattern(tree, configFilePath, [
'mytests/**/*.spec.ts',
'mysecondtests/**/*.spec.ts',
]);
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {excludeSpecPattern: ["mytests/**/*.spec.ts","mysecondtests/**/*.spec.ts"],
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
it('should update the existing excludeSpecPattern if one exists when using string', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: "somefile.spec.ts"
},
});`
);
// ACT
addExcludeSpecPattern(tree, configFilePath, 'mytests/**/*.spec.ts');
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: ["mytests/**/*.spec.ts"]
},
});"
`);
});
it('should update the existing excludeSpecPattern if one exists when using array', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: ["somefile.spec.ts"]
},
});`
);
// ACT
addExcludeSpecPattern(tree, configFilePath, ['mytests/**/*.spec.ts']);
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: ["mytests/**/*.spec.ts"]
},
});"
`);
});
it('should update the existing excludeSpecPattern if one exists when using string with an array of new options', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: "somefile.spec.ts"
},
});`
);
// ACT
addExcludeSpecPattern(tree, configFilePath, [
'mytests/**/*.spec.ts',
'mysecondtests/**/*.spec.ts',
]);
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: ["mytests/**/*.spec.ts","mysecondtests/**/*.spec.ts"]
},
});"
`);
});
it('should update the existing excludeSpecPattern if one exists when using array with a new pattern string', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: ["somefile.spec.ts"]
},
});`
);
// ACT
addExcludeSpecPattern(tree, configFilePath, 'mytests/**/*.spec.ts');
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
excludeSpecPattern: ["mytests/**/*.spec.ts"]
},
});"
`);
});
});

View File

@ -0,0 +1,55 @@
import type { Tree } from '@nx/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
export function addExcludeSpecPattern(
tree: Tree,
configFilePath: string,
excludeSpecPattern: string | string[]
) {
let configFileContents = tree.read(configFilePath, 'utf-8');
let ast = tsquery.ast(configFileContents);
const E2E_CONFIG_SELECTOR =
'PropertyAssignment:has(Identifier[name=e2e]) > ObjectLiteralExpression';
const e2eConfigNodes = tsquery(ast, E2E_CONFIG_SELECTOR, {
visitAllChildren: true,
});
if (e2eConfigNodes.length !== 0) {
const e2eConfigNode = e2eConfigNodes[0];
const EXCLUDE_SPEC_PATTERN_SELECTOR =
'PropertyAssignment:has(Identifier[name="excludeSpecPattern"])';
const excludeSpecPatternNodes = tsquery(
e2eConfigNode,
EXCLUDE_SPEC_PATTERN_SELECTOR,
{ visitAllChildren: true }
);
if (excludeSpecPatternNodes.length !== 0) {
const excludeSpecPatternNode = excludeSpecPatternNodes[0];
let updatedExcludePattern = Array.isArray(excludeSpecPattern)
? excludeSpecPattern
: [excludeSpecPattern];
tree.write(
configFilePath,
`${configFileContents.slice(
0,
excludeSpecPatternNode.getStart()
)}excludeSpecPattern: ${JSON.stringify(
updatedExcludePattern
)}${configFileContents.slice(excludeSpecPatternNode.getEnd())}`
);
} else {
tree.write(
configFilePath,
`${configFileContents.slice(
0,
e2eConfigNode.getStart() + 1
)}excludeSpecPattern: ${JSON.stringify(
excludeSpecPattern
)},${configFileContents.slice(e2eConfigNode.getStart() + 1)}`
);
}
}
}

View File

@ -0,0 +1,18 @@
export const targetOptionsToCliMap = {
headed: 'headed',
headless: 'headless',
key: 'key',
record: 'record',
parallel: 'parallel',
browser: 'browser',
env: 'env',
spec: 'spec',
ciBuildId: 'ci-build-id',
group: 'group',
reporter: 'reporter',
reporterOptions: 'reporter-options',
tag: 'tag',
port: 'port',
quiet: 'quiet',
runnerUi: 'runner-ui',
};

View File

@ -0,0 +1,78 @@
import type { Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
import { upsertBaseUrl } from './upsert-baseUrl';
describe('upsertBaseUrl', () => {
let tree: Tree;
const configFilePath = 'cypress.config.ts';
const configFileContents = `import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});`;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
tree.write(configFilePath, configFileContents);
});
it('should do nothing if the baseUrl value exists and matches', () => {
// ACT
upsertBaseUrl(tree, configFilePath, 'http://localhost:4200');
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toEqual(configFileContents);
});
it('should update the config if the baseUrl value exists and does not match', () => {
// ACT
upsertBaseUrl(tree, configFilePath, 'http://localhost:4201');
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4201",
},
});"
`);
});
it('should add the baseUrl property if it does not exist', () => {
// ARRANGE
tree.write(
configFilePath,
`import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
},
});`
);
// ACT
upsertBaseUrl(tree, configFilePath, 'http://localhost:4200');
// ASSERT
expect(tree.read(configFilePath, 'utf-8')).toMatchInlineSnapshot(`
"import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
...nxE2EPreset(__filename, { cypressDir: 'src' }),
baseUrl: "http://localhost:4200",
},
});"
`);
});
});

View File

@ -0,0 +1,56 @@
import type { Tree } from '@nx/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
export function upsertBaseUrl(
tree: Tree,
configFilePath: string,
baseUrlValueInProject: string
) {
const configFileContents = tree.read(configFilePath, 'utf-8');
const ast = tsquery.ast(configFileContents);
const BASE_URL_SELECTOR =
'PropertyAssignment:has(Identifier[name=e2e]) PropertyAssignment:has(Identifier[name="baseUrl"])';
const baseUrlNodes = tsquery(ast, BASE_URL_SELECTOR, {
visitAllChildren: true,
});
if (baseUrlNodes.length !== 0) {
// The property exists in the config
const baseUrlValueNode = baseUrlNodes[0].getChildAt(2);
const baseUrlValue = baseUrlValueNode.getText().replace(/(["'])/, '');
if (baseUrlValue === baseUrlValueInProject) {
return;
}
tree.write(
configFilePath,
`${configFileContents.slice(
0,
baseUrlValueNode.getStart()
)}"${baseUrlValueInProject}"${configFileContents.slice(
baseUrlValueNode.getEnd()
)}`
);
} else {
const E2E_OBJECT_SELECTOR =
'PropertyAssignment:has(Identifier[name=e2e]) ObjectLiteralExpression';
const e2eConfigNodes = tsquery(ast, E2E_OBJECT_SELECTOR, {
visitAllChildren: true,
});
if (e2eConfigNodes.length !== 0) {
const e2eConfigNode = e2eConfigNodes[0];
tree.write(
configFilePath,
`${configFileContents.slice(
0,
e2eConfigNode.getEnd() - 1
)}baseUrl: "${baseUrlValueInProject}",
${configFileContents.slice(e2eConfigNode.getEnd() - 1)}`
);
}
}
}

View File

@ -0,0 +1,19 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "NxCypressConvertToInferred",
"description": "Convert existing Cypress project(s) using `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"title": "Convert Cypress project from executor to plugin",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "The project to convert from using the `@nx/cypress:cypress` executor to use `@nx/cypress/plugin`.",
"x-priority": "important"
},
"skipFormat": {
"type": "boolean",
"description": "Whether to format files at the end of the migration.",
"default": false
}
}
}

View File

@ -32,7 +32,8 @@ const {
type PluginOptionsBuilder<T> = (targetName: string) => T;
type PostTargetTransformer = (
targetConfiguration: TargetConfiguration
targetConfiguration: TargetConfiguration,
tree?: Tree
) => TargetConfiguration;
type SkipTargetFilter = (
targetConfiguration: TargetConfiguration
@ -129,7 +130,7 @@ class ExecutorToPluginMigrator<T> {
delete projectTarget.executor;
deleteMatchingProperties(projectTarget, createdTarget);
projectTarget = this.#postTargetTransformer(projectTarget);
projectTarget = this.#postTargetTransformer(projectTarget, this.tree);
if (
projectTarget.options &&