feat(testing): add create-nodes plugin for playwright e2e targets (#20099)

This commit is contained in:
Craigory Coppola 2023-12-20 16:34:32 -05:00 committed by GitHub
parent 3981c90fe8
commit 2374d8eaba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 957 additions and 22 deletions

View File

@ -87,6 +87,7 @@ It only uses language primitives and immutable objects
- [StringChange](../../devkit/documents/StringChange) - [StringChange](../../devkit/documents/StringChange)
- [TargetDefaults](../../devkit/documents/TargetDefaults) - [TargetDefaults](../../devkit/documents/TargetDefaults)
- [TaskGraphExecutor](../../devkit/documents/TaskGraphExecutor) - [TaskGraphExecutor](../../devkit/documents/TaskGraphExecutor)
- [ToJSOptions](../../devkit/documents/ToJSOptions)
- [WorkspaceJsonConfiguration](../../devkit/documents/WorkspaceJsonConfiguration) - [WorkspaceJsonConfiguration](../../devkit/documents/WorkspaceJsonConfiguration)
### Variables ### Variables

View File

@ -0,0 +1,11 @@
# Type alias: ToJSOptions
Ƭ **ToJSOptions**: `Object`
#### Type declaration
| Name | Type |
| :---------- | :------------------------------ |
| `extension` | `".js"` \| `".mjs"` \| `".cjs"` |
| `module?` | `ModuleKind` |
| `target?` | `ScriptTarget` |

View File

@ -1,14 +1,15 @@
# Function: toJS # Function: toJS
**toJS**(`tree`): `void` **toJS**(`tree`, `options?`): `void`
Rename and transpile any new typescript files created to javascript files Rename and transpile any new typescript files created to javascript files
#### Parameters #### Parameters
| Name | Type | | Name | Type |
| :----- | :------------------------------------ | | :--------- | :-------------------------------------------------- |
| `tree` | [`Tree`](../../devkit/documents/Tree) | | `tree` | [`Tree`](../../devkit/documents/Tree) |
| `options?` | [`ToJSOptions`](../../devkit/documents/ToJSOptions) |
#### Returns #### Returns

View File

@ -87,6 +87,7 @@ It only uses language primitives and immutable objects
- [StringChange](../../devkit/documents/StringChange) - [StringChange](../../devkit/documents/StringChange)
- [TargetDefaults](../../devkit/documents/TargetDefaults) - [TargetDefaults](../../devkit/documents/TargetDefaults)
- [TaskGraphExecutor](../../devkit/documents/TaskGraphExecutor) - [TaskGraphExecutor](../../devkit/documents/TaskGraphExecutor)
- [ToJSOptions](../../devkit/documents/ToJSOptions)
- [WorkspaceJsonConfiguration](../../devkit/documents/WorkspaceJsonConfiguration) - [WorkspaceJsonConfiguration](../../devkit/documents/WorkspaceJsonConfiguration)
### Variables ### Variables

View File

@ -1,6 +1,6 @@
{ {
"name": "playwright", "name": "playwright",
"implementation": "/packages/playwright/src/executors/playwright/playwright.ts", "implementation": "/packages/playwright/src/executors/playwright/playwright.impl.ts",
"schema": { "schema": {
"$schema": "http://json-schema.org/schema", "$schema": "http://json-schema.org/schema",
"version": 2, "version": 2,

View File

@ -6,6 +6,7 @@ import {
ensurePlaywrightBrowsersInstallation, ensurePlaywrightBrowsersInstallation,
getPackageManagerCommand, getPackageManagerCommand,
getSelectedPackageManager, getSelectedPackageManager,
readJson,
} from '@nx/e2e/utils'; } from '@nx/e2e/utils';
const TEN_MINS_MS = 600_000; const TEN_MINS_MS = 600_000;
@ -67,3 +68,97 @@ describe('Playwright E2E Test runner', () => {
TEN_MINS_MS 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
);
});

View File

@ -157,7 +157,9 @@ export function getStrippedEnvironmentVariables() {
return true; return true;
} }
if (key.startsWith('NX_')) { const allowedKeys = ['NX_PCV3'];
if (key.startsWith('NX_') && !allowedKeys.includes(key)) {
return false; return false;
} }

View File

@ -303,11 +303,19 @@ function getInputs(
/** /**
* Load the module after ensuring that the require cache is cleared. * Load the module after ensuring that the require cache is cleared.
*/ */
const packageInstallationDirectories = ['node_modules', '.yarn'];
function load(path: string): any { function load(path: string): any {
// Clear cache if the path is in the cache // Clear cache if the path is in the cache
if (require.cache[path]) { if (require.cache[path]) {
for (const k of Object.keys(require.cache)) { 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];
}
} }
} }

View File

@ -21,7 +21,7 @@ export { generateFiles } from './src/generators/generate-files';
/** /**
* @category Generators * @category Generators
*/ */
export { toJS } from './src/generators/to-js'; export { toJS, ToJSOptions } from './src/generators/to-js';
/** /**
* @category Generators * @category Generators

View File

@ -1,15 +1,22 @@
import type { Tree } from 'nx/src/generators/tree'; import type { Tree } from 'nx/src/generators/tree';
import type { ScriptTarget, ModuleKind } from 'typescript';
import { typescriptVersion } from '../utils/versions'; import { typescriptVersion } from '../utils/versions';
import { ensurePackage } from '../utils/package-json'; 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 * Rename and transpile any new typescript files created to javascript files
*/ */
export function toJS(tree: Tree): void { export function toJS(tree: Tree, options?: ToJSOptions): void {
const { JsxEmit, ScriptTarget, transpile } = ensurePackage( const { JsxEmit, ScriptTarget, transpile, ModuleKind } = ensurePackage(
'typescript', 'typescript',
typescriptVersion typescriptVersion
); ) as typeof import('typescript');
for (const c of tree.listChanges()) { for (const c of tree.listChanges()) {
if ( if (
@ -21,10 +28,14 @@ export function toJS(tree: Tree): void {
transpile(c.content.toString('utf-8'), { transpile(c.content.toString('utf-8'), {
allowJs: true, allowJs: true,
jsx: JsxEmit.Preserve, 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')
);
} }
} }
} }

View File

@ -176,4 +176,5 @@ export class WorkspaceContext {
incrementalUpdate(updatedFiles: Array<string>, deletedFiles: Array<string>): Record<string, string> incrementalUpdate(updatedFiles: Array<string>, deletedFiles: Array<string>): Record<string, string>
updateProjectFiles(projectRootMappings: ProjectRootMappings, projectFiles: ExternalObject<ProjectFiles>, globalFiles: ExternalObject<Array<FileData>>, updatedFiles: Record<string, string>, deletedFiles: Array<string>): UpdatedWorkspaceFiles updateProjectFiles(projectRootMappings: ProjectRootMappings, projectFiles: ExternalObject<ProjectFiles>, globalFiles: ExternalObject<Array<FileData>>, updatedFiles: Record<string, string>, deletedFiles: Array<string>): UpdatedWorkspaceFiles
allFileData(): Array<FileData> allFileData(): Array<FileData>
getFilesInDirectory(directory: string): Array<string>
} }

View File

@ -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}; use std::path::{Path, PathBuf};
impl Normalize for Path { impl Normalize for Path {
@ -28,3 +28,50 @@ where
path.as_ref().display().to_string() path.as_ref().display().to_string()
} }
} }
pub fn get_child_files<P: AsRef<Path>>(directory: P, files: Vec<FileData>) -> Vec<String> {
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",
]);
}
}

