From 25eeddc3389f60875a0e5611ca2ba7812c898c31 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 24 Apr 2024 14:06:23 +0100 Subject: [PATCH] feat(testing): add playwright generator to convert from executors to plugin (#22784) Co-authored-by: FrozenPandaz --- .../devkit/ExpandedPluginConfiguration.md | 12 +- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/nx-api.json | 9 + docs/generated/packages-metadata.json | 9 + .../generators/convert-to-inferred.json | 30 + docs/shared/reference/sitemap.md | 1 + packages/devkit/package.json | 3 +- .../executor-to-plugin-migrator.ts | 331 ++++++++++ .../plugin-migration-utils.spec.ts | 78 +++ .../plugin-migration-utils.ts | 81 +++ packages/devkit/src/utils/add-plugin.ts | 4 +- packages/nx/src/config/nx-json.ts | 4 +- packages/nx/src/devkit-internals.ts | 1 + packages/playwright/generators.json | 5 + .../convert-to-inferred.spec.ts | 609 ++++++++++++++++++ .../convert-to-inferred.ts | 53 ++ .../convert-to-inferred/schema.json | 19 + 17 files changed, 1250 insertions(+), 7 deletions(-) create mode 100644 docs/generated/packages/playwright/generators/convert-to-inferred.json create mode 100644 packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts create mode 100644 packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.spec.ts create mode 100644 packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts create mode 100644 packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.spec.ts create mode 100644 packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts create mode 100644 packages/playwright/src/generators/convert-to-inferred/schema.json diff --git a/docs/generated/devkit/ExpandedPluginConfiguration.md b/docs/generated/devkit/ExpandedPluginConfiguration.md index 58ad5aafc1..809695fe54 100644 --- a/docs/generated/devkit/ExpandedPluginConfiguration.md +++ b/docs/generated/devkit/ExpandedPluginConfiguration.md @@ -1,6 +1,12 @@ -# Type alias: ExpandedPluginConfiguration +# Type alias: ExpandedPluginConfiguration\ -Ƭ **ExpandedPluginConfiguration**: `Object` +Ƭ **ExpandedPluginConfiguration**\<`T`\>: `Object` + +#### Type parameters + +| Name | Type | +| :--- | :-------- | +| `T` | `unknown` | #### Type declaration @@ -8,5 +14,5 @@ | :--------- | :--------- | | `exclude?` | `string`[] | | `include?` | `string`[] | -| `options?` | `unknown` | +| `options?` | `T` | | `plugin` | `string` | diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 10d7a71214..898e1eefa1 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -8575,6 +8575,14 @@ "children": [], "isExternal": false, "disableCollapsible": false + }, + { + "id": "convert-to-inferred", + "path": "/nx-api/playwright/generators/convert-to-inferred", + "name": "convert-to-inferred", + "children": [], + "isExternal": false, + "disableCollapsible": false } ], "isExternal": false, diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json index 226930936e..573c9275c4 100644 --- a/docs/generated/manifests/nx-api.json +++ b/docs/generated/manifests/nx-api.json @@ -2021,6 +2021,15 @@ "originalFilePath": "/packages/playwright/src/generators/init/schema.json", "path": "/nx-api/playwright/generators/init", "type": "generator" + }, + "/nx-api/playwright/generators/convert-to-inferred": { + "description": "Convert existing Playwright project(s) using `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`.", + "file": "generated/packages/playwright/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/playwright/src/generators/convert-to-inferred/schema.json", + "path": "/nx-api/playwright/generators/convert-to-inferred", + "type": "generator" } }, "path": "/nx-api/playwright" diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index ec494ac7d9..bf39a7a535 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -1997,6 +1997,15 @@ "originalFilePath": "/packages/playwright/src/generators/init/schema.json", "path": "playwright/generators/init", "type": "generator" + }, + { + "description": "Convert existing Playwright project(s) using `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`.", + "file": "generated/packages/playwright/generators/convert-to-inferred.json", + "hidden": false, + "name": "convert-to-inferred", + "originalFilePath": "/packages/playwright/src/generators/convert-to-inferred/schema.json", + "path": "playwright/generators/convert-to-inferred", + "type": "generator" } ], "githubRoot": "https://github.com/nrwl/nx/blob/master", diff --git a/docs/generated/packages/playwright/generators/convert-to-inferred.json b/docs/generated/packages/playwright/generators/convert-to-inferred.json new file mode 100644 index 0000000000..432705ec89 --- /dev/null +++ b/docs/generated/packages/playwright/generators/convert-to-inferred.json @@ -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": "NxPlaywrightConvertToInferred", + "description": "Convert existing Playwright project(s) using `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Playwright project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + }, + "presets": [] + }, + "description": "Convert existing Playwright project(s) using `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`.", + "implementation": "/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts", + "aliases": [], + "hidden": false, + "path": "/packages/playwright/src/generators/convert-to-inferred/schema.json", + "type": "generator" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index bdc12dea0a..330a974401 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -556,6 +556,7 @@ - [generators](/nx-api/playwright/generators) - [configuration](/nx-api/playwright/generators/configuration) - [init](/nx-api/playwright/generators/init) + - [convert-to-inferred](/nx-api/playwright/generators/convert-to-inferred) - [plugin](/nx-api/plugin) - [documents](/nx-api/plugin/documents) - [Overview](/nx-api/plugin/documents/overview) diff --git a/packages/devkit/package.json b/packages/devkit/package.json index 2a6b84be3d..133b83cbc9 100644 --- a/packages/devkit/package.json +++ b/packages/devkit/package.json @@ -34,7 +34,8 @@ "tmp": "~0.2.1", "tslib": "^2.3.0", "semver": "^7.5.3", - "yargs-parser": "21.1.1" + "yargs-parser": "21.1.1", + "minimatch": "9.0.3" }, "peerDependencies": { "nx": ">= 16 <= 19" diff --git a/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts new file mode 100644 index 0000000000..0f0078d0fe --- /dev/null +++ b/packages/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator.ts @@ -0,0 +1,331 @@ +import type { TargetConfiguration } from 'nx/src/config/workspace-json-project-json'; +import type { + ExpandedPluginConfiguration, + NxJsonConfiguration, +} from 'nx/src/config/nx-json'; +import type { Tree } from 'nx/src/generators/tree'; +import type { + CreateNodes, + CreateNodesContext, +} from 'nx/src/project-graph/plugins'; +import type { ProjectGraph } from 'nx/src/config/project-graph'; +import type { RunCommandsOptions } from 'nx/src/executors/run-commands/run-commands.impl'; +import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils'; + +import { minimatch } from 'minimatch'; + +import { forEachExecutorOptions } from '../executor-options-utils'; +import { deleteMatchingProperties } from './plugin-migration-utils'; +import { requireNx } from '../../../nx'; + +const { + glob, + readNxJson, + updateNxJson, + mergeTargetConfigurations, + updateProjectConfiguration, + readProjectConfiguration, + retrieveProjectConfigurations, + LoadedNxPlugin, + ProjectConfigurationsError, +} = requireNx(); + +type PluginOptionsBuilder = (targetName: string) => T; +type PostTargetTransformer = ( + targetConfiguration: TargetConfiguration +) => TargetConfiguration; +type SkipTargetFilter = ( + targetConfiguration: TargetConfiguration +) => [boolean, string]; + +class ExecutorToPluginMigrator { + readonly tree: Tree; + readonly #projectGraph: ProjectGraph; + readonly #executor: string; + readonly #pluginPath: string; + readonly #pluginOptionsBuilder: PluginOptionsBuilder; + readonly #postTargetTransformer: PostTargetTransformer; + readonly #skipTargetFilter: SkipTargetFilter; + readonly #specificProjectToMigrate: string; + #nxJson: NxJsonConfiguration; + #targetDefaultsForExecutor: Partial; + #targetAndProjectsToMigrate: Map>; + #pluginToAddForTarget: Map>; + #createNodes: CreateNodes; + #configFiles: string[]; + #createNodesResultsForTargets: Map; + + constructor( + tree: Tree, + projectGraph: ProjectGraph, + executor: string, + pluginPath: string, + pluginOptionsBuilder: PluginOptionsBuilder, + postTargetTransformer: PostTargetTransformer, + createNodes: CreateNodes, + specificProjectToMigrate?: string, + skipTargetFilter?: SkipTargetFilter + ) { + this.tree = tree; + this.#projectGraph = projectGraph; + this.#executor = executor; + this.#pluginPath = pluginPath; + this.#pluginOptionsBuilder = pluginOptionsBuilder; + this.#postTargetTransformer = postTargetTransformer; + this.#createNodes = createNodes; + this.#specificProjectToMigrate = specificProjectToMigrate; + this.#skipTargetFilter = skipTargetFilter ?? ((...args) => [false, '']); + } + + async run(): Promise { + await this.#init(); + for (const targetName of this.#targetAndProjectsToMigrate.keys()) { + this.#migrateTarget(targetName); + } + this.#addPlugins(); + } + + async #init() { + const nxJson = readNxJson(this.tree); + nxJson.plugins ??= []; + this.#nxJson = nxJson; + this.#targetAndProjectsToMigrate = new Map(); + this.#pluginToAddForTarget = new Map(); + this.#createNodesResultsForTargets = new Map(); + + this.#getTargetDefaultsForExecutor(); + this.#getTargetAndProjectsToMigrate(); + await this.#getCreateNodesResults(); + } + + #migrateTarget(targetName: string) { + const include: string[] = []; + for (const projectName of this.#targetAndProjectsToMigrate.get( + targetName + )) { + include.push(this.#migrateProject(projectName, targetName)); + } + + this.#pluginToAddForTarget.set(targetName, { + plugin: this.#pluginPath, + options: this.#pluginOptionsBuilder(targetName), + include, + }); + } + + #migrateProject(projectName: string, targetName: string) { + const projectFromGraph = this.#projectGraph.nodes[projectName]; + const projectConfig = readProjectConfiguration(this.tree, projectName); + + const createdTarget = this.#getCreatedTargetForProjectRoot( + targetName, + projectFromGraph.data.root + ); + let projectTarget = projectConfig.targets[targetName]; + projectTarget = mergeTargetConfigurations( + projectTarget, + this.#targetDefaultsForExecutor + ); + delete projectTarget.executor; + + deleteMatchingProperties(projectTarget, createdTarget); + projectTarget = this.#postTargetTransformer(projectTarget); + + if ( + projectTarget.options && + Object.keys(projectTarget.options).length === 0 + ) { + delete projectTarget.options; + } + + if (Object.keys(projectTarget).length > 0) { + projectConfig.targets[targetName] = projectTarget; + } else { + delete projectConfig.targets[targetName]; + } + updateProjectConfiguration(this.tree, projectName, projectConfig); + + return `${projectFromGraph.data.root}/**/*`; + } + + #addPlugins() { + for (const [targetName, plugin] of this.#pluginToAddForTarget.entries()) { + const pluginOptions = this.#pluginOptionsBuilder(targetName); + + const existingPlugin = this.#nxJson.plugins.find( + (plugin: ExpandedPluginConfiguration) => { + if ( + typeof plugin === 'string' || + plugin.plugin !== this.#pluginPath + ) { + return; + } + + for (const key in plugin.options) { + if (plugin.options[key] !== pluginOptions[key]) { + return false; + } + } + + return true; + } + ) as ExpandedPluginConfiguration; + + if (existingPlugin?.include) { + for (const pluginIncludes of existingPlugin.include) { + for (const projectPath of plugin.include) { + if (!minimatch(projectPath, pluginIncludes, { dot: true })) { + existingPlugin.include.push(projectPath); + } + } + } + + const allConfigFilesAreIncluded = this.#configFiles.every( + (configFile) => { + for (const includePattern of existingPlugin.include) { + if (minimatch(configFile, includePattern, { dot: true })) { + return true; + } + } + return false; + } + ); + + if (allConfigFilesAreIncluded) { + existingPlugin.include = undefined; + } + } + + if (!existingPlugin) { + this.#nxJson.plugins.push(plugin); + } + } + + updateNxJson(this.tree, this.#nxJson); + } + + #getTargetAndProjectsToMigrate() { + forEachExecutorOptions( + this.tree, + this.#executor, + (targetConfiguration, projectName, targetName, configurationName) => { + if (configurationName) { + return; + } + + if ( + this.#specificProjectToMigrate && + projectName !== this.#specificProjectToMigrate + ) { + return; + } + + const [skipTarget, reasonTargetWasSkipped] = + this.#skipTargetFilter(targetConfiguration); + if (skipTarget) { + const errorMsg = `${targetName} target on project "${projectName}" cannot be migrated. ${reasonTargetWasSkipped}`; + if (this.#specificProjectToMigrate) { + throw new Error(errorMsg); + } else { + console.warn(errorMsg); + } + return; + } + + if (this.#targetAndProjectsToMigrate.has(targetName)) { + this.#targetAndProjectsToMigrate.get(targetName).add(projectName); + } else { + this.#targetAndProjectsToMigrate.set( + targetName, + new Set([projectName]) + ); + } + } + ); + + if (this.#targetAndProjectsToMigrate.size === 0) { + const errorMsg = this.#specificProjectToMigrate + ? `Project "${ + this.#specificProjectToMigrate + }" does not contain any targets using the "${ + this.#executor + }" executor. Please select a project that does.` + : `Could not find any targets using the "${this.#executor}" executor.`; + throw new Error(errorMsg); + } + } + + #getTargetDefaultsForExecutor() { + this.#targetDefaultsForExecutor = + this.#nxJson.targetDefaults?.[this.#executor]; + } + + #getCreatedTargetForProjectRoot(targetName: string, projectRoot: string) { + const createdProject = Object.entries( + this.#createNodesResultsForTargets.get(targetName)?.projects ?? {} + ).find(([root]) => root === projectRoot)[1]; + const createdTarget: TargetConfiguration = + createdProject.targets[targetName]; + delete createdTarget.command; + delete createdTarget.options?.cwd; + + return createdTarget; + } + + async #getCreateNodesResults() { + for (const targetName of this.#targetAndProjectsToMigrate.keys()) { + const loadedPlugin = new LoadedNxPlugin( + { + createNodes: this.#createNodes, + name: this.#pluginPath, + }, + { + plugin: this.#pluginPath, + options: this.#pluginOptionsBuilder(targetName), + } + ); + let projectConfigs: ConfigurationResult; + try { + projectConfigs = await retrieveProjectConfigurations( + [loadedPlugin], + this.tree.root, + this.#nxJson + ); + } catch (e) { + if (e instanceof ProjectConfigurationsError) { + projectConfigs = e.partialProjectConfigurationsResult; + } else { + throw e; + } + } + + this.#configFiles = Array.from(projectConfigs.matchingProjectFiles); + this.#createNodesResultsForTargets.set(targetName, projectConfigs); + } + } +} + +export async function migrateExecutorToPlugin( + tree: Tree, + projectGraph: ProjectGraph, + executor: string, + pluginPath: string, + pluginOptionsBuilder: PluginOptionsBuilder, + postTargetTransformer: PostTargetTransformer, + createNodes: CreateNodes, + specificProjectToMigrate?: string, + skipTargetFilter?: SkipTargetFilter +): Promise { + const migrator = new ExecutorToPluginMigrator( + tree, + projectGraph, + executor, + pluginPath, + pluginOptionsBuilder, + postTargetTransformer, + createNodes, + specificProjectToMigrate, + skipTargetFilter + ); + await migrator.run(); +} diff --git a/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.spec.ts b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.spec.ts new file mode 100644 index 0000000000..c2671e45a5 --- /dev/null +++ b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.spec.ts @@ -0,0 +1,78 @@ +import { deleteMatchingProperties } from './plugin-migration-utils'; +describe('Plugin Migration Utils', () => { + describe('deleteMatchingProperties', () => { + it('should delete properties that are identical between two different objects, leaving an empty object', () => { + // ARRANGE + const activeObject = { + foo: 1, + bar: 'myval', + baz: { + nested: { + key: 'val', + }, + }, + arr: ['string', 1], + }; + + const comparableObject = { + foo: 1, + bar: 'myval', + baz: { + nested: { + key: 'val', + }, + }, + arr: ['string', 1], + }; + + // ACT + deleteMatchingProperties(activeObject, comparableObject); + + // ASSERT + expect(activeObject).toMatchInlineSnapshot(`{}`); + }); + + it('should delete properties that are identical between two different objects, leaving an object containing only the differences', () => { + // ARRANGE + const activeObject = { + foo: 1, + bar: 'myval', + baz: { + nested: { + key: 'differentValue', + }, + }, + arr: ['string', 2], + }; + + const comparableObject = { + foo: 1, + bar: 'myval', + baz: { + nested: { + key: 'val', + }, + }, + arr: ['string', 1], + }; + + // ACT + deleteMatchingProperties(activeObject, comparableObject); + + // ASSERT + expect(activeObject).toMatchInlineSnapshot(` + { + "arr": [ + "string", + 2, + ], + "baz": { + "nested": { + "key": "differentValue", + }, + }, + } + `); + }); + }); +}); diff --git a/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts new file mode 100644 index 0000000000..4819eba76e --- /dev/null +++ b/packages/devkit/src/generators/plugin-migrations/plugin-migration-utils.ts @@ -0,0 +1,81 @@ +/** + * Iterate through the current target in the project.json and its options comparing it to the target created by the Plugin itself + * Delete matching properties from current target. + * + * _Note: Deletes by reference_ + * + * @example + * // Run the plugin to get all the projects + * const { projects } = await createNodes[1]( + * playwrightConfigPath, + * { targetName, ciTargetName: 'e2e-ci' }, + * { workspaceRoot: tree.root, nxJsonConfiguration, configFiles } + * ); + * + * // Find the project that matches the one that is being migrated + * const createdProject = Object.entries(projects ?? {}).find( + * ([root]) => root === projectFromGraph.data.root + * )[1]; + * + * // Get the created TargetConfiguration for the target being migrated + * const createdTarget: TargetConfiguration = + * createdProject.targets[targetName]; + * + * // Delete specific run-commands options + * delete createdTarget.command; + * delete createdTarget.options?.cwd; + * + * // Get the TargetConfiguration for the target being migrated from project.json + * const projectConfig = readProjectConfiguration(tree, projectName); + * let targetToMigrate = projectConfig.targets[targetName]; + * + * // Merge the target defaults for the executor to the target being migrated + * target = mergeTargetConfigurations(targetToMigrate, targetDefaultsForExecutor); + * + * // Delete executor and any additional options that are no longer necessary + * delete target.executor; + * delete target.options?.config; + * + * // Run deleteMatchingProperties to delete further options that match what the plugin creates + * deleteMatchingProperties(target, createdTarget); + * + * // Delete the target if it is now empty, otherwise, set it to the updated TargetConfiguration + * if (Object.keys(target).length > 0) { + * projectConfig.targets[targetName] = target; + * } else { + * delete projectConfig.targets[targetName]; + * } + * + * updateProjectConfiguration(tree, projectName, projectConfig); + * + * @param targetToMigrate The target from project.json + * @param createdTarget The target created by the Plugin + */ +export function deleteMatchingProperties( + targetToMigrate: object, + createdTarget: object +): void { + for (const key in targetToMigrate) { + if (Array.isArray(targetToMigrate[key])) { + if ( + targetToMigrate[key].every((v) => createdTarget[key]?.includes(v)) && + targetToMigrate[key].length === createdTarget[key]?.length + ) { + delete targetToMigrate[key]; + } + } else if ( + typeof targetToMigrate[key] === 'object' && + typeof createdTarget[key] === 'object' + ) { + deleteMatchingProperties(targetToMigrate[key], createdTarget[key]); + } else if (targetToMigrate[key] === createdTarget[key]) { + delete targetToMigrate[key]; + } + if ( + typeof targetToMigrate[key] === 'object' && + Object.keys(targetToMigrate[key]).length === 0 + ) { + delete targetToMigrate[key]; + } + } +} diff --git a/packages/devkit/src/utils/add-plugin.ts b/packages/devkit/src/utils/add-plugin.ts index 18e412db07..1a4a87fadc 100644 --- a/packages/devkit/src/utils/add-plugin.ts +++ b/packages/devkit/src/utils/add-plugin.ts @@ -46,7 +46,9 @@ export async function addPlugin( nxJson.plugins ??= []; if ( nxJson.plugins.some((p) => - typeof p === 'string' ? p === pluginName : p.plugin === pluginName + typeof p === 'string' + ? p === pluginName + : p.plugin === pluginName && !p.include ) ) { // Plugin has already been added diff --git a/packages/nx/src/config/nx-json.ts b/packages/nx/src/config/nx-json.ts index 83c7d2bac2..d52623e49f 100644 --- a/packages/nx/src/config/nx-json.ts +++ b/packages/nx/src/config/nx-json.ts @@ -438,9 +438,9 @@ export interface NxJsonConfiguration { export type PluginConfiguration = string | ExpandedPluginConfiguration; -export type ExpandedPluginConfiguration = { +export type ExpandedPluginConfiguration = { plugin: string; - options?: unknown; + options?: T; include?: string[]; exclude?: string[]; }; diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index 2c9565d9ad..1deeefad14 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -8,6 +8,7 @@ export { getExecutorInformation } from './command-line/run/executor-utils'; export { readNxJson as readNxJsonFromDisk } from './config/nx-json'; export { calculateDefaultProjectName } from './config/calculate-default-project-name'; export { retrieveProjectConfigurationsWithAngularProjects } from './project-graph/utils/retrieve-workspace-files'; +export { mergeTargetConfigurations } from './project-graph/utils/project-configuration-utils'; export { readProjectConfigurationsFromRootMap } from './project-graph/utils/project-configuration-utils'; export { splitTarget } from './utils/split-target'; export { combineOptionsForExecutor } from './utils/params'; diff --git a/packages/playwright/generators.json b/packages/playwright/generators.json index 5f4ac21090..f256cc1baa 100644 --- a/packages/playwright/generators.json +++ b/packages/playwright/generators.json @@ -11,6 +11,11 @@ "factory": "./src/generators/init/init#initGeneratorInternal", "schema": "./src/generators/init/schema.json", "description": "Initializes a Playwright project in the current workspace" + }, + "convert-to-inferred": { + "factory": "./src/generators/convert-to-inferred/convert-to-inferred", + "schema": "./src/generators/convert-to-inferred/schema.json", + "description": "Convert existing Playwright project(s) using `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`." } } } diff --git a/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.spec.ts b/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.spec.ts new file mode 100644 index 0000000000..bc2ba735b6 --- /dev/null +++ b/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.spec.ts @@ -0,0 +1,609 @@ +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('@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 CreatePlaywrightTestProjectOptions { + appName: string; + appRoot: string; + e2eTargetName: string; + outputPath: string; +} + +const defaultCreatePlaywrightTestProjectOptions: CreatePlaywrightTestProjectOptions = + { + appName: 'myapp-e2e', + appRoot: 'myapp-e2e', + e2eTargetName: 'e2e', + outputPath: '{workspaceRoot}/dist/.playwright/myapp-e2e', + }; + +function createTestProject( + tree: Tree, + opts: Partial = defaultCreatePlaywrightTestProjectOptions +) { + let projectOpts = { ...defaultCreatePlaywrightTestProjectOptions, ...opts }; + const project: ProjectConfiguration = { + name: projectOpts.appName, + root: projectOpts.appRoot, + projectType: 'application', + targets: { + [projectOpts.e2eTargetName]: { + executor: '@nx/playwright:playwright', + outputs: [projectOpts.outputPath], + options: { + config: `${projectOpts.appRoot}/playwright.config.ts`, + }, + }, + }, + }; + + const playwrightConfigContents = `import { defineConfig, devices } from '@playwright/test'; + import { nxE2EPreset } from '@nx/playwright/preset'; + import { workspaceRoot } from '@nx/devkit'; + + const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; + + export default defineConfig({ + ...nxE2EPreset(__filename, { testDir: './src' }), + use: { + baseURL, + trace: 'on-first-retry', + }, + webServer: { + command: 'npx nx serve myapp', + url: 'http://localhost:4200', + reuseExistingServer: !process.env.CI, + cwd: workspaceRoot, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + });`; + + tree.write( + `${projectOpts.appRoot}/playwright.config.ts`, + playwrightConfigContents + ); + fs.createFileSync( + `${projectOpts.appRoot}/playwright.config.ts`, + playwrightConfigContents + ); + jest.doMock( + join(fs.tempDir, `${projectOpts.appRoot}/playwright.config.ts`), + () => ({ + default: { + outputDir: '../dist/.playwright/myapp-e2e', + }, + }), + { + virtual: true, + } + ); + + addProjectConfiguration(tree, project.name, project); + fs.createFileSync( + `${projectOpts.appRoot}/project.json`, + JSON.stringify(project) + ); + return project; +} + +describe('Playwright - Convert Executors To Plugin', () => { + let tree: Tree; + + beforeEach(() => { + fs = new TempFs('playwright'); + tree = createTreeWithEmptyWorkspace(); + tree.root = fs.tempDir; + + projectGraph = { + nodes: {}, + dependencies: {}, + externalNodes: {}, + }; + }); + + afterEach(() => { + fs.reset(); + }); + + describe('--project', () => { + it('should setup a new Playwright 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/playwright/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 addedTestPlaywrightPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/playwright/plugin' && + plugin.include?.length === 1 + ) { + return true; + } + }); + expect(addedTestPlaywrightPlugin).toBeTruthy(); + expect( + (addedTestPlaywrightPlugin as ExpandedPluginConfiguration).include + ).toEqual(['myapp-e2e/**/*']); + }); + + it('should add project to existing plugins includes', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + e2eTargetName: 'e2e', + }); + const project = createTestProject(tree, { + e2eTargetName: 'e2e', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + e2eTargetName: 'e2e', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + e2eTargetName: 'e2e', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/playwright/plugin', + include: ['existing/**/*'], + 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 addedTestPlaywrightPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/playwright/plugin' && + plugin.include?.length === 2 + ) { + return true; + } + }); + expect(addedTestPlaywrightPlugin).toBeTruthy(); + expect( + (addedTestPlaywrightPlugin as ExpandedPluginConfiguration).include + ).toEqual(['existing/**/*', 'myapp-e2e/**/*']); + }); + + it('should remove include when all projects are included', async () => { + // ARRANGE + const existingProject = createTestProject(tree, { + appRoot: 'existing', + appName: 'existing', + e2eTargetName: 'e2e', + }); + const project = createTestProject(tree, { + e2eTargetName: 'e2e', + }); + const secondProject = createTestProject(tree, { + appRoot: 'second', + appName: 'second', + e2eTargetName: 'e2e', + }); + const thirdProject = createTestProject(tree, { + appRoot: 'third', + appName: 'third', + e2eTargetName: 'e2e', + }); + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx/playwright/plugin', + include: ['existing/**/*', 'second/**/*', 'third/**/*'], + 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 addedTestPlaywrightPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/playwright/plugin' && + !plugin.include + ) { + return true; + } + }); + expect(addedTestPlaywrightPlugin).toBeTruthy(); + expect( + (addedTestPlaywrightPlugin as ExpandedPluginConfiguration).include + ).not.toBeDefined(); + }); + }); + + describe('--all', () => { + it('should successfully migrate a project using Playwright 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 hasPlaywrightPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/playwright/plugin' + : plugin.plugin === '@nx/playwright/plugin' + ); + expect(hasPlaywrightPlugin).toBeTruthy(); + if (typeof hasPlaywrightPlugin !== 'string') { + [ + ['targetName', 'e2e'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasPlaywrightPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + + it('should setup Playwright 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 hasPlaywrightPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/playwright/plugin' + : plugin.plugin === '@nx/playwright/plugin' + ); + expect(hasPlaywrightPlugin).toBeTruthy(); + if (typeof hasPlaywrightPlugin !== 'string') { + [ + ['targetName', 'test'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasPlaywrightPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + + it('should setup a new Playwright 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/playwright/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 addedTestPlaywrightPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/playwright/plugin' && + plugin.include?.length === 2 + ) { + return true; + } + }); + expect(addedTestPlaywrightPlugin).toBeTruthy(); + expect( + (addedTestPlaywrightPlugin as ExpandedPluginConfiguration).include + ).toEqual(['myapp-e2e/**/*', 'second/**/*']); + + const addedIntegrationPlaywrightPlugin = nxJsonPlugins.find((plugin) => { + if ( + typeof plugin !== 'string' && + plugin.plugin === '@nx/playwright/plugin' && + plugin.include?.length === 1 + ) { + return true; + } + }); + expect(addedIntegrationPlaywrightPlugin).toBeTruthy(); + expect( + (addedIntegrationPlaywrightPlugin as ExpandedPluginConfiguration) + .include + ).toEqual(['third/**/*']); + }); + + it('should keep Playwright options in project.json', async () => { + // ARRANGE + const project = createTestProject(tree); + project.targets.e2e.options.globalTimeout = 100000; + 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": { + "global-timeout": 100000, + }, + } + `); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasPlaywrightPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/playwright/plugin' + : plugin.plugin === '@nx/playwright/plugin' + ); + expect(hasPlaywrightPlugin).toBeTruthy(); + if (typeof hasPlaywrightPlugin !== 'string') { + [ + ['targetName', 'e2e'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasPlaywrightPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + + it('should add Playwright options found in targetDefaults for the executor to the project.json', async () => { + // ARRANGE + const nxJson = readNxJson(tree); + nxJson.targetDefaults ??= {}; + nxJson.targetDefaults['@nx/playwright:playwright'] = { + options: { + globalTimeout: 100000, + }, + }; + 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": { + "global-timeout": 100000, + }, + } + `); + + // nx.json modifications + const nxJsonPlugins = readNxJson(tree).plugins; + const hasPlaywrightPlugin = nxJsonPlugins.find((plugin) => + typeof plugin === 'string' + ? plugin === '@nx/playwright/plugin' + : plugin.plugin === '@nx/playwright/plugin' + ); + expect(hasPlaywrightPlugin).toBeTruthy(); + if (typeof hasPlaywrightPlugin !== 'string') { + [ + ['targetName', 'e2e'], + ['ciTargetName', 'e2e-ci'], + ].forEach(([targetOptionName, targetName]) => { + expect(hasPlaywrightPlugin.options[targetOptionName]).toEqual( + targetName + ); + }); + } + }); + }); +}); diff --git a/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts b/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts new file mode 100644 index 0000000000..4a833fb601 --- /dev/null +++ b/packages/playwright/src/generators/convert-to-inferred/convert-to-inferred.ts @@ -0,0 +1,53 @@ +import { + createProjectGraphAsync, + formatFiles, + names, + type TargetConfiguration, + type Tree, +} from '@nx/devkit'; +import { createNodes, PlaywrightPluginOptions } from '../../plugins/plugin'; +import { migrateExecutorToPlugin } from '@nx/devkit/src/generators/plugin-migrations/executor-to-plugin-migrator'; + +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/playwright:playwright', + '@nx/playwright/plugin', + (targetName) => ({ targetName, ciTargetName: 'e2e-ci' }), + postTargetTransformer, + createNodes, + options.project + ); + + if (!options.skipFormat) { + await formatFiles(tree); + } +} + +function postTargetTransformer( + target: TargetConfiguration +): TargetConfiguration { + if (target.options) { + if (target.options?.config) { + delete target.options.config; + } + + for (const [key, value] of Object.entries(target.options)) { + const newKeyName = names(key).fileName; + delete target.options[key]; + target.options[newKeyName] = value; + } + } + + return target; +} + +export default convertToInferred; diff --git a/packages/playwright/src/generators/convert-to-inferred/schema.json b/packages/playwright/src/generators/convert-to-inferred/schema.json new file mode 100644 index 0000000000..02400f535f --- /dev/null +++ b/packages/playwright/src/generators/convert-to-inferred/schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "NxPlaywrightConvertToInferred", + "description": "Convert existing Playwright project(s) using `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`. Defaults to migrating all projects. Pass '--project' to migrate only one target.", + "title": "Convert Playwright project from executor to plugin", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to convert from using the `@nx/playwright:playwright` executor to use `@nx/playwright/plugin`.", + "x-priority": "important" + }, + "skipFormat": { + "type": "boolean", + "description": "Whether to format files at the end of the migration.", + "default": false + } + } +}