From 3965c8fe025efb028c576fffd2408cf23dde56e3 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Sat, 20 Apr 2024 11:51:55 +1200 Subject: [PATCH] feat: support ESLint v9 (#355) --- .github/workflows/validate.yml | 12 +++- package.json | 3 +- src/__tests__/lib/rules/prefer-empty.js | 4 +- src/__tests__/lib/rules/prefer-focus.js | 2 +- src/__tests__/lib/rules/prefer-in-document.js | 2 +- .../lib/rules/prefer-prefer-to-have-class.js | 2 +- .../lib/rules/prefer-to-have-attribute.js | 2 +- .../lib/rules/prefer-to-have-style.js | 2 +- .../lib/rules/prefer-to-have-text-content.js | 2 +- .../lib/rules/prefer-to-have-value.js | 2 +- src/__tests__/rule-tester.js | 66 +++++++++++++++++++ src/assignment-ast.js | 23 ++++--- src/context.js | 19 ++++++ src/rules/prefer-empty.js | 3 +- src/rules/prefer-in-document.js | 6 +- src/rules/prefer-to-have-attribute.js | 7 +- src/rules/prefer-to-have-class.js | 21 +++--- src/rules/prefer-to-have-style.js | 11 ++-- src/rules/prefer-to-have-text-content.js | 5 +- src/rules/prefer-to-have-value.js | 5 +- 20 files changed, 154 insertions(+), 45 deletions(-) create mode 100644 src/__tests__/rule-tester.js create mode 100644 src/context.js diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 669902b..26596e7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -22,10 +22,16 @@ jobs: strategy: fail-fast: false matrix: - eslint: [6, 7, 8] + eslint: [6, 7, 8, 9] node: [12.x, 14.x, 16.x, 18.x, 20.x, 21.x] testing-library-dom: [8, 9, 10] exclude: + - eslint: 9 + node: 12.x + - eslint: 9 + node: 14.x + - eslint: 9 + node: 16.x - testing-library-dom: 9 node: 12.x - testing-library-dom: 10 @@ -49,6 +55,10 @@ jobs: with: useLockFile: false + # see https://github.com/npm/cli/issues/7349 + - if: ${{ matrix.eslint == 9 }} + run: npm un @typescript-eslint/parser + - name: Install ESLint v${{ matrix.eslint }} run: npm install --no-save --force eslint@${{ matrix.eslint }} diff --git a/package.json b/package.json index c92e1fd..218710b 100644 --- a/package.json +++ b/package.json @@ -52,11 +52,12 @@ "eslint-remote-tester": "^3.0.0", "eslint-remote-tester-repositories": "^1.0.1", "kcd-scripts": "^12.0.0", + "semver": "^7.6.0", "typescript": "^5.1.3" }, "peerDependencies": { "@testing-library/dom": "^8.0.0 || ^9.0.0 || ^10.0.0", - "eslint": "^6.8.0 || ^7.0.0 || ^8.0.0" + "eslint": "^6.8.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" }, "peerDependenciesMeta": { "@testing-library/dom": { diff --git a/src/__tests__/lib/rules/prefer-empty.js b/src/__tests__/lib/rules/prefer-empty.js index 5a4b529..95cbe76 100644 --- a/src/__tests__/lib/rules/prefer-empty.js +++ b/src/__tests__/lib/rules/prefer-empty.js @@ -8,8 +8,8 @@ // Requirements //------------------------------------------------------------------------------ -import { RuleTester } from "eslint"; -import * as rule from "../../../rules/prefer-empty"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; +import * as rule from '../../../rules/prefer-empty'; //------------------------------------------------------------------------------ // Tests diff --git a/src/__tests__/lib/rules/prefer-focus.js b/src/__tests__/lib/rules/prefer-focus.js index aa4be8c..3d5705d 100644 --- a/src/__tests__/lib/rules/prefer-focus.js +++ b/src/__tests__/lib/rules/prefer-focus.js @@ -3,7 +3,7 @@ * @author Ben Monro */ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-focus"; const ruleTester = new RuleTester(); diff --git a/src/__tests__/lib/rules/prefer-in-document.js b/src/__tests__/lib/rules/prefer-in-document.js index 74b330e..9a9fdd1 100644 --- a/src/__tests__/lib/rules/prefer-in-document.js +++ b/src/__tests__/lib/rules/prefer-in-document.js @@ -7,7 +7,7 @@ // Requirements //------------------------------------------------------------------------------ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-in-document"; //------------------------------------------------------------------------------ diff --git a/src/__tests__/lib/rules/prefer-prefer-to-have-class.js b/src/__tests__/lib/rules/prefer-prefer-to-have-class.js index f06b496..60c6acf 100644 --- a/src/__tests__/lib/rules/prefer-prefer-to-have-class.js +++ b/src/__tests__/lib/rules/prefer-prefer-to-have-class.js @@ -1,4 +1,4 @@ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-to-have-class"; const errors = [{ messageId: "use-to-have-class" }]; diff --git a/src/__tests__/lib/rules/prefer-to-have-attribute.js b/src/__tests__/lib/rules/prefer-to-have-attribute.js index e55e03e..7264474 100644 --- a/src/__tests__/lib/rules/prefer-to-have-attribute.js +++ b/src/__tests__/lib/rules/prefer-to-have-attribute.js @@ -8,7 +8,7 @@ // Requirements //------------------------------------------------------------------------------ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-to-have-attribute"; //------------------------------------------------------------------------------ diff --git a/src/__tests__/lib/rules/prefer-to-have-style.js b/src/__tests__/lib/rules/prefer-to-have-style.js index f9d72ff..4d3c560 100644 --- a/src/__tests__/lib/rules/prefer-to-have-style.js +++ b/src/__tests__/lib/rules/prefer-to-have-style.js @@ -1,4 +1,4 @@ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-to-have-style"; const errors = [ diff --git a/src/__tests__/lib/rules/prefer-to-have-text-content.js b/src/__tests__/lib/rules/prefer-to-have-text-content.js index 1d47704..26e8c0d 100644 --- a/src/__tests__/lib/rules/prefer-to-have-text-content.js +++ b/src/__tests__/lib/rules/prefer-to-have-text-content.js @@ -8,7 +8,7 @@ // Requirements //------------------------------------------------------------------------------ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-to-have-text-content"; //------------------------------------------------------------------------------ diff --git a/src/__tests__/lib/rules/prefer-to-have-value.js b/src/__tests__/lib/rules/prefer-to-have-value.js index aed17c6..e50fb74 100644 --- a/src/__tests__/lib/rules/prefer-to-have-value.js +++ b/src/__tests__/lib/rules/prefer-to-have-value.js @@ -8,7 +8,7 @@ // Requirements //------------------------------------------------------------------------------ -import { RuleTester } from "eslint"; +import { FlatCompatRuleTester as RuleTester } from '../../rule-tester'; import * as rule from "../../../rules/prefer-to-have-value"; //------------------------------------------------------------------------------ diff --git a/src/__tests__/rule-tester.js b/src/__tests__/rule-tester.js new file mode 100644 index 0000000..2e27146 --- /dev/null +++ b/src/__tests__/rule-tester.js @@ -0,0 +1,66 @@ +/* eslint-disable jest/no-export */ + +import { RuleTester } from 'eslint'; +import semver from 'semver'; +import { version as eslintVersion } from 'eslint/package.json'; + +// we need to have a test as kcd-scripts doesn't let us +// exclude this file from being run via jest as a test +it('is true', () => { + expect(true).toBe(true); +}); + +export const usingFlatConfig = semver.major(eslintVersion) >= 9; + +export class FlatCompatRuleTester extends RuleTester { + constructor(testerConfig) { + super(FlatCompatRuleTester._flatCompat(testerConfig)); + } + + run( + ruleName, + rule, + tests, + ) { + super.run(ruleName, rule, { + valid: tests.valid.map(t => FlatCompatRuleTester._flatCompat(t)), + invalid: tests.invalid.map(t => FlatCompatRuleTester._flatCompat(t)), + }); + } + + static _flatCompat(config) { + if (!config || !usingFlatConfig || typeof config === 'string') { + return config; + } + + const obj = { + languageOptions: { parserOptions: {} }, + }; + + for (const [key, value] of Object.entries(config)) { + if (key === 'parser') { + obj.languageOptions.parser = require(value); + + continue; + } + + if (key === 'parserOptions') { + for (const [option, val] of Object.entries(value)) { + if (option === 'ecmaVersion' || option === 'sourceType') { + obj.languageOptions[option] = val + + continue; + } + + obj.languageOptions.parserOptions[option] = val; + } + + continue; + } + + obj[key] = value; + } + + return obj; + } +} diff --git a/src/assignment-ast.js b/src/assignment-ast.js index e923af2..5f53a06 100644 --- a/src/assignment-ast.js +++ b/src/assignment-ast.js @@ -1,4 +1,5 @@ import { queries } from "./queries"; +import { getScope } from './context'; /** * Gets the inner relevant node (CallExpression, Identity, et al.) given a generic expression node @@ -6,19 +7,20 @@ import { queries } from "./queries"; * someElement as HTMLDivElement => someElement * * @param {Object} context - Context for a rule + * @param {Object} node - Node for a rule * @param {Object} expression - An expression node * @returns {Object} - A node */ -export function getInnerNodeFrom(context, expression) { +export function getInnerNodeFrom(context, node, expression) { switch (expression.type) { case "Identifier": - return getAssignmentForIdentifier(context, expression.name); + return getAssignmentForIdentifier(context, node, expression.name); case "TSAsExpression": - return getInnerNodeFrom(context, expression.expression); + return getInnerNodeFrom(context, node, expression.expression); case "AwaitExpression": - return getInnerNodeFrom(context, expression.argument); + return getInnerNodeFrom(context, node, expression.argument); case "MemberExpression": - return getInnerNodeFrom(context, expression.object); + return getInnerNodeFrom(context, node, expression.object); default: return expression; } @@ -28,11 +30,12 @@ export function getInnerNodeFrom(context, expression) { * Get the node corresponding to the latest assignment to a variable named `identifierName` * * @param {Object} context - Context for a rule + * @param {Object} node - Node for a rule * @param {String} identifierName - Name of an identifier * @returns {Object} - A node, possibly undefined */ -export function getAssignmentForIdentifier(context, identifierName) { - const variable = context.getScope().set.get(identifierName); +export function getAssignmentForIdentifier(context, node, identifierName) { + const variable = getScope(context, node).set.get(identifierName); if (!variable) return; const init = variable.defs[0].node.init; @@ -40,7 +43,7 @@ export function getAssignmentForIdentifier(context, identifierName) { let assignmentNode; if (init) { // let foo = bar; - assignmentNode = getInnerNodeFrom(context, init); + assignmentNode = getInnerNodeFrom(context, node, init); } else { // let foo; // foo = bar; @@ -50,7 +53,7 @@ export function getAssignmentForIdentifier(context, identifierName) { if (!assignmentRef) { return; } - assignmentNode = getInnerNodeFrom(context, assignmentRef.writeExpr); + assignmentNode = getInnerNodeFrom(context, node, assignmentRef.writeExpr); } return assignmentNode; } @@ -64,7 +67,7 @@ export function getAssignmentForIdentifier(context, identifierName) { * @returns {Object} - Object with query, queryArg & isDTLQuery */ export function getQueryNodeFrom(context, nodeWithValueProp) { - const queryNode = getInnerNodeFrom(context, nodeWithValueProp); + const queryNode = getInnerNodeFrom(context, nodeWithValueProp, nodeWithValueProp); if (!queryNode || !queryNode.callee) { return { diff --git a/src/context.js b/src/context.js new file mode 100644 index 0000000..44529ba --- /dev/null +++ b/src/context.js @@ -0,0 +1,19 @@ +/* istanbul ignore next */ +export function getSourceCode(context) { + if ('sourceCode' in context) { + return context.sourceCode; + } + + return context.getSourceCode(); +} + +/* istanbul ignore next */ +export function getScope(context, node) { + const sourceCode = getSourceCode(context); + + if (sourceCode && sourceCode.getScope) { + return sourceCode.getScope(node); + } + + return context.getScope(); +} diff --git a/src/rules/prefer-empty.js b/src/rules/prefer-empty.js index a121f91..be52f4b 100644 --- a/src/rules/prefer-empty.js +++ b/src/rules/prefer-empty.js @@ -2,6 +2,7 @@ * @fileoverview Prefer toBeEmpty over checking innerHTML * @author Ben Monro */ +import { getSourceCode } from '../context'; export const meta = { docs: { @@ -16,7 +17,7 @@ export const meta = { export const create = (context) => { function isNonEmptyStringOrTemplateLiteral(node) { return !['""', "''", "``", "null"].includes( - context.getSourceCode().getText(node) + getSourceCode(context).getText(node) ); } diff --git a/src/rules/prefer-in-document.js b/src/rules/prefer-in-document.js index d9b255b..4acc0db 100644 --- a/src/rules/prefer-in-document.js +++ b/src/rules/prefer-in-document.js @@ -7,6 +7,7 @@ import { queries } from "../queries"; import { getAssignmentForIdentifier } from "../assignment-ast"; +import { getSourceCode } from '../context'; export const meta = { type: "suggestion", @@ -78,6 +79,7 @@ export const create = (context) => { if (matcherArguments[0].type === "Identifier") { const assignment = getAssignmentForIdentifier( context, + matcherArguments[0], matcherArguments[0].name ); if (!assignment) { @@ -186,7 +188,7 @@ export const create = (context) => { // Remove any arguments in the matcher for (const argument of Array.from(matcherArguments)) { - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); const token = sourceCode.getTokenAfter(argument); if (token.value === "," && token.type === "Punctuator") { // Remove commas if toHaveLength had more than one argument or a trailing comma @@ -257,6 +259,7 @@ export const create = (context) => { ) { const queryNode = getAssignmentForIdentifier( context, + node, node.object.object.arguments[0].name ); @@ -285,6 +288,7 @@ export const create = (context) => { // Value expression being assigned to the left-hand value const rightValueNode = getAssignmentForIdentifier( context, + node, node.object.arguments[0].name ); diff --git a/src/rules/prefer-to-have-attribute.js b/src/rules/prefer-to-have-attribute.js index 5c0b16d..3b68452 100644 --- a/src/rules/prefer-to-have-attribute.js +++ b/src/rules/prefer-to-have-attribute.js @@ -2,6 +2,7 @@ * @fileoverview prefer toHaveAttribute over checking getAttribute/hasAttribute * @author Ben Monro */ +import { getSourceCode } from '../context'; //------------------------------------------------------------------------------ // Rule Definition @@ -42,7 +43,7 @@ export const create = (context) => ({ [`CallExpression[callee.property.name='getAttribute'][parent.callee.name='expect'][parent.parent.property.name=/toContain$|toMatch$/]`]( node ) { - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); context.report({ node: node.parent, message: `Use toHaveAttribute instead of asserting on getAttribute`, @@ -66,7 +67,7 @@ export const create = (context) => ({ const arg = node.parent.parent.parent.arguments; const isNull = arg.length > 0 && arg[0].value === null; - const sourceCode = context.getSourceCode(); + const sourceCode = getSourceCode(context); context.report({ node: node.parent, message: `Use toHaveAttribute instead of asserting on getAttribute`, @@ -127,7 +128,7 @@ export const create = (context) => ({ ), fixer.replaceText( node.parent.parent.parent.arguments[0], - context.getSourceCode().getText(node.arguments[0]) + getSourceCode(context).getText(node.arguments[0]) ), ], }); diff --git a/src/rules/prefer-to-have-class.js b/src/rules/prefer-to-have-class.js index ce950d5..74530df 100644 --- a/src/rules/prefer-to-have-class.js +++ b/src/rules/prefer-to-have-class.js @@ -4,6 +4,7 @@ */ import { getQueryNodeFrom } from "../assignment-ast"; +import { getSourceCode } from '../context'; //------------------------------------------------------------------------------ // Rule Definition @@ -48,11 +49,11 @@ export const create = (context) => ({ matcherArg ? fixer.replaceText( matcherArg, - context.getSourceCode().getText(classValue) + getSourceCode(context).getText(classValue) ) : fixer.insertTextBefore( - context.getSourceCode().getTokenAfter(matcher, { skip: 1 }), - context.getSourceCode().getText(classValue) + getSourceCode(context).getTokenAfter(matcher, { skip: 1 }), + getSourceCode(context).getText(classValue) ), ]; }, @@ -82,7 +83,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( classValue, - context.getSourceCode().getText(classValue) + getSourceCode(context).getText(classValue) ), ]; }, @@ -133,7 +134,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( classValue, - `${context.getSourceCode().getText(classValue)}${ + `${getSourceCode(context).getText(classValue)}${ matcher.name === "toContain" ? "" : ", { exact: true }" }` ), @@ -164,7 +165,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( node.arguments[0], - `${context.getSourceCode().getText(classValue)}` + `${getSourceCode(context).getText(classValue)}` ), ]; }, @@ -195,7 +196,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( classValue, - `${context.getSourceCode().getText(classValue)}${ + `${getSourceCode(context).getText(classValue)}${ matcher.name === "toContain" ? "" : ", { exact: true }" }` ), @@ -237,7 +238,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( classArg, - context.getSourceCode().getText(classValueArg) + getSourceCode(context).getText(classValueArg) ), fixer.replaceText(classValueArg, `{ exact: true }`), ]; @@ -278,7 +279,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( classArg, - context.getSourceCode().getText(classValueArg) + getSourceCode(context).getText(classValueArg) ), fixer.replaceText(classValueArg, `{ exact: true }`), ]; @@ -321,7 +322,7 @@ export const create = (context) => ({ fixer.replaceText(matcher, "toHaveClass"), fixer.replaceText( classArg, - context.getSourceCode().getText(classValueArg) + getSourceCode(context).getText(classValueArg) ), fixer.removeRange([classArg.range[1], classValue.range[1]]), ]; diff --git a/src/rules/prefer-to-have-style.js b/src/rules/prefer-to-have-style.js index 001bb8c..cbf9a1b 100644 --- a/src/rules/prefer-to-have-style.js +++ b/src/rules/prefer-to-have-style.js @@ -2,6 +2,7 @@ * @fileoverview prefer toHaveStyle over checking element style * @author Ben Monro */ +import { getSourceCode } from '../context'; //------------------------------------------------------------------------------ // Rule Definition @@ -23,16 +24,16 @@ export const create = (context) => { return camelCase(styleName.value); } - return `[${context.getSourceCode().getText(styleName)}]`; + return `[${getSourceCode(context).getText(styleName)}]`; } function getReplacementStyleParam(styleName, styleValue) { return styleName.type === "Literal" ? `{${camelCase(styleName.value)}: ${context .getSourceCode() .getText(styleValue)}}` - : `${context.getSourceCode().getText(styleName).slice(0, -1)}: ${ + : `${getSourceCode(context).getText(styleName).slice(0, -1)}: ${ styleValue.type === "TemplateLiteral" - ? context.getSourceCode().getText(styleValue).substring(1) + ? getSourceCode(context).getText(styleValue).substring(1) : `${styleValue.value}\`` }`; } @@ -104,7 +105,7 @@ export const create = (context) => { styleName, styleName.type === "Literal" ? `{${camelCase(styleName.value)}: expect.anything()}` - : context.getSourceCode().getText(styleName) + : getSourceCode(context).getText(styleName) ), ]; }, @@ -128,7 +129,7 @@ export const create = (context) => { styleName, styleName.type === "Literal" ? `{${camelCase(styleName.value)}: expect.anything()}` - : context.getSourceCode().getText(styleName) + : getSourceCode(context).getText(styleName) ), ]; }, diff --git a/src/rules/prefer-to-have-text-content.js b/src/rules/prefer-to-have-text-content.js index fe1826b..c8d9ba4 100644 --- a/src/rules/prefer-to-have-text-content.js +++ b/src/rules/prefer-to-have-text-content.js @@ -2,6 +2,7 @@ * @fileoverview prefer toHaveAttribute over checking getAttribute/hasAttribute * @author Ben Monro */ +import { getSourceCode } from '../context'; export const meta = { docs: { @@ -19,7 +20,7 @@ export const create = (context) => ({ ) { const expectedArg = node.parent.parent.parent.arguments[0]; - const expectedArgSource = context.getSourceCode().getText(expectedArg); + const expectedArgSource = getSourceCode(context).getText(expectedArg); context.report({ node: node.parent, message: `Use toHaveTextContent instead of asserting on DOM node attributes`, @@ -80,7 +81,7 @@ export const create = (context) => ({ node ) { const expectedArg = node.parent.parent.parent.parent.arguments[0]; - const expectedArgSource = context.getSourceCode().getText(expectedArg); + const expectedArgSource = getSourceCode(context).getText(expectedArg); context.report({ node: node.parent, message: `Use toHaveTextContent instead of asserting on DOM node attributes`, diff --git a/src/rules/prefer-to-have-value.js b/src/rules/prefer-to-have-value.js index a4320bb..e33064f 100644 --- a/src/rules/prefer-to-have-value.js +++ b/src/rules/prefer-to-have-value.js @@ -4,6 +4,7 @@ */ import { getQueryNodeFrom } from "../assignment-ast"; +import { getSourceCode } from '../context'; //------------------------------------------------------------------------------ // Rule Definition @@ -53,7 +54,7 @@ export const create = (context) => { node, fix(fixer) { return [ - fixer.remove(context.getSourceCode().getTokenBefore(valueProp)), + fixer.remove(getSourceCode(context).getTokenBefore(valueProp)), fixer.remove(valueProp), fixer.replaceText(matcher, "toHaveValue"), ]; @@ -79,7 +80,7 @@ export const create = (context) => { fix(fixer) { return [ fixer.removeRange([ - context.getSourceCode().getTokenBefore(valueProp).range[0], + getSourceCode(context).getTokenBefore(valueProp).range[0], valueProp.range[1], ]), fixer.replaceText(matcher, "toHaveValue"),