nx/packages/eslint/src/plugins/plugin.spec.ts
Jack Hsu b9c0e3db5f
feat(eslint): rename eslint.config.js to eslint.config.cjs to resolve them as CommonJS (#29334)
This PR updates our generators to use `eslint.config.cjs` instead of
`eslint.config.js` so that Node resolution will treat it as CommonJS.
This solves an issue where having `"type": "module"` in `package.json`
will result in an error when Node tries to resolve the config file as
ESM.

Also allows us to clean up out Remix generators to not have to rename to
`eslint.config.cjs` to solve the same issue.

<!-- 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 -->

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

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

Fixes #
2024-12-18 16:34:10 -05:00

923 lines
30 KiB
TypeScript

import { CreateNodesContextV2 } from '@nx/devkit';
import { minimatch } from 'minimatch';
import { TempFs } from 'nx/src/internal-testing-utils/temp-fs';
import { createNodesV2, EslintPluginOptions } 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, { targetName: '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, { targetName: '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.cjs" 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.cjs': `module.exports = {};`,
'project.json': `{}`,
});
// NOTE: It should set ESLINT_USE_FLAT_CONFIG to true because of the use of eslint.config.cjs
expect(
await invokeCreateNodesOnMatchingFiles(context, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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, { targetName: '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}",
],
},
},
},
},
}
`);
});
});
describe('plugin options', () => {
it('should use the default target name when no options are provided', async () => {
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
expect(await invokeCreateNodesOnMatchingFiles(context))
.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 use the custom target name when the target name is provided', async () => {
createFiles({
'.eslintrc.json': `{}`,
'package.json': `{}`,
'src/index.ts': `console.log('hello world')`,
});
expect(
await invokeCreateNodesOnMatchingFiles(context, {
targetName: 'custom-lint',
})
).toMatchInlineSnapshot(`
{
"projects": {
".": {
"targets": {
"custom-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}",
],
},
},
},
},
}
`);
});
});
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,
options?: EslintPluginOptions
) {
const aggregateProjects: Record<string, any> = {};
const results = await createNodesV2[1](configFiles, options, context);
for (const [, nodes] of results) {
Object.assign(aggregateProjects, nodes.projects);
}
return {
projects: aggregateProjects,
};
}
});