UNPKG

stylelint-vitest-rule-tester

Version:
364 lines (352 loc) 11.3 kB
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 };