@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 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
- * Run only this test case. Useful for debugging.
16
- *
17
- * Do not commit code with this flag set.
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
- * Skip running this test case. Useful for work-in-progress tests.
23
- *
24
- * Do not commit code with this flag set.
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
- assertNoLanguageReports,
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 { 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, 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).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(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,7 +153,13 @@ 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 {
@@ -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
- 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 ? 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 = path.resolve(vfs.getCurrentDirectory(), name);
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
- for (const testCase of invalid) this.#itInvalidCase(rule, testCase);
208
+ const seenTestCases = [];
209
+ for (const testCase of invalid) this.#itInvalidCase(rule, testCase, seenTestCases);
198
210
  });
199
211
  this.#testerOptions.describe("valid", () => {
200
- for (const testCase of valid) this.#itValidCase(rule, testCase);
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) if ("skip" in test && typeof test.skip === "function") test = test.skip;
223
- else test = this.#testerOptions.skip;
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.19.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
- "@flint.fyi/core": "^0.25.0",
27
- "@flint.fyi/utils": "^0.16.0"
28
+ "pathe": "^2.0.3"
28
29
  },
29
30
  "devDependencies": {
30
- "tsdown": "0.22.3",
31
- "vitest": "4.1.0",
32
- "@flint.fyi/build": "^0.1.0"
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"