@flint.fyi/rule-tester 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +12 -25
- package/dist/index.mjs +61 -35
- package/package.json +7 -6
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { AnyOptionalSchema, AnyRule, InferredInputObject, RuleAbout } from "@flint.fyi/core";
|
|
2
|
-
|
|
3
2
|
//#region src/types.d.ts
|
|
4
3
|
interface InvalidTestCase<Options extends object | undefined = object | undefined> extends TestCase<Options> {
|
|
5
4
|
output?: string;
|
|
@@ -12,17 +11,17 @@ interface TestCase<Options extends object | undefined = object | undefined> {
|
|
|
12
11
|
files?: Record<string, string> | undefined;
|
|
13
12
|
name?: string | undefined;
|
|
14
13
|
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
* Run only this test case. Useful for debugging.
|
|
15
|
+
*
|
|
16
|
+
* Do not commit code with this flag set.
|
|
17
|
+
*/
|
|
19
18
|
only?: boolean;
|
|
20
19
|
options?: Options | undefined;
|
|
21
20
|
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
* Skip running this test case. Useful for work-in-progress tests.
|
|
22
|
+
*
|
|
23
|
+
* Do not commit code with this flag set.
|
|
24
|
+
*/
|
|
26
25
|
skip?: boolean;
|
|
27
26
|
}
|
|
28
27
|
type TestSuggestion = TestSuggestionForFile | TestSuggestionForFiles;
|
|
@@ -62,22 +61,10 @@ interface TestCases<Options extends object | undefined> {
|
|
|
62
61
|
}
|
|
63
62
|
type TesterSetupDescribe = (description: string, setup: () => void) => void;
|
|
64
63
|
type TesterSetupIt = (description: string, setup: () => Promise<void>) => void;
|
|
65
|
-
declare class RuleTester {
|
|
64
|
+
export declare class RuleTester {
|
|
66
65
|
#private;
|
|
67
|
-
constructor({
|
|
68
|
-
|
|
69
|
-
defaults,
|
|
70
|
-
describe,
|
|
71
|
-
diskBackedFSRoot,
|
|
72
|
-
it,
|
|
73
|
-
only,
|
|
74
|
-
scope,
|
|
75
|
-
skip
|
|
76
|
-
}?: RuleTesterOptions);
|
|
77
|
-
describe<OptionsSchema extends AnyOptionalSchema | undefined>(rule: AnyRule<RuleAbout, OptionsSchema>, {
|
|
78
|
-
invalid,
|
|
79
|
-
valid
|
|
80
|
-
}: TestCases<InferredInputObject<OptionsSchema>>): void;
|
|
66
|
+
constructor({ assertNoLanguageReports, defaults, describe, diskBackedFSRoot, it, only, scope, skip }?: RuleTesterOptions);
|
|
67
|
+
describe<OptionsSchema extends AnyOptionalSchema | undefined>(rule: AnyRule<RuleAbout, OptionsSchema>, { invalid, valid }: TestCases<InferredInputObject<OptionsSchema>>): void;
|
|
81
68
|
}
|
|
82
69
|
//#endregion
|
|
83
|
-
export {
|
|
70
|
+
export type { InvalidTestCase, TestCase, TestSuggestion, TestSuggestionFileCase, TestSuggestionForFile, TestSuggestionForFiles, ValidTestCase, ValidTestCaseObject };
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import { CachedFactory } from "cached-factory";
|
|
4
|
-
import {
|
|
4
|
+
import { resolve } from "pathe";
|
|
5
|
+
import { applyChangesToText, createDiskBackedLinterHost, createEphemeralLinterHost, createVFSLinterHost, formatReport, getPositionOfColumnAndLine, isSuggestionForFiles, parseOptions, processRuleReport, withRepositoryRoot } from "@flint.fyi/core";
|
|
5
6
|
import { isTruthy, normalizePath, nullThrows, pathKey } from "@flint.fyi/utils";
|
|
6
7
|
//#region src/createOutput.ts
|
|
7
8
|
function createOutput(reports, testCaseNormalized) {
|
|
@@ -98,38 +99,41 @@ function isTestSuggestionForFiles(suggestion) {
|
|
|
98
99
|
function resolveReportedSuggestions(reports, testCaseNormalized) {
|
|
99
100
|
const suggestionsReported = reports.flatMap((report) => report.suggestions).filter(isTruthy);
|
|
100
101
|
if (!suggestionsReported.length) return;
|
|
101
|
-
return suggestionsReported.map((suggestionReported) =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
return suggestionsReported.map((suggestionReported, index) => {
|
|
103
|
+
const suggestionExpected = testCaseNormalized.suggestions?.[index];
|
|
104
|
+
if (isSuggestionForFiles(suggestionReported)) return {
|
|
105
|
+
files: resolveReportedSuggestionForFiles(suggestionReported, suggestionExpected),
|
|
106
|
+
id: suggestionReported.id
|
|
107
|
+
};
|
|
108
|
+
if (suggestionExpected && isTestSuggestionForFiles(suggestionExpected)) throw new Error("This test case describes a suggestion across files, but the rule is only reporting changes to its own file.");
|
|
109
|
+
return {
|
|
110
|
+
id: suggestionReported.id,
|
|
111
|
+
updated: applyChangesToText([suggestionReported], testCaseNormalized.code)
|
|
112
|
+
};
|
|
107
113
|
});
|
|
108
114
|
}
|
|
109
|
-
function resolveReportedSuggestionForFiles(suggestionReported,
|
|
110
|
-
if (!
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
})];
|
|
121
|
-
});
|
|
115
|
+
function resolveReportedSuggestionForFiles(suggestionReported, suggestionExpected) {
|
|
116
|
+
if (!suggestionExpected) return {};
|
|
117
|
+
if (!isTestSuggestionForFiles(suggestionExpected)) throw new Error("This test case describes a suggestion to its own file, but the rule is reporting changes across files.");
|
|
118
|
+
assert.deepStrictEqual(Object.keys(suggestionReported.files).sort(), Object.keys(suggestionExpected.files).sort(), "Reported suggestion target paths must exactly match expected target paths.");
|
|
119
|
+
return Object.fromEntries(Object.entries(suggestionExpected.files).map(([filePath, suggestionCasesExpected]) => {
|
|
120
|
+
const changes = suggestionReported.files[filePath];
|
|
121
|
+
assert.ok(changes, `Expected reported suggestion "${suggestionReported.id}" to provide changes for target "${filePath}".`);
|
|
122
|
+
return [filePath, suggestionCasesExpected.map((suggestionCaseExpected) => ({
|
|
123
|
+
original: suggestionCaseExpected.original,
|
|
124
|
+
updated: applyChangesToText(changes, suggestionCaseExpected.original)
|
|
125
|
+
}))];
|
|
122
126
|
}));
|
|
123
127
|
}
|
|
124
128
|
//#endregion
|
|
125
129
|
//#region src/runTestCaseRule.ts
|
|
126
130
|
async function runTestCaseRule(fileFactories, linterHost, { options, rule }, { code, fileName, files }, { collectLanguageReports = false } = {}) {
|
|
127
|
-
const filePathAbsolute = normalizePath(
|
|
131
|
+
const filePathAbsolute = normalizePath(resolve(linterHost.getCurrentDirectory(), fileName));
|
|
128
132
|
const caseSensitive = linterHost.isCaseSensitiveFS();
|
|
129
133
|
const targetKey = pathKey(filePathAbsolute, caseSensitive);
|
|
130
134
|
for (const oldFile of linterHost.vfsListFiles().keys()) if (pathKey(oldFile, caseSensitive) !== targetKey) linterHost.vfsDeleteFile(oldFile);
|
|
131
135
|
for (const [name, content] of Object.entries(files ?? {})) {
|
|
132
|
-
const filePath = normalizePath(
|
|
136
|
+
const filePath = normalizePath(resolve(linterHost.getCurrentDirectory(), name));
|
|
133
137
|
assert.notEqual(filePath, filePathAbsolute, `Expected 'files' not to shadow '${fileName}'`);
|
|
134
138
|
linterHost.vfsUpsertFile(filePath, content);
|
|
135
139
|
}
|
|
@@ -149,7 +153,13 @@ async function runTestCaseRule(fileFactories, linterHost, { options, rule }, { c
|
|
|
149
153
|
}
|
|
150
154
|
});
|
|
151
155
|
if (ruleRuntime) {
|
|
152
|
-
rule.language.runFileVisitors(file,
|
|
156
|
+
if (ruleRuntime.visitors) rule.language.runFileVisitors(file, [{
|
|
157
|
+
services: {
|
|
158
|
+
options,
|
|
159
|
+
...file.services
|
|
160
|
+
},
|
|
161
|
+
visitors: ruleRuntime.visitors
|
|
162
|
+
}]);
|
|
153
163
|
await ruleRuntime.teardown?.();
|
|
154
164
|
}
|
|
155
165
|
return {
|
|
@@ -164,12 +174,13 @@ var RuleTester = class {
|
|
|
164
174
|
#linterHost;
|
|
165
175
|
#testerOptions;
|
|
166
176
|
constructor({ assertNoLanguageReports = true, defaults = {}, describe, diskBackedFSRoot, it, only, scope = globalThis, skip } = {}) {
|
|
167
|
-
|
|
177
|
+
const virtualRoot = diskBackedFSRoot == null ? void 0 : resolve(process.cwd(), diskBackedFSRoot, "_flint-rule-tester-virtual");
|
|
178
|
+
let baseHost = virtualRoot != null ? createEphemeralLinterHost(withRepositoryRoot(createDiskBackedLinterHost(virtualRoot), virtualRoot)) : void 0;
|
|
168
179
|
const { files: defaultFiles = {} } = defaults;
|
|
169
180
|
if (Object.keys(defaultFiles).length) {
|
|
170
181
|
const vfs = createVFSLinterHost(baseHost == null ? { cwd: process.cwd() } : { baseHost });
|
|
171
182
|
for (const [name, content] of Object.entries(defaultFiles)) {
|
|
172
|
-
const filePath =
|
|
183
|
+
const filePath = resolve(vfs.getCurrentDirectory(), name);
|
|
173
184
|
vfs.vfsUpsertFile(filePath, content);
|
|
174
185
|
}
|
|
175
186
|
baseHost = vfs;
|
|
@@ -194,16 +205,18 @@ var RuleTester = class {
|
|
|
194
205
|
describe(rule, { invalid, valid }) {
|
|
195
206
|
this.#testerOptions.describe(rule.about.id, () => {
|
|
196
207
|
this.#testerOptions.describe("invalid", () => {
|
|
197
|
-
|
|
208
|
+
const seenTestCases = [];
|
|
209
|
+
for (const testCase of invalid) this.#itInvalidCase(rule, testCase, seenTestCases);
|
|
198
210
|
});
|
|
199
211
|
this.#testerOptions.describe("valid", () => {
|
|
200
|
-
|
|
212
|
+
const seenTestCases = [];
|
|
213
|
+
for (const testCase of valid) this.#itValidCase(rule, testCase, seenTestCases);
|
|
201
214
|
});
|
|
202
215
|
});
|
|
203
216
|
}
|
|
204
|
-
#itInvalidCase(rule, testCase) {
|
|
217
|
+
#itInvalidCase(rule, testCase, seenTestCases) {
|
|
205
218
|
const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
|
|
206
|
-
this.#itTestCase(testCaseNormalized, async () => {
|
|
219
|
+
this.#itTestCase(testCaseNormalized, seenTestCases, async () => {
|
|
207
220
|
const { languageReports, reports } = await runTestCaseRule(this.#fileFactories, this.#linterHost, {
|
|
208
221
|
options: parseOptions(rule.options, testCase.options),
|
|
209
222
|
rule
|
|
@@ -217,22 +230,25 @@ var RuleTester = class {
|
|
|
217
230
|
assert.deepStrictEqual(actualSuggestions, testCase.suggestions);
|
|
218
231
|
});
|
|
219
232
|
}
|
|
220
|
-
#itTestCase(testCase, setup) {
|
|
233
|
+
#itTestCase(testCase, seenTestCases, setup) {
|
|
221
234
|
let test = testCase.only ? this.#testerOptions.only : this.#testerOptions.it;
|
|
222
|
-
if (testCase.skip)
|
|
223
|
-
|
|
235
|
+
if (testCase.skip) {
|
|
236
|
+
if ("skip" in test && typeof test.skip === "function") test = test.skip;
|
|
237
|
+
else test = this.#testerOptions.skip;
|
|
238
|
+
}
|
|
224
239
|
test(testCase.name ?? ("files" in testCase ? JSON.stringify({
|
|
225
240
|
[testCase.fileName]: testCase.code,
|
|
226
241
|
...testCase.files
|
|
227
242
|
}, null, 2) : testCase.code), () => {
|
|
243
|
+
assertNoDuplicateTestCase(testCase, seenTestCases);
|
|
228
244
|
if (testCase.files != null) assert.notEqual(Object.keys(testCase.files).length, 0, `'files' must have at least one file`);
|
|
229
245
|
return setup();
|
|
230
246
|
});
|
|
231
247
|
}
|
|
232
|
-
#itValidCase(rule, testCaseRaw) {
|
|
248
|
+
#itValidCase(rule, testCaseRaw, seenTestCases) {
|
|
233
249
|
const testCase = typeof testCaseRaw === "string" ? { code: testCaseRaw } : testCaseRaw;
|
|
234
250
|
const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
|
|
235
|
-
this.#itTestCase(testCaseNormalized, async () => {
|
|
251
|
+
this.#itTestCase(testCaseNormalized, seenTestCases, async () => {
|
|
236
252
|
const { languageReports, reports } = await runTestCaseRule(this.#fileFactories, this.#linterHost, {
|
|
237
253
|
options: parseOptions(rule.options, testCase.options),
|
|
238
254
|
rule
|
|
@@ -242,6 +258,16 @@ var RuleTester = class {
|
|
|
242
258
|
});
|
|
243
259
|
}
|
|
244
260
|
};
|
|
261
|
+
function assertNoDuplicateTestCase(testCase, seenTestCases) {
|
|
262
|
+
const duplicateProperties = {
|
|
263
|
+
code: testCase.code,
|
|
264
|
+
fileName: testCase.fileName,
|
|
265
|
+
files: testCase.files,
|
|
266
|
+
options: testCase.options
|
|
267
|
+
};
|
|
268
|
+
if (seenTestCases.some((seenTestCase) => isDeepStrictEqual(seenTestCase, duplicateProperties))) assert.fail("Expected no duplicate test cases, but an earlier test case has the same code, fileName, files, and options.");
|
|
269
|
+
seenTestCases.push(duplicateProperties);
|
|
270
|
+
}
|
|
245
271
|
function assertNoLanguageReports(languageReports) {
|
|
246
272
|
if (languageReports.length) assert.fail([`Expected no language reports, but found ${languageReports.length}:`, ...languageReports.map((languageReport) => languageReport.text)].join("\n\n"));
|
|
247
273
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flint.fyi/rule-tester",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "[Experimental] Rule unit tests for Flint.",
|
|
5
5
|
"homepage": "https://flint.fyi",
|
|
6
6
|
"repository": {
|
|
@@ -22,14 +22,15 @@
|
|
|
22
22
|
"dist/"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@flint.fyi/core": "^0.27.0",
|
|
26
|
+
"@flint.fyi/utils": "^0.16.0",
|
|
25
27
|
"cached-factory": "^0.3.0",
|
|
26
|
-
"
|
|
27
|
-
"@flint.fyi/utils": "^0.16.0"
|
|
28
|
+
"pathe": "^2.0.3"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
31
|
+
"@flint.fyi/build": "^0.1.0",
|
|
32
|
+
"tsdown": "0.23.0",
|
|
33
|
+
"vitest": "5.0.1"
|
|
33
34
|
},
|
|
34
35
|
"engines": {
|
|
35
36
|
"node": ">=26.1.0"
|