@flint.fyi/rule-tester 0.19.1 → 0.20.1
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 +2 -2
- package/dist/index.mjs +67 -40
- package/package.json +9 -7
package/dist/index.d.mts
CHANGED
|
@@ -61,10 +61,10 @@ interface TestCases<Options extends object | undefined> {
|
|
|
61
61
|
}
|
|
62
62
|
type TesterSetupDescribe = (description: string, setup: () => void) => void;
|
|
63
63
|
type TesterSetupIt = (description: string, setup: () => Promise<void>) => void;
|
|
64
|
-
declare class RuleTester {
|
|
64
|
+
export declare class RuleTester {
|
|
65
65
|
#private;
|
|
66
66
|
constructor({ assertNoLanguageReports, defaults, describe, diskBackedFSRoot, it, only, scope, skip }?: RuleTesterOptions);
|
|
67
67
|
describe<OptionsSchema extends AnyOptionalSchema | undefined>(rule: AnyRule<RuleAbout, OptionsSchema>, { invalid, valid }: TestCases<InferredInputObject<OptionsSchema>>): void;
|
|
68
68
|
}
|
|
69
69
|
//#endregion
|
|
70
|
-
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, isFileSystemCaseSensitive, 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).toSorted(), Object.keys(suggestionExpected.files).toSorted(), "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,11 +153,17 @@ 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 {
|
|
156
|
-
languageReports: collectLanguageReports ? rule.language.getLanguageReports?.(file) ?? [] : [],
|
|
166
|
+
languageReports: collectLanguageReports ? rule.language.getLanguageReports?.(file, linterHost) ?? [] : [],
|
|
157
167
|
reports: reports.toSorted((a, b) => a.range.begin.raw - b.range.begin.raw || a.range.end.raw - b.range.end.raw)
|
|
158
168
|
};
|
|
159
169
|
}
|
|
@@ -164,17 +174,24 @@ 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 ? void 0 : createEphemeralLinterHost(withRepositoryRoot(createDiskBackedLinterHost(virtualRoot), virtualRoot));
|
|
168
179
|
const { files: defaultFiles = {} } = defaults;
|
|
169
180
|
if (Object.keys(defaultFiles).length) {
|
|
170
|
-
const vfs = createVFSLinterHost(baseHost == null ? {
|
|
181
|
+
const vfs = createVFSLinterHost(baseHost == null ? {
|
|
182
|
+
caseSensitive: isFileSystemCaseSensitive(),
|
|
183
|
+
cwd: process.cwd()
|
|
184
|
+
} : { baseHost });
|
|
171
185
|
for (const [name, content] of Object.entries(defaultFiles)) {
|
|
172
|
-
const filePath =
|
|
186
|
+
const filePath = resolve(vfs.getCurrentDirectory(), name);
|
|
173
187
|
vfs.vfsUpsertFile(filePath, content);
|
|
174
188
|
}
|
|
175
189
|
baseHost = vfs;
|
|
176
190
|
}
|
|
177
|
-
this.#linterHost = createVFSLinterHost(baseHost == null ? {
|
|
191
|
+
this.#linterHost = createVFSLinterHost(baseHost == null ? {
|
|
192
|
+
caseSensitive: isFileSystemCaseSensitive(),
|
|
193
|
+
cwd: process.cwd()
|
|
194
|
+
} : { baseHost });
|
|
178
195
|
this.#fileFactories = new CachedFactory((language) => language.createFileFactory(this.#linterHost));
|
|
179
196
|
it = defaultTo(it, scope, "it");
|
|
180
197
|
if (!skip && "skip" in it && typeof it.skip === "function") skip = it.skip;
|
|
@@ -194,16 +211,18 @@ var RuleTester = class {
|
|
|
194
211
|
describe(rule, { invalid, valid }) {
|
|
195
212
|
this.#testerOptions.describe(rule.about.id, () => {
|
|
196
213
|
this.#testerOptions.describe("invalid", () => {
|
|
197
|
-
|
|
214
|
+
const seenTestCases = [];
|
|
215
|
+
for (const testCase of invalid) this.#itInvalidCase(rule, testCase, seenTestCases);
|
|
198
216
|
});
|
|
199
217
|
this.#testerOptions.describe("valid", () => {
|
|
200
|
-
|
|
218
|
+
const seenTestCases = [];
|
|
219
|
+
for (const testCase of valid) this.#itValidCase(rule, testCase, seenTestCases);
|
|
201
220
|
});
|
|
202
221
|
});
|
|
203
222
|
}
|
|
204
|
-
#itInvalidCase(rule, testCase) {
|
|
223
|
+
#itInvalidCase(rule, testCase, seenTestCases) {
|
|
205
224
|
const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
|
|
206
|
-
this.#itTestCase(testCaseNormalized, async () => {
|
|
225
|
+
this.#itTestCase(testCaseNormalized, seenTestCases, async () => {
|
|
207
226
|
const { languageReports, reports } = await runTestCaseRule(this.#fileFactories, this.#linterHost, {
|
|
208
227
|
options: parseOptions(rule.options, testCase.options),
|
|
209
228
|
rule
|
|
@@ -217,24 +236,22 @@ var RuleTester = class {
|
|
|
217
236
|
assert.deepStrictEqual(actualSuggestions, testCase.suggestions);
|
|
218
237
|
});
|
|
219
238
|
}
|
|
220
|
-
#itTestCase(testCase, setup) {
|
|
239
|
+
#itTestCase(testCase, seenTestCases, setup) {
|
|
221
240
|
let test = testCase.only ? this.#testerOptions.only : this.#testerOptions.it;
|
|
222
|
-
if (testCase.skip)
|
|
223
|
-
if ("skip" in test && typeof test.skip === "function") test = test.skip;
|
|
224
|
-
else test = this.#testerOptions.skip;
|
|
225
|
-
}
|
|
241
|
+
if (testCase.skip) test = "skip" in test && typeof test.skip === "function" ? test.skip : this.#testerOptions.skip;
|
|
226
242
|
test(testCase.name ?? ("files" in testCase ? JSON.stringify({
|
|
227
243
|
[testCase.fileName]: testCase.code,
|
|
228
244
|
...testCase.files
|
|
229
245
|
}, null, 2) : testCase.code), () => {
|
|
246
|
+
assertNoDuplicateTestCase(testCase, seenTestCases);
|
|
230
247
|
if (testCase.files != null) assert.notEqual(Object.keys(testCase.files).length, 0, `'files' must have at least one file`);
|
|
231
248
|
return setup();
|
|
232
249
|
});
|
|
233
250
|
}
|
|
234
|
-
#itValidCase(rule, testCaseRaw) {
|
|
251
|
+
#itValidCase(rule, testCaseRaw, seenTestCases) {
|
|
235
252
|
const testCase = typeof testCaseRaw === "string" ? { code: testCaseRaw } : testCaseRaw;
|
|
236
253
|
const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
|
|
237
|
-
this.#itTestCase(testCaseNormalized, async () => {
|
|
254
|
+
this.#itTestCase(testCaseNormalized, seenTestCases, async () => {
|
|
238
255
|
const { languageReports, reports } = await runTestCaseRule(this.#fileFactories, this.#linterHost, {
|
|
239
256
|
options: parseOptions(rule.options, testCase.options),
|
|
240
257
|
rule
|
|
@@ -244,6 +261,16 @@ var RuleTester = class {
|
|
|
244
261
|
});
|
|
245
262
|
}
|
|
246
263
|
};
|
|
264
|
+
function assertNoDuplicateTestCase(testCase, seenTestCases) {
|
|
265
|
+
const duplicateProperties = {
|
|
266
|
+
code: testCase.code,
|
|
267
|
+
fileName: testCase.fileName,
|
|
268
|
+
files: testCase.files,
|
|
269
|
+
options: testCase.options
|
|
270
|
+
};
|
|
271
|
+
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.");
|
|
272
|
+
seenTestCases.push(duplicateProperties);
|
|
273
|
+
}
|
|
247
274
|
function assertNoLanguageReports(languageReports) {
|
|
248
275
|
if (languageReports.length) assert.fail([`Expected no language reports, but found ${languageReports.length}:`, ...languageReports.map((languageReport) => languageReport.text)].join("\n\n"));
|
|
249
276
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flint.fyi/rule-tester",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
4
4
|
"description": "[Experimental] Rule unit tests for Flint.",
|
|
5
5
|
"homepage": "https://flint.fyi",
|
|
6
6
|
"repository": {
|
|
@@ -16,20 +16,22 @@
|
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"type": "module",
|
|
18
18
|
"exports": {
|
|
19
|
-
".": "./dist/index.mjs"
|
|
19
|
+
".": "./dist/index.mjs",
|
|
20
|
+
"./package.json": "./package.json"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"dist/"
|
|
23
24
|
],
|
|
24
25
|
"dependencies": {
|
|
25
|
-
"@flint.fyi/core": "^0.
|
|
26
|
-
"@flint.fyi/utils": "^0.16.
|
|
27
|
-
"cached-factory": "^0.3.0"
|
|
26
|
+
"@flint.fyi/core": "^0.28.0",
|
|
27
|
+
"@flint.fyi/utils": "^0.16.1",
|
|
28
|
+
"cached-factory": "^0.3.0",
|
|
29
|
+
"pathe": "^2.0.3"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
30
32
|
"@flint.fyi/build": "^0.1.0",
|
|
31
|
-
"tsdown": "0.
|
|
32
|
-
"vitest": "
|
|
33
|
+
"tsdown": "0.23.0",
|
|
34
|
+
"vitest": "5.0.1"
|
|
33
35
|
},
|
|
34
36
|
"engines": {
|
|
35
37
|
"node": ">=26.1.0"
|