feat(testing): add create-nodes plugin for playwright e2e targets (#20099)
This commit is contained in:
parent
3981c90fe8
commit
2374d8eaba
@ -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
|
||||
|
||||
11
docs/generated/devkit/ToJSOptions.md
Normal file
11
docs/generated/devkit/ToJSOptions.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Type alias: ToJSOptions
|
||||
|
||||
Ƭ **ToJSOptions**: `Object`
|
||||
|
||||
#### Type declaration
|
||||
|
||||
| Name | Type |
|
||||
| :---------- | :------------------------------ |
|
||||
| `extension` | `".js"` \| `".mjs"` \| `".cjs"` |
|
||||
| `module?` | `ModuleKind` |
|
||||
| `target?` | `ScriptTarget` |
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/nx/src/native/index.d.ts
vendored
1
packages/nx/src/native/index.d.ts
vendored
@ -176,4 +176,5 @@ export class WorkspaceContext {
|
||||
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
|
||||
allFileData(): Array<FileData>
|
||||
getFilesInDirectory(directory: string): Array<string>
|
||||
}
|
||||
|
||||
@ -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<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",
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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<FileData> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<string, string>,
|
||||
rustReferences: NxWorkspaceFilesExternals,
|
||||
|
||||
@ -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."
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
3
packages/playwright/migrations.json
Normal file
3
packages/playwright/migrations.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"generators": {}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/playwright/plugin.ts
Normal file
1
packages/playwright/plugin.ts
Normal file
@ -0,0 +1 @@
|
||||
export { createNodes, PlaywrightPluginOptions } from './src/plugins/plugin';
|
||||
@ -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'
|
||||
}`,
|
||||
},
|
||||
};
|
||||
|
||||
106
packages/playwright/src/generators/init/init.spec.ts
Normal file
106
packages/playwright/src/generators/init/init.spec.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
245
packages/playwright/src/plugins/plugin.spec.ts
Normal file
245
packages/playwright/src/plugins/plugin.spec.ts
Normal 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)}`
|
||||
);
|
||||
}
|
||||
284
packages/playwright/src/plugins/plugin.ts
Normal file
284
packages/playwright/src/plugins/plugin.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
56
packages/playwright/src/utils/load-config-file.ts
Normal file
56
packages/playwright/src/utils/load-config-file.ts
Normal 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];
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user