(HACK WEEK 2025) a11y-audit-ignore codemod (#2946)
* chore: 🤖 add ember-a11y-ignore tool * chore: 🤖 fix handling existing setRunOptions imports * chore: 🤖 move codemod to support folder * chore: 🤖 fix edge case for handling property key names * chore: 🤖 handle case where multiple of same violation in test This fixes a bug where multiple cases of the same violation would end up with multiple entries * chore: 🤖 ignore axe rules that are currently failing in admin * chore: 🤖 ignore axe rules that are currently failing in desktop * docs: ✏️ add a comment explaining the codemod * chore: 🤖 include test.each cases for a11y ignores * fix: 🐛 handle test.each test cases for a11y ignore codemod
This commit is contained in:
1 parent
1f4076d8d4
commit
dc9376a460
114 files changed
+8044
No files matched your search
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
// This is a codemod that takes a ember a11y report and ignores the failing a11y rules in the tests
|
||||
// they were reported from. It can be ran the command line from the root of this monorepo like this:
|
||||
//
|
||||
// EMBER_A11Y_REPORT_FILE=~/Downloads/ember-a11y-reports-latest/desktop.json pnpm dlx jscodeshift -t ./support/ember-a11y-ignore.mjs ui/desktop/tests
|
||||
//
|
||||
// This file passes exports a transform function which is used by the jscodeshift package to execute
|
||||
// the codemode. Since codeshift doesn't provide a way of specifying arguments, the report file is
|
||||
// specified by the environment variable `EMBER_A11Y_REPORT_FILE` The report file referenced by
|
||||
// `EMBER_A11Y_REPORT_FILE` is an ember a11y testing report. These reports are currently being
|
||||
// generated by the CI with one report for admin and one for desktop.
|
||||
//
|
||||
// The `-t` flag for `jscodeshift` represents the transform file that it should read
|
||||
//
|
||||
// The only argument passed (shown as "ui/desktop/tests" in the above example) specifies the files
|
||||
// to run the transform against.
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
let reportPath = process.env.EMBER_A11Y_REPORT_FILE;
|
||||
if (!reportPath) {
|
||||
throw new Error('EMBER_A11Y_REPORT_FILE environment variable is required');
|
||||
}
|
||||
const reportContents = JSON.parse(readFileSync(reportPath).toString());
|
||||
|
||||
function getKeyNameFromProperty(property) {
|
||||
// property key can be of type `Identifier`: ({ key: "hello" }) or `StringLiteral` where the key is quoted: ({ "key": "hello" })
|
||||
// and the path for determining the key's name is different depending on if its an `Identifier` or `StringLiteral`
|
||||
const nameForIdentifier = property.key.name;
|
||||
const nameForStringLiteral = property.key.value;
|
||||
|
||||
return nameForIdentifier ?? nameForStringLiteral;
|
||||
}
|
||||
|
||||
export default function transformer(file, api) {
|
||||
const j = api.jscodeshift;
|
||||
const f = j(file.source);
|
||||
const { statement } = j.template;
|
||||
|
||||
const tests = f.find(j.CallExpression, {
|
||||
callee: {
|
||||
name: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
const testsEach = f.find(j.CallExpression, {
|
||||
callee: {
|
||||
object: { name: 'test' },
|
||||
property: { name: 'each' }
|
||||
}
|
||||
});
|
||||
|
||||
function closestModules(test) {
|
||||
const modules = [];
|
||||
let parent = test.closest(j.CallExpression, {
|
||||
callee: {
|
||||
name: 'module',
|
||||
},
|
||||
});
|
||||
|
||||
while (parent.length > 0) {
|
||||
modules.unshift(parent.get().value.arguments[0].value);
|
||||
|
||||
parent = parent.closest(j.CallExpression, {
|
||||
callee: {
|
||||
name: 'module',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
const testHandler = (testAst, i) => {
|
||||
const isTestEach = Boolean(testAst.value.callee.object?.name === 'test' && testAst.value.callee.property?.name === 'each');
|
||||
const test = isTestEach ? testsEach.at(i) : tests.at(i);
|
||||
let testName = test.get().value.arguments[0].value;
|
||||
|
||||
if (isTestEach) {
|
||||
const testArguments = test.get().value.arguments;
|
||||
let eachTestNames;
|
||||
// if the second argument is an object with properties
|
||||
if (testArguments[1]?.properties) {
|
||||
eachTestNames = testArguments[1]?.properties?.map(property => getKeyNameFromProperty(property));
|
||||
} else {
|
||||
// if an array is provided and it's argument is a literal (number, boolean, string) then this is used for the test name
|
||||
// otherwise if it's an object the index in the array is what is used for the test name
|
||||
eachTestNames = testArguments[1].elements.map((element, i) => element.type.endsWith('Literal') ? element.value : i);
|
||||
}
|
||||
|
||||
testName = eachTestNames.map(eachTestName => `${testName} [${eachTestName}]`);
|
||||
} else {
|
||||
testName = [testName];
|
||||
}
|
||||
|
||||
// the ember-a11y-testing testem middleware reporter reports the modules as a single string
|
||||
// with nested modules separated by " > "
|
||||
const moduleName = closestModules(test).join(' > ');
|
||||
const violationsIdsIncluded = new Set();
|
||||
const violations = reportContents
|
||||
.filter((testEntry) => {
|
||||
// search the report for a matching module name and test name
|
||||
return (
|
||||
testEntry.moduleName === moduleName && testName.includes(testEntry.testName)
|
||||
);
|
||||
})
|
||||
.map((testEntry) => testEntry.violations)
|
||||
.flat()
|
||||
.filter((violation) => {
|
||||
if (!violationsIdsIncluded.has(violation.id)) {
|
||||
violationsIdsIncluded.add(violation.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const violationNames = new Set(
|
||||
violations
|
||||
.map((v) => {
|
||||
return v.id;
|
||||
})
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// early return, only when there are violations do we do further processing on the file
|
||||
if (violations.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEmberA11yImport = f.find(j.ImportDeclaration, {
|
||||
source: {
|
||||
value: 'ember-a11y-testing/test-support',
|
||||
},
|
||||
});
|
||||
|
||||
const runOptionsEmberA11yImport = statement`import { setRunOptions } from 'ember-a11y-testing/test-support'`;
|
||||
if (existingEmberA11yImport.length > 0) {
|
||||
const importNode = existingEmberA11yImport.get();
|
||||
|
||||
const hasExistingSetRunOptionImport = importNode.value.specifiers.some(
|
||||
(specifier) => specifier.imported.name === 'setRunOptions',
|
||||
);
|
||||
|
||||
importNode.value.specifiers = [
|
||||
...importNode.value.specifiers,
|
||||
...(hasExistingSetRunOptionImport
|
||||
? []
|
||||
: runOptionsEmberA11yImport.specifiers),
|
||||
];
|
||||
} else {
|
||||
f.find(j.ImportDeclaration).at(-1).insertAfter(runOptionsEmberA11yImport);
|
||||
}
|
||||
|
||||
let setRunOptions = test.find(j.CallExpression, {
|
||||
callee: {
|
||||
name: 'setRunOptions',
|
||||
},
|
||||
});
|
||||
|
||||
let setRunOptionsArgs = [];
|
||||
let setRunOptionsRules = [];
|
||||
const alreadyIgnoredRules = new Set();
|
||||
if (setRunOptions.length > 0) {
|
||||
// filter out the rules property from args so that it can be reconstructed
|
||||
// with rules merged
|
||||
setRunOptionsArgs = setRunOptions
|
||||
.get()
|
||||
.value.arguments[0].properties.filter((property) => {
|
||||
return getKeyNameFromProperty(property) !== 'rules';
|
||||
});
|
||||
|
||||
// get the rules from within setRunOptions
|
||||
setRunOptionsRules = setRunOptions
|
||||
.get()
|
||||
.value.arguments[0].properties.find((property) => {
|
||||
return getKeyNameFromProperty(property) === 'rules';
|
||||
});
|
||||
|
||||
if (setRunOptionsRules) {
|
||||
setRunOptionsRules.value.properties.forEach((ruleProperty) => {
|
||||
const ruleAlreadyIgnored = ruleProperty.value.properties.some(
|
||||
(property) => {
|
||||
return (
|
||||
getKeyNameFromProperty(property) === 'enabled' &&
|
||||
property.value.value === false
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// track rules that are already ignored so that they aren't updated (preserves existing comment and timestamp)
|
||||
if (ruleAlreadyIgnored) {
|
||||
alreadyIgnoredRules.add(getKeyNameFromProperty(ruleProperty));
|
||||
}
|
||||
});
|
||||
|
||||
// if rules currently existing within `setRunOptions` then filter out any
|
||||
// rules that currently have violations those will be handled separately
|
||||
setRunOptionsRules = setRunOptionsRules.value.properties.filter(
|
||||
(ruleProperty) => {
|
||||
const ruleName = getKeyNameFromProperty(ruleProperty);
|
||||
return (
|
||||
!violationNames.has(ruleName) || alreadyIgnoredRules.has(ruleName)
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setRunOptionsRules = [];
|
||||
}
|
||||
}
|
||||
|
||||
// this represents the actual injection of properties to ignore based on
|
||||
// violations from the report, this amounts to an array of property definitions:
|
||||
// ruleName: { enabled: false }
|
||||
const injectedViolationsRunOptionsRules = violations
|
||||
.filter((violation) => !alreadyIgnoredRules.has(violation.id))
|
||||
.map((violation) => {
|
||||
const enabledFalseProperty = j.objectProperty(
|
||||
j.identifier('enabled'),
|
||||
j.booleanLiteral(false),
|
||||
);
|
||||
|
||||
enabledFalseProperty.comments = [
|
||||
j.commentLine(
|
||||
` [ember-a11y-ignore]: axe rule "${violation.id}" automatically ignored on ${new Date().toISOString().split('T')[0]}`,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
return j.objectProperty(
|
||||
j.identifier(`"${violation.id}"`),
|
||||
j.objectExpression([enabledFalseProperty]),
|
||||
);
|
||||
});
|
||||
|
||||
const injectedSetRunOptions = j.callExpression(
|
||||
j.identifier('setRunOptions'),
|
||||
[
|
||||
j.objectExpression([
|
||||
...setRunOptionsArgs,
|
||||
j.objectProperty(
|
||||
j.identifier('rules'),
|
||||
// splat the existing rules, along with the violation rules
|
||||
j.objectExpression([
|
||||
...setRunOptionsRules,
|
||||
...injectedViolationsRunOptionsRules,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
if (setRunOptions.length > 0) {
|
||||
setRunOptions.replaceWith(function () {
|
||||
return injectedSetRunOptions;
|
||||
});
|
||||
} else {
|
||||
const testBodyArgumentIndex = isTestEach ? 2 : 1;
|
||||
test
|
||||
.get()
|
||||
.value.arguments[testBodyArgumentIndex].body.body.unshift(
|
||||
j.expressionStatement(injectedSetRunOptions),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
tests.forEach(testHandler);
|
||||
testsEach.forEach(testHandler);
|
||||
|
||||
const code = f.toSource();
|
||||
|
||||
// the codemod will insert a newline between existing import statements,
|
||||
// this regex removes the extra new line
|
||||
//
|
||||
return code.replace(
|
||||
/(?<newline>\n)(import .*ember-a11y-testing\/test-support.*)/g,
|
||||
'$2',
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user