Created
February 23, 2025 19:05
-
-
Save timdeschryver/bb49c998c5a39ebe289b77412aa8fd0f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Tree, HostTree } from '@angular-devkit/schematics'; | |
import { | |
SchematicTestRunner, | |
UnitTestTree, | |
} from '@angular-devkit/schematics/testing'; | |
import { possibleFlatConfigPaths } from 'modules/eslint-plugin/schematics/ng-add'; | |
import * as path from 'path'; | |
const schematicRunner = new SchematicTestRunner( | |
'@ngrx/eslint-plugin', | |
path.join(__dirname, '../../schematics/collection.json') | |
); | |
describe('addNgRxESLintPlugin for ESLint < v9 (JSON)', () => { | |
test('registers the plugin with the all config', async () => { | |
const appTree = new UnitTestTree(Tree.empty()); | |
const initialConfig = {}; | |
appTree.create('./.eslintrc.json', JSON.stringify(initialConfig, null, 2)); | |
await schematicRunner.runSchematic('ng-add', {}, appTree); | |
const eslintContent = appTree.readContent(`.eslintrc.json`); | |
const eslintJson = JSON.parse(eslintContent); | |
expect(eslintJson).toEqual({ | |
overrides: [{ files: ['*.ts'], extends: [`plugin:@ngrx/all`] }], | |
}); | |
}); | |
test('registers the plugin with a different config', async () => { | |
const appTree = new UnitTestTree(Tree.empty()); | |
const initialConfig = {}; | |
appTree.create('./.eslintrc.json', JSON.stringify(initialConfig, null, 2)); | |
const options = { config: 'store' }; | |
await schematicRunner.runSchematic('ng-add', options, appTree); | |
const eslintContent = appTree.readContent(`.eslintrc.json`); | |
const eslintJson = JSON.parse(eslintContent); | |
expect(eslintJson).toEqual({ | |
overrides: [ | |
{ | |
files: ['*.ts'], | |
extends: [`plugin:@ngrx/${options.config}`], | |
}, | |
], | |
}); | |
}); | |
test('registers the plugin in overrides when it supports TS', async () => { | |
const appTree = new UnitTestTree(Tree.empty()); | |
const initialConfig = { | |
overrides: [ | |
{ | |
files: ['*.ts'], | |
parserOptions: { | |
project: ['tsconfig.eslint.json'], | |
createDefaultProgram: true, | |
}, | |
extends: [ | |
'plugin:@angular-eslint/recommended', | |
'eslint:recommended', | |
'plugin:@typescript-eslint/recommended', | |
'plugin:@typescript-eslint/recommended-requiring-type-checking', | |
'plugin:@angular-eslint/template/process-inline-templates', | |
'plugin:prettier/recommended', | |
], | |
}, | |
{ | |
files: ['*.html'], | |
extends: [ | |
'plugin:@angular-eslint/template/recommended', | |
'plugin:prettier/recommended', | |
], | |
rules: {}, | |
}, | |
], | |
}; | |
appTree.create('.eslintrc.json', JSON.stringify(initialConfig, null, 2)); | |
await schematicRunner.runSchematic('ng-add', {}, appTree); | |
const eslintContent = appTree.readContent(`.eslintrc.json`); | |
const eslintJson = JSON.parse(eslintContent); | |
expect(eslintJson).toEqual({ | |
overrides: [ | |
{ | |
files: ['*.ts'], | |
parserOptions: { | |
project: ['tsconfig.eslint.json'], | |
createDefaultProgram: true, | |
}, | |
extends: [ | |
'plugin:@angular-eslint/recommended', | |
'eslint:recommended', | |
'plugin:@typescript-eslint/recommended', | |
'plugin:@typescript-eslint/recommended-requiring-type-checking', | |
'plugin:@angular-eslint/template/process-inline-templates', | |
'plugin:prettier/recommended', | |
], | |
}, | |
{ | |
files: ['*.html'], | |
extends: [ | |
'plugin:@angular-eslint/template/recommended', | |
'plugin:prettier/recommended', | |
], | |
rules: {}, | |
}, | |
{ | |
files: ['*.ts'], | |
extends: [`plugin:@ngrx/all`], | |
}, | |
], | |
}); | |
}); | |
test('does not add the plugin if it is already added manually', async () => { | |
const appTree = new UnitTestTree(Tree.empty()); | |
const initialConfig = { | |
extends: ['plugin:@ngrx/all'], | |
}; | |
appTree.create('.eslintrc.json', JSON.stringify(initialConfig, null, 2)); | |
await schematicRunner.runSchematic('ng-add', {}, appTree); | |
const eslintContent = appTree.readContent(`.eslintrc.json`); | |
const eslintJson = JSON.parse(eslintContent); | |
expect(eslintJson).toEqual(initialConfig); | |
}); | |
test('does not add the plugin if it is already added manually as an override', async () => { | |
const appTree = new UnitTestTree(Tree.empty()); | |
const initialConfig = { | |
overrides: [ | |
{ | |
extends: ['plugin:@ngrx/all'], | |
}, | |
], | |
}; | |
appTree.create('.eslintrc.json', JSON.stringify(initialConfig, null, 2)); | |
await schematicRunner.runSchematic('ng-add', {}, appTree); | |
const eslintContent = appTree.readContent(`.eslintrc.json`); | |
const eslintJson = JSON.parse(eslintContent); | |
expect(eslintJson).toEqual(initialConfig); | |
}); | |
}); | |
describe('addNgRxESLintPlugin for ESLint >= 9 (flat config)', () => { | |
let host: UnitTestTree; | |
beforeEach(() => { | |
host = new UnitTestTree(new HostTree()); | |
}); | |
possibleFlatConfigPaths.forEach((configPath) => { | |
describe(`with ${configPath}`, () => { | |
it('registers the plugin with CommonJS', async () => { | |
host.create( | |
'eslint.config.js', | |
` | |
// @ts-check | |
const eslint = require('@eslint/js'); | |
const tseslint = require('typescript-eslint'); | |
const angular = require('angular-eslint'); | |
module.exports = tseslint.config( | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
eslint.configs.recommended, | |
...tseslint.configs.recommended, | |
...tseslint.configs.stylistic, | |
...angular.configs.tsRecommended, | |
], | |
processor: angular.processInlineTemplates, | |
rules: { | |
'@angular-eslint/directive-selector': [ | |
'error', | |
{ | |
type: 'attribute', | |
prefix: 'app', | |
style: 'camelCase', | |
}, | |
], | |
'@angular-eslint/component-selector': [ | |
'error', | |
{ | |
type: 'element', | |
prefix: 'app', | |
style: 'kebab-case', | |
}, | |
], | |
}, | |
}, | |
{ | |
files: ['**/*.html'], | |
extends: [ | |
...angular.configs.templateRecommended, | |
...angular.configs.templateAccessibility, | |
], | |
rules: {}, | |
}, | |
);` | |
); | |
await schematicRunner.runSchematic('ng-add', { config: 'store' }, host); | |
// verify it does not register the plugin twice | |
await schematicRunner.runSchematic('ng-add', { config: 'store' }, host); | |
const content = host.readText('eslint.config.js'); | |
expect(content).toContain(`@ngrx/eslint-plugin`); | |
expect(content).toMatchInlineSnapshot(` | |
" | |
// @ts-check | |
const eslint = require('@eslint/js'); | |
const tseslint = require('typescript-eslint'); | |
const angular = require('angular-eslint'); | |
const ngrx = require('@ngrx/eslint-plugin'); | |
module.exports = tseslint.config( | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
eslint.configs.recommended, | |
...tseslint.configs.recommended, | |
...tseslint.configs.stylistic, | |
...angular.configs.tsRecommended, | |
], | |
processor: angular.processInlineTemplates, | |
rules: { | |
'@angular-eslint/directive-selector': [ | |
'error', | |
{ | |
type: 'attribute', | |
prefix: 'app', | |
style: 'camelCase', | |
}, | |
], | |
'@angular-eslint/component-selector': [ | |
'error', | |
{ | |
type: 'element', | |
prefix: 'app', | |
style: 'kebab-case', | |
}, | |
], | |
}, | |
}, | |
{ | |
files: ['**/*.html'], | |
extends: [ | |
...angular.configs.templateRecommended, | |
...angular.configs.templateAccessibility, | |
], | |
rules: {}, | |
}, | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
...ngrx.configs.store, | |
], | |
rules: {}, | |
}, | |
);" | |
`); | |
}); | |
it('registers the plugin with ESM', async () => { | |
host.create( | |
'eslint.config.js', | |
` | |
// @ts-check | |
import eslint from '@eslint/js'; | |
import tseslint from 'typescript-eslint'; | |
import angular from 'angular-eslint'; | |
export default tseslint.config( | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
eslint.configs.recommended, | |
...tseslint.configs.recommended, | |
...tseslint.configs.stylistic, | |
...angular.configs.tsRecommended, | |
], | |
processor: angular.processInlineTemplates, | |
rules: { | |
'@angular-eslint/directive-selector': [ | |
'error', | |
{ | |
type: 'attribute', | |
prefix: 'app', | |
style: 'camelCase', | |
}, | |
], | |
'@angular-eslint/component-selector': [ | |
'error', | |
{ | |
type: 'element', | |
prefix: 'app', | |
style: 'kebab-case', | |
}, | |
], | |
}, | |
}, | |
{ | |
files: ['**/*.html'], | |
extends: [ | |
...angular.configs.templateRecommended, | |
...angular.configs.templateAccessibility, | |
], | |
rules: {}, | |
} | |
);` | |
); | |
await schematicRunner.runSchematic( | |
'ng-add', | |
{ config: 'effects' }, | |
host | |
); | |
// verify it does not register the plugin twice | |
await schematicRunner.runSchematic( | |
'ng-add', | |
{ config: 'effects' }, | |
host | |
); | |
const content = host.readText('eslint.config.js'); | |
expect(content).toContain(`@ngrx/eslint-plugin`); | |
expect(content).toMatchInlineSnapshot(` | |
" | |
// @ts-check | |
import eslint from '@eslint/js'; | |
import tseslint from 'typescript-eslint'; | |
import angular from 'angular-eslint'; | |
import ngrx from '@ngrx/eslint-plugin'; | |
export default tseslint.config( | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
eslint.configs.recommended, | |
...tseslint.configs.recommended, | |
...tseslint.configs.stylistic, | |
...angular.configs.tsRecommended, | |
], | |
processor: angular.processInlineTemplates, | |
rules: { | |
'@angular-eslint/directive-selector': [ | |
'error', | |
{ | |
type: 'attribute', | |
prefix: 'app', | |
style: 'camelCase', | |
}, | |
], | |
'@angular-eslint/component-selector': [ | |
'error', | |
{ | |
type: 'element', | |
prefix: 'app', | |
style: 'kebab-case', | |
}, | |
], | |
}, | |
}, | |
{ | |
files: ['**/*.html'], | |
extends: [ | |
...angular.configs.templateRecommended, | |
...angular.configs.templateAccessibility, | |
], | |
rules: {}, | |
}, | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
...ngrx.configs.effects, | |
], | |
rules: {}, | |
} | |
);" | |
`); | |
}); | |
it('registers the plugin when there are no existing plugins', async () => { | |
host.create('eslint.config.js', `module.exports = tseslint.config();`); | |
await schematicRunner.runSchematic('ng-add', { config: 'all' }, host); | |
const content = host.readText('eslint.config.js'); | |
expect(content).toContain(`@ngrx/eslint-plugin`); | |
expect(content).toMatchInlineSnapshot(` | |
" | |
const ngrx = require('@ngrx/eslint-plugin'); | |
module.exports = tseslint.config( | |
{ | |
files: ['**/*.ts'], | |
extends: [ | |
...ngrx.configs.all, | |
], | |
rules: {}, | |
} | |
);" | |
`); | |
}); | |
it('does not register the plugin if tseslint is missing', async () => { | |
const originalContent = ` | |
import somePlugin from 'some-plugin'; | |
export default []; | |
`; | |
host.create('eslint.config.js', originalContent); | |
await schematicRunner.runSchematic('ng-add', { config: 'all' }, host); | |
const content = host.readText('eslint.config.js'); | |
expect(content).toBe(originalContent); | |
}); | |
}); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; | |
import stripJsonComments from 'strip-json-comments'; | |
import type { Schema } from './schema'; | |
import * as ts from 'typescript'; | |
export const possibleFlatConfigPaths = [ | |
'eslint.config.js', | |
'eslint.config.mjs', | |
'eslint.config.cjs', | |
]; | |
export default function addNgRxESLintPlugin(schema: Schema): Rule { | |
return (host: Tree, context: SchematicContext) => { | |
const jsonConfigPath = '.eslintrc.json'; | |
const flatConfigPath = possibleFlatConfigPaths.find((path) => | |
host.exists(path) | |
); | |
const docs = 'https://ngrx.io/guide/eslint-plugin'; | |
if (flatConfigPath) { | |
updateFlatConfig(host, context, flatConfigPath, schema, docs); | |
return host; | |
} | |
if (!host.exists(jsonConfigPath)) { | |
context.logger.warn(` | |
Could not find an ESLint config at any of ${possibleFlatConfigPaths.join( | |
', ' | |
)} or \`${jsonConfigPath}\`. | |
The NgRx ESLint Plugin is installed but not configured. | |
Please see ${docs} to configure the NgRx ESLint Plugin. | |
`); | |
return host; | |
} | |
updateJsonConfig(host, context, jsonConfigPath, schema, docs); | |
return host; | |
}; | |
} | |
function updateFlatConfig( | |
host: Tree, | |
context: SchematicContext, | |
flatConfigPath: string, | |
schema: Schema, | |
docs: string | |
): void { | |
const content = host.read(flatConfigPath)?.toString('utf-8'); | |
if (!content) { | |
context.logger.error( | |
`Could not read the ESLint flat config at \`${flatConfigPath}\`.` | |
); | |
return; | |
} | |
if (content.includes('@ngrx/eslint-plugin')) { | |
context.logger.info( | |
`Skipping the installing, the NgRx ESLint Plugin is already installed in your flat config.` | |
); | |
return; | |
} | |
if (!content.includes('tseslint.config')) { | |
context.logger.warn( | |
`No tseslint found, skipping the installation of the NgRx ESLint Plugin in your flat config.` | |
); | |
return; | |
} | |
const source = ts.createSourceFile( | |
flatConfigPath, | |
content, | |
ts.ScriptTarget.Latest, | |
true | |
); | |
const recorder = host.beginUpdate(flatConfigPath); | |
addImport(); | |
addNgRxPlugin(); | |
host.commitUpdate(recorder); | |
context.logger.info(` | |
The NgRx ESLint Plugin is installed and configured using the '${schema.config}' configuration in your flat config. | |
See ${docs} for more details. | |
`); | |
function addImport() { | |
const isESM = content!.includes('export default'); | |
if (isESM) { | |
const lastImport = source.statements | |
.filter((statement) => ts.isImportDeclaration(statement)) | |
.reverse()[0]; | |
recorder.insertRight( | |
lastImport?.end ?? 0, | |
`\nimport ngrx from '@ngrx/eslint-plugin';` | |
); | |
} else { | |
const lastRequireVariableDeclaration = source.statements | |
.filter((statement) => { | |
if (!ts.isVariableStatement(statement)) return false; | |
const decl = statement.declarationList.declarations[0]; | |
if (!decl.initializer) return false; | |
return ( | |
ts.isCallExpression(decl.initializer) && | |
decl.initializer.expression.getText() === 'require' | |
); | |
}) | |
.reverse()[0]; | |
recorder.insertRight( | |
lastRequireVariableDeclaration?.end ?? 0, | |
`\nconst ngrx = require('@ngrx/eslint-plugin');\n` | |
); | |
} | |
} | |
function addNgRxPlugin() { | |
let tseslintConfigCall: ts.CallExpression | null = null; | |
function findTsEslintConfigCalls(node: ts.Node) { | |
if (tseslintConfigCall) { | |
return; | |
} | |
if ( | |
ts.isCallExpression(node) && | |
node.expression.getText() === 'tseslint.config' | |
) { | |
tseslintConfigCall = node; | |
} | |
ts.forEachChild(node, findTsEslintConfigCalls); | |
} | |
findTsEslintConfigCalls(source); | |
if (tseslintConfigCall) { | |
tseslintConfigCall = tseslintConfigCall as ts.CallExpression; | |
const lastArgument = | |
tseslintConfigCall.arguments[tseslintConfigCall.arguments.length - 1]; | |
const plugin = ` { | |
files: ['**/*.ts'], | |
extends: [ | |
...ngrx.configs.${schema.config}, | |
], | |
rules: {}, | |
}`; | |
if (lastArgument) { | |
recorder.remove(lastArgument.pos, lastArgument.end - lastArgument.pos); | |
recorder.insertRight( | |
lastArgument.pos, | |
`${lastArgument.getFullText()},\n${plugin}` | |
); | |
} else { | |
recorder.insertRight(tseslintConfigCall.end - 1, `\n${plugin}\n`); | |
} | |
} | |
} | |
} | |
function updateJsonConfig( | |
host: Tree, | |
context: SchematicContext, | |
jsonConfigPath: string, | |
schema: Schema, | |
docs: string | |
): void { | |
const eslint = host.read(jsonConfigPath)?.toString('utf-8'); | |
if (!eslint) { | |
context.logger.error(` | |
Could not find the ESLint config at \`${jsonConfigPath}\`. | |
The NgRx ESLint Plugin is installed but not configured. | |
Please see ${docs} to configure the NgRx ESLint Plugin. | |
`); | |
return; | |
} | |
try { | |
const json = JSON.parse(stripJsonComments(eslint)); | |
const plugin = { | |
files: ['*.ts'], | |
extends: [`plugin:@ngrx/${schema.config}`], | |
}; | |
if (json.overrides) { | |
if ( | |
!json.overrides.some((override: any) => | |
override.extends?.some((extend: any) => | |
extend.startsWith('plugin:@ngrx') | |
) | |
) | |
) { | |
json.overrides.push(plugin); | |
} | |
} else if ( | |
!json.extends?.some((extend: any) => extend.startsWith('plugin:@ngrx')) | |
) { | |
json.overrides = [plugin]; | |
} | |
host.overwrite(jsonConfigPath, JSON.stringify(json, null, 2)); | |
context.logger.info(` | |
The NgRx ESLint Plugin is installed and configured with the '${schema.config}' config. | |
Take a look at the docs at ${docs} if you want to change the default configuration. | |
`); | |
} catch (err) { | |
const detailsContent = | |
err instanceof Error | |
? ` | |
Details: | |
${err.message} | |
` | |
: ''; | |
context.logger.warn(` | |
Something went wrong while adding the NgRx ESLint Plugin. | |
The NgRx ESLint Plugin is installed but not configured. | |
Please see ${docs} to configure the NgRx ESLint Plugin. | |
${detailsContent} | |
`); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment