/** * 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( /(?\n)(import .*ember-a11y-testing\/test-support.*)/g, '$2', ); } }