ember-template-lint
Version:
Linter for Ember or Handlebars templates.
609 lines (514 loc) • 18.3 kB
JavaScript
import assert from 'node:assert';
import { processRules } from '../get-config.js';
import Linter from '../linter.js';
export const ConfigDefaults = {
filePath: 'layout.hbs',
workingDir: '.',
};
function getVerifyOptions(template, meta) {
const options = {
source: template,
filePath: meta.filePath,
workingDir: meta.workingDir,
checkHbsTemplateLiterals: true,
};
if ('configResolver' in meta) {
options.configResolver = meta.configResolver;
}
return options;
}
function cloneSimpleObject(obj) {
return JSON.parse(JSON.stringify(obj || {}));
}
const ALLOWED_GENERATE_RULE_TEST_PROPERTIES = new Set([
'bad',
'config',
'error',
'focusMethod',
'good',
'groupMethodBefore',
'groupingMethod',
'meta',
'name',
'plugins',
'skipDisabledTests',
'testMethod',
]);
const ALLOWED_TEST_CASE_PROPERTIES_GOOD = new Set(['config', 'focus', 'meta', 'name', 'template']);
const ALLOWED_TEST_CASE_PROPERTIES_BAD = new Set([
'config',
'fixedTemplate', // Only in bad test cases.
'focus',
'meta',
'name',
'result',
'results',
'skipDisabledTests',
'template',
'verifyResults',
]);
const ALLOWED_TEST_CASE_PROPERTIES_ERROR = new Set([
'config',
'meta',
'name',
'result',
'results',
'template',
'verifyResults',
]);
const ALLOWED_RESULT_PROPERTIES_BAD = new Set([
'column',
'endColumn',
'endLine',
'filePath',
'isFixable', // Only in bad test cases.
'line',
'message',
'rule',
'severity',
'source',
]);
const ALLOWED_RESULT_PROPERTIES_ERROR = new Set([
'column',
'endColumn',
'endLine',
'fatal', // Only in error test cases.
'filePath',
'line',
'message',
'rule',
'severity',
'source',
]);
function validateKeys(instance, allowedKeys, message) {
for (const key of Object.keys(instance)) {
assert(
allowedKeys.has(key),
`${message}: ${key}. Expected one of: ${[...allowedKeys.keys()].join(', ')}.`
);
}
}
function validateInput(args) {
// Validate arguments.
assert(args.length === 1, '`generateRuleTests` should only be called with one argument.');
assert(
typeof args === 'object',
'`generateRuleTests` should only be called with an object argument.'
);
const [properties] = args;
// Validate top-level properties.
validateKeys(
properties,
ALLOWED_GENERATE_RULE_TEST_PROPERTIES,
`Unexpected property passed to \`generateRuleTests\``
);
// Validate good test cases.
for (const _item of properties.good || []) {
let item = typeof _item === 'string' ? { template: _item } : _item;
validateKeys(
item,
ALLOWED_TEST_CASE_PROPERTIES_GOOD,
`Unexpected property passed to good test case`
);
}
// Validate bad test cases.
for (const item of properties.bad || []) {
validateKeys(
item,
ALLOWED_TEST_CASE_PROPERTIES_BAD,
`Unexpected property passed to bad test case`
);
assert(
!(item.result && item.results),
'Bad test case should not have both `result` and `results`.'
);
let results = item.results || (item.result ? [item.result] : []);
for (const result of results) {
validateKeys(
result,
ALLOWED_RESULT_PROPERTIES_BAD,
`Unexpected property passed to bad test case results`
);
}
}
// Validate error test cases.
for (const item of properties.error || []) {
validateKeys(
item,
ALLOWED_TEST_CASE_PROPERTIES_ERROR,
`Unexpected property passed to error test case`
);
assert(
!(item.result && item.results),
'Error test case should not have both `result` and `results`.'
);
let results = item.results || (item.result ? [item.result] : []);
for (const result of results) {
validateKeys(
result,
ALLOWED_RESULT_PROPERTIES_ERROR,
`Unexpected property passed to error test case results`
);
}
}
}
/**
* allows tests to be defined without needing to setup test infrastructure every time
* @param {Array} [bad=[]] - an array of items that describe the use case that should fail
* [{
name: 'prevents debugger',
template: '{{debugger}}',
result: {
message,
filePath: 'layout.hbs',
source: '{{debugger}}',
line: 1,
column: 0,
},
}]
*
* @param {Array} [error=[]] - an array of items that describe the use case that should error
* [{
name: 'errors for "sometimes"',
config: 'sometimes',
template: 'test',
result: {
fatal: true,
filePath: 'layout.hbs',
message: 'You specified `"sometimes"`',
},
}]
*
* @param {Array} [good=[]] - an array of items that should not fail
* [
'<img alt="hullo">',
'<img alt={{foo}}>',
'<img alt="blah {{derp}}">',
'<img aria-hidden="true">',
'<img alt="">',
'<img alt>',
{
name: 'works with custom config',
config: 'customized',
template: '<img>',
}
]
*
* @param {String} name - a name to describe which lint rule is being tested
* @param {Function} groupingMethodEach - function to call before test setup is defined (mocha - beforeEach, qunit - beforeEach)
* @param {Function} groupingMethod - function to call when test setup is defined (mocha - describe, qunit - module)
* @param {Function} testMethod - function to call when test block is to be run (mocha - it, qunit - test)
* @param {Function} [focusMethod] - function to call when a specific test is to be run on its own (mocha - it.only, qunit - QUnit.only)
* @param {Boolean} skipDisabledTests - boolean to skip disabled tests or not
* @param {Object} config
* @param {Object} [meta]
* @param {Array} plugins - an array of plugins to load
*/
export default function generateRuleTests(...args) {
validateInput(args);
const [
{
bad = [],
good = [],
error = [],
name,
groupingMethod,
groupMethodBefore,
testMethod,
focusMethod,
skipDisabledTests,
config: defaultConfig,
meta: defaultMeta,
plugins,
},
] = args;
groupingMethod(name, function () {
let DISABLE_ALL = '{{! template-lint-disable }}';
let DISABLE_ONE = `{{! template-lint-disable ${name} }}`;
let DISABLE_ONE_LONG = `{{!-- template-lint-disable ${name} --}}`;
let linter;
let usedLinters = new WeakSet();
function metaToObject(item) {
const fallbackMeta = cloneSimpleObject(defaultMeta);
if (typeof item !== 'object' || item === null) {
return fallbackMeta;
}
if (typeof item.meta === 'object') {
return cloneSimpleObject(item.meta);
} else {
return fallbackMeta;
}
}
function parseMeta(item) {
let meta = metaToObject(item);
meta.filePath = 'filePath' in meta ? meta.filePath : ConfigDefaults.filePath;
meta.workingDir = 'workingDir' in meta ? meta.workingDir : ConfigDefaults.workingDir;
const editorConfig = meta.editorConfig || {};
if (!('configResolver' in meta)) {
meta.configResolver = {
editorConfig() {
return editorConfig;
},
};
}
return meta;
}
async function updateLinterConfig(localConfig) {
if (localConfig === undefined) {
return;
}
const config = await linter.getConfig();
config.rules[name] = localConfig;
config.rules = processRules(config);
}
/**
* Prepare an individual snippet to be linted in a test:
* - update linter config with the config passed along the snippet (optional)
* - update linter workingDir
* - return options, to be provided to linter
*
* @param {string|Object} item - a snippet to lint
* @param {string} [item.template] - if item was an object, the snippet
* @param {boolean|Object} localConfig - the config passed along the snippet
* @returns {Object} options - options to pass to the linter instance
*/
async function prepare(item, localConfig) {
let template = typeof item === 'string' ? item : item.template;
let meta = parseMeta(item);
await updateLinterConfig(localConfig);
linter.workingDir = meta.workingDir;
return getVerifyOptions(template, meta);
}
groupMethodBefore(function () {
let initialConfig = {
plugins,
rules: {},
};
initialConfig.rules[name] = defaultConfig;
linter = new Linter({
config: initialConfig,
});
});
function parseResult(result, actual) {
let defaults = {
severity: 2,
};
// default the endLine/endColumn information so that adding them is not
// a breaking change for existing users of the rule test harness (e.g.
// plugin authors)
if ('endLine' in actual) {
defaults.endLine = actual.endLine;
}
if ('endColumn' in actual) {
defaults.endColumn = actual.endColumn;
}
if (!skipDisabledTests) {
defaults.rule = name;
}
if (typeof result.filePath !== 'string') {
defaults.filePath = ConfigDefaults.filePath;
delete result.filePath;
}
return Object.assign({}, defaults, result);
}
function checkLinterReuse() {
if (usedLinters.has(linter)) {
throw new Error(
'ember-template-lint: Test harness found invalid setup (`groupingMethodBefore` not called once per test). Maybe you meant to pass `groupingMethodBefore: beforeEach`?'
);
}
usedLinters.add(linter);
}
/**
* Serialize test cases in such a way that we'll be able to conveniently check for duplicates.
* @param {Object} item - test case object
* @returns {string}
*/
function serializeTestCase(item) {
const itemCleanedAndSorted = {};
const keysSorted = Object.keys(item).sort(); // Sort to ignore differences in the ordering of test case properties.
for (const key of keysSorted) {
if (typeof item[key] === 'function') {
// Can't serialize functions (e.g. `verifyResults`) so just store them as a boolean since we only care about their presence and not their contents.
itemCleanedAndSorted[key] = true;
} else {
itemCleanedAndSorted[key] = item[key];
}
}
return JSON.stringify(itemCleanedAndSorted);
}
/**
* If possible, check if this test case is a duplicate of one we have seen before.
* @param {Object} item - test case object
* @param {Set<String>} seenTestCases - set of serialized test cases we have seen so far (managed by this function)
* @param {String} testCaseType - what type of test case we're running the check for
*/
function checkDuplicateTestCase(item, seenTestCases, testCaseType) {
if (['object', 'function'].includes(typeof item.config)) {
// Functions can't be serialized.
// Objects like RegExp can't be serialized.
// So just ignore configs that can potentially not be serialized.
return;
}
const serializedTestCase = serializeTestCase(item);
assert(
!seenTestCases.has(serializedTestCase),
`detected duplicate \`${testCaseType}\` test case`
);
seenTestCases.add(serializedTestCase);
}
const seenBadTestCases = new Set();
for (const item of bad) {
let template = item.template;
let shouldRunDisabledTests =
'skipDisabledTests' in item ? !item.skipDisabledTests : !skipDisabledTests;
let shouldFocus = item.focus === true && typeof focusMethod === 'function';
let testOrOnly = shouldFocus ? focusMethod : testMethod;
let testName = item.name || template;
testOrOnly(`${testName}: logs errors`, async function () {
checkLinterReuse();
checkDuplicateTestCase(item, seenBadTestCases, 'bad');
let options = await prepare(item, item.config);
let actual = await linter.verify(options);
assert(actual.length > 0, '`bad` test cases should always emit at least one failure');
for (const violation of actual) {
if (violation.isFixable) {
assert(
typeof item.fixedTemplate === 'string',
'fixable test cases must assert the `fixedTemplate`'
);
}
}
if (actual.every((violation) => !violation.isFixable)) {
assert(!item.fixedTemplate, 'non-fixable test cases should not provide `fixedTemplate`');
}
assert(
item.template !== item.fixedTemplate,
'Test case `template` should not equal the `fixedTemplate`'
);
if (item.fatal) {
assert.strictEqual(actual.length, 1); // can't have more than one fatal error
delete actual[0].source; // remove the source (stack trace is not easy to assert)
assert.deepStrictEqual(actual[0], item.fatal);
} else if (item.verifyResults) {
item.verifyResults(actual);
} else {
if (!('result' in item) && !('results' in item)) {
const hasRequiredProps = 'template' in item && 'fixedTemplate' in item;
assert(
hasRequiredProps,
`\`bad\` test cases without "result" or "results" property supported only for template fixes with shape like { template: string, fixedTemplate: string }, given: ${JSON.stringify(
item
)}`
);
}
// allow to have simple fixers tests, without result / results properties
let results = item.results || (item.result ? [item.result] : []);
if (results.length) {
// assert only if given bad results exists
let expectedResults = results.map((result, index) =>
parseResult(result, actual[index])
);
assert.deepStrictEqual(actual, expectedResults);
}
}
});
if (shouldRunDisabledTests) {
testOrOnly(`${testName}: passes when rule is disabled`, async function () {
checkLinterReuse();
let config = false;
let options = await prepare(item, config);
let actual = await linter.verify(options);
assert.deepStrictEqual(actual, []);
});
testOrOnly(
`${testName}: passes when disabled via inline comment - single rule`,
async function () {
checkLinterReuse();
let options = await prepare(`${DISABLE_ONE}\n${template}`);
let actual = await linter.verify(options);
assert.deepStrictEqual(actual, []);
}
);
testOrOnly(
`${testName}: passes when disabled via long-form inline comment - single rule`,
async function () {
checkLinterReuse();
let options = await prepare(`${DISABLE_ONE_LONG}\n${template}`);
let actual = await linter.verify(options);
assert.deepStrictEqual(actual, []);
}
);
testOrOnly(
`${testName}: passes when disabled via inline comment - all rules`,
async function () {
checkLinterReuse();
let options = await prepare(`${DISABLE_ALL}\n${template}`);
let actual = await linter.verify(options);
assert.deepStrictEqual(actual, []);
}
);
}
if (item.fixedTemplate) {
testOrOnly(`\`${item.template}\` -> ${item.fixedTemplate}\``, async function () {
checkLinterReuse();
let options = await prepare(item, item.config);
let result = await linter.verifyAndFix(options);
assert.deepStrictEqual(result.output, item.fixedTemplate);
});
testOrOnly(`${item.fixedTemplate}\` is idempotent`, async function () {
checkLinterReuse();
let options = await prepare(item, item.config);
options.source = item.fixedTemplate;
let result = await linter.verifyAndFix(options);
assert.deepStrictEqual(result.output, item.fixedTemplate);
});
}
}
const seenGoodTestCases = new Set();
for (const _item of good) {
let item = typeof _item === 'string' ? { template: _item } : _item;
let shouldFocus = item.focus === true && typeof focusMethod === 'function';
let testOrOnly = shouldFocus ? focusMethod : testMethod;
let testName = item.name || item.template;
testOrOnly(`${testName}: passes`, async function () {
checkLinterReuse();
checkDuplicateTestCase(item, seenGoodTestCases, 'good');
let options = await prepare(item, item.config);
let actual = await linter.verify(options);
assert.deepStrictEqual(actual, []);
});
}
const seenErrorTestCases = new Set();
for (const item of error) {
let { name, config, template } = item;
let shouldFocus = item.focus === true && typeof focusMethod === 'function';
let testOrOnly = shouldFocus ? focusMethod : testMethod;
let friendlyConfig = JSON.stringify(config);
let testName = name || template;
testOrOnly(`${testName}: errors with config \`${friendlyConfig}\``, async function () {
checkLinterReuse();
checkDuplicateTestCase(item, seenErrorTestCases, 'error');
let options = await prepare(item, item.config);
let actual = await linter.verify(options);
let expectedResults = item.results || [item.result];
expectedResults = expectedResults.map((result, index) =>
parseResult(result, actual[index])
);
for (const [i, element] of actual.entries()) {
if (element.fatal) {
delete expectedResults[i].rule;
delete element.source;
assert.ok(
element.message.includes(expectedResults[i].message),
`Expected that message:\n<< \n${element.message}\n>>\nmust contain: "${expectedResults[i].message}"`
);
delete element.message;
delete expectedResults[i].message;
}
}
assert.deepStrictEqual(actual, expectedResults);
});
}
});
}