Skip to content

Instantly share code, notes, and snippets.

@timdeschryver
Created February 23, 2025 19:05
Show Gist options
  • Save timdeschryver/bb49c998c5a39ebe289b77412aa8fd0f to your computer and use it in GitHub Desktop.
Save timdeschryver/bb49c998c5a39ebe289b77412aa8fd0f to your computer and use it in GitHub Desktop.
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);
});
});
});
});
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