Files
threshold-ui/support/ember-a11y-ignore.mjs
T
Lisbet Alvarez 4d7ce40469 fix: 🐛 newline being removed for existing import & fix transform errors for a11y ignores codemod (#3183)
* fix: 🐛 newline being removed for existing import & fix errors

* fix: 🐛 avoid testName being undefined
2026-06-04 17:00:56 -07:00

297 lines
9.7 KiB
JavaScript

/**
* Copyright IBM Corp. 2021, 2026
* 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/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),
);
}
};
const existingEmberA11yImport = f.find(j.ImportDeclaration, {
source: {
value: 'ember-a11y-testing/test-support',
},
});
tests.forEach(testHandler);
testsEach.forEach(testHandler);
const code = f.toSource();
if (existingEmberA11yImport.length > 0) {
return code;
} else {
// 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',
);
}
}