nx/packages/eslint/src/plugins/plugin.spec.ts
Jack Hsu d90a735540
feat(core): add --help content to project details view (#26629)
This PR adds help text for each inferred target that provides
`metadata.help.command`. To pass options/args to the target, users are
directed to open `project.json` and copy the values from `--help` output
into `options` property of the target.

To display the options help section, the inferred target must provide
metadata as follows:

```json5
 metadata: {
      help: {
        command: `foo --help`
        example: {
          options: {
            bar: true
          },
        },
      },
    },
```

The `help.command` value will be used to retrieve help text for the
underlying CLI (e.g. `jest --help`). The `help.example` property
contains sample options and args that users can add to their
`project.json` file -- currently rendered in the hover tooltip of
`project.json` hint text.

---

Example with `vite build --help`:

<img width="1257" alt="Screenshot 2024-06-21 at 3 06 21 PM"
src="https://github.com/nrwl/nx/assets/53559/b94cdcde-80da-4fa5-9f93-11af7fbcaf27">


Result of clicking `Run`:
<img width="1257" alt="Screenshot 2024-06-21 at 3 06 24 PM"
src="https://github.com/nrwl/nx/assets/53559/6803a5a8-9bbd-4510-b9ff-fa895a5b3402">

`project.json` tooltip hint:
<img width="1392" alt="Screenshot 2024-06-25 at 12 44 02 PM"
src="https://github.com/nrwl/nx/assets/53559/565002ae-7993-4dda-ac5d-4b685710f65e">
2024-06-27 13:33:35 -04:00

795 lines
27 KiB
TypeScript

import { CreateNodesContextV2 } from '@nx/devkit';
import { minimatch } from 'minimatch';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { createNodesV2 } from './plugin';
import { mkdirSync, rmdirSync } from 'fs';
jest.mock('nx/src/utils/cache-directory', () => ({
...jest.requireActual('nx/src/utils/cache-directory'),
workspaceDataDirectory: 'tmp/project-graph-cache',
}));
describe('@nx/eslint/plugin', () => {
let context: CreateNodesContextV2;
let tempFs: TempFs;
let configFiles: string[] = [];
beforeEach(async () => {
mkdirSync('tmp/project-graph-cache', { recursive: true });
tempFs = new TempFs('eslint-plugin');
context = {
nxJsonConfiguration: {
// These defaults should be overridden by the plugin
targetDefaults: {
lint: {
cache: false,
inputs: ['foo', '^foo'],
},
},
namedInputs: {
default: ['{projectRoot}/**/*'],
production: ['!{projectRoot}/**/*.spec.ts'],
},
},
workspaceRoot: tempFs.tempDir,
};
});
afterEach(() => {
jest.resetModules();
tempFs.cleanup();
tempFs = null;
rmdirSync('tmp/project-graph-cache', { recursive: true });
});
it('should not create any nodes when there are no eslint configs', async () => {
createFiles({
'package.json': `{}`,
'project.json': `{}`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
describe('root eslint config only', () => {
it('should not create any nodes for just a package.json and root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
// TODO(leo): dynamic import of the flat config fails with jest:
// "TypeError: A dynamic import callback was invoked without --experimental-vm-modules"
// mocking the "eslint.config.js" file import is not working, figure out if there's a way
it.skip('should not create a node for a root level eslint config when accompanied by a project.json, if no src directory is present', async () => {
createFiles({
'eslint.config.js': `module.exports = {};`,
'project.json': `{}`,
});
// NOTE: It should set ESLINT_USE_FLAT_CONFIG to true because of the use of eslint.config.js
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
// Standalone Nx workspace style setup
it('should create a node for just a package.json and root level eslint config if accompanied by a src directory', async () => {
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
".": {
"targets": {
"lint": {
"cache": true,
"command": "eslint ./src",
"inputs": [
"default",
"^default",
"{projectRoot}/eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": ".",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should create a node for just a package.json and root level eslint config if accompanied by a lib directory', async () => {
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'lib/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
".": {
"targets": {
"lint": {
"cache": true,
"command": "eslint ./lib",
"inputs": [
"default",
"^default",
"{projectRoot}/eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": ".",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should not create a node for just a package.json and root level eslint config if accompanied by a src directory when all files are ignored (.eslintignore)', async () => {
createFiles({
'.eslintrc.json': `{}`,
'.eslintignore': `**/*`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create a node for just a package.json and root level eslint config if accompanied by a src directory when all files are ignored (ignorePatterns in .eslintrc.json)', async () => {
createFiles({
'.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
// NOTE: The command is specifically targeting the src directory in the case of a standalone Nx workspace
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should create a node for a nested project (with a project.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "apps/my-app",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should create a node for a nested project (with a package.json and any lintable file) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "apps/my-app",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should not create a node for a nested project (with a package.json and no lintable files) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/package.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create a node for a nested project (with a project.json and no lintable files) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
// These files are not lintable so do not create the target
'apps/my-app/one.png': `...`,
'apps/my-app/two.mov': `...`,
'apps/my-app/three.css': `...`,
'apps/my-app/config-one.yaml': `...`,
'apps/my-app/config-two.yml': `...`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create a node for a nested project (with a project.json and all files ignored) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/project.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create a node for a nested project (with a package.json and all files ignored) which does not have its own eslint config if accompanied by a root level eslint config', async () => {
createFiles({
'.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/package.json': `{}`,
// This file is lintable so create the target
'apps/my-app/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
});
describe('nested eslint configs only', () => {
it('should create appropriate nodes for nested projects without a root level eslint config', async () => {
createFiles({
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "apps/my-app",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
"libs/my-lib": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "libs/my-lib",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should not create nodes for nested projects without a root level eslint config when all files are ignored (.eslintignore)', async () => {
createFiles({
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/.eslintignore': `**/*`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/.eslintignore': `**/*`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
it('should not create nodes for nested projects without a root level eslint config when all files are ignored (ignorePatterns in .eslintrc.json)', async () => {
createFiles({
'apps/my-app/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{ "ignorePatterns": ["**/*"] }`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {},
}
`);
});
});
describe('root eslint config and nested eslint configs', () => {
it('should create appropriate nodes for just a package.json and root level eslint config combined with nested eslint configs', async () => {
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'apps/my-app/.eslintrc.json': `{}`,
'apps/my-app/project.json': `{}`,
'apps/my-app/index.ts': `console.log('hello world')`,
'libs/my-lib/.eslintrc.json': `{}`,
'libs/my-lib/project.json': `{}`,
'libs/my-lib/index.ts': `console.log('hello world')`,
});
// NOTE: The nested projects have the root level config as an input to their lint targets
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/my-app": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "apps/my-app",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
"libs/my-lib": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "libs/my-lib",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should create appropriate nodes for a nested project without its own eslint config but with an orphaned eslint config in its parent hierarchy', async () => {
createFiles({
'.eslintrc.json': '{}',
'apps/.eslintrc.json': '{}',
'apps/myapp/project.json': '{}',
'apps/myapp/index.ts': 'console.log("hello world")',
});
// NOTE: The nested projects have the root level config as an input to their lint targets
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/myapp": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{workspaceRoot}/apps/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "apps/myapp",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
it('should handle multiple levels of nesting and ignored files correctly', async () => {
createFiles({
'.eslintrc.json': '{ "root": true, "ignorePatterns": ["**/*"] }',
'apps/myapp/.eslintrc.json': '{ "extends": "../../.eslintrc.json" }', // no lintable files, don't create task
'apps/myapp/project.json': '{}',
'apps/myapp/index.ts': 'console.log("hello world")',
'apps/myapp/nested/mylib/.eslintrc.json': JSON.stringify({
extends: '../../../../.eslintrc.json',
ignorePatterns: ['!**/*'], // include all files, create task
}),
'apps/myapp/nested/mylib/project.json': '{}',
'apps/myapp/nested/mylib/index.ts': 'console.log("hello world")',
});
expect(await invokeCreateNodesOnMatchingFiles(context, 'lint'))
.toMatchInlineSnapshot(`
{
"projects": {
"apps/myapp/nested/mylib": {
"targets": {
"lint": {
"cache": true,
"command": "eslint .",
"inputs": [
"default",
"^default",
"{workspaceRoot}/.eslintrc.json",
"{projectRoot}/.eslintrc.json",
"{workspaceRoot}/tools/eslint-rules/**/*",
{
"externalDependencies": [
"eslint",
],
},
],
"metadata": {
"description": "Runs ESLint on project",
"help": {
"command": "npx eslint --help",
"example": {
"options": {
"max-warnings": 0,
},
},
},
"technologies": [
"eslint",
],
},
"options": {
"cwd": "apps/myapp/nested/mylib",
},
"outputs": [
"{options.outputFile}",
],
},
},
},
},
}
`);
});
});
function createFiles(fileSys: Record<string, string>) {
tempFs.createFilesSync(fileSys);
configFiles = getMatchingFiles(Object.keys(fileSys));
}
function getMatchingFiles(allConfigFiles: string[]): string[] {
return allConfigFiles.filter((file) =>
minimatch(file, createNodesV2[0], { dot: true })
);
}
async function invokeCreateNodesOnMatchingFiles(
context: CreateNodesContextV2,
targetName: string
) {
const aggregateProjects: Record<string, any> = {};
const results = await createNodesV2[1](
configFiles,
{ targetName },
context
);
for (const [, nodes] of results) {
Object.assign(aggregateProjects, nodes.projects);
}
return {
projects: aggregateProjects,
};
}
});