From 2374d8eaba89f1f82e789cc14b62230c2393862d Mon Sep 17 00:00:00 2001 From: Craigory Coppola Date: Wed, 20 Dec 2023 16:34:32 -0500 Subject: [PATCH] feat(testing): add create-nodes plugin for playwright e2e targets (#20099) --- docs/generated/devkit/README.md | 1 + docs/generated/devkit/ToJSOptions.md | 11 + docs/generated/devkit/toJS.md | 9 +- .../packages/devkit/documents/nx_devkit.md | 1 + .../playwright/executors/playwright.json | 2 +- e2e/playwright/src/playwright.test.ts | 95 ++++++ e2e/utils/get-env-info.ts | 4 +- packages/cypress/src/plugins/plugin.ts | 10 +- packages/devkit/public-api.ts | 2 +- packages/devkit/src/generators/to-js.ts | 21 +- packages/nx/src/native/index.d.ts | 1 + packages/nx/src/native/utils/path.rs | 49 ++- packages/nx/src/native/workspace/context.rs | 8 +- packages/nx/src/utils/workspace-context.ts | 8 + packages/playwright/executors.json | 2 +- packages/playwright/index.ts | 2 +- packages/playwright/migrations.json | 3 + packages/playwright/package.json | 8 +- packages/playwright/plugin.ts | 1 + .../{playwright.ts => playwright.impl.ts} | 0 .../generators/configuration/configuration.ts | 23 +- .../src/generators/init/init.spec.ts | 106 +++++++ .../playwright/src/generators/init/init.ts | 27 ++ .../playwright/src/plugins/plugin.spec.ts | 245 +++++++++++++++ packages/playwright/src/plugins/plugin.ts | 284 ++++++++++++++++++ .../playwright/src/utils/load-config-file.ts | 56 ++++ 26 files changed, 957 insertions(+), 22 deletions(-) create mode 100644 docs/generated/devkit/ToJSOptions.md create mode 100644 packages/playwright/migrations.json create mode 100644 packages/playwright/plugin.ts rename packages/playwright/src/executors/playwright/{playwright.ts => playwright.impl.ts} (100%) create mode 100644 packages/playwright/src/generators/init/init.spec.ts create mode 100644 packages/playwright/src/plugins/plugin.spec.ts create mode 100644 packages/playwright/src/plugins/plugin.ts create mode 100644 packages/playwright/src/utils/load-config-file.ts diff --git a/docs/generated/devkit/README.md b/docs/generated/devkit/README.md index 889a8fe033..b0f2fa2468 100644 --- a/docs/generated/devkit/README.md +++ b/docs/generated/devkit/README.md @@ -87,6 +87,7 @@ It only uses language primitives and immutable objects - [StringChange](../../devkit/documents/StringChange) - [TargetDefaults](../../devkit/documents/TargetDefaults) - [TaskGraphExecutor](../../devkit/documents/TaskGraphExecutor) +- [ToJSOptions](../../devkit/documents/ToJSOptions) - [WorkspaceJsonConfiguration](../../devkit/documents/WorkspaceJsonConfiguration) ### Variables diff --git a/docs/generated/devkit/ToJSOptions.md b/docs/generated/devkit/ToJSOptions.md new file mode 100644 index 0000000000..73060f872d --- /dev/null +++ b/docs/generated/devkit/ToJSOptions.md @@ -0,0 +1,11 @@ +# Type alias: ToJSOptions + +Ƭ **ToJSOptions**: `Object` + +#### Type declaration + +| Name | Type | +| :---------- | :------------------------------ | +| `extension` | `".js"` \| `".mjs"` \| `".cjs"` | +| `module?` | `ModuleKind` | +| `target?` | `ScriptTarget` | diff --git a/docs/generated/devkit/toJS.md b/docs/generated/devkit/toJS.md index be8963faba..46fae2143a 100644 --- a/docs/generated/devkit/toJS.md +++ b/docs/generated/devkit/toJS.md @@ -1,14 +1,15 @@ # Function: toJS -▸ **toJS**(`tree`): `void` +▸ **toJS**(`tree`, `options?`): `void` Rename and transpile any new typescript files created to javascript files #### Parameters -| Name | Type | -| :----- | :------------------------------------ | -| `tree` | [`Tree`](../../devkit/documents/Tree) | +| Name | Type | +| :--------- | :-------------------------------------------------- | +| `tree` | [`Tree`](../../devkit/documents/Tree) | +| `options?` | [`ToJSOptions`](../../devkit/documents/ToJSOptions) | #### Returns diff --git a/docs/generated/packages/devkit/documents/nx_devkit.md b/docs/generated/packages/devkit/documents/nx_devkit.md index 889a8fe033..b0f2fa2468 100644 --- a/docs/generated/packages/devkit/documents/nx_devkit.md +++ b/docs/generated/packages/devkit/documents/nx_devkit.md @@ -87,6 +87,7 @@ It only uses language primitives and immutable objects - [StringChange](../../devkit/documents/StringChange) - [TargetDefaults](../../devkit/documents/TargetDefaults) - [TaskGraphExecutor](../../devkit/documents/TaskGraphExecutor) +- [ToJSOptions](../../devkit/documents/ToJSOptions) - [WorkspaceJsonConfiguration](../../devkit/documents/WorkspaceJsonConfiguration) ### Variables diff --git a/docs/generated/packages/playwright/executors/playwright.json b/docs/generated/packages/playwright/executors/playwright.json index 7a2a7d6289..460b1aaeac 100644 --- a/docs/generated/packages/playwright/executors/playwright.json +++ b/docs/generated/packages/playwright/executors/playwright.json @@ -1,6 +1,6 @@ { "name": "playwright", - "implementation": "/packages/playwright/src/executors/playwright/playwright.ts", + "implementation": "/packages/playwright/src/executors/playwright/playwright.impl.ts", "schema": { "$schema": "http://json-schema.org/schema", "version": 2, diff --git a/e2e/playwright/src/playwright.test.ts b/e2e/playwright/src/playwright.test.ts index 15b724aded..3a2a7e86ac 100644 --- a/e2e/playwright/src/playwright.test.ts +++ b/e2e/playwright/src/playwright.test.ts @@ -6,6 +6,7 @@ import { ensurePlaywrightBrowsersInstallation, getPackageManagerCommand, getSelectedPackageManager, + readJson, } from '@nx/e2e/utils'; const TEN_MINS_MS = 600_000; @@ -67,3 +68,97 @@ describe('Playwright E2E Test runner', () => { TEN_MINS_MS ); }); + +describe('Playwright E2E Test Runner - PCV3', () => { + let env: string | undefined; + + beforeAll(() => { + env = process.env.NX_PCV3; + newProject({ + name: uniq('playwright'), + unsetProjectNameAndRootFormat: false, + }); + process.env.NX_PCV3 = 'true'; + }); + + afterAll(() => { + if (env) { + process.env.NX_PCV3 = env; + } else { + delete process.env.NX_PCV3; + } + }); + + it( + 'should test and lint example app', + + () => { + ensurePlaywrightBrowsersInstallation(); + + const pmc = getPackageManagerCommand(); + + runCLI( + `g @nx/web:app demo-e2e --directory apps/demo-e2e --unitTestRunner=none --bundler=vite --e2eTestRunner=none --style=css --no-interactive --projectNameAndRootFormat=as-provided` + ); + runCLI( + `g @nx/playwright:configuration --project demo-e2e --webServerCommand="${pmc.runNx} serve demo-e2e" --webServerAddress="http://localhost:4200"` + ); + + const e2eResults = runCLI(`e2e demo-e2e`); + expect(e2eResults).toContain('Successfully ran target e2e for project'); + + const { targets } = readJson('apps/demo-e2e/project.json'); + expect(targets?.e2e).not.toBeDefined(); + + const { plugins } = readJson('nx.json'); + const playwrightPlugin = plugins?.find( + (p) => p.plugin === '@nx/playwright/plugin' + ); + expect(playwrightPlugin).toMatchInlineSnapshot(` + { + "options": { + "targetName": "e2e", + }, + "plugin": "@nx/playwright/plugin", + } + `); + }, + TEN_MINS_MS + ); + + it( + 'should test and lint example app with js', + () => { + ensurePlaywrightBrowsersInstallation(); + + const pmc = getPackageManagerCommand(); + + runCLI( + `g @nx/web:app demo-js-e2e --directory apps/demo-js-e2e --unitTestRunner=none --bundler=vite --e2eTestRunner=none --style=css --no-interactive --projectNameAndRootFormat=as-provided` + ); + runCLI( + `g @nx/playwright:configuration --project demo-js-e2e --js --webServerCommand="${pmc.runNx} serve demo-e2e" --webServerAddress="http://localhost:4200"` + ); + + const e2eResults = runCLI(`e2e demo-js-e2e`); + expect(e2eResults).toContain('Successfully ran target e2e for project'); + + const { targets } = readJson('apps/demo-js-e2e/project.json'); + expect(targets?.e2e).not.toBeDefined(); + + const { plugins } = readJson('nx.json'); + const playwrightPlugin = plugins?.find( + (p) => p.plugin === '@nx/playwright/plugin' + ); + expect(playwrightPlugin).toMatchInlineSnapshot(` + { + "options": { + "targetName": "e2e", + }, + "plugin": "@nx/playwright/plugin", + } + `); + }, + TEN_MINS_MS + ); +}); diff --git a/e2e/utils/get-env-info.ts b/e2e/utils/get-env-info.ts index defa245bd9..cb834febdb 100644 --- a/e2e/utils/get-env-info.ts +++ b/e2e/utils/get-env-info.ts @@ -157,7 +157,9 @@ export function getStrippedEnvironmentVariables() { return true; } - if (key.startsWith('NX_')) { + const allowedKeys = ['NX_PCV3']; + + if (key.startsWith('NX_') && !allowedKeys.includes(key)) { return false; } diff --git a/packages/cypress/src/plugins/plugin.ts b/packages/cypress/src/plugins/plugin.ts index 6e05c9185d..2bda8424ef 100644 --- a/packages/cypress/src/plugins/plugin.ts +++ b/packages/cypress/src/plugins/plugin.ts @@ -303,11 +303,19 @@ function getInputs( /** * Load the module after ensuring that the require cache is cleared. */ +const packageInstallationDirectories = ['node_modules', '.yarn']; + function load(path: string): any { // Clear cache if the path is in the cache if (require.cache[path]) { for (const k of Object.keys(require.cache)) { - delete require.cache[k]; + // We don't want to clear the require cache of installed packages. + // Clearing them can cause some issues when running Nx without the daemon + // and may cause issues for other packages that use the module state + // in some to store cached information. + if (!packageInstallationDirectories.some((dir) => k.includes(dir))) { + delete require.cache[k]; + } } } diff --git a/packages/devkit/public-api.ts b/packages/devkit/public-api.ts index e98b7ed326..480456f7ea 100644 --- a/packages/devkit/public-api.ts +++ b/packages/devkit/public-api.ts @@ -21,7 +21,7 @@ export { generateFiles } from './src/generators/generate-files'; /** * @category Generators */ -export { toJS } from './src/generators/to-js'; +export { toJS, ToJSOptions } from './src/generators/to-js'; /** * @category Generators diff --git a/packages/devkit/src/generators/to-js.ts b/packages/devkit/src/generators/to-js.ts index 15da18d053..0a4f30bc21 100644 --- a/packages/devkit/src/generators/to-js.ts +++ b/packages/devkit/src/generators/to-js.ts @@ -1,15 +1,22 @@ import type { Tree } from 'nx/src/generators/tree'; +import type { ScriptTarget, ModuleKind } from 'typescript'; import { typescriptVersion } from '../utils/versions'; import { ensurePackage } from '../utils/package-json'; +export type ToJSOptions = { + target?: ScriptTarget; + module?: ModuleKind; + extension: '.js' | '.mjs' | '.cjs'; +}; + /** * Rename and transpile any new typescript files created to javascript files */ -export function toJS(tree: Tree): void { - const { JsxEmit, ScriptTarget, transpile } = ensurePackage( +export function toJS(tree: Tree, options?: ToJSOptions): void { + const { JsxEmit, ScriptTarget, transpile, ModuleKind } = ensurePackage( 'typescript', typescriptVersion - ); + ) as typeof import('typescript'); for (const c of tree.listChanges()) { if ( @@ -21,10 +28,14 @@ export function toJS(tree: Tree): void { transpile(c.content.toString('utf-8'), { allowJs: true, jsx: JsxEmit.Preserve, - target: ScriptTarget.ESNext, + target: options?.target ?? ScriptTarget.ESNext, + module: options?.module ?? ModuleKind.ESNext, }) ); - tree.rename(c.path, c.path.replace(/\.tsx?$/, '.js')); + tree.rename( + c.path, + c.path.replace(/\.tsx?$/, options?.extension ?? '.js') + ); } } } diff --git a/packages/nx/src/native/index.d.ts b/packages/nx/src/native/index.d.ts index 65c19a0f2f..bd27217e74 100644 --- a/packages/nx/src/native/index.d.ts +++ b/packages/nx/src/native/index.d.ts @@ -176,4 +176,5 @@ export class WorkspaceContext { incrementalUpdate(updatedFiles: Array, deletedFiles: Array): Record updateProjectFiles(projectRootMappings: ProjectRootMappings, projectFiles: ExternalObject, globalFiles: ExternalObject>, updatedFiles: Record, deletedFiles: Array): UpdatedWorkspaceFiles allFileData(): Array + getFilesInDirectory(directory: string): Array } diff --git a/packages/nx/src/native/utils/path.rs b/packages/nx/src/native/utils/path.rs index 97f7e529b2..409f70a78a 100644 --- a/packages/nx/src/native/utils/path.rs +++ b/packages/nx/src/native/utils/path.rs @@ -1,4 +1,4 @@ -use crate::native::utils::normalize_trait::Normalize; +use crate::native::{utils::normalize_trait::Normalize, types::FileData}; use std::path::{Path, PathBuf}; impl Normalize for Path { @@ -28,3 +28,50 @@ where path.as_ref().display().to_string() } } + +pub fn get_child_files>(directory: P, files: Vec) -> Vec { + files + .into_iter() + .filter(|file_data| Path::new(&file_data.file).starts_with(directory.as_ref())) + .map(|file_data| file_data.file) + .collect() +} + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + #[test] + fn should_get_child_files() { + let directory = PathBuf::from("foo"); + let files = vec![ + FileData { + file: "foo/bar".into(), + hash: "123".into(), + }, + FileData { + file: "foo/baz".into(), + hash: "123".into(), + }, + FileData { + file: "foo/child/bar".into(), + hash: "123".into(), + }, + FileData { + file: "bar/baz".into(), + hash: "123".into(), + }, + FileData { + file: "foo-other/not-child".into(), + hash: "123".into(), + } + ]; + let child_files = get_child_files(&directory, files); + assert_eq!(child_files, [ + "foo/bar", + "foo/baz", + "foo/child/bar", + ]); + } +} \ No newline at end of file diff --git a/packages/nx/src/native/workspace/context.rs b/packages/nx/src/native/workspace/context.rs index 02ceaa2f0b..42de266854 100644 --- a/packages/nx/src/native/workspace/context.rs +++ b/packages/nx/src/native/workspace/context.rs @@ -2,7 +2,7 @@ use napi::bindgen_prelude::External; use std::collections::HashMap; use crate::native::hasher::hash; -use crate::native::utils::Normalize; +use crate::native::utils::{Normalize, path::get_child_files}; use rayon::prelude::*; use std::ops::Deref; use std::path::{Path, PathBuf}; @@ -299,4 +299,10 @@ impl WorkspaceContext { pub fn all_file_data(&self) -> Vec { self.files_worker.get_files() } + + #[napi] + pub fn get_files_in_directory(&self, directory: String) -> Vec { + get_child_files(&directory, self.files_worker + .get_files()) + } } diff --git a/packages/nx/src/utils/workspace-context.ts b/packages/nx/src/utils/workspace-context.ts index 8d9c65ee61..f45b302623 100644 --- a/packages/nx/src/utils/workspace-context.ts +++ b/packages/nx/src/utils/workspace-context.ts @@ -58,6 +58,14 @@ export function getAllFileDataInContext(workspaceRoot: string) { return workspaceContext.allFileData(); } +export function getFilesInDirectoryUsingContext( + workspaceRoot: string, + dir: string +) { + ensureContextAvailable(workspaceRoot); + return workspaceContext.getFilesInDirectory(dir); +} + export function updateProjectFiles( projectRootMappings: Record, rustReferences: NxWorkspaceFilesExternals, diff --git a/packages/playwright/executors.json b/packages/playwright/executors.json index dcb3239e10..8ac4bedf19 100644 --- a/packages/playwright/executors.json +++ b/packages/playwright/executors.json @@ -1,7 +1,7 @@ { "executors": { "playwright": { - "implementation": "./src/executors/playwright/playwright", + "implementation": "./src/executors/playwright/playwright.impl", "schema": "./src/executors/playwright/schema.json", "description": "Run Playwright tests." } diff --git a/packages/playwright/index.ts b/packages/playwright/index.ts index 1639e3ff06..aca172c506 100644 --- a/packages/playwright/index.ts +++ b/packages/playwright/index.ts @@ -1,6 +1,6 @@ export { playwrightExecutor, PlaywrightExecutorSchema, -} from './src/executors/playwright/playwright'; +} from './src/executors/playwright/playwright.impl'; export { initGenerator } from './src/generators/init/init'; export { configurationGenerator } from './src/generators/configuration/configuration'; diff --git a/packages/playwright/migrations.json b/packages/playwright/migrations.json new file mode 100644 index 0000000000..65a4590b7c --- /dev/null +++ b/packages/playwright/migrations.json @@ -0,0 +1,3 @@ +{ + "generators": {} +} diff --git a/packages/playwright/package.json b/packages/playwright/package.json index b5ba99daef..46f889b2e2 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -34,7 +34,9 @@ "dependencies": { "@nx/devkit": "file:../devkit", "@nx/eslint": "file:../eslint", - "tslib": "^2.3.0" + "@nx/js": "file:../js", + "tslib": "^2.3.0", + "minimatch": "3.0.5" }, "peerDependencies": { "@playwright/test": "^1.36.0" @@ -54,6 +56,10 @@ "./generators/*/schema.json": "./src/generators/*/schema.json", "./executors.json": "./executors.json", "./executors/*/schema.json": "./src/executors/*/schema.json", + "./plugin": "./plugin.js", "./preset": "./src/utils/preset.js" + }, + "nx-migrations": { + "migrations": "./migrations.json" } } diff --git a/packages/playwright/plugin.ts b/packages/playwright/plugin.ts new file mode 100644 index 0000000000..255dad140d --- /dev/null +++ b/packages/playwright/plugin.ts @@ -0,0 +1 @@ +export { createNodes, PlaywrightPluginOptions } from './src/plugins/plugin'; diff --git a/packages/playwright/src/executors/playwright/playwright.ts b/packages/playwright/src/executors/playwright/playwright.impl.ts similarity index 100% rename from packages/playwright/src/executors/playwright/playwright.ts rename to packages/playwright/src/executors/playwright/playwright.impl.ts diff --git a/packages/playwright/src/generators/configuration/configuration.ts b/packages/playwright/src/generators/configuration/configuration.ts index b197fb450a..0c27905856 100644 --- a/packages/playwright/src/generators/configuration/configuration.ts +++ b/packages/playwright/src/generators/configuration/configuration.ts @@ -1,4 +1,5 @@ import { + ensurePackage, formatFiles, generateFiles, GeneratorCallback, @@ -15,6 +16,7 @@ import * as path from 'path'; import { ConfigurationGeneratorSchema } from './schema'; import initGenerator from '../init/init'; import { addLinterToPlaywrightProject } from '../../utils/add-linter'; +import { typescriptVersion } from '@nx/js/src/utils/versions'; export async function configurationGenerator( tree: Tree, @@ -36,8 +38,17 @@ export async function configurationGenerator( ...options, }); - addE2eTarget(tree, options); - setupE2ETargetDefaults(tree); + const hasPlugin = readNxJson(tree).plugins?.some((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ); + + if (!hasPlugin) { + addE2eTarget(tree, options); + setupE2ETargetDefaults(tree); + } + tasks.push( await addLinterToPlaywrightProject(tree, { project: options.project, @@ -51,7 +62,11 @@ export async function configurationGenerator( ); if (options.js) { - toJS(tree); + const { ModuleKind } = ensurePackage( + 'typescript', + typescriptVersion + ) as typeof import('typescript'); + toJS(tree, { extension: '.cjs', module: ModuleKind.CommonJS }); } if (!options.skipFormat) { await formatFiles(tree); @@ -93,7 +108,7 @@ Rename or remove the existing e2e target.`); outputs: [`{workspaceRoot}/dist/.playwright/${projectConfig.root}`], options: { config: `${projectConfig.root}/playwright.config.${ - options.js ? 'js' : 'ts' + options.js ? 'cjs' : 'ts' }`, }, }; diff --git a/packages/playwright/src/generators/init/init.spec.ts b/packages/playwright/src/generators/init/init.spec.ts new file mode 100644 index 0000000000..1289875c41 --- /dev/null +++ b/packages/playwright/src/generators/init/init.spec.ts @@ -0,0 +1,106 @@ +import { Tree, readNxJson, updateNxJson } from '@nx/devkit'; +import { withEnvironmentVariables } from '@nx/devkit/internal-testing-utils'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import { initGenerator } from './init'; + +describe('@nx/playwright:init', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should add the plugin if PCV3 is set', async () => { + await withEnvironmentVariables( + { + NX_PCV3: 'true', + }, + async () => { + await initGenerator(tree, { + skipFormat: true, + skipPackageJson: false, + }); + } + ); + const nxJson = readNxJson(tree); + expect(nxJson.plugins).toMatchInlineSnapshot(` + [ + { + "options": { + "targetName": "e2e", + }, + "plugin": "@nx/playwright/plugin", + }, + ] + `); + }); + + it('should not overwrite existing plugins', async () => { + updateNxJson(tree, { + plugins: ['foo'], + }); + await withEnvironmentVariables( + { + NX_PCV3: 'true', + }, + async () => { + await initGenerator(tree, { + skipFormat: true, + skipPackageJson: false, + }); + } + ); + const nxJson = readNxJson(tree); + expect(nxJson.plugins).toMatchInlineSnapshot(` + [ + "foo", + { + "options": { + "targetName": "e2e", + }, + "plugin": "@nx/playwright/plugin", + }, + ] + `); + }); + + it('should not add plugin if already in array', async () => { + updateNxJson(tree, { + plugins: ['@nx/playwright/plugin'], + }); + await withEnvironmentVariables( + { + NX_PCV3: 'true', + }, + async () => { + await initGenerator(tree, { + skipFormat: true, + skipPackageJson: false, + }); + } + ); + const nxJson = readNxJson(tree); + expect(nxJson.plugins).toMatchInlineSnapshot(` + [ + "@nx/playwright/plugin", + ] + `); + }); + + it('should not add plugin if environment variable is not set', async () => { + await withEnvironmentVariables( + { + NX_PCV3: undefined, + }, + async () => { + await initGenerator(tree, { + skipFormat: true, + skipPackageJson: false, + }); + } + ); + const nxJson = readNxJson(tree); + expect(nxJson.plugins).toMatchInlineSnapshot(`undefined`); + }); +}); diff --git a/packages/playwright/src/generators/init/init.ts b/packages/playwright/src/generators/init/init.ts index 2768b79d2b..c769a85d56 100644 --- a/packages/playwright/src/generators/init/init.ts +++ b/packages/playwright/src/generators/init/init.ts @@ -4,9 +4,11 @@ import { GeneratorCallback, getPackageManagerCommand, output, + readNxJson, runTasksInSerial, Tree, updateJson, + updateNxJson, workspaceRoot, } from '@nx/devkit'; import { InitGeneratorSchema } from './schema'; @@ -56,6 +58,10 @@ export async function initGenerator(tree: Tree, options: InitGeneratorSchema) { ); } + if (process.env.NX_PCV3 === 'true') { + addPlugin(tree); + } + if (!options.skipInstall) { tasks.push(() => { output.log({ @@ -70,4 +76,25 @@ export async function initGenerator(tree: Tree, options: InitGeneratorSchema) { return runTasksInSerial(...tasks); } +function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + nxJson.plugins ??= []; + + if ( + !nxJson.plugins.some((p) => + typeof p === 'string' + ? p === '@nx/playwright/plugin' + : p.plugin === '@nx/playwright/plugin' + ) + ) { + nxJson.plugins.push({ + plugin: '@nx/playwright/plugin', + options: { + targetName: 'e2e', + }, + }); + updateNxJson(tree, nxJson); + } +} + export default initGenerator; diff --git a/packages/playwright/src/plugins/plugin.spec.ts b/packages/playwright/src/plugins/plugin.spec.ts new file mode 100644 index 0000000000..7353f5f86a --- /dev/null +++ b/packages/playwright/src/plugins/plugin.spec.ts @@ -0,0 +1,245 @@ +import { CreateNodesContext } from '@nx/devkit'; +import { TempFs } from '@nx/devkit/internal-testing-utils'; + +import { createNodes } from './plugin'; +import { PlaywrightTestConfig } from '@playwright/test'; + +// Jest can't handle the dynamic import, and mocking it doesn't work either. +// we overwrite the dynamic import function to use the regular syntax, which +// jest does handle. +import * as lcf from '../utils/load-config-file'; +(lcf as any).dynamicImport = (m) => import(m); + +describe('@nx/playwright/plugin', () => { + let createNodesFunction = createNodes[1]; + let context: CreateNodesContext; + let tempFs: TempFs; + + beforeEach(async () => { + tempFs = new TempFs('playwright-plugin'); + await tempFs.createFiles({ + 'package.json': '{}', + 'playwright.config.js': 'module.exports = {}', + }); + + context = { + nxJsonConfiguration: { + namedInputs: { + default: ['{projectRoot}/**/*'], + production: ['!{projectRoot}/**/*.spec.ts'], + }, + }, + workspaceRoot: tempFs.tempDir, + }; + }); + + afterEach(() => { + tempFs.cleanup(); + jest.resetModules(); + }); + + it('should create nodes with default playwright configuration', async () => { + await mockPlaywrightConfig(tempFs, {}); + const { projects } = await createNodesFunction( + 'playwright.config.js', + { + targetName: 'e2e', + }, + context + ); + + expect(projects).toMatchInlineSnapshot(` + { + ".": { + "root": ".", + "targets": { + "e2e": { + "cache": true, + "command": "playwright test", + "inputs": [ + "default", + "^production", + ], + "options": { + "cwd": "{projectRoot}", + }, + "outputs": [ + "{projectRoot}/test-results", + ], + }, + "e2e-ci": { + "cache": true, + "dependsOn": [], + "executor": "nx:noop", + "inputs": [ + "default", + "^production", + ], + "outputs": [ + "{projectRoot}/test-results", + ], + }, + }, + }, + } + `); + }); + + it('should create nodes with reporters configured', async () => { + await mockPlaywrightConfig(tempFs, { + reporter: [ + ['list'], + ['json', { outputFile: 'test-results/report.json' }], + ['html', { outputFolder: 'test-results/html' }], + ], + }); + const { projects } = await createNodesFunction( + 'playwright.config.js', + { + targetName: 'e2e', + }, + context + ); + + expect(projects).toMatchInlineSnapshot(` + { + ".": { + "root": ".", + "targets": { + "e2e": { + "cache": true, + "command": "playwright test", + "inputs": [ + "default", + "^production", + ], + "options": { + "cwd": "{projectRoot}", + }, + "outputs": [ + "{projectRoot}/playwright-report", + "{projectRoot}/test-results/report.json", + "{projectRoot}/test-results/html", + "{projectRoot}/test-results", + ], + }, + "e2e-ci": { + "cache": true, + "dependsOn": [], + "executor": "nx:noop", + "inputs": [ + "default", + "^production", + ], + "outputs": [ + "{projectRoot}/playwright-report", + "{projectRoot}/test-results/report.json", + "{projectRoot}/test-results/html", + "{projectRoot}/test-results", + ], + }, + }, + }, + } + `); + }); + + it('should create nodes for distributed CI', async () => { + await mockPlaywrightConfig( + tempFs, + `module.exports = { + testDir: 'tests', + testIgnore: [/.*skip.*/, '**/ignored/**'], + }` + ); + await tempFs.createFiles({ + 'tests/run-me.spec.ts': '', + 'tests/run-me-2.spec.ts': '', + 'tests/skip-me.spec.ts': '', + 'tests/ignored/run-me.spec.ts': '', + 'not-tests/run-me.spec.ts': '', + }); + + const { projects } = await createNodesFunction( + 'playwright.config.js', + { + targetName: 'e2e', + ciTargetName: 'e2e-ci', + }, + context + ); + const { targets } = projects['.']; + expect(targets['e2e-ci']).toMatchInlineSnapshot(` + { + "cache": true, + "dependsOn": [ + { + "params": "forward", + "projects": "self", + "target": "e2e-ci--tests/run-me-2.spec.ts", + }, + { + "params": "forward", + "projects": "self", + "target": "e2e-ci--tests/run-me.spec.ts", + }, + ], + "executor": "nx:noop", + "inputs": [ + "default", + "^production", + ], + "outputs": [ + "{projectRoot}/test-results", + ], + } + `); + expect(targets['e2e-ci--tests/run-me.spec.ts']).toMatchInlineSnapshot(` + { + "cache": true, + "command": "playwright test tests/run-me.spec.ts", + "inputs": [ + "default", + "^production", + ], + "options": { + "cwd": "{projectRoot}", + }, + "outputs": [ + "{projectRoot}/test-results", + ], + } + `); + expect(targets['e2e-ci--tests/run-me-2.spec.ts']).toMatchInlineSnapshot(` + { + "cache": true, + "command": "playwright test tests/run-me-2.spec.ts", + "inputs": [ + "default", + "^production", + ], + "options": { + "cwd": "{projectRoot}", + }, + "outputs": [ + "{projectRoot}/test-results", + ], + } + `); + expect(targets['e2e-ci--tests/skip-me.spec.ts']).not.toBeDefined(); + expect(targets['e2e-ci--tests/ignored/run-me.spec.ts']).not.toBeDefined(); + expect(targets['e2e-ci--not-tests/run-me.spec.ts']).not.toBeDefined(); + }); +}); + +async function mockPlaywrightConfig( + tempFs: TempFs, + config: PlaywrightTestConfig | string +) { + await tempFs.writeFile( + 'playwright.config.js', + typeof config === 'string' + ? config + : `module.exports = ${JSON.stringify(config)}` + ); +} diff --git a/packages/playwright/src/plugins/plugin.ts b/packages/playwright/src/plugins/plugin.ts new file mode 100644 index 0000000000..ed33ea4c2a --- /dev/null +++ b/packages/playwright/src/plugins/plugin.ts @@ -0,0 +1,284 @@ +import { existsSync, readdirSync } from 'fs'; +import { dirname, join, relative } from 'path'; + +import { + CreateDependencies, + CreateNodes, + CreateNodesContext, + detectPackageManager, + joinPathFragments, + readJsonFile, + TargetConfiguration, + writeJsonFile, +} from '@nx/devkit'; +import { getNamedInputs } from '@nx/devkit/src/utils/get-named-inputs'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; + +import type { PlaywrightTestConfig } from '@playwright/test'; +import { getFilesInDirectoryUsingContext } from 'nx/src/utils/workspace-context'; +import minimatch = require('minimatch'); +import { loadPlaywrightConfig } from '../utils/load-config-file'; +import { projectGraphCacheDirectory } from 'nx/src/utils/cache-directory'; +import { getLockFileName } from '@nx/js'; + +export interface PlaywrightPluginOptions { + targetName?: string; + ciTargetName?: string; +} + +interface NormalizedOptions { + targetName: string; + ciTargetName?: string; +} + +const cachePath = join(projectGraphCacheDirectory, 'playwright.hash'); + +const targetsCache = existsSync(cachePath) ? readTargetsCache() : {}; + +const calculatedTargets: Record< + string, + Record +> = {}; + +function readTargetsCache(): Record< + string, + Record +> { + return readJsonFile(cachePath); +} + +function writeTargetsToCache( + targets: Record> +) { + writeJsonFile(cachePath, targets); +} + +export const createDependencies: CreateDependencies = () => { + writeTargetsToCache(calculatedTargets); + return []; +}; + +export const createNodes: CreateNodes = [ + '**/playwright.config.{js,ts,cjs,cts,mjs,mts}', + async (configFilePath, options, context) => { + const projectRoot = dirname(configFilePath); + + // Do not create a project if package.json and project.json isn't there. + const siblingFiles = readdirSync(projectRoot); + if ( + !siblingFiles.includes('package.json') && + !siblingFiles.includes('project.json') + ) { + return {}; + } + + const normalizedOptions = normalizeOptions(options); + + const hash = calculateHashForCreateNodes(projectRoot, options, context, [ + getLockFileName(detectPackageManager(context.workspaceRoot)), + ]); + + const targets = + targetsCache[hash] ?? + (await buildPlaywrightTargets( + configFilePath, + projectRoot, + normalizedOptions, + context + )); + + calculatedTargets[hash] = targets; + + return { + projects: { + [projectRoot]: { + root: projectRoot, + targets, + }, + }, + }; + }, +]; + +async function buildPlaywrightTargets( + configFilePath: string, + projectRoot: string, + options: NormalizedOptions, + context: CreateNodesContext +) { + const playwrightConfig: PlaywrightTestConfig = await loadPlaywrightConfig( + join(context.workspaceRoot, configFilePath) + ); + + const namedInputs = getNamedInputs(projectRoot, context); + + const targets: Record> = {}; + + const baseTargetConfig: TargetConfiguration = { + command: 'playwright test', + options: { + cwd: '{projectRoot}', + }, + }; + + targets[options.targetName] = { + ...baseTargetConfig, + cache: true, + inputs: + 'production' in namedInputs + ? ['default', '^production'] + : ['default', '^default'], + outputs: getOutputs(projectRoot, playwrightConfig), + }; + + if (options.ciTargetName) { + const ciBaseTargetConfig: TargetConfiguration = { + ...baseTargetConfig, + cache: true, + inputs: + 'production' in namedInputs + ? ['default', '^production'] + : ['default', '^default'], + outputs: getOutputs(projectRoot, playwrightConfig), + }; + + const testDir = playwrightConfig.testDir + ? joinPathFragments(projectRoot, playwrightConfig.testDir) + : projectRoot; + + // Playwright defaults to the following pattern. + playwrightConfig.testMatch ??= '**/*.@(spec|test).?(c|m)[jt]s?(x)'; + + const dependsOn: TargetConfiguration['dependsOn'] = []; + forEachTestFile( + (testFile) => { + const relativeToProjectRoot = relative(projectRoot, testFile); + const targetName = `${options.ciTargetName}--${relativeToProjectRoot}`; + targets[targetName] = { + ...ciBaseTargetConfig, + command: `${baseTargetConfig.command} ${relativeToProjectRoot}`, + }; + dependsOn.push({ + target: targetName, + projects: 'self', + params: 'forward', + }); + }, + { + context, + path: testDir, + config: playwrightConfig, + } + ); + + targets[options.ciTargetName] ??= {}; + + targets[options.ciTargetName] = { + executor: 'nx:noop', + cache: ciBaseTargetConfig.cache, + inputs: ciBaseTargetConfig.inputs, + outputs: ciBaseTargetConfig.outputs, + dependsOn, + }; + } + + return targets; +} + +async function forEachTestFile( + cb: (path: string) => void, + opts: { + context: CreateNodesContext; + path: string; + config: PlaywrightTestConfig; + } +) { + const files = getFilesInDirectoryUsingContext( + opts.context.workspaceRoot, + opts.path + ); + const matcher = createMatcher(opts.config.testMatch); + const ignoredMatcher = opts.config.testIgnore + ? createMatcher(opts.config.testIgnore) + : () => false; + for (const file of files) { + if (matcher(file) && !ignoredMatcher(file)) { + cb(file); + } + } +} + +function createMatcher(pattern: string | RegExp | Array) { + if (Array.isArray(pattern)) { + const matchers = pattern.map((p) => createMatcher(p)); + return (path: string) => matchers.some((m) => m(path)); + } else if (pattern instanceof RegExp) { + return (path: string) => pattern.test(path); + } else { + return (path: string) => { + try { + return minimatch(path, pattern); + } catch (e) { + throw new Error(`Error matching ${path} with ${pattern}: ${e.message}`); + } + }; + } +} + +function getOutputs( + projectRoot: string, + playwrightConfig: PlaywrightTestConfig +): string[] { + function getOutput(path: string): string { + if (path.startsWith('..')) { + return join('{workspaceRoot}', join(projectRoot, path)); + } else { + return join('{projectRoot}', path); + } + } + + const outputs = []; + + const { reporter, outputDir } = playwrightConfig; + + if (reporter) { + const DEFAULT_REPORTER_OUTPUT = getOutput('playwright-report'); + if (reporter === 'html' || reporter === 'json') { + // Reporter is a string, so it uses the default output directory. + outputs.push(DEFAULT_REPORTER_OUTPUT); + } else if (Array.isArray(reporter)) { + for (const r of reporter) { + const [, opts] = r; + // There are a few different ways to specify an output file or directory + // depending on the reporter. This is a best effort to find the output. + if (!opts) { + outputs.push(DEFAULT_REPORTER_OUTPUT); + } else if (opts.outputFile) { + outputs.push(getOutput(opts.outputFile)); + } else if (opts.outputDir) { + outputs.push(getOutput(opts.outputDir)); + } else if (opts.outputFolder) { + outputs.push(getOutput(opts.outputFolder)); + } else { + outputs.push(DEFAULT_REPORTER_OUTPUT); + } + } + } + } + + if (outputDir) { + outputs.push(getOutput(outputDir)); + } else { + outputs.push(getOutput('./test-results')); + } + + return outputs; +} + +function normalizeOptions(options: PlaywrightPluginOptions): NormalizedOptions { + return { + ...options, + targetName: options.targetName ?? 'e2e', + ciTargetName: options.ciTargetName ?? 'e2e-ci', + }; +} diff --git a/packages/playwright/src/utils/load-config-file.ts b/packages/playwright/src/utils/load-config-file.ts new file mode 100644 index 0000000000..37a02dd917 --- /dev/null +++ b/packages/playwright/src/utils/load-config-file.ts @@ -0,0 +1,56 @@ +import { extname } from 'path'; +import { getRootTsConfigPath } from '@nx/js'; +import { registerTsProject } from '@nx/js/src/internal'; + +import type { PlaywrightTestConfig } from '@playwright/test'; + +export let dynamicImport = new Function( + 'modulePath', + 'return import(modulePath);' +); + +export async function loadPlaywrightConfig( + configFilePath +): Promise { + { + let module: any; + if (extname(configFilePath) === '.ts') { + const tsConfigPath = getRootTsConfigPath(); + + if (tsConfigPath) { + const unregisterTsProject = registerTsProject(tsConfigPath); + try { + // Require's cache doesn't notice when the file is updated, and + // this function is ran during daemon operation. If the config file + // is updated, we need to read its new contents, so we need to clear the cache. + // We can't just delete the cache entry for the config file, because + // it might have imports that need to be updated as well. + clearRequireCache(); + // ts-node doesn't support dynamic import, so we need to use require + module = require(configFilePath); + } finally { + unregisterTsProject(); + } + } else { + module = await dynamicImport(configFilePath); + } + } else { + module = await dynamicImport(configFilePath); + } + return module.default ?? module; + } +} + +const packageInstallationDirectories = ['node_modules', '.yarn']; + +function clearRequireCache() { + Object.keys(require.cache).forEach((key: string) => { + // We don't want to clear the require cache of installed packages. + // Clearing them can cause some issues when running Nx without the daemon + // and may cause issues for other packages that use the module state + // in some to store cached information. + if (!packageInstallationDirectories.some((dir) => key.includes(dir))) { + delete require.cache[key]; + } + }); +}