diff --git a/apps/oxlint/package.json b/apps/oxlint/package.json index 4b34ddd3afb46..2dcf88bcb85d5 100644 --- a/apps/oxlint/package.json +++ b/apps/oxlint/package.json @@ -22,6 +22,7 @@ "devDependencies": { "@types/esquery": "^1.5.4", "@types/estree": "^1.0.8", + "@types/json-stable-stringify-without-jsonify": "^1.0.2", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/typescript-estree": "^8.47.0", "cross-env": "catalog:", @@ -29,6 +30,7 @@ "esquery": "^1.6.0", "execa": "^9.6.0", "jiti": "^2.6.0", + "json-stable-stringify-without-jsonify": "^1.0.1", "oxc-parser": "^0.99.0", "rolldown": "catalog:", "tsdown": "catalog:", diff --git a/apps/oxlint/src-js/index.ts b/apps/oxlint/src-js/index.ts index e4adfd8d05260..b8b1c698888d4 100644 --- a/apps/oxlint/src-js/index.ts +++ b/apps/oxlint/src-js/index.ts @@ -1,6 +1,11 @@ +// Functions and classes export { definePlugin, defineRule } from "./package/define.js"; +export { RuleTester } from "./package/rule_tester.js"; +// ESTree types export type * as ESTree from "./generated/types.d.ts"; + +// Plugin types export type { Context, LanguageOptions } from "./plugins/context.ts"; export type { Fix, Fixer, FixFn } from "./plugins/fix.ts"; export type { CreateOnceRule, CreateRule, Plugin, Rule } from "./plugins/load.ts"; @@ -57,3 +62,23 @@ export type { Visitor, VisitorWithHooks, } from "./plugins/types.ts"; + +// Rule tester types. +// Export as namespace to avoid lengthy type names. +import type { + Config as _Config, + DescribeFn as _DescribeFn, + ItFn as _ItFn, + ValidTestCase as _ValidTestCase, + InvalidTestCase as _InvalidTestCase, + TestCases as _TestCases, +} from "./package/rule_tester.ts"; + +export namespace RuleTester { + export type Config = _Config; + export type DescribeFn = _DescribeFn; + export type ItFn = _ItFn; + export type ValidTestCase = _ValidTestCase; + export type InvalidTestCase = _InvalidTestCase; + export type TestCases = _TestCases; +} diff --git a/apps/oxlint/src-js/package/rule_tester.ts b/apps/oxlint/src-js/package/rule_tester.ts new file mode 100644 index 0000000000000..441fbf7b9118c --- /dev/null +++ b/apps/oxlint/src-js/package/rule_tester.ts @@ -0,0 +1,641 @@ +/* + * `RuleTester` class. + * + * Heavily based on ESLint's `RuleTester`, but without the complications of configs. + * Has the same user-facing API as ESLint's version. + * Code: https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/lib/rule-tester/rule-tester.js + * License (MIT): https://github.com/eslint/eslint/blob/0f5a94a84beee19f376025c74f703f275d52c94b/LICENSE + */ + +import { default as assert, AssertionError } from "node:assert"; +import util from "node:util"; +import stringify from "json-stable-stringify-without-jsonify"; +import { registerPlugin, registeredRules } from "../plugins/load.js"; +import { lintFileImpl } from "../plugins/lint.js"; +import { allOptions, mergeOptions } from "../plugins/options.js"; +import { diagnostics } from "../plugins/report.js"; +import { parse } from "./raw_transfer.js"; + +import type { Plugin, Rule } from "../plugins/load.ts"; +import type { Options } from "../plugins/options.ts"; +import type { DiagnosticReport } from "../plugins/report.ts"; + +const { hasOwn } = Object, + { isArray } = Array; + +// ------------------------------------------------------------------------------ +// `describe` and `it` functions +// ------------------------------------------------------------------------------ + +export type DescribeFn = (text: string, fn: () => void) => void; +export type ItFn = ((text: string, fn: () => void) => void) & { only?: ItFn }; + +/** + * Default `describe` function, if `describe` doesn't exist as a global. + * @param text - Description of the test case + * @param method - Test case logic + * @returns Returned value of `method` + */ +function defaultDescribe(text: string, method: () => R): R { + return method.call(this); +} + +const globalObj = globalThis as { describe?: DescribeFn; it?: ItFn }; + +// `describe` function. Can be overwritten via `RuleTester.describe` setter. +let describe: DescribeFn = + typeof globalObj.describe === "function" ? globalObj.describe : defaultDescribe; + +/** + * Default `it` function, if `it` doesn't exist as a global. + * @param text - Description of the test case + * @param method - Test case logic + * @throws {Error} Any error upon execution of `method` + * @returns Returned value of `method` + */ +function defaultIt(text: string, method: () => R): R { + try { + return method.call(this); + } catch (err) { + if (err instanceof AssertionError) { + err.message += ` (${util.inspect(err.actual)} ${err.operator} ${util.inspect(err.expected)})`; + } + throw err; + } +} + +// `it` function. Can be overwritten via `RuleTester.it` setter. +let it: ItFn = typeof globalObj.it === "function" ? globalObj.it : defaultIt; + +// `it.only` function. Can be overwritten via `RuleTester.it` or `RuleTester.itOnly` setters. +let itOnly: ItFn | null = + it !== defaultIt && typeof it.only === "function" ? Function.bind.call(it.only, it) : null; + +/** + * Get `it` function. + * @param only - `true` if `it.only` should be used + * @throws {Error} If `it.only` is not available + * @returns `it` or `it.only` function + */ +function getIt(only?: boolean): ItFn { + return only ? getItOnly() : it; +} + +/** + * Get `it.only` function. + * @throws {Error} If `it.only` is not available + * @returns `it.only` function + */ +function getItOnly(): ItFn { + if (itOnly === null) { + throw new Error( + "To use `only`, use `RuleTester` with a test framework that provides `it.only()` like Mocha, " + + "or provide a custom `it.only` function by assigning it to `RuleTester.itOnly`.", + ); + } + return itOnly; +} + +// ------------------------------------------------------------------------------ +// Config +// ------------------------------------------------------------------------------ + +/** + * Configuration for `RuleTester`. + */ +export type Config = Record; + +// Default shared config +const DEFAULT_SHARED_CONFIG: Config = {}; + +// `RuleTester` uses this config as its default. Can be overwritten via `RuleTester.setDefaultConfig()`. +let sharedDefaultConfig: Config = DEFAULT_SHARED_CONFIG; + +// ------------------------------------------------------------------------------ +// Test cases +// ------------------------------------------------------------------------------ + +/** + * Test case. + */ +interface TestCase { + code: string; + name?: string; + only?: boolean; + filename?: string; + options?: Options; + before?: (this: this) => void; + after?: (this: this) => void; +} + +/** + * Test case for valid code. + */ +export interface ValidTestCase extends TestCase { + // TODO +} + +/** + * Test case for invalid code. + */ +export interface InvalidTestCase extends TestCase { + output?: string | null; + errors: number | string[]; + // TODO +} + +/** + * Test cases for a rule. + */ +export interface TestCases { + valid: (ValidTestCase | string)[]; + invalid: InvalidTestCase[]; +} + +// Default path for test cases if not provided +const DEFAULT_PATH = "file.js"; + +// ------------------------------------------------------------------------------ +// `RuleTester` class +// ------------------------------------------------------------------------------ + +/** + * Utility class for testing rules. + */ +export class RuleTester { + #config: Config | null; + + /** + * Creates a new instance of RuleTester. + * @param config? - Extra configuration for the tester (optional) + */ + constructor(config?: Config) { + this.#config = config === undefined ? null : config; + } + + /** + * Set the configuration to use for all future tests. + * @param config - The configuration to use + * @throws {TypeError} If `config` is not an object + */ + static setDefaultConfig(config: Config): void { + if (typeof config !== "object" || config === null) { + throw new TypeError("`config` must be an object"); + } + sharedDefaultConfig = config; + } + + /** + * Get the current configuration used for all tests. + * @returns The current configuration + */ + static getDefaultConfig(): Config { + return sharedDefaultConfig; + } + + /** + * Reset the configuration to the initial configuration of the tester removing + * any changes made until now. + * @returns {void} + */ + static resetDefaultConfig() { + sharedDefaultConfig = DEFAULT_SHARED_CONFIG; + } + + // Getters/setters for `describe` and `it` functions + + static get describe(): DescribeFn { + return describe; + } + + static set describe(value: DescribeFn) { + describe = value; + } + + static get it(): ItFn { + return it; + } + + static set it(value: ItFn) { + it = value; + if (typeof it.only === "function") { + itOnly = Function.bind.call(it.only, it); + } else { + itOnly = null; + } + } + + static get itOnly(): ItFn { + return getItOnly(); + } + + static set itOnly(value: ItFn) { + itOnly = value; + } + + /** + * Add the `only` property to a test to run it in isolation. + * @param item - A single test to run by itself + * @returns The test with `only` set + */ + static only(item: string | TestCase): TestCase { + if (typeof item === "string") return { code: item, only: true }; + return { ...item, only: true }; + } + + /** + * Adds a new rule test to execute. + * @param ruleName - Name of the rule to run + * @param rule - Rule to test + * @param tests - Collection of tests to run + * @throws {TypeError|Error} If `rule` is not an object with a `create` method, + * or if non-object `test`, or if a required scenario of the given type is missing + */ + run(ruleName: string, rule: Rule, tests: TestCases): void { + // TODO: Merge `#config` + `sharedDefaultConfig` into config used for tests + const _ = this.#config; + + // Create plugin for the rule + const plugin: Plugin = { + meta: { name: "rule-to-test" }, + rules: { [ruleName]: rule }, + }; + + describe(ruleName, () => { + if (tests.valid.length > 0) { + describe("valid", () => { + const seenTestCases = new Set(); + for (let test of tests.valid) { + if (typeof test === "string") test = { code: test }; + + const it = getIt(test.only); + it(getTestName(test), () => { + runValidTestCase(test, plugin, seenTestCases); + }); + } + }); + } + + if (tests.invalid.length > 0) { + describe("invalid", () => { + const seenTestCases = new Set(); + for (const test of tests.invalid) { + const it = getIt(test.only); + it(getTestName(test), () => { + runInvalidTestCase(test, plugin, seenTestCases); + }); + } + }); + } + }); + } +} + +/** + * Run valid test case. + * @param test - Valid test case + * @param plugin - Plugin containing rule being tested + * @param seenTestCases - Set of serialized test cases to check for duplicates + * @throws {AssertionError} If the test case fails + */ +function runValidTestCase(test: ValidTestCase, plugin: Plugin, seenTestCases: Set): void { + try { + runBeforeHook(test); + checkValidTestCase(test, seenTestCases); + runValidTestCaseImpl(test, plugin); + } finally { + runAfterHook(test); + } +} + +function runValidTestCaseImpl(test: ValidTestCase, plugin: Plugin): void { + const diagnostics = lint(test, plugin); + + assert.strictEqual( + diagnostics.length, + 0, + util.format( + "Should have no errors but had %d: %s", + diagnostics.length, + util.inspect(diagnostics), + ), + ); +} + +/** + * Run invalid test case. + * @param test - Invalid test case + * @param plugin - Plugin containing rule being tested + * @param seenTestCases - Set of serialized test cases to check for duplicates + * @throws {AssertionError} If the test case fails + */ +function runInvalidTestCase( + test: InvalidTestCase, + plugin: Plugin, + seenTestCases: Set, +): void { + const ruleName = Object.keys(plugin.rules)[0]; + try { + runBeforeHook(test); + checkInvalidTestCase(test, seenTestCases, ruleName); + runInvalidTestCaseImpl(test, plugin); + } finally { + runAfterHook(test); + } +} + +function runInvalidTestCaseImpl(test: InvalidTestCase, plugin: Plugin): void { + const diagnostics = lint(test, plugin); + + if (typeof test.errors === "number") { + checkErrorCount(diagnostics, test.errors); + } else { + checkErrorCount(diagnostics, test.errors.length); + // TODO: Check diagnostics + } + + // TODO: Check output after fixes +} + +function checkErrorCount(diagnostics: DiagnosticReport[], expectedErrorCount: number): void { + assert.strictEqual( + diagnostics.length, + expectedErrorCount, + util.format( + "Should have %d error%s but had %d: %s", + expectedErrorCount, + expectedErrorCount === 1 ? "" : "s", + diagnostics.length, + util.inspect(diagnostics), + ), + ); +} + +/** + * Lint a test case. + * @param test - Test case + * @param plugin - Plugin containing rule being tested + * @returns Array of diagnostics + */ +function lint(test: TestCase, plugin: Plugin): DiagnosticReport[] { + try { + registerPlugin(plugin, null); + + const testOptions = test.options, + { defaultOptions } = registeredRules[0]; + const options = + testOptions == null ? defaultOptions : mergeOptions(testOptions, defaultOptions); + allOptions.push(options); // Index 1 (0 is empty array default) + + // Parse file into buffer + const path = test.filename ?? DEFAULT_PATH; + parse(path, test.code); + + // Lint file. + // Buffer is stored already, at index 0. No need to pass it. + const settingsJSON = "{}"; // TODO + lintFileImpl(path, 0, null, [0], [1], settingsJSON); + + // Return diagnostics + return diagnostics.slice(); + } finally { + // Reset state + registeredRules.length = 0; + allOptions.length = 1; + diagnostics.length = 0; + } +} + +/** + * Get name of test case. + * @param test - Test case + * @returns Name of test case + */ +function getTestName(test: TestCase): string { + return sanitize(test.name || test.code); +} + +/** + * Replace control characters with `\u00xx` form. + * @param text - Text to sanitize + * @returns Sanitized text + */ +function sanitize(text: string): string { + if (typeof text !== "string") return ""; + return text.replace( + /[\u0000-\u0009\u000b-\u001a]/gu, // oxlint-disable-line no-control-regex -- Escaping controls + (c) => `\\u${c.codePointAt(0)!.toString(16).padStart(4, "0")}`, + ); +} + +/** + * Runs before hook on the given test case. + * @param test - Test to run the hook on + * @throws {Error} - If the hook is not a function + * @throws {*} - Value thrown by the hook function + */ +function runBeforeHook(test: TestCase): void { + if (hasOwn(test, "before")) runHook(test, test.before, "before"); +} + +/** + * Runs after hook on the given test case. + * @param test - Test to run the hook on + * @throws {Error} - If the hook is not a function + * @throws {*} - Value thrown by the hook function + */ +function runAfterHook(test: TestCase): void { + if (hasOwn(test, "after")) runHook(test, test.after, "after"); +} + +/** + * Runs a hook on the given test case. + * @param test - Test to run the hook on + * @param hook - Hook function + * @param name - Name of the hook + * @throws {Error} - If the property is not a function + * @throws {*} - Value thrown by the hook function + */ +function runHook( + test: T, + hook: ((this: T) => void) | undefined, + name: string, +): void { + assert.strictEqual( + typeof hook, + "function", + `Optional test case property \`${name}\` must be a function`, + ); + hook!.call(test); +} + +/** + * Assert that a valid test case object is valid. + * A valid test case must specify a string value for `code`. + * Optional properties are checked for correct types. + * + * @param test - Valid test case object to check + * @param seenTestCases - Set of serialized test cases to check for duplicates + * @throws {AssertionError} If the test case is not valid + */ +function checkValidTestCase(test: ValidTestCase, seenTestCases: Set): void { + checkTestCaseCommonProperties(test); + + // Must not have properties of invalid test cases + assert( + !("errors" in test) || test.errors === undefined, + "Valid test case must not have `errors` property", + ); + assert( + !("output" in test) || test.output === undefined, + "Valid test case must not have `output` property", + ); + + checkDuplicateTestCase(test, seenTestCases); +} + +/** + * Assert that an invalid test case object is valid. + * An invalid test case must specify a string value for `code` and must have an `errors` property. + * Optional properties are checked for correct types. + * + * @param test - Invalid test case object to check + * @param seenTestCases - Set of serialized test cases to check for duplicates + * @param ruleName - Name of the rule being tested + * @throws {AssertionError} If the test case is not valid + */ +function checkInvalidTestCase( + test: InvalidTestCase, + seenTestCases: Set, + ruleName: string, +): void { + checkTestCaseCommonProperties(test); + + // `errors` must be a number greater than 0, or a non-empty array + const { errors } = test; + if (typeof errors === "number") { + assert(errors > 0, "Invalid cases must have `error` value greater than 0"); + } else { + assert( + errors !== undefined, + `Did not specify errors for an invalid test of rule \`${ruleName}\``, + ); + assert( + isArray(errors), + `Invalid 'errors' property for invalid test of rule \`${ruleName}\`:` + + `expected a number or an array but got ${errors === null ? "null" : typeof errors}`, + ); + assert(errors.length !== 0, "Invalid cases must have at least one error"); + } + + // `output` is optional, but if it exists it must be a string or `null` + if (hasOwn(test, "output")) { + assert( + test.output === null || typeof test.output === "string", + "Test property `output`, if specified, must be a string or null. " + + "If no autofix is expected, then omit the `output` property or set it to null.", + ); + } + + checkDuplicateTestCase(test, seenTestCases); +} + +/** + * Assert that the common properties of a valid/invalid test case have the correct types. + * @param {Object} test - Test case object to check + * @throws {AssertionError} If the test case is not valid + */ +function checkTestCaseCommonProperties(test: TestCase): void { + assert(typeof test.code === "string", "Test case must specify a string value for `code`"); + + // optional properties + if (test.name) { + assert(typeof test.name === "string", "Optional test case property `name` must be a string"); + } + if (hasOwn(test, "only")) { + assert(typeof test.only === "boolean", "Optional test case property `only` must be a boolean"); + } + if (hasOwn(test, "filename")) { + assert( + typeof test.filename === "string", + "Optional test case property `filename` must be a string", + ); + } + if (hasOwn(test, "options")) { + assert(Array.isArray(test.options), "Optional test case property `options` must be an array"); + } +} + +// Ignored test case properties when checking for test case duplicates +const DUPLICATION_IGNORED_PROPS = new Set(["name", "errors", "output"]); + +/** + * Assert that this test case is not a duplicate of one we have seen before. + * @param test - Test case object + * @param seenTestCases - Set of serialized test cases we have seen so far (managed by this function) + * @throws {AssertionError} If the test case is a duplicate + */ +function checkDuplicateTestCase(test: TestCase, seenTestCases: Set): void { + // If we can't serialize a test case (because it contains a function, RegExp, etc), skip the check. + // This might happen with properties like: `options`, `plugins`, `settings`, `languageOptions.parser`, + // `languageOptions.parserOptions`. + if (!isSerializable(test)) return; + + const serializedTestCase = stringify(test, { + replacer(key, value) { + // `this` is the currently stringified object --> only ignore top-level properties + return test !== this || !DUPLICATION_IGNORED_PROPS.has(key) ? value : undefined; + }, + }); + + assert(!seenTestCases.has(serializedTestCase), "Detected duplicate test case"); + seenTestCases.add(serializedTestCase); +} + +/** + * Check if a value is serializable. + * Functions or objects like RegExp cannot be serialized by JSON.stringify(). + * Inspired by: https://stackoverflow.com/questions/30579940/reliable-way-to-check-if-objects-is-serializable-in-javascript + * @param value - Value + * @param seenObjects - Objects already seen in this path from the root object. + * @returns {boolean} `true` if the value is serializable + */ +function isSerializable(value: unknown, seenObjects: Set = new Set()): boolean { + if (!isSerializablePrimitiveOrPlainObject(value)) return false; + + if (value === null || typeof value !== "object") return true; + + // Since this is a depth-first traversal, encountering the same object again means there is a circular reference. + // Objects with circular references are not serializable. + if (seenObjects.has(value)) return false; + + for (const property in value) { + if (!Object.hasOwn(value, property)) continue; + + const prop = (value as { [property]: unknown })[property]; + if (!isSerializablePrimitiveOrPlainObject(prop)) return false; + if (prop === null || typeof prop !== "object") continue; + + // We're creating a new Set of seen objects because we want to ensure that `val` doesn't appear again in this path, + // but it can appear in other paths. This allows for reusing objects in the graph, as long as there are no cycles. + if (!isSerializable(prop, new Set([...seenObjects, value]))) return false; + } + + return true; +} + +/** + * Check if a value is a primitive or plain object created by the `Object` constructor. + * @param value - Value to check + * @returns `true` if `value` is a primitive or plain object + */ +function isSerializablePrimitiveOrPlainObject(value: unknown): boolean { + return ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" || + (typeof value === "object" && value.constructor === Object) || + isArray(value) + ); +} diff --git a/apps/oxlint/src-js/plugins/lint.ts b/apps/oxlint/src-js/plugins/lint.ts index 35e780d0e3df2..4efb5edf8c7a7 100644 --- a/apps/oxlint/src-js/plugins/lint.ts +++ b/apps/oxlint/src-js/plugins/lint.ts @@ -82,7 +82,8 @@ export function lintFile( * @throws {Error} If any parameters are invalid * @throws {*} If any rule throws */ -function lintFileImpl( +// Exported for use in `RuleTester` +export function lintFileImpl( filePath: string, bufferId: number, buffer: Uint8Array | null, diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index 8351af794696c..d585432fc1fea 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -132,7 +132,8 @@ export async function loadPlugin(url: string, packageName: string | null): Promi * @throws {TypeError} If one of plugin's rules is malformed, or its `createOnce` method returns invalid visitor * @throws {TypeError} If `plugin.meta.name` is not a string */ -function registerPlugin(plugin: Plugin, packageName: string | null): PluginDetails { +// Exported for use in `RuleTester` +export function registerPlugin(plugin: Plugin, packageName: string | null): PluginDetails { // TODO: Use a validation library to assert the shape of the plugin, and of rules const pluginName = getPluginName(plugin, packageName); diff --git a/apps/oxlint/src-js/plugins/report.ts b/apps/oxlint/src-js/plugins/report.ts index d6509c6c41817..33bf2bab68f3a 100644 --- a/apps/oxlint/src-js/plugins/report.ts +++ b/apps/oxlint/src-js/plugins/report.ts @@ -51,7 +51,7 @@ interface SuggestionBase { } // Diagnostic in form sent to Rust -interface DiagnosticReport { +export interface DiagnosticReport { message: string; start: number; end: number; diff --git a/apps/oxlint/test/rule_tester.test.ts b/apps/oxlint/test/rule_tester.test.ts new file mode 100644 index 0000000000000..8e44b6acc24df --- /dev/null +++ b/apps/oxlint/test/rule_tester.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { RuleTester } from "../src-js/index.js"; + +import type { Rule } from "../src-js/index.ts"; + +/** + * Test case. + */ +interface Case { + path: string[]; + fn: () => void; + only: boolean; +} + +// Current test cases +const cases: Case[] = []; + +// Current test case stack +const caseStack: string[] = []; + +// Set up `RuleTester` to use these `describe` and `it` functions +function describeHook(name: string, fn: () => void): void { + caseStack.push(name); + try { + return fn(); + } finally { + caseStack.pop(); + } +} +RuleTester.describe = describeHook; + +function itHook(name: string, fn: () => void): void { + cases.push({ path: caseStack.concat([name]), fn, only: false }); +} +itHook.only = function itOnlyHook(name: string, fn: () => void): void { + cases.push({ path: caseStack.concat([name]), fn, only: true }); +}; +RuleTester.it = itHook; + +/** + * Run all current test cases. + * @returns Array containing errors for each test case + */ +function runCases(): (Error | null)[] { + const errors = []; + for (const testCase of cases) { + let error = null; + try { + testCase.fn(); + } catch (err) { + error = err; + } + errors.push(error); + } + return errors; +} + +describe("RuleTester", () => { + beforeEach(() => { + RuleTester.resetDefaultConfig(); + cases.length = 0; + caseStack.length = 0; + }); + + describe("can be constructed", () => { + it("with no config", () => { + expect(() => new RuleTester()).not.toThrow(); + }); + + it("with config", () => { + expect(() => new RuleTester({})).not.toThrow(); + }); + }); + + it("generates test cases", () => { + const rule: Rule = { + create() { + return {}; + }, + }; + + const tester = new RuleTester(); + tester.run("my-rule", rule, { + valid: [ + "valid code string", + { + code: "valid code from object", + options: [], + }, + { + name: "valid case name", + code: "let x = 1;", + options: [], + }, + ], + invalid: [ + { + code: "invalid code from object", + options: [], + errors: 1, + }, + { + name: "invalid case name", + code: "let x = 1;", + options: [], + errors: 1, + }, + ], + }); + + expect(cases).toEqual([ + { path: ["my-rule", "valid", "valid code string"], fn: expect.any(Function), only: false }, + { + path: ["my-rule", "valid", "valid code from object"], + fn: expect.any(Function), + only: false, + }, + { path: ["my-rule", "valid", "valid case name"], fn: expect.any(Function), only: false }, + { + path: ["my-rule", "invalid", "invalid code from object"], + fn: expect.any(Function), + only: false, + }, + { path: ["my-rule", "invalid", "invalid case name"], fn: expect.any(Function), only: false }, + ]); + }); + + const noFooRule: Rule = { + create(context) { + return { + Identifier(node) { + if (node.name === "foo") context.report({ message: "No foo!", node }); + }, + }; + }, + }; + + it("tests correct valid cases", () => { + const tester = new RuleTester(); + tester.run("no-foo", noFooRule, { + valid: ["let x;", "let y;"], + invalid: [], + }); + + expect(runCases()).toEqual([null, null]); + }); + + it("tests incorrect valid cases", () => { + const tester = new RuleTester(); + tester.run("no-foo", noFooRule, { + valid: ["let foo;", "foo.foo;"], + invalid: [], + }); + + expect(runCases()).toMatchInlineSnapshot(` + [ + [AssertionError: Should have no errors but had 1: [ { message: 'No foo!', start: 4, end: 7, ruleIndex: 0, fixes: null } ] + + 1 !== 0 + ], + [AssertionError: Should have no errors but had 2: [ + { message: 'No foo!', start: 0, end: 3, ruleIndex: 0, fixes: null }, + { message: 'No foo!', start: 4, end: 7, ruleIndex: 0, fixes: null } + ] + + 2 !== 0 + ], + ] + `); + }); + + it("tests correct invalid cases", () => { + const tester = new RuleTester(); + tester.run("no-foo", noFooRule, { + valid: [], + invalid: [ + { code: "let foo;", errors: 1 }, + { code: "foo.foo;", errors: 2 }, + ], + }); + + expect(runCases()).toEqual([null, null]); + }); + + it("tests incorrect invalid cases", () => { + const tester = new RuleTester(); + tester.run("no-foo", noFooRule, { + valid: [], + invalid: [ + { code: "let x;", errors: 1 }, + { code: "let foo;", errors: 2 }, + ], + }); + + expect(runCases()).toMatchInlineSnapshot(` + [ + [AssertionError: Should have 1 error but had 0: [] + + 0 !== 1 + ], + [AssertionError: Should have 2 errors but had 1: [ { message: 'No foo!', start: 4, end: 7, ruleIndex: 0, fixes: null } ] + + 1 !== 2 + ], + ] + `); + }); + + it("errors when parsing failure", () => { + const tester = new RuleTester(); + tester.run("no-foo", noFooRule, { + valid: ["let"], + invalid: [{ code: "let", errors: 1 }], + }); + expect(runCases()).toMatchInlineSnapshot(` + [ + [Error: Parsing failed], + [Error: Parsing failed], + ] + `); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 023b61ae345c3..b5161d4cf3931 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: '@types/estree': specifier: ^1.0.8 version: 1.0.8 + '@types/json-stable-stringify-without-jsonify': + specifier: ^1.0.2 + version: 1.0.2 '@typescript-eslint/scope-manager': specifier: 8.46.2 version: 8.46.2(patch_hash=08ab34b5f27480602bc518186b717a8915aa4d6b2b5df1adc747320ba25b1f8b) @@ -122,6 +125,9 @@ importers: jiti: specifier: ^2.6.0 version: 2.6.1 + json-stable-stringify-without-jsonify: + specifier: ^1.0.1 + version: 1.0.1 oxc-parser: specifier: ^0.99.0 version: 0.99.0 @@ -1984,6 +1990,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/json-stable-stringify-without-jsonify@1.0.2': + resolution: {integrity: sha512-X/Kn5f5fv1KBGqGDaegrj72Dlh+qEKN3ELwMAB6RdVlVzkf6NTeEnJpgR/Hr0AlpgTlYq/Vd0U3f79lavn6aDA==} + '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} @@ -5742,6 +5751,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/json-stable-stringify-without-jsonify@1.0.2': {} + '@types/mocha@10.0.10': {} '@types/node@24.9.1':