286 lines
9.5 KiB
JavaScript
286 lines
9.5 KiB
JavaScript
/**
|
|
* 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/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',
|
|
);
|
|
}
|