UNPKG

stylelint-vitest-rule-tester

Version:

Styelint rule tester with Vitest.

365 lines (364 loc) 12.5 kB
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 };