grafana/packages/grafana-eslint-rules/rules/no-aria-label-e2e-selectors.cjs
Jack Westbrook 8c41137bcf
Frontend: Update to Eslint 9 (#94823)
* chore(eslint): bump all eslint related packages to latest

* chore(eslint): update linting scripts work with v9

* chore(eslint): introduce flat config

* chore(eslint): delete legacy configs

* feat(grafana-eslint-rules): update rules to use eslint 9 APIs

* chore(eslint): migrate all nested eslintrc files over to root config

* chore(packages): bump eslint dependencies

* feat(betterer): make it work with eslint 9

* style(grafana-data): remove non-existant ban-types rule from disable declarations

* chore(wip): [wip] link eslint-config-grafana

* chore(packages): add @eslint/compat

* chore(eslint): add compat to testing library and fix alerting rules

* chore(eslint): bump grafana eslint-config to v8

* chore(explore): delete legacy eslint config

* chore: clean codeowners file, remove grafana/eslint-config from e2e plugins

* test(eslint-rules): fix no-border-radius-literal and no-aria-label-e2e-selectors rule tests

* Add .js to prettier checks so new eslint.config.js file isn't missed

* chore(eslint): move emotion/syntax-preference to grafana/defaults

* test(eslint): use core-js structured-clone

* revert(services): undo merge backend-format githook changes

* test(eslint-rules): remove structured-clone polyfill from tests

* chore(eslint): add back public/lib/monaco to ignore list, sort alphabetically

* chore(e2e-plugins): remove eslint config 7 from plugins package.json

---------

Co-authored-by: Tom Ratcliffe <tom.ratcliffe@grafana.com>
2024-11-07 16:31:06 +01:00

147 lines
5.0 KiB
JavaScript

// @ts-check
const { ESLintUtils } = require('@typescript-eslint/utils');
/**
* @typedef {import("@typescript-eslint/utils").TSESTree.Expression} Expression
* @typedef {import('@typescript-eslint/utils').TSESTree.JSXEmptyExpression } JSXEmptyExpression
* @typedef {import('@typescript-eslint/utils').TSESTree.PrivateIdentifier } PrivateIdentifier
* @typedef {import('@typescript-eslint/utils').TSESTree.MemberExpressionComputedName } MemberExpressionComputedName
* @typedef {import('@typescript-eslint/utils').TSESTree.MemberExpressionNonComputedName } MemberExpressionNonComputedName
* @typedef {import('@typescript-eslint/utils').TSESTree.Identifier} Identifier
*
* @typedef {import('@typescript-eslint/utils').TSESLint.Scope.Scope} Scope
* @typedef {import('@typescript-eslint/utils').TSESLint.Scope.Variable} Variable
*/
const GRAFANA_E2E_PACKAGE_NAME = '@grafana/e2e-selectors';
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`);
// A relative simple lint rule that will look of the `selectors` export from @grafana/e2e-selectors
// is used in an aria-label
// There's probably a few ways around this, but the point isn't to be exhaustive but to find the
// majority of instances to count them
const rule = createRule({
create(context) {
return {
JSXAttribute(node) {
// Only inspect aria-label props
if (node.name.name !== 'aria-label' || !node.value) {
return;
}
// We're only interested in props with expression values (aria-label={...})
// This allows all simple strings
if (node.value.type !== 'JSXExpressionContainer') {
return;
}
const identifiers = findIdentifiers(node.value.expression);
for (const identifier of identifiers) {
const scope = context.sourceCode.getScope(node);
// Find the actual "scoped variable" to inspect it's import
// This is relatively fragile, and will fail to find the import if the variable is reassigned
const variable = findVariableInScope(scope, identifier.name);
const importDef = variable?.defs.find(
(v) =>
v.type === 'ImportBinding' &&
v.parent.type === 'ImportDeclaration' &&
v.parent.source.value === GRAFANA_E2E_PACKAGE_NAME
);
if (importDef) {
context.report({
messageId: 'useDataTestId',
node,
});
}
}
},
};
},
name: 'no-aria-label-selectors',
meta: {
docs: {
description: 'aria-label should not contain e2e selectors',
},
messages: {
useDataTestId: 'Use data-testid for E2E selectors instead of aria-label',
},
type: 'suggestion',
schema: [],
},
defaultOptions: [],
});
module.exports = rule;
/**
* Finds identifiers (variables) mentioned in various types of expressions:
* - Identifier: `selectors` -> `selectors`
* - MemberExpression: `selectors.foo.bar` -> `selectors`
* - CallExpression: `selectors.foo.bar()` -> `selectors`
* - BinaryExpression: `"hello" + selectors.foo.bar` -> `selectors`
* - TemplateLiteral: ``hello ${selectors.foo.bar}`` -> `selectors`
*
* Unsupported expressions will just silently not return anything (rather than crashing eslint)
*
* @param { Expression | JSXEmptyExpression | PrivateIdentifier } node
* @returns { Array.<Identifier> }
*/
function findIdentifiers(node /* JSXEmptyExpression | Expression */) {
if (node.type === 'Identifier') {
return [node];
} else if (node.type === 'MemberExpression') {
return [getIdentifierFromMemberExpression(node)];
} else if (node.type === 'CallExpression') {
return findIdentifiers(node.callee);
} else if (node.type === 'BinaryExpression') {
return [...findIdentifiers(node.left), ...findIdentifiers(node.right)].filter(Boolean);
} else if (node.type === 'TemplateLiteral') {
return node.expressions.flatMap((v) => findIdentifiers(v)).filter(Boolean);
}
return [];
}
/**
* Given a MemberExpression (`selectors.foo.bar.baz`) recursively follow children to
* find the 'root' Identifier (`selectors`)
*
* @param { MemberExpressionNonComputedName | MemberExpressionComputedName } node
* @returns { Identifier }
*/
function getIdentifierFromMemberExpression(node) {
if (node.object.type === 'Identifier') {
return node.object;
} else if (node.object.type === 'MemberExpression') {
return getIdentifierFromMemberExpression(node.object);
} else {
throw new Error('unknown object type');
}
}
/**
* @param { Scope } initialScope
* @param { string } variableName
* @returns { Variable | undefined }
*/
function findVariableInScope(initialScope, variableName) {
/** @type {Scope | null} */
let scope = initialScope;
while (scope !== null) {
const variable = scope.set.get(variableName);
if (variable) {
return variable;
}
scope = scope.upper;
}
return undefined;
}