fix(testing): playwright migration should find correct targetName (#27386)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
Migration is not correctly finding the intended target name to use for
the config file


## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
Migration should find the correct target name for the config file

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Colum Ferry 2024-08-12 19:18:00 +01:00 committed by GitHub
parent 4108bfc8a6
commit 64f1aaf282
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 288 additions and 33 deletions

View File

@ -37,6 +37,8 @@
"@phenomnomnominal/tsquery": "~5.0.1",
"@nx/devkit": "file:../devkit",
"@nx/eslint": "file:../eslint",
"@nx/webpack": "file:../webpack",
"@nx/vite": "file:../vite",
"@nx/js": "file:../js",
"tslib": "^2.3.0",
"minimatch": "9.0.3"

View File

@ -1,7 +1,8 @@
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { ProjectGraph, type Tree } from '@nx/devkit';
import { ProjectGraph, readNxJson, type Tree, updateNxJson } from '@nx/devkit';
import useServeStaticPreviewForCommand from './use-serve-static-preview-for-command';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { join } from 'path';
let projectGraph: ProjectGraph;
jest.mock('@nx/devkit', () => ({
@ -28,6 +29,7 @@ describe('useServeStaticPreviewForCommand', () => {
afterEach(() => {
tempFs.reset();
jest.resetModules();
});
it('should update when it does not use serve-static for non-vite', async () => {
@ -69,8 +71,72 @@ describe('useServeStaticPreviewForCommand', () => {
"
`);
});
it('should use the serveStaticTargetName in the nx.json', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/webpack/plugin',
options: {
serveStaticTargetName: 'webpack:serve-static',
},
});
updateNxJson(tree, nxJson);
addProject(tree, tempFs, { noVite: true });
mockWebpackConfig({
output: {
path: 'dist/foo',
},
});
// ACT
await useServeStaticPreviewForCommand(tree);
// ASSERT
expect(tree.read('app-e2e/playwright.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
use: {
baseURL,
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run app:webpack:serve-static',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
"
`);
});
it('should update when it does not use preview for vite', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
previewTargetName: 'vite:preview',
},
});
updateNxJson(tree, nxJson);
addProject(tree, tempFs);
// ACT
@ -93,7 +159,7 @@ describe('useServeStaticPreviewForCommand', () => {
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run app:preview',
command: 'npx nx run app:vite:preview',
url: 'http://localhost:4300',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
@ -108,10 +174,66 @@ describe('useServeStaticPreviewForCommand', () => {
"
`);
});
it('should not replace the full command', async () => {
// ARRANGE
const nxJson = readNxJson(tree);
nxJson.plugins ??= [];
nxJson.plugins.push({
plugin: '@nx/vite/plugin',
options: {
previewTargetName: 'vite:preview',
},
});
updateNxJson(tree, nxJson);
addProject(tree, tempFs, { hasAdditionalCommand: true });
// ACT
await useServeStaticPreviewForCommand(tree);
// ASSERT
expect(tree.read('app-e2e/playwright.config.ts', 'utf-8'))
.toMatchInlineSnapshot(`
"import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
const baseURL = process.env['BASE_URL'] || 'http://localhost:4300';
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
use: {
baseURL,
trace: 'on-first-retry',
},
webServer: {
command: 'echo "start" && npx nx run app:vite:preview',
url: 'http://localhost:4300',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
"
`);
});
function mockWebpackConfig(config: any) {
jest.mock(join(tempFs.tempDir, 'app/webpack.config.ts'), () => config, {
virtual: true,
});
}
});
const basePlaywrightConfig = (
appName: string
appName: string,
hasAdditionalCommand?: boolean
) => `import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
@ -126,7 +248,9 @@ export default defineConfig({
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run ${appName}:serve',
command: '${
hasAdditionalCommand ? 'echo "start" && ' : ''
}npx nx run ${appName}:serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
cwd: workspaceRoot,
@ -174,6 +298,7 @@ function addProject(
tempFs: TempFs,
overrides: {
noVite?: boolean;
hasAdditionalCommand?: boolean;
} = {}
) {
const appProjectConfig = {
@ -193,20 +318,36 @@ function addProject(
if (!overrides.noVite) {
tree.write(`app/vite.config.ts`, viteConfig);
} else {
tree.write(`app/webpack.config.ts`, ``);
tree.write(
`app/webpack.config.ts`,
`module.exports = {output: {
path: 'dist/foo',
}}`
);
}
tree.write(`app/project.json`, JSON.stringify(appProjectConfig));
tree.write(`app-e2e/playwright.config.ts`, basePlaywrightConfig('app'));
tree.write(
`app-e2e/playwright.config.ts`,
basePlaywrightConfig('app', overrides.hasAdditionalCommand)
);
tree.write(`app-e2e/project.json`, JSON.stringify(e2eProjectConfig));
if (!overrides.noVite) {
tempFs.createFile(`app/vite.config.ts`, viteConfig);
tempFs.createFileSync(`app/vite.config.ts`, viteConfig);
} else {
tempFs.createFile(`app/webpack.config.ts`, ``);
tempFs.createFileSync(
`app/webpack.config.ts`,
`module.exports = {output: {
path: 'dist/foo',
}}`
);
}
tempFs.createFilesSync({
[`app/project.json`]: JSON.stringify(appProjectConfig),
[`app-e2e/playwright.config.ts`]: basePlaywrightConfig('app'),
[`app-e2e/playwright.config.ts`]: basePlaywrightConfig(
'app',
overrides.hasAdditionalCommand
),
[`app-e2e/project.json`]: JSON.stringify(e2eProjectConfig),
});

View File

@ -1,18 +1,41 @@
import {
CreateNodesV2,
createProjectGraphAsync,
formatFiles,
getPackageManagerCommand,
joinPathFragments,
parseTargetString,
readNxJson,
type Tree,
visitNotIgnoredFiles,
} from '@nx/devkit';
import { tsquery } from '@phenomnomnominal/tsquery';
import addE2eCiTargetDefaults from './add-e2e-ci-target-defaults';
import type { ConfigurationResult } from 'nx/src/project-graph/utils/project-configuration-utils';
import { LoadedNxPlugin } from 'nx/src/project-graph/plugins/internal-api';
import { retrieveProjectConfigurations } from 'nx/src/project-graph/utils/retrieve-workspace-files';
import { ProjectConfigurationsError } from 'nx/src/project-graph/error-types';
import {
WebpackPluginOptions,
createNodesV2 as webpackCreateNodesV2,
} from '@nx/webpack/src/plugins/plugin';
import {
VitePluginOptions,
createNodesV2 as viteCreateNodesV2,
} from '@nx/vite/plugin';
import type { Node } from 'typescript';
export default async function (tree: Tree) {
const graph = await createProjectGraphAsync();
visitNotIgnoredFiles(tree, '', (path) => {
const collectedProjects: {
projectName: string;
configFile: string;
configFileType: 'webpack' | 'vite';
playwrightConfigFile: string;
commandValueNode: Node;
}[] = [];
visitNotIgnoredFiles(tree, '', async (path) => {
if (!path.endsWith('playwright.config.ts')) {
return;
}
@ -33,8 +56,8 @@ export default async function (tree: Tree) {
const command = commandValueNode.getText();
let project: string;
if (command.includes('nx run')) {
const NX_TARGET_REGEX = "(?<=nx run )[^']+";
const matches = command.match(NX_TARGET_REGEX);
const NX_RUN_TARGET_REGEX = "(?<=nx run )[^']+";
const matches = command.match(NX_RUN_TARGET_REGEX);
if (!matches) {
return;
}
@ -63,34 +86,71 @@ export default async function (tree: Tree) {
joinPathFragments(graph.nodes[project].data.root, 'vite.config.js'),
].find((p) => tree.exists(p));
if (!pathToViteConfig) {
const newCommand = `${
getPackageManagerCommand().exec
} nx run ${project}:serve-static`;
const pathToWebpackConfig = [
joinPathFragments(graph.nodes[project].data.root, 'webpack.config.ts'),
joinPathFragments(graph.nodes[project].data.root, 'webpack.config.js'),
].find((p) => tree.exists(p));
collectedProjects.push({
projectName: project,
configFile: pathToWebpackConfig ?? pathToViteConfig,
configFileType: pathToWebpackConfig ? 'webpack' : 'vite',
playwrightConfigFile: path,
commandValueNode,
});
});
for (const projectToMigrate of collectedProjects) {
let playwrightConfigFileContents = tree.read(
projectToMigrate.playwrightConfigFile,
'utf-8'
);
const targetName = await getServeStaticTargetNameForConfigFile(
tree,
projectToMigrate.configFileType === 'webpack'
? '@nx/webpack/plugin'
: '@nx/vite/plugin',
projectToMigrate.configFile,
projectToMigrate.configFileType === 'webpack'
? 'serve-static'
: 'preview',
projectToMigrate.configFileType === 'webpack'
? 'serveStaticTargetName'
: 'previewTargetName',
projectToMigrate.configFileType === 'webpack'
? webpackCreateNodesV2
: viteCreateNodesV2
);
const oldCommand = projectToMigrate.commandValueNode.getText();
const newCommand = oldCommand.replace(
/nx.*[^"']/,
`nx run ${projectToMigrate.projectName}:${targetName}`
);
if (projectToMigrate.configFileType === 'webpack') {
tree.write(
path,
projectToMigrate.playwrightConfigFile,
`${playwrightConfigFileContents.slice(
0,
commandValueNode.getStart()
)}"${newCommand}"${playwrightConfigFileContents.slice(
commandValueNode.getEnd()
projectToMigrate.commandValueNode.getStart()
)}${newCommand}${playwrightConfigFileContents.slice(
projectToMigrate.commandValueNode.getEnd()
)}`
);
} else {
const newCommand = `${
getPackageManagerCommand().exec
} nx run ${project}:preview`;
tree.write(
path,
projectToMigrate.playwrightConfigFile,
`${playwrightConfigFileContents.slice(
0,
commandValueNode.getStart()
)}"${newCommand}"${playwrightConfigFileContents.slice(
commandValueNode.getEnd()
projectToMigrate.commandValueNode.getStart()
)}${newCommand}${playwrightConfigFileContents.slice(
projectToMigrate.commandValueNode.getEnd()
)}`
);
playwrightConfigFileContents = tree.read(path, 'utf-8');
ast = tsquery.ast(playwrightConfigFileContents);
playwrightConfigFileContents = tree.read(
projectToMigrate.playwrightConfigFile,
'utf-8'
);
let ast = tsquery.ast(playwrightConfigFileContents);
const BASE_URL_SELECTOR =
'VariableDeclaration:has(Identifier[name=baseURL])';
@ -105,7 +165,7 @@ export default async function (tree: Tree) {
const newBaseUrlVariableDeclaration =
"baseURL = process.env['BASE_URL'] || 'http://localhost:4300';";
tree.write(
path,
projectToMigrate.playwrightConfigFile,
`${playwrightConfigFileContents.slice(
0,
baseUrlNode.getStart()
@ -114,7 +174,10 @@ export default async function (tree: Tree) {
)}`
);
playwrightConfigFileContents = tree.read(path, 'utf-8');
playwrightConfigFileContents = tree.read(
projectToMigrate.playwrightConfigFile,
'utf-8'
);
ast = tsquery.ast(playwrightConfigFileContents);
const WEB_SERVER_URL_SELECTOR =
'PropertyAssignment:has(Identifier[name=webServer]) PropertyAssignment:has(Identifier[name=url]) > StringLiteral';
@ -128,7 +191,7 @@ export default async function (tree: Tree) {
const webServerUrlNode = webServerUrlNodes[0];
const newWebServerUrl = "'http://localhost:4300'";
tree.write(
path,
projectToMigrate.playwrightConfigFile,
`${playwrightConfigFileContents.slice(
0,
webServerUrlNode.getStart()
@ -137,8 +200,57 @@ export default async function (tree: Tree) {
)}`
);
}
});
}
await addE2eCiTargetDefaults(tree);
await formatFiles(tree);
}
async function getServeStaticTargetNameForConfigFile<T>(
tree: Tree,
pluginName: string,
configFile: string,
defaultTargetName: string,
targetNamePluginOption: keyof T,
createNodesV2: CreateNodesV2<T>
) {
const nxJson = readNxJson(tree);
const matchingPluginRegistrations = nxJson.plugins?.filter((p) =>
typeof p === 'string' ? p === pluginName : p.plugin === pluginName
);
if (!matchingPluginRegistrations) {
return defaultTargetName;
}
let targetName = defaultTargetName;
for (const plugin of matchingPluginRegistrations) {
let projectConfigs: ConfigurationResult;
try {
const loadedPlugin = new LoadedNxPlugin(
{ createNodesV2, name: pluginName },
plugin
);
projectConfigs = await retrieveProjectConfigurations(
[loadedPlugin],
tree.root,
nxJson
);
} catch (e) {
if (e instanceof ProjectConfigurationsError) {
projectConfigs = e.partialProjectConfigurationsResult;
} else {
throw e;
}
}
if (projectConfigs.matchingProjectFiles.includes(configFile)) {
targetName =
typeof plugin === 'string'
? defaultTargetName
: (plugin.options?.[targetNamePluginOption] as string) ??
defaultTargetName;
}
}
return targetName;
}