Skip to content

Commit 0ef5845

Browse files
committed
feat(linter/plugins): introduce RuleTester
1 parent abdbba7 commit 0ef5845

File tree

4 files changed

+400
-12
lines changed

4 files changed

+400
-12
lines changed

apps/oxlint/src-js/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
export { definePlugin, defineRule } from "./package/define.js";
2+
export { RuleTester } from "./package/rule_tester.js";
23

4+
// ESTree types
35
export type * as ESTree from "./generated/types.d.ts";
6+
7+
// Plugin types
48
export type { Context, LanguageOptions } from "./plugins/context.ts";
59
export type { Fix, Fixer, FixFn } from "./plugins/fix.ts";
610
export type { CreateOnceRule, CreateRule, Plugin, Rule } from "./plugins/load.ts";
@@ -57,3 +61,22 @@ export type {
5761
Visitor,
5862
VisitorWithHooks,
5963
} from "./plugins/types.ts";
64+
65+
// Rule tester types
66+
import type {
67+
Config as _Config,
68+
DescribeFn as _DescribeFn,
69+
ItFn as _ItFn,
70+
ValidTestCase as _ValidTestCase,
71+
InvalidTestCase as _InvalidTestCase,
72+
TestCases as _TestCases,
73+
} from "./package/rule_tester.ts";
74+
75+
export namespace RuleTester {
76+
export type Config = _Config;
77+
export type DescribeFn = _DescribeFn;
78+
export type ItFn = _ItFn;
79+
export type ValidTestCase = _ValidTestCase;
80+
export type InvalidTestCase = _InvalidTestCase;
81+
export type TestCases = _TestCases;
82+
}
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
/*
2+
* RuleTester class.
3+
*/
4+
5+
import { default as assert, AssertionError } from "node:assert";
6+
import { inspect } from "node:util";
7+
import { registerPlugin, registeredRules } from "../plugins/load.js";
8+
import { lintFileImpl } from "../plugins/lint.js";
9+
10+
import type { Plugin, Rule } from "../plugins/load.ts";
11+
12+
// ------------------------------------------------------------------------------
13+
// `describe` and `it` functions
14+
// ------------------------------------------------------------------------------
15+
16+
export type DescribeFn = <R>(text: string, method: () => R) => R;
17+
export type ItFn = (<R>(text: string, method: () => R) => R) & { only?: ItFn };
18+
19+
declare global {
20+
var describe: DescribeFn | undefined;
21+
var it: ItFn | undefined;
22+
}
23+
24+
/**
25+
* Default `describe` function, if `describe` doesn't exist as a global.
26+
* @param text - Description of the test case
27+
* @param method - Test case logic
28+
* @returns Returned value of `method`
29+
*/
30+
function defaultDescribe<R>(text: string, method: () => R): R {
31+
return method.call(this);
32+
}
33+
34+
// `describe` function. Can be overwritten via `RuleTester.describe` setter.
35+
let describe: DescribeFn =
36+
typeof globalThis.describe === "function" ? globalThis.describe : defaultDescribe;
37+
38+
/**
39+
* Default `it` function, if `it` doesn't exist as a global.
40+
* @param text - Description of the test case
41+
* @param method - Test case logic
42+
* @throws {Error} Any error upon execution of `method`
43+
* @returns Returned value of `method`
44+
*/
45+
function defaultIt<R>(text: string, method: () => R): R {
46+
try {
47+
return method.call(this);
48+
} catch (err) {
49+
if (err instanceof AssertionError) {
50+
err.message += ` (${inspect(err.actual)} ${err.operator} ${inspect(err.expected)})`;
51+
}
52+
throw err;
53+
}
54+
}
55+
56+
// `it` function. Can be overwritten via `RuleTester.it` setter.
57+
let it: ItFn = typeof globalThis.it === "function" ? globalThis.it : defaultIt;
58+
59+
// `it.only` function. Can be overwritten via `RuleTester.it` or `RuleTester.itOnly` setters.
60+
let itOnly: ItFn | null =
61+
it !== defaultIt && typeof it.only === "function" ? Function.bind.call(it.only, it) : null;
62+
63+
/**
64+
* Get `it` function.
65+
* @param only - `true` if `it.only` should be used
66+
* @returns `it` function
67+
*/
68+
function getIt(only?: boolean): ItFn {
69+
return only ? getItOnly() : it;
70+
}
71+
72+
/**
73+
* Get `it.only` function.
74+
* @param only - `true` if `it.only` should be used
75+
* @returns `it` function
76+
*/
77+
function getItOnly(): ItFn {
78+
if (itOnly === null) {
79+
throw new Error(
80+
"To use `only`, use `RuleTester` with a test framework that provides `it.only()` like Mocha, " +
81+
"or provide a custom `it.only` function by assigning it to `RuleTester.itOnly`.",
82+
);
83+
}
84+
return itOnly;
85+
}
86+
87+
// ------------------------------------------------------------------------------
88+
// Config
89+
// ------------------------------------------------------------------------------
90+
91+
/**
92+
* Configuration for `RuleTester`.
93+
*/
94+
export interface Config {
95+
rules: Record<string, unknown>;
96+
[key: string]: unknown;
97+
}
98+
99+
// Empty rules object
100+
const EMPTY_RULES = {};
101+
102+
// Default shared config
103+
const DEFAULT_SHARED_CONFIG: Config = { rules: EMPTY_RULES };
104+
105+
// `RuleTester` uses this config as its default. Can be overwritten via `RuleTester.setDefaultConfig()`.
106+
let sharedDefaultConfig: Config = DEFAULT_SHARED_CONFIG;
107+
108+
// ------------------------------------------------------------------------------
109+
// Test cases
110+
// ------------------------------------------------------------------------------
111+
112+
interface TestCase {
113+
code: string;
114+
name?: string;
115+
only?: boolean;
116+
}
117+
118+
/**
119+
* Test case for valid code.
120+
*/
121+
export interface ValidTestCase extends TestCase {
122+
before?: (this: ValidTestCase) => void;
123+
after?: (this: ValidTestCase) => void;
124+
// TODO
125+
}
126+
127+
/**
128+
* Test case for invalid code.
129+
*/
130+
export interface InvalidTestCase extends TestCase {
131+
before?: (this: InvalidTestCase) => void;
132+
after?: (this: InvalidTestCase) => void;
133+
// TODO
134+
}
135+
136+
/**
137+
* Test cases for a rule.
138+
*/
139+
export interface TestCases {
140+
valid: (ValidTestCase | string)[];
141+
invalid: InvalidTestCase[];
142+
}
143+
144+
// ------------------------------------------------------------------------------
145+
// `RuleTester` class
146+
// ------------------------------------------------------------------------------
147+
148+
/**
149+
* Utility class for testing rules.
150+
*/
151+
export class RuleTester {
152+
/**
153+
* Creates a new instance of RuleTester.
154+
* @param config? - Extra configuration for the tester (optional)
155+
*/
156+
constructor(config?: Config) {
157+
// TODO: Merge this into config used for tests
158+
const _ = config;
159+
}
160+
161+
/**
162+
* Set the configuration to use for all future tests.
163+
* @param config - The configuration to use
164+
* @throws {TypeError} If `config` is not an object
165+
*/
166+
static setDefaultConfig(config: Config): void {
167+
if (typeof config !== "object" || config === null) {
168+
throw new TypeError("`config` must be an object");
169+
}
170+
sharedDefaultConfig = config;
171+
172+
// Make sure the rules object exists since it is assumed to exist later
173+
if (sharedDefaultConfig.rules == null) sharedDefaultConfig.rules = EMPTY_RULES;
174+
}
175+
176+
/**
177+
* Get the current configuration used for all tests.
178+
* @returns The current configuration
179+
*/
180+
static getDefaultConfig(): Config {
181+
return sharedDefaultConfig;
182+
}
183+
184+
/**
185+
* Reset the configuration to the initial configuration of the tester removing
186+
* any changes made until now.
187+
* @returns {void}
188+
*/
189+
static resetDefaultConfig() {
190+
sharedDefaultConfig = DEFAULT_SHARED_CONFIG;
191+
}
192+
193+
// Getters/setters for `describe` and `it` functions
194+
195+
static get describe(): DescribeFn {
196+
return describe;
197+
}
198+
199+
static set describe(value: DescribeFn) {
200+
describe = value;
201+
}
202+
203+
static get it(): ItFn {
204+
return it;
205+
}
206+
207+
static set it(value: ItFn) {
208+
it = value;
209+
if (typeof it.only === "function") {
210+
itOnly = Function.bind.call(it.only, it);
211+
} else {
212+
itOnly = null;
213+
}
214+
}
215+
216+
static get itOnly(): ItFn {
217+
return getItOnly();
218+
}
219+
220+
static set itOnly(value: ItFn) {
221+
itOnly = value;
222+
}
223+
224+
/**
225+
* Add the `only` property to a test to run it in isolation.
226+
* @param item - A single test to run by itself
227+
* @returns The test with `only` set
228+
*/
229+
static only(item: string | ValidTestCase | InvalidTestCase): ValidTestCase | InvalidTestCase {
230+
if (typeof item === "string") return { code: item, only: true };
231+
return { ...item, only: true };
232+
}
233+
234+
/**
235+
* Adds a new rule test to execute.
236+
* @param ruleName - Name of the rule to run
237+
* @param rule - Rule to test
238+
* @param tests - Collection of tests to run
239+
* @throws {TypeError|Error} If `rule` is not an object with a `create` method,
240+
* or if non-object `test`, or if a required scenario of the given type is missing
241+
*/
242+
run(ruleName: string, rule: Rule, tests: TestCases): void {
243+
// TODO
244+
let _ = tests;
245+
246+
// Reset registered rules, so this rule is registered as rule 0
247+
registeredRules.length = 0;
248+
249+
// Register the rule
250+
const plugin: Plugin = {
251+
meta: {
252+
name: "rule-to-test",
253+
},
254+
rules: {
255+
[ruleName]: rule,
256+
},
257+
};
258+
registerPlugin(plugin, null);
259+
260+
// TODO: Create buffer
261+
const path = "file.js",
262+
bufferId = 0,
263+
buffer = null,
264+
ruleIds = [0],
265+
optionsIds = [0],
266+
settingsJSON = "{}";
267+
268+
describe(ruleName, () => {
269+
if (tests.valid.length > 0) {
270+
describe("valid", () => {
271+
const _seenTestCases = new Set();
272+
for (let test of tests.valid) {
273+
if (typeof test === "string") test = { code: test };
274+
275+
const it = getIt(test.only);
276+
it(getTestName(test), () => {
277+
try {
278+
runHook(test, "before");
279+
280+
// assertValidTestCase(item, seenTestCases);
281+
// testValidTemplate(item);
282+
283+
// TODO: Write `code` to buffer
284+
285+
lintFileImpl(path, bufferId, buffer, ruleIds, optionsIds, settingsJSON);
286+
287+
// TODO: Check diagnostics
288+
} finally {
289+
runHook(test, "after");
290+
}
291+
});
292+
}
293+
});
294+
}
295+
296+
if (tests.invalid.length > 0) {
297+
describe("invalid", () => {
298+
const _seenTestCases = new Set();
299+
for (const test of tests.invalid) {
300+
const it = getIt(test.only);
301+
it(getTestName(test), () => {
302+
try {
303+
runHook(test, "before");
304+
// assertInvalidTestCase(item, seenTestCases, ruleName);
305+
// testInvalidTemplate(item);
306+
307+
// TODO: Write `code` to buffer
308+
309+
lintFileImpl(path, bufferId, buffer, ruleIds, optionsIds, settingsJSON);
310+
311+
// TODO: Check diagnostics
312+
} finally {
313+
runHook(test, "after");
314+
}
315+
});
316+
}
317+
});
318+
}
319+
});
320+
}
321+
}
322+
323+
/**
324+
* Get name of test case.
325+
* @param test - Test case
326+
* @returns Name of test case
327+
*/
328+
function getTestName(test: TestCase): string {
329+
return sanitize(test.name || test.code);
330+
}
331+
332+
/**
333+
* Replace control characters with `\u00xx` form.
334+
* @param text - Text to sanitize
335+
* @returns Sanitized text
336+
*/
337+
function sanitize(text: string): string {
338+
if (typeof text !== "string") return "";
339+
return text.replace(
340+
/[\u0000-\u0009\u000b-\u001a]/gu, // oxlint-disable-line no-control-regex -- Escaping controls
341+
(c) => `\\u${c.codePointAt(0)!.toString(16).padStart(4, "0")}`,
342+
);
343+
}
344+
345+
/**
346+
* Runs a hook on the given test case.
347+
* @param test - Test to run the hook on
348+
* @param prop - Hook type
349+
* @throws {Error} - If the property is not a function or that function throws an error
350+
*/
351+
function runHook<T extends ValidTestCase | InvalidTestCase>(
352+
test: T,
353+
prop: "before" | "after",
354+
): void {
355+
if (Object.hasOwn(test, prop)) {
356+
assert.strictEqual(
357+
typeof test[prop],
358+
"function",
359+
`Optional test case property \`${prop}\` must be a function`,
360+
);
361+
test[prop]!();
362+
}
363+
}

0 commit comments

Comments
 (0)