feat(testing): add playwright generator to convert from executors to plugin (#22784)

Co-authored-by: FrozenPandaz <jasonjean1993@gmail.com>
This commit is contained in:
Colum Ferry 2024-04-24 14:06:23 +01:00 committed by GitHub
parent 0197bfecc9
commit 25eeddc338
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1250 additions and 7 deletions

View File

@ -1,6 +1,12 @@
# Type alias: ExpandedPluginConfiguration
# Type alias: ExpandedPluginConfiguration\<T\>
Ƭ **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` |

View File

@ -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,

View File

@ -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"

View File

@ -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",

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": "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"
}

View File

@ -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)

View File

@ -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"

View File

@ -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<T> = (targetName: string) => T;
type PostTargetTransformer = (
targetConfiguration: TargetConfiguration
) => TargetConfiguration;
type SkipTargetFilter = (
targetConfiguration: TargetConfiguration
) => [boolean, string];
class ExecutorToPluginMigrator<T> {
readonly tree: Tree;
readonly #projectGraph: ProjectGraph;
readonly #executor: string;
readonly #pluginPath: string;
readonly #pluginOptionsBuilder: PluginOptionsBuilder<T>;
readonly #postTargetTransformer: PostTargetTransformer;
readonly #skipTargetFilter: SkipTargetFilter;
readonly #specificProjectToMigrate: string;
#nxJson: NxJsonConfiguration;
#targetDefaultsForExecutor: Partial<TargetConfiguration>;
#targetAndProjectsToMigrate: Map<string, Set<string>>;
#pluginToAddForTarget: Map<string, ExpandedPluginConfiguration<T>>;
#createNodes: CreateNodes<T>;
#configFiles: string[];
#createNodesResultsForTargets: Map<string, ConfigurationResult>;
constructor(
tree: Tree,
projectGraph: ProjectGraph,
executor: string,
pluginPath: string,
pluginOptionsBuilder: PluginOptionsBuilder<T>,
postTargetTransformer: PostTargetTransformer,
createNodes: CreateNodes<T>,
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<void> {
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<T>) => {
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<T>;
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<RunCommandsOptions> =
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<T>(
tree: Tree,
projectGraph: ProjectGraph,
executor: string,
pluginPath: string,
pluginOptionsBuilder: PluginOptionsBuilder<T>,
postTargetTransformer: PostTargetTransformer,
createNodes: CreateNodes<T>,
specificProjectToMigrate?: string,
skipTargetFilter?: SkipTargetFilter
): Promise<void> {
const migrator = new ExecutorToPluginMigrator<T>(
tree,
projectGraph,
executor,
pluginPath,
pluginOptionsBuilder,
postTargetTransformer,
createNodes,
specificProjectToMigrate,
skipTargetFilter
);
await migrator.run();
}

View File

@ -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",
},
},
}
`);
});
});
});

View File

@ -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<RunCommandsOptions> =
* 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];
}
}
}

View File

@ -46,7 +46,9 @@ export async function addPlugin<PluginOptions>(
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

View File

@ -438,9 +438,9 @@ export interface NxJsonConfiguration<T = '*' | string[]> {
export type PluginConfiguration = string | ExpandedPluginConfiguration;
export type ExpandedPluginConfiguration = {
export type ExpandedPluginConfiguration<T = unknown> = {
plugin: string;
options?: unknown;
options?: T;
include?: string[];
exclude?: string[];
};

View File

@ -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';

View File

@ -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`."
}
}
}

View File

@ -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<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 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<CreatePlaywrightTestProjectOptions> = 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
);
});
}
});
});
});

View File

@ -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<PlaywrightPluginOptions>(
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;

View File

@ -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
}
}
}