stylelint-vitest-rule-tester
Version:
Styelint rule tester with Vitest.
364 lines (352 loc) • 11.3 kB
JavaScript
import { toArray, isString, interopDefault, isUndefined, isFunction, isNumber, isEmptyArray, isNull } from '@ntnyq/utils';
export { unindent as $, unindent } from '@ntnyq/utils';
import stylelint from 'stylelint';
import { expect, describe, it } from 'vitest';
import { createRequire } from 'node:module';
import deepmerge from 'deepmerge';
const DEFAULT_FILE_NAMES = Object.freeze({
css: "file.css",
less: "file.less",
postcss: "file.postcss",
sass: "file.sass",
scss: "file.scss",
styl: "file.styl",
stylus: "file.stylus"
});
const DEFAULT_FILE_NAME = DEFAULT_FILE_NAMES.css;
const require = createRequire(import.meta.url);
async function resolveRulesFromTesterOptions(options) {
const rules = [];
function loadPluginRule(plugin) {
if ("default" in plugin && plugin.default) {
rules.push(plugin.default.rule);
} else if ("rule" in plugin && plugin.rule) {
rules.push(plugin.rule);
}
}
if (options.stylelintConfig?.plugins) {
const plugins = toArray(options.stylelintConfig.plugins);
for await (const plugin of plugins) {
if (isString(plugin)) {
const pluginRules = toArray(
await interopDefault(require(plugin))
);
pluginRules.forEach((rule) => {
loadPluginRule(rule);
});
} else {
loadPluginRule(plugin);
}
}
}
return rules;
}
async function resolveRuleMeta(options) {
let ruleMeta;
if (options.name.includes("/")) {
const rules = await resolveRulesFromTesterOptions(options);
const matched = rules.find((rule) => rule.ruleName === options.name);
if (matched) {
ruleMeta = matched.meta;
}
} else {
ruleMeta = (await stylelint.rules[options.name]).meta;
}
return ruleMeta;
}
function isInvalidTestCase(testCase) {
return !!testCase.warnings || !!testCase.deprecations || !!testCase.parseErrors || !!testCase.invalidOptionWarnings || !!testCase.output;
}
function normalizeTestCase(testCase, defaultFilenames, type) {
const obj = isString(testCase) ? { code: testCase } : { ...testCase };
const normalized = obj;
normalized.type ||= type || (isInvalidTestCase(normalized) ? "invalid" : "valid");
normalized.filename ||= defaultFilenames.css || DEFAULT_FILE_NAME;
return normalized;
}
function resolveRuleOptions(testCase, options, ruleMeta) {
const url = ruleMeta?.url;
const mergedRuleOptions = testCase.ruleOptions || options.ruleOptions;
const resolvedOptions = isUndefined(mergedRuleOptions) ? true : mergedRuleOptions;
if (Array.isArray(resolvedOptions)) {
if (resolvedOptions.length === 1) {
return url ? [resolvedOptions[0], { url }] : resolvedOptions;
} else {
return url && isUndefined(resolvedOptions[1].url) ? [resolvedOptions[0], { ...resolvedOptions[1], url }] : resolvedOptions;
}
} else {
return url ? [resolvedOptions, { url }] : [resolvedOptions];
}
}
function normalizeCaseMessage(message) {
if (isString(message)) {
return {
text: message
};
}
const clone = { ...message };
return clone;
}
async function verifyLintResultMessages({
type,
testCase,
messages
}) {
if (!testCase[type]) {
return;
}
if (isFunction(testCase[type])) {
await testCase[type]?.(messages);
} else if (isNumber(testCase[type])) {
expect.soft(messages.length, `number of ${type}`).toBe(testCase[type]);
} else {
const testCaseMessages = toArray(testCase[type]).map(
(message) => normalizeCaseMessage(message)
);
expect(testCaseMessages.length, `number of ${type}`).toBe(
testCase[type].length
);
testCaseMessages.forEach((expected, idx) => {
expect.soft(messages[idx], `object of ${type}-${idx}`).toMatchObject(expected);
});
}
}
async function validateLintResult(testCase, lintResult) {
await verifyLintResultMessages({
type: "warnings",
testCase,
messages: lintResult.warnings
});
await verifyLintResultMessages({
type: "parseErrors",
testCase,
messages: lintResult.parseErrors
});
await verifyLintResultMessages({
type: "deprecations",
testCase,
messages: lintResult.deprecations
});
await verifyLintResultMessages({
type: "invalidOptionWarnings",
testCase,
messages: lintResult.invalidOptionWarnings
});
}
function resolveLinterOptions(options, testCase, ruleOptions) {
const linterOptions = {
...options.linterOptions,
config: {
...deepmerge(
options.stylelintConfig || {},
testCase.stylelintConfig || {}
),
rules: {
[options.name]: ruleOptions
}
},
code: testCase.code,
codeFilename: testCase.filename,
fix: false,
quietDeprecationWarnings: true
};
return linterOptions;
}
function normalizeLinterResult(result) {
const { cwd: _, results = [], report: _result, ...rest } = result;
return {
...rest,
results: results.map(({ _postcssResult, source, ...result2 }) => ({
...result2
}))
};
}
function createRuleTester(options) {
const defaultFilenames = {
...DEFAULT_FILE_NAMES,
...options.defaultFileNames
};
async function each(c) {
const testcase = normalizeTestCase(c, defaultFilenames);
const {
recursive = 10,
verifyAfterFix = true
// verifyFixChanges = true,
} = {
...options,
...testcase
};
const ruleMeta = await resolveRuleMeta(options);
const ruleOptions = resolveRuleOptions(testcase, options, ruleMeta);
const linterOptions = resolveLinterOptions(options, testcase, ruleOptions);
await testcase.before?.call(testcase, linterOptions);
const linterResult = await stylelint.lint(linterOptions);
const [lintResult] = linterResult.results;
await validateLintResult(testcase, lintResult);
async function fix(code) {
const linterResult2 = await stylelint.lint({
...linterOptions,
code,
fix: true
});
const fixed = linterResult2.code !== code;
return {
...normalizeLinterResult(linterResult2),
fixed
};
}
const fixedLinterResult = await fix(testcase.code);
const result = {
...fixedLinterResult,
steps: [fixedLinterResult]
};
if (result.fixed && recursive !== false) {
let r = recursive;
for (r = recursive; r >= 0; r--) {
const step = await fix(result.code);
result.steps?.push(step);
result.code = step.code;
if (!step.fixed) {
break;
}
}
if (r === 0) {
throw new Error(
`Fix recursion limit exceeded, possibly the fix is not stable. Last output:
-------
${result.code}
-------`
);
}
}
if (!isUndefined(testcase.output)) {
if (isNull(testcase.output)) {
expect(result.code, "output").toBe(testcase.code);
} else if (isFunction(testcase.output)) {
await testcase.output(result.code || "", testcase.code);
} else {
expect(result.code, "output").toBe(testcase.output);
}
}
if (testcase.type === "invalid" && isUndefined(testcase.output) && isUndefined(testcase.warnings) && isUndefined(testcase.parseErrors) && isUndefined(testcase.deprecations) && isUndefined(testcase.invalidOptionWarnings)) {
throw new Error(
`Invalid test case must have either 'output', 'warnings', 'parseErrors', 'deprecations', or 'invalidOptionWarnings' property.`
);
}
if (result.fixed && verifyAfterFix) {
const { results = [] } = await stylelint.lint({
...linterOptions,
code: result.code,
fix: false
});
const [lintResult2] = results;
expect.soft(lintResult2, "no lint result").toBeDefined();
expect.soft(lintResult2.warnings, "no warnings after fix").toEqual([]);
}
await testcase.onResult?.(result);
await testcase.after?.call(testcase, result);
return {
testcase,
result
};
}
async function valid(arg) {
const { testcase, result } = await each(arg);
const [lintResult] = result.results;
expect.soft(lintResult, "no lint result").toBeDefined();
expect.soft(result.fixed, "no need to fix for valid cases").toBeFalsy();
expect.soft(lintResult.warnings, "no warnings on valid cases").toEqual([]);
expect.soft(lintResult.deprecations, "no deprecations on valid cases").toEqual([]);
expect.soft(lintResult.parseErrors, "no parseErrors on valid cases").toEqual([]);
expect.soft(
lintResult.invalidOptionWarnings,
"no invalidOptionWarnings on valid cases"
).toEqual([]);
return {
testcase,
result
};
}
async function invalid(arg) {
const { testcase, result } = await each(arg);
const [lintResult] = result.results;
expect.soft(lintResult, "no lint result").toBeDefined();
if (result.fixed) {
expect.soft(lintResult.warnings, "expect no warnings on fixed invalid case").toEqual([]);
} else {
const noMessages = isEmptyArray(lintResult.warnings) && isEmptyArray(lintResult.deprecations) && isEmptyArray(lintResult.parseErrors) && isEmptyArray(lintResult.invalidOptionWarnings);
expect.soft(
noMessages,
"expect either have warnings, deprecations, parseErrors or invalidOptionWarnings"
).toBeFalsy();
}
return {
testcase,
result
};
}
async function run(cases) {
describe(options.name, () => {
if (cases.valid?.length) {
describe("valid", () => {
cases.valid.forEach((c, index) => {
const testCase = normalizeTestCase(c, defaultFilenames, "valid");
let run2 = it;
if (testCase.only) {
run2 = it.only;
}
if (testCase.skip) {
run2 = it.skip;
}
run2(
`Valid #${index}: ${testCase.description || testCase.code}`,
async () => {
const { testcase, result } = await valid(testCase);
await cases?.onResult?.(testcase, result);
}
);
});
});
}
if (cases.invalid?.length) {
describe("invalid", () => {
cases.invalid.forEach((c, index) => {
const testCase = normalizeTestCase(c, defaultFilenames, "invalid");
let run2 = it;
if (testCase.only) {
run2 = it.only;
}
if (testCase.skip) {
run2 = it.skip;
}
run2(
`Invalid #${index}: ${testCase.description || testCase.code}`,
async () => {
const { testcase, result } = await invalid(testCase);
await cases?.onResult?.(testcase, result);
}
);
});
});
}
});
}
return {
each,
valid,
invalid,
run
};
}
function run(options) {
const tester = createRuleTester(options);
return tester.run(options);
}
function runClassic(ruleName, cases, options) {
const tester = createRuleTester({
name: ruleName,
...options
});
return tester.run(cases);
}
export { createRuleTester, normalizeCaseMessage, normalizeLinterResult, normalizeTestCase, resolveLinterOptions, resolveRuleMeta, resolveRuleOptions, run, runClassic, validateLintResult };