Jack Hsu 1e032fb9e5
fix(misc): update e2e config generators to align with new TS solution setup (#29638)
This PR updates the `@nx/detox:app` generator to match the new TS
solution setup. The `@nx/cypress:configuration` and
`@nx/cypress:configuration` generators are also updated so that they can
be run on existing projects and generator the correct tsconfig files.

The Playwright/Cypress example can be seen as follows:

```shell
# Skip e2e
nx g @nx/react:app apps/demo --bundler vite --e2eTestRunner none

# now configure e2e
nx g @nx/playwright --project demo
```

Now if you add this line to `apps/demo/e2e/example.spec.ts`:
```
const x: number = 'a';
```

And run `nx typecheck demo`, it will pass. This happens because the
`e2e/**/*.ts` pattern is missing. Thus, we need to ensure that a
`tsconfig.e2e.json` project is added for the Playwright spec files. Same
thing with Cypress.

The Detox generator does not support adding configuration to existing
project, so we don't quite get the same problem. The fix for Detox is
just to make sure the tsconfig content is not following the old
(integrated) version, but the updated TS solution version.

## Current Behavior
Detox TS setup is incorrect. Running Cypress and Playwright
configuration generator on existing projects generate invalid setup,
such that spec files are not typechecked.

## Expected Behavior
E2E generators should all generate correct TS setup.

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

Fixes #
2025-01-16 13:10:34 -05:00

367 lines
10 KiB
TypeScript

import {
addDependenciesToPackageJson,
formatFiles,
generateFiles,
GeneratorCallback,
getPackageManagerCommand,
joinPathFragments,
logger,
offsetFromRoot,
output,
readNxJson,
readProjectConfiguration,
runTasksInSerial,
toJS,
Tree,
updateJson,
updateNxJson,
updateProjectConfiguration,
workspaceRoot,
writeJson,
} from '@nx/devkit';
import { resolveImportPath } from '@nx/devkit/src/generators/project-name-and-root-utils';
import { getRelativePathToRootTsConfig } from '@nx/js';
import { normalizeLinterOption } from '@nx/js/src/utils/generator-prompts';
import {
getProjectPackageManagerWorkspaceState,
getProjectPackageManagerWorkspaceStateWarningTask,
} from '@nx/js/src/utils/package-manager-workspaces';
import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { execSync } from 'child_process';
import { PackageJson } from 'nx/src/utils/package-json';
import * as path from 'path';
import { addLinterToPlaywrightProject } from '../../utils/add-linter';
import { nxVersion } from '../../utils/versions';
import { initGenerator } from '../init/init';
import type {
ConfigurationGeneratorSchema,
NormalizedGeneratorOptions,
} from './schema';
export function configurationGenerator(
tree: Tree,
options: ConfigurationGeneratorSchema
) {
return configurationGeneratorInternal(tree, { addPlugin: false, ...options });
}
export async function configurationGeneratorInternal(
tree: Tree,
rawOptions: ConfigurationGeneratorSchema
) {
const options = await normalizeOptions(tree, rawOptions);
const tasks: GeneratorCallback[] = [];
tasks.push(
await initGenerator(tree, {
skipFormat: true,
skipPackageJson: options.skipPackageJson,
addPlugin: options.addPlugin,
})
);
const projectConfig = readProjectConfiguration(tree, options.project);
const offsetFromProjectRoot = offsetFromRoot(projectConfig.root);
generateFiles(tree, path.join(__dirname, 'files'), projectConfig.root, {
offsetFromRoot: offsetFromProjectRoot,
projectRoot: projectConfig.root,
webServerCommand: options.webServerCommand ?? null,
webServerAddress: options.webServerAddress ?? null,
...options,
});
const isTsSolutionSetup = isUsingTsSolutionSetup(tree);
const tsconfigPath = joinPathFragments(projectConfig.root, 'tsconfig.json');
if (tree.exists(tsconfigPath)) {
if (isTsSolutionSetup) {
const tsconfig: any = {
extends: getRelativePathToRootTsConfig(tree, projectConfig.root),
compilerOptions: {
allowJs: true,
outDir: 'out-tsc/playwright',
sourceMap: false,
},
include: [
joinPathFragments(options.directory, '**/*.ts'),
joinPathFragments(options.directory, '**/*.js'),
'playwright.config.ts',
],
exclude: ['out-tsc', 'test-output'],
};
// skip eslint from typechecking since it extends from root file that is outside rootDir
if (options.linter === 'eslint') {
tsconfig.exclude.push(
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs'
);
}
writeJson(
tree,
joinPathFragments(projectConfig.root, 'tsconfig.e2e.json'),
tsconfig
);
updateJson(tree, tsconfigPath, (json) => {
// add the project tsconfig to the workspace root tsconfig.json references
json.references ??= [];
json.references.push({ path: './tsconfig.e2e.json' });
return json;
});
}
} else {
const tsconfig: any = {
extends: getRelativePathToRootTsConfig(tree, projectConfig.root),
compilerOptions: {
allowJs: true,
outDir: `${offsetFromProjectRoot}dist/out-tsc`,
sourceMap: false,
},
include: [
'**/*.ts',
'**/*.js',
'playwright.config.ts',
'src/**/*.spec.ts',
'src/**/*.spec.js',
'src/**/*.test.ts',
'src/**/*.test.js',
'src/**/*.d.ts',
],
};
if (isTsSolutionSetup) {
tsconfig.exclude = ['out-tsc', 'test-output'];
// skip eslint from typechecking since it extends from root file that is outside rootDir
if (options.linter === 'eslint') {
tsconfig.exclude.push(
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs'
);
}
tsconfig.compilerOptions.outDir = 'out-tsc/playwright';
if (!options.rootProject) {
updateJson(tree, 'tsconfig.json', (json) => {
// add the project tsconfig to the workspace root tsconfig.json references
json.references ??= [];
json.references.push({ path: './' + projectConfig.root });
return json;
});
}
} else {
tsconfig.compilerOptions.outDir = `${offsetFromProjectRoot}dist/out-tsc`;
tsconfig.compilerOptions.module = 'commonjs';
}
writeJson(tree, tsconfigPath, tsconfig);
}
if (isTsSolutionSetup) {
const packageJsonPath = joinPathFragments(
projectConfig.root,
'package.json'
);
if (!tree.exists(packageJsonPath)) {
const importPath = resolveImportPath(
tree,
projectConfig.name,
projectConfig.root
);
const packageJson: PackageJson = {
name: importPath,
version: '0.0.1',
private: true,
nx: {
name: options.project,
},
};
writeJson(tree, packageJsonPath, packageJson);
}
ignoreTestOutput(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,
linter: options.linter,
skipPackageJson: options.skipPackageJson,
js: options.js,
directory: options.directory,
setParserOptionsProject: options.setParserOptionsProject,
rootProject: options.rootProject ?? projectConfig.root === '.',
addPlugin: options.addPlugin,
})
);
if (options.js) {
const { ModuleKind } = ensureTypescript();
toJS(tree, { extension: '.cjs', module: ModuleKind.CommonJS });
}
recommendVsCodeExtensions(tree);
if (!options.skipPackageJson) {
tasks.push(
addDependenciesToPackageJson(
tree,
{},
{
// required since used in playwright config
'@nx/devkit': nxVersion,
}
)
);
}
if (!options.skipInstall) {
tasks.push(getBrowsersInstallTask());
}
if (!options.skipFormat) {
await formatFiles(tree);
}
if (isTsSolutionSetup) {
const projectPackageManagerWorkspaceState =
getProjectPackageManagerWorkspaceState(tree, projectConfig.root);
if (projectPackageManagerWorkspaceState !== 'included') {
tasks.push(
getProjectPackageManagerWorkspaceStateWarningTask(
projectPackageManagerWorkspaceState,
tree.root
)
);
}
}
return runTasksInSerial(...tasks);
}
async function normalizeOptions(
tree: Tree,
options: ConfigurationGeneratorSchema
): Promise<NormalizedGeneratorOptions> {
const nxJson = readNxJson(tree);
const addPlugin =
options.addPlugin ??
(process.env.NX_ADD_PLUGINS !== 'false' &&
nxJson.useInferencePlugins !== false);
const linter = await normalizeLinterOption(tree, options.linter);
return {
...options,
addPlugin,
linter,
directory: options.directory ?? 'e2e',
};
}
function getBrowsersInstallTask() {
return () => {
output.log({
title: 'Ensuring Playwright is installed.',
bodyLines: ['use --skipInstall to skip installation.'],
});
const pmc = getPackageManagerCommand();
execSync(`${pmc.exec} playwright install`, {
cwd: workspaceRoot,
windowsHide: false,
});
};
}
function recommendVsCodeExtensions(tree: Tree): void {
if (tree.exists('.vscode/extensions.json')) {
updateJson(tree, '.vscode/extensions.json', (json) => {
json.recommendations ??= [];
const recs = new Set(json.recommendations);
recs.add('ms-playwright.playwright');
json.recommendations = Array.from(recs);
return json;
});
} else {
writeJson(tree, '.vscode/extensions.json', {
recommendations: ['ms-playwright.playwright'],
});
}
}
function setupE2ETargetDefaults(tree: Tree) {
const nxJson = readNxJson(tree);
if (!nxJson.namedInputs) {
return;
}
// E2e targets depend on all their project's sources + production sources of dependencies
nxJson.targetDefaults ??= {};
const productionFileSet = !!nxJson.namedInputs?.production;
nxJson.targetDefaults.e2e ??= {};
nxJson.targetDefaults.e2e.cache ??= true;
nxJson.targetDefaults.e2e.inputs ??= [
'default',
productionFileSet ? '^production' : '^default',
];
updateNxJson(tree, nxJson);
}
function addE2eTarget(tree: Tree, options: ConfigurationGeneratorSchema) {
const projectConfig = readProjectConfiguration(tree, options.project);
if (projectConfig?.targets?.e2e) {
throw new Error(`Project ${options.project} already has an e2e target.
Rename or remove the existing e2e target.`);
}
projectConfig.targets ??= {};
projectConfig.targets.e2e = {
executor: '@nx/playwright:playwright',
outputs: [`{workspaceRoot}/dist/.playwright/${projectConfig.root}`],
options: {
config: `${projectConfig.root}/playwright.config.${
options.js ? 'cjs' : 'ts'
}`,
},
};
updateProjectConfiguration(tree, options.project, projectConfig);
}
function ignoreTestOutput(tree: Tree): void {
if (!tree.exists('.gitignore')) {
logger.warn(`Couldn't find a root .gitignore file to update.`);
}
let content = tree.read('.gitignore', 'utf-8');
if (/^test-output$/gm.test(content)) {
return;
}
content = `${content}\ntest-output\n`;
tree.write('.gitignore', content);
}
export default configurationGenerator;