@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 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 { type InvalidTestCase, RuleTester, type TestCase, type TestSuggestion, type TestSuggestionFileCase, type TestSuggestionForFile, type TestSuggestionForFiles, type ValidTestCase, type ValidTestCaseObject };
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 path from "node:path";
2
+ import { isDeepStrictEqual } from "node:util";
3
3
  import { CachedFactory } from "cached-factory";
4
- import { applyChangesToText, createDiskBackedLinterHost, createEphemeralLinterHost, createVFSLinterHost, formatReport, getPositionOfColumnAndLine, isSuggestionForFiles, parseOptions, processRuleReport } from "@flint.fyi/core";
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) => isSuggestionForFiles(suggestionReported) ? {
102
- files: resolveReportedSuggestionForFiles(suggestionReported, testCaseNormalized),
103
- id: suggestionReported.id
104
- } : {
105
- id: suggestionReported.id,
106
- updated: applyChangesToText([suggestionReported], testCaseNormalized.code)
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, testCaseNormalized) {
110
- if (!testCaseNormalized.suggestions) return {};
111
- if (!testCaseNormalized.suggestions.every(isTestSuggestionForFiles)) throw new Error("This test case describes suggestions across files, but the rule is only reporting changes to its own file.");
112
- return Object.fromEntries(testCaseNormalized.suggestions.flatMap((suggestionExpected) => {
113
- return Object.entries(suggestionExpected.files).map(([filePath, suggestionCasesExpected]) => {
114
- return [filePath, suggestionCasesExpected.map((suggestionCaseExpected) => {
115
- const changes = suggestionReported.files[filePath];
116
- return {
117
- original: suggestionCaseExpected.original,
118
- updated: changes ? applyChangesToText(changes, suggestionCaseExpected.original) : suggestionCaseExpected.original
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(path.resolve(linterHost.getCurrentDirectory(), fileName));
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(path.resolve(linterHost.getCurrentDirectory(), name));
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, options, ruleRuntime);
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
- let baseHost = diskBackedFSRoot != null ? createEphemeralLinterHost(createDiskBackedLinterHost(path.resolve(process.cwd(), diskBackedFSRoot, "_flint-rule-tester-virtual"))) : void 0;
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 ? { cwd: process.cwd() } : { baseHost });
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 = path.resolve(vfs.getCurrentDirectory(), name);
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 ? { cwd: process.cwd() } : { baseHost });
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
- for (const testCase of invalid) this.#itInvalidCase(rule, testCase);
214
+ const seenTestCases = [];
215
+ for (const testCase of invalid) this.#itInvalidCase(rule, testCase, seenTestCases);
198
216
  });
199
217
  this.#testerOptions.describe("valid", () => {
200
- for (const testCase of valid) this.#itValidCase(rule, testCase);
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.19.1",
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.0",
26
- "@flint.fyi/utils": "^0.16.0",
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.22.14",
32
- "vitest": "4.1.11"
33
+ "tsdown": "0.23.0",
34
+ "vitest": "5.0.1"
33
35
  },
34
36
  "engines": {
35
37
  "node": ">=26.1.0"