stylelint-vitest-rule-tester
Version:
Styelint rule tester with Vitest.
365 lines (364 loc) • 12.5 kB
JavaScript
import { createRequire } from "node:module";
import { interopDefault, isEmptyArray, isFunction, isNull, isNumber, isString, isUndefined, toArray, unindent } from "@ntnyq/utils";
import stylelint from "stylelint";
import { describe, expect, it } from "vitest";
import deepmerge from "deepmerge";
//#region src/constants.ts
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;
//#endregion
//#region src/utils/resolveRuleMeta.ts
const require = createRequire(import.meta.url);
/**
* Resolve all stylelint rules by tester options
*
* @param options - tester options
* @returns stylelint rules
*/
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)) toArray(await interopDefault(require(plugin))).forEach((rule) => {
loadPluginRule(rule);
});
else loadPluginRule(plugin);
}
return rules;
}
/**
* Resolve rule meta by tester options
* @param options - tester options
* @returns a promise resolved rule meta or undefined
*/
async function resolveRuleMeta(options) {
let ruleMeta;
if (options.name.includes("/")) {
const matched = (await resolveRulesFromTesterOptions(options)).find((rule) => rule.ruleName === options.name);
if (matched) ruleMeta = matched.meta;
} else ruleMeta = (await stylelint.rules[options.name]).meta;
return ruleMeta;
}
//#endregion
//#region src/utils/normalizeTestCase.ts
/**
* Check if given test case is invalid
*
* @param testCase - normalized test case
* @returns true if given case is invalid
*/
function isInvalidTestCase(testCase) {
return !!testCase.warnings || !!testCase.deprecations || !!testCase.parseErrors || !!testCase.invalidOptionWarnings || !!testCase.output;
}
/**
* Normalize test case
*
* @param testCase - test case
* @param defaultFilenames - given default file name
* @param type - case type
* @returns normalized test case
*/
function normalizeTestCase(testCase, defaultFilenames, type) {
const normalized = isString(testCase) ? { code: testCase } : { ...testCase };
normalized.type ||= type || (isInvalidTestCase(normalized) ? "invalid" : "valid");
normalized.filename ||= defaultFilenames.css || DEFAULT_FILE_NAME;
return normalized;
}
//#endregion
//#region src/utils/resolveRuleOptions.ts
/**
* Normalize rule options
*
* @param testCase - test case
* @param options - tester options
* @returns normalized rule option
*/
function resolveRuleOptions(testCase, options, ruleMeta) {
const url = ruleMeta?.url;
const mergedRuleOptions = testCase.ruleOptions || options.ruleOptions;
const resolvedOptions = isUndefined(mergedRuleOptions) ? true : mergedRuleOptions;
const injectUrl = (opts) => url ? {
...opts,
url
} : opts;
if (!Array.isArray(resolvedOptions)) return url ? [resolvedOptions, { url }] : [resolvedOptions];
const [primary, secondary] = resolvedOptions;
if (resolvedOptions.length === 1) return url ? [primary, { url }] : resolvedOptions;
if (url && isUndefined(secondary?.url)) return [primary, injectUrl(secondary)];
return resolvedOptions;
}
//#endregion
//#region src/utils/normalizeCaseMessage.ts
/**
* Normalize test case message
*
* @param message - message string or lint result
* @returns normalized message
*/
function normalizeCaseMessage(message) {
if (isString(message)) return { text: message };
return { ...message };
}
//#endregion
//#region src/utils/validateLintResult.ts
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
});
}
//#endregion
//#region src/utils/resolveLinterOptions.ts
/**
* Resolve linter options of stylelint
*
* @param options - tester init options
* @param testCase - normalized test case
* @returns resolved linter options
*/
function resolveLinterOptions(options, testCase, ruleOptions) {
return {
...options.linterOptions,
config: {
...deepmerge(options.stylelintConfig || {}, testCase.stylelintConfig || {}),
rules: { [options.name]: ruleOptions }
},
code: testCase.code,
codeFilename: testCase.filename,
fix: false,
quietDeprecationWarnings: true
};
}
//#endregion
//#region src/utils/normalizeLinterResult.ts
/**
* Normalize linter result
*
* @param result - linter result {@link Stylelint.LinterResult}
*/
function normalizeLinterResult(result) {
const { cwd: _, results = [], report: _result, ...rest } = result;
return {
...rest,
results: results.map(({ _postcssResult, source, ...result }) => ({ ...result }))
};
}
//#endregion
//#region src/tester.ts
function createRuleTester(options) {
const defaultFilenames = {
...DEFAULT_FILE_NAMES,
...options.defaultFileNames
};
/**
* Apply fix to code and check if the code changed
*/
async function applyFix(code, linterOptions) {
const linterResult = await stylelint.lint({
...linterOptions,
code,
fix: true
});
const fixed = linterResult.code !== code;
return {
...normalizeLinterResult(linterResult),
fixed
};
}
/**
* Apply fixes recursively until code stabilizes or limit reached
*/
async function applyRecursiveFix(initialResult, linterOptions, recursive) {
const result = { ...initialResult };
if (!result.fixed || recursive === false) return result;
let remainingAttempts;
for (remainingAttempts = recursive; remainingAttempts >= 0; remainingAttempts--) {
const step = await applyFix(result.code, linterOptions);
result.steps?.push(step);
result.code = step.code;
if (!step.fixed) break;
}
if (remainingAttempts === 0) {
const totalAttempts = recursive + 1;
throw new Error(`Fix recursion limit exceeded after ${totalAttempts} attempts, possibly the fix is not stable. Last output:\n-------\n${result.code}\n-------`);
}
return result;
}
/**
* Verify the output matches expected value
*/
async function verifyOutput(testcase, result) {
const normalizedTestCase = testcase;
if (isUndefined(normalizedTestCase.output)) return;
if (isNull(normalizedTestCase.output)) expect(result.code, "output").toBe(normalizedTestCase.code);
else if (isFunction(normalizedTestCase.output)) await normalizedTestCase.output(result.code || "", normalizedTestCase.code);
else expect(result.code, "output").toBe(normalizedTestCase.output);
}
/**
* Verify fixed result has no warnings
*/
async function verifyFixedResult(result, linterOptions, verifyAfterFix) {
if (!result.fixed || !verifyAfterFix) return;
const { results = [] } = await stylelint.lint({
...linterOptions,
code: result.code,
fix: false
});
const [lintResult] = results;
expect.soft(lintResult, "no lint result").toBeDefined();
expect.soft(lintResult.warnings, "no warnings after fix").toEqual([]);
}
/**
* Validate invalid test case has required assertions
*/
function validateInvalidTestCase(testcase) {
const normalized = testcase;
if (normalized.type === "invalid" && isUndefined(normalized.output) && isUndefined(normalized.warnings) && isUndefined(normalized.parseErrors) && isUndefined(normalized.deprecations) && isUndefined(normalized.invalidOptionWarnings)) throw new Error(`Invalid test case must have either 'output', 'warnings', 'parseErrors', 'deprecations', or 'invalidOptionWarnings' property.`);
}
async function each(c) {
const testcase = normalizeTestCase(c, defaultFilenames);
const { recursive = 10, verifyAfterFix = true } = {
...options,
...testcase
};
const linterOptions = resolveLinterOptions(options, testcase, resolveRuleOptions(testcase, options, await resolveRuleMeta(options)));
await testcase.before?.call(testcase, linterOptions);
const [lintResult] = (await stylelint.lint(linterOptions)).results;
await validateLintResult(testcase, lintResult);
const fixedLinterResult = await applyFix(testcase.code, linterOptions);
let result = {
...fixedLinterResult,
steps: [fixedLinterResult]
};
result = await applyRecursiveFix(result, linterOptions, recursive);
await verifyOutput(testcase, result);
validateInvalidTestCase(testcase);
await verifyFixedResult(result, linterOptions, verifyAfterFix);
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 run = it;
if (testCase.only) run = it.only;
if (testCase.skip) run = it.skip;
run(`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 run = it;
if (testCase.only) run = it.only;
if (testCase.skip) run = it.skip;
run(`Invalid #${index}: ${testCase.description || testCase.code}`, async () => {
const { testcase, result } = await invalid(testCase);
await cases?.onResult?.(testcase, result);
});
});
});
});
}
return {
each,
valid,
invalid,
run
};
}
//#endregion
//#region src/run.ts
/**
* Shortcut to run test cases for a rule
*/
function run(options) {
return createRuleTester(options).run(options);
}
/**
* Shortcut to run test cases for a rule in classic style
*/
function runClassic(ruleName, cases, options) {
return createRuleTester({
name: ruleName,
...options
}).run(cases);
}
//#endregion
export { unindent as $, unindent, createRuleTester, normalizeCaseMessage, normalizeLinterResult, normalizeTestCase, resolveLinterOptions, resolveRuleMeta, resolveRuleOptions, run, runClassic, validateLintResult };