View File

@ -2,7 +2,7 @@ use napi::bindgen_prelude::External;
use std::collections::HashMap; use std::collections::HashMap;
use crate::native::hasher::hash; use crate::native::hasher::hash;
use crate::native::utils::Normalize; use crate::native::utils::{Normalize, path::get_child_files};
use rayon::prelude::*; use rayon::prelude::*;
use std::ops::Deref; use std::ops::Deref;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -299,4 +299,10 @@ impl WorkspaceContext {
pub fn all_file_data(&self) -> Vec<FileData> { pub fn all_file_data(&self) -> Vec<FileData> {
self.files_worker.get_files() self.files_worker.get_files()
} }
#[napi]
pub fn get_files_in_directory(&self, directory: String) -> Vec<String> {
get_child_files(&directory, self.files_worker
.get_files())
}
} }

View File

@ -58,6 +58,14 @@ export function getAllFileDataInContext(workspaceRoot: string) {
return workspaceContext.allFileData(); return workspaceContext.allFileData();
} }
export function getFilesInDirectoryUsingContext(
workspaceRoot: string,
dir: string
) {
ensureContextAvailable(workspaceRoot);
return workspaceContext.getFilesInDirectory(dir);
}
export function updateProjectFiles( export function updateProjectFiles(
projectRootMappings: Record<string, string>, projectRootMappings: Record<string, string>,
rustReferences: NxWorkspaceFilesExternals, rustReferences: NxWorkspaceFilesExternals,

View File

@ -1,7 +1,7 @@
{ {
"executors": { "executors": {
"playwright": { "playwright": {
"implementation": "./src/executors/playwright/playwright", "implementation": "./src/executors/playwright/playwright.impl",
"schema": "./src/executors/playwright/schema.json", "schema": "./src/executors/playwright/schema.json",
"description": "Run Playwright tests." "description": "Run Playwright tests."
} }

View File

@ -1,6 +1,6 @@
export { export {
playwrightExecutor, playwrightExecutor,
PlaywrightExecutorSchema, PlaywrightExecutorSchema,
} from './src/executors/playwright/playwright'; } from './src/executors/playwright/playwright.impl';
export { initGenerator } from './src/generators/init/init'; export { initGenerator } from './src/generators/init/init';
export { configurationGenerator } from './src/generators/configuration/configuration'; export { configurationGenerator } from './src/generators/configuration/configuration';

View File

@ -0,0 +1,3 @@
{
"generators": {}
}

View File

@ -34,7 +34,9 @@
"dependencies": { "dependencies": {
"@nx/devkit": "file:../devkit", "@nx/devkit": "file:../devkit",
"@nx/eslint": "file:../eslint", "@nx/eslint": "file:../eslint",
"tslib": "^2.3.0" "@nx/js": "file:../js",
"tslib": "^2.3.0",
"minimatch": "3.0.5"
}, },
"peerDependencies": { "peerDependencies": {
"@playwright/test": "^1.36.0" "@playwright/test": "^1.36.0"
@ -54,6 +56,10 @@
"./generators/*/schema.json": "./src/generators/*/schema.json", "./generators/*/schema.json": "./src/generators/*/schema.json",
"./executors.json": "./executors.json", "./executors.json": "./executors.json",
"./executors/*/schema.json": "./src/executors/*/schema.json", "./executors/*/schema.json": "./src/executors/*/schema.json",
"./plugin": "./plugin.js",
"./preset": "./src/utils/preset.js" "./preset": "./src/utils/preset.js"
},
"nx-migrations": {
"migrations": "./migrations.json"
} }
} }

View File

@ -0,0 +1 @@
export { createNodes, PlaywrightPluginOptions } from './src/plugins/plugin';

View File

@ -1,4 +1,5 @@
import { import {
ensurePackage,
formatFiles, formatFiles,
generateFiles, generateFiles,
GeneratorCallback, GeneratorCallback,
@ -15,6 +16,7 @@ import * as path from 'path';
import { ConfigurationGeneratorSchema } from './schema'; import { ConfigurationGeneratorSchema } from './schema';
import initGenerator from '../init/init'; import initGenerator from '../init/init';
import { addLinterToPlaywrightProject } from '../../utils/add-linter'; import { addLinterToPlaywrightProject } from '../../utils/add-linter';
import { typescriptVersion } from '@nx/js/src/utils/versions';
export async function configurationGenerator( export async function configurationGenerator(
tree: Tree, tree: Tree,
@ -36,8 +38,17 @@ export async function configurationGenerator(
...options, ...options,
}); });
addE2eTarget(tree, options); const hasPlugin = readNxJson(tree).plugins?.some((p) =>
setupE2ETargetDefaults(tree); typeof p === 'string'
? p === '@nx/playwright/plugin'
: p.plugin === '@nx/playwright/plugin'
);
if (!hasPlugin) {
addE2eTarget(tree, options);
setupE2ETargetDefaults(tree);
}
tasks.push( tasks.push(
await addLinterToPlaywrightProject(tree, { await addLinterToPlaywrightProject(tree, {
project: options.project, project: options.project,
@ -51,7 +62,11 @@ export async function configurationGenerator(
); );
if (options.js) { if (options.js) {
toJS(tree); const { ModuleKind } = ensurePackage(
'typescript',
typescriptVersion
) as typeof import('typescript');
toJS(tree, { extension: '.cjs', module: ModuleKind.CommonJS });
} }
if (!options.skipFormat) { if (!options.skipFormat) {
await formatFiles(tree); await formatFiles(tree);
@ -93,7 +108,7 @@ Rename or remove the existing e2e target.`);
outputs: [`{workspaceRoot}/dist/.playwright/${projectConfig.root}`], outputs: [`{workspaceRoot}/dist/.playwright/${projectConfig.root}`],
options: { options: {
config: `${projectConfig.root}/playwright.config.${ config: `${projectConfig.root}/playwright.config.${
options.js ? 'js' : 'ts' options.js ? 'cjs' : 'ts'
}`, }`,
}, },
}; };

View File

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

View File

@ -4,9 +4,11 @@ import {
GeneratorCallback, GeneratorCallback,
getPackageManagerCommand, getPackageManagerCommand,
output, output,
readNxJson,
runTasksInSerial, runTasksInSerial,
Tree, Tree,
updateJson, updateJson,
updateNxJson,
workspaceRoot, workspaceRoot,
} from '@nx/devkit'; } from '@nx/devkit';
import { InitGeneratorSchema } from './schema'; 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) { if (!options.skipInstall) {
tasks.push(() => { tasks.push(() => {
output.log({ output.log({
@ -70,4 +76,25 @@ export async function initGenerator(tree: Tree, options: InitGeneratorSchema) {
return runTasksInSerial(...tasks); 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; export default initGenerator;

View File

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

View File

@ -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<string, TargetConfiguration>
> = {};
function readTargetsCache(): Record<
string,
Record<string, TargetConfiguration>
> {
return readJsonFile(cachePath);
}
function writeTargetsToCache(
targets: Record<string, Record<string, TargetConfiguration>>
) {
writeJsonFile(cachePath, targets);
}
export const createDependencies: CreateDependencies = () => {
writeTargetsToCache(calculatedTargets);
return [];
};
export const createNodes: CreateNodes<PlaywrightPluginOptions> = [
'**/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<string, TargetConfiguration<unknown>> = {};
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<string | RegExp>) {
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',
};
}

View File

@ -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<PlaywrightTestConfig> {
{
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];
}
});
}