@flint.fyi/rule-tester 0.15.0 → 0.16.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.
@@ -1,24 +1,42 @@
1
- import { AnyOptionalSchema, AnyRule, InferredObject, RuleAbout } from "@flint.fyi/core";
2
1
  import { InvalidTestCase, ValidTestCase } from "./types.js";
3
- export interface RuleTesterOptions {
4
- defaults?: {
5
- fileName?: string;
6
- };
7
- describe?: TesterSetupDescribe;
8
- it?: TesterSetupIt;
9
- only?: TesterSetupIt;
10
- scope?: Record<string, unknown>;
11
- skip?: TesterSetupIt;
2
+ import { AnyOptionalSchema, AnyRule, InferredInputObject, RuleAbout } from "@flint.fyi/core";
3
+
4
+ //#region src/RuleTester.d.ts
5
+ interface RuleTesterDefaults {
6
+ fileName?: string;
7
+ files?: Record<string, string>;
12
8
  }
13
- export interface TestCases<Options extends object | undefined> {
14
- invalid: InvalidTestCase<Options>[];
15
- valid: ValidTestCase<Options>[];
9
+ interface RuleTesterOptions {
10
+ defaults?: RuleTesterDefaults;
11
+ describe?: TesterSetupDescribe;
12
+ diskBackedFSRoot?: string;
13
+ it?: TesterSetupIt;
14
+ only?: TesterSetupIt;
15
+ scope?: Record<string, unknown>;
16
+ skip?: TesterSetupIt;
16
17
  }
17
- export type TesterSetupDescribe = (description: string, setup: () => void) => void;
18
- export type TesterSetupIt = (description: string, setup: () => Promise<void>) => void;
19
- export declare class RuleTester {
20
- #private;
21
- constructor({ defaults, describe, it, only, scope, skip, }?: RuleTesterOptions);
22
- describe<OptionsSchema extends AnyOptionalSchema | undefined>(rule: AnyRule<RuleAbout, OptionsSchema>, { invalid, valid }: TestCases<InferredObject<OptionsSchema>>): void;
18
+ interface TestCases<Options extends object | undefined> {
19
+ invalid: InvalidTestCase<Options>[];
20
+ valid: ValidTestCase<Options>[];
23
21
  }
22
+ type TesterSetupDescribe = (description: string, setup: () => void) => void;
23
+ type TesterSetupIt = (description: string, setup: () => Promise<void>) => void;
24
+ declare class RuleTester {
25
+ #private;
26
+ constructor({
27
+ defaults,
28
+ describe,
29
+ diskBackedFSRoot,
30
+ it,
31
+ only,
32
+ scope,
33
+ skip
34
+ }?: RuleTesterOptions);
35
+ describe<OptionsSchema extends AnyOptionalSchema | undefined>(rule: AnyRule<RuleAbout, OptionsSchema>, {
36
+ invalid,
37
+ valid
38
+ }: TestCases<InferredInputObject<OptionsSchema>>): void;
39
+ }
40
+ //#endregion
41
+ export { RuleTester };
24
42
  //# sourceMappingURL=RuleTester.d.ts.map
package/lib/RuleTester.js CHANGED
@@ -1,100 +1,99 @@
1
- import { CachedFactory } from "cached-factory";
2
- import assert from "node:assert/strict";
1
+ import { createOutput } from "./createOutput.js";
3
2
  import { createReportSnapshot } from "./createReportSnapshot.js";
4
3
  import { normalizeTestCase } from "./normalizeTestCase.js";
5
4
  import { resolveReportedSuggestions } from "./resolveReportedSuggestions.js";
6
5
  import { runTestCaseRule } from "./runTestCaseRule.js";
7
- export class RuleTester {
8
- #fileFactories;
9
- #testerOptions;
10
- constructor({ defaults, describe, it, only, scope = globalThis, skip, } = {}) {
11
- this.#fileFactories = new CachedFactory((language) => language.prepare());
12
- it = defaultTo(it, scope, "it");
13
- if (!skip && "skip" in it && typeof it.skip === "function") {
14
- skip = it.skip;
15
- }
16
- if (!only && "only" in it && typeof it.only === "function") {
17
- only = it.only;
18
- }
19
- if (!skip) {
20
- throw new TypeError("RuleTester needs a `skip` function");
21
- }
22
- if (!only) {
23
- throw new TypeError("RuleTester needs a `only` function");
24
- }
25
- this.#testerOptions = {
26
- defaults: defaults ?? {},
27
- describe: defaultTo(describe, scope, "describe"),
28
- it,
29
- only,
30
- scope,
31
- skip,
32
- };
33
- }
34
- describe(rule, { invalid, valid }) {
35
- this.#testerOptions.describe(rule.about.id, () => {
36
- this.#testerOptions.describe("invalid", () => {
37
- for (const testCase of invalid) {
38
- this.#itInvalidCase(rule, testCase);
39
- }
40
- });
41
- this.#testerOptions.describe("valid", () => {
42
- for (const testCase of valid) {
43
- this.#itValidCase(rule, testCase);
44
- }
45
- });
46
- });
47
- }
48
- #itInvalidCase(rule, testCase) {
49
- const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
50
- this.#itTestCase(testCaseNormalized, async () => {
51
- const reports = await runTestCaseRule(this.#fileFactories, {
52
- // TODO: Figure out a way around the type assertion...
53
- options: testCase.options ?? {},
54
- rule,
55
- }, testCaseNormalized);
56
- const actualSnapshot = createReportSnapshot(testCase.code, reports);
57
- assert.equal(actualSnapshot, testCase.snapshot);
58
- const actualSuggestions = resolveReportedSuggestions(reports, testCaseNormalized);
59
- assert.deepStrictEqual(actualSuggestions, testCase.suggestions);
60
- });
61
- }
62
- #itTestCase(testCase, setup) {
63
- let test = testCase.only
64
- ? this.#testerOptions.only
65
- : this.#testerOptions.it;
66
- if (testCase.skip) {
67
- if ("skip" in test && typeof test.skip === "function") {
68
- test = test.skip;
69
- }
70
- else {
71
- test = this.#testerOptions.skip;
72
- }
73
- }
74
- test(testCase.code, setup);
75
- }
76
- #itValidCase(rule, testCaseRaw) {
77
- const testCase = typeof testCaseRaw === "string" ? { code: testCaseRaw } : testCaseRaw;
78
- const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
79
- this.#itTestCase(testCaseNormalized, async () => {
80
- const reports = await runTestCaseRule(this.#fileFactories, {
81
- // TODO: Figure out a way around the type assertion...
82
- options: (testCase.options ?? {}),
83
- rule,
84
- }, testCaseNormalized);
85
- if (reports.length) {
86
- assert.deepStrictEqual(createReportSnapshot(testCaseNormalized.code, reports), testCaseNormalized.code);
87
- }
88
- });
89
- }
90
- }
6
+ import { createDiskBackedLinterHost, createEphemeralLinterHost, createVFSLinterHost, parseOptions } from "@flint.fyi/core";
7
+ import { CachedFactory } from "cached-factory";
8
+ import assert from "node:assert/strict";
9
+ import path from "node:path";
10
+ //#region src/RuleTester.ts
11
+ var RuleTester = class {
12
+ #fileFactories;
13
+ #linterHost;
14
+ #testerOptions;
15
+ constructor({ defaults = {}, describe, diskBackedFSRoot, it, only, scope = globalThis, skip } = {}) {
16
+ let baseHost = diskBackedFSRoot != null ? createEphemeralLinterHost(createDiskBackedLinterHost(path.resolve(process.cwd(), diskBackedFSRoot, "_flint-rule-tester-virtual"))) : void 0;
17
+ const { files: defaultFiles = {} } = defaults;
18
+ if (Object.keys(defaultFiles).length) {
19
+ const vfs = createVFSLinterHost(baseHost == null ? { cwd: process.cwd() } : { baseHost });
20
+ for (const [name, content] of Object.entries(defaultFiles)) {
21
+ const filePath = path.resolve(vfs.getCurrentDirectory(), name);
22
+ vfs.vfsUpsertFile(filePath, content);
23
+ }
24
+ baseHost = vfs;
25
+ }
26
+ this.#linterHost = createVFSLinterHost(baseHost == null ? { cwd: process.cwd() } : { baseHost });
27
+ this.#fileFactories = new CachedFactory((language) => language.createFileFactory(this.#linterHost));
28
+ it = defaultTo(it, scope, "it");
29
+ if (!skip && "skip" in it && typeof it.skip === "function") skip = it.skip;
30
+ if (!only && "only" in it && typeof it.only === "function") only = it.only;
31
+ if (!skip) throw new TypeError("RuleTester needs a `skip` function");
32
+ if (!only) throw new TypeError("RuleTester needs a `only` function");
33
+ this.#testerOptions = {
34
+ defaults,
35
+ describe: defaultTo(describe, scope, "describe"),
36
+ it,
37
+ only,
38
+ scope,
39
+ skip
40
+ };
41
+ }
42
+ describe(rule, { invalid, valid }) {
43
+ this.#testerOptions.describe(rule.about.id, () => {
44
+ this.#testerOptions.describe("invalid", () => {
45
+ for (const testCase of invalid) this.#itInvalidCase(rule, testCase);
46
+ });
47
+ this.#testerOptions.describe("valid", () => {
48
+ for (const testCase of valid) this.#itValidCase(rule, testCase);
49
+ });
50
+ });
51
+ }
52
+ #itInvalidCase(rule, testCase) {
53
+ const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
54
+ this.#itTestCase(testCaseNormalized, async () => {
55
+ const reports = await runTestCaseRule(this.#fileFactories, this.#linterHost, {
56
+ options: parseOptions(rule.options, testCase.options),
57
+ rule
58
+ }, testCaseNormalized);
59
+ const actualSnapshot = createReportSnapshot(testCase.code, reports);
60
+ assert.equal(actualSnapshot, testCase.snapshot);
61
+ const actualOutput = createOutput(reports, testCaseNormalized);
62
+ assert.equal(testCase.output, actualOutput, "Expected `output` property to equal:");
63
+ const actualSuggestions = resolveReportedSuggestions(reports, testCaseNormalized);
64
+ assert.deepStrictEqual(actualSuggestions, testCase.suggestions);
65
+ });
66
+ }
67
+ #itTestCase(testCase, setup) {
68
+ let test = testCase.only ? this.#testerOptions.only : this.#testerOptions.it;
69
+ if (testCase.skip) if ("skip" in test && typeof test.skip === "function") test = test.skip;
70
+ else test = this.#testerOptions.skip;
71
+ test(testCase.name ?? ("files" in testCase ? JSON.stringify({
72
+ [testCase.fileName]: testCase.code,
73
+ ...testCase.files
74
+ }, null, 2) : testCase.code), () => {
75
+ if (testCase.files != null) assert.notEqual(Object.keys(testCase.files), 0, `'files' must have at least one file`);
76
+ return setup();
77
+ });
78
+ }
79
+ #itValidCase(rule, testCaseRaw) {
80
+ const testCase = typeof testCaseRaw === "string" ? { code: testCaseRaw } : testCaseRaw;
81
+ const testCaseNormalized = normalizeTestCase(testCase, this.#testerOptions.defaults.fileName);
82
+ this.#itTestCase(testCaseNormalized, async () => {
83
+ const reports = await runTestCaseRule(this.#fileFactories, this.#linterHost, {
84
+ options: parseOptions(rule.options, testCase.options),
85
+ rule
86
+ }, testCaseNormalized);
87
+ if (reports.length) assert.deepStrictEqual(createReportSnapshot(testCaseNormalized.code, reports), testCaseNormalized.code);
88
+ });
89
+ }
90
+ };
91
91
  function defaultTo(provided, scope, scopeKey) {
92
- if (provided) {
93
- return provided;
94
- }
95
- if (scopeKey in scope && typeof scope[scopeKey] === "function") {
96
- return scope[scopeKey];
97
- }
98
- throw new Error(`No ${scopeKey} function found`);
92
+ if (provided) return provided;
93
+ if (scopeKey in scope && typeof scope[scopeKey] === "function") return scope[scopeKey];
94
+ throw new Error(`No ${scopeKey} function found`);
99
95
  }
96
+ //#endregion
97
+ export { RuleTester };
98
+
100
99
  //# sourceMappingURL=RuleTester.js.map
@@ -0,0 +1,5 @@
1
+ import { type NormalizedReport } from "@flint.fyi/core";
2
+ import type { TestCaseNormalized } from "./normalizeTestCase.ts";
3
+ import type { InvalidTestCase } from "./types.ts";
4
+ export declare function createOutput(reports: NormalizedReport[], testCaseNormalized: InvalidTestCase & TestCaseNormalized): string | undefined;
5
+ //# sourceMappingURL=createOutput.d.ts.map
@@ -0,0 +1,10 @@
1
+ import { applyChangesToText } from "@flint.fyi/core";
2
+ //#region src/createOutput.ts
3
+ function createOutput(reports, testCaseNormalized) {
4
+ const changes = reports.filter((report) => report.fix !== void 0).flatMap((report) => report.fix);
5
+ return changes.length ? applyChangesToText(changes, testCaseNormalized.code) : void 0;
6
+ }
7
+ //#endregion
8
+ export { createOutput };
9
+
10
+ //# sourceMappingURL=createOutput.js.map
@@ -1,3 +1,3 @@
1
- import { NormalizedReport } from "@flint.fyi/core";
1
+ import { type NormalizedReport } from "@flint.fyi/core";
2
2
  export declare function createReportSnapshot(sourceText: string, reports: NormalizedReport[]): string;
3
3
  //# sourceMappingURL=createReportSnapshot.d.ts.map
@@ -1,39 +1,78 @@
1
- import { formatReportPrimary } from "@flint.fyi/core";
2
- export function createReportSnapshot(sourceText, reports) {
3
- let result = sourceText;
4
- for (const report of reports.toReversed()) {
5
- result = createReportSnapshotAt(result, report);
6
- }
7
- return result;
1
+ import { formatReport, getPositionOfColumnAndLine } from "@flint.fyi/core";
2
+ import { nullThrows } from "@flint.fyi/utils";
3
+ //#region src/createReportSnapshot.ts
4
+ function createReportSnapshot(sourceText, reports) {
5
+ let result = sourceText;
6
+ for (const report of reports.toReversed()) result = createReportSnapshotAt(result, report);
7
+ return result;
8
8
  }
9
9
  function createReportSnapshotAt(sourceText, report) {
10
- const { begin, end } = report.range;
11
- const lineStartIndex = sourceText.lastIndexOf("\n", begin.raw) + 1;
12
- let lineEndIndex = sourceText.indexOf("\n", end.raw);
13
- if (lineEndIndex < 0) {
14
- lineEndIndex = sourceText.length;
15
- }
16
- const lines = sourceText.slice(lineStartIndex, lineEndIndex).split("\n");
17
- const output = [];
18
- for (let i = begin.line; i <= end.line; i++) {
19
- const line = lines[i - begin.line];
20
- output.push(line);
21
- const prevLineIndent = /^[\t ]*/.exec(line)?.[0] ?? "";
22
- if (i === begin.line) {
23
- const indent = prevLineIndent.padEnd(begin.column, " ");
24
- const squiggleEnd = begin.line === end.line ? end.column : line.length;
25
- output.push(indent.padEnd(squiggleEnd, "~"));
26
- for (const errorMessageLine of formatReportPrimary(report).split("\n")) {
27
- output.push(indent + errorMessageLine);
28
- }
29
- }
30
- else {
31
- const squiggleEnd = i === end.line ? end.column : line.length;
32
- output.push(prevLineIndent.padEnd(squiggleEnd, "~"));
33
- }
34
- }
35
- return (sourceText.slice(0, lineStartIndex) +
36
- output.join("\n") +
37
- sourceText.slice(lineEndIndex));
10
+ const { begin, end } = getDisplayedRange(sourceText, report.range);
11
+ const lineStartIndex = begin.raw - begin.column;
12
+ const lineEndIndex = getLineBounds(sourceText, end.line).end;
13
+ const lines = sourceText.slice(lineStartIndex, lineEndIndex).split("\n");
14
+ const output = [];
15
+ for (let i = begin.line; i <= end.line; i++) {
16
+ const line = nullThrows(lines[i - begin.line], "Line is expected to be present by the loop condition");
17
+ output.push(line);
18
+ const prevLineIndent = /^[\t ]*/.exec(line)?.[0] ?? "";
19
+ if (i === begin.line) {
20
+ const indent = prevLineIndent.padEnd(begin.column, " ");
21
+ const squiggleEnd = begin.line === end.line ? end.column : line.length;
22
+ output.push(indent.padEnd(squiggleEnd, "~"));
23
+ for (const errorMessageLine of formatReport(report.data, report.message.primary).split("\n")) output.push(indent + errorMessageLine);
24
+ } else {
25
+ const squiggleEnd = i === end.line ? end.column : line.length;
26
+ output.push(prevLineIndent.padEnd(squiggleEnd, "~"));
27
+ }
28
+ }
29
+ return sourceText.slice(0, lineStartIndex) + output.join("\n") + sourceText.slice(lineEndIndex);
38
30
  }
31
+ function getDisplayedRange(sourceText, { begin, end }) {
32
+ if (end.column > 0 || end.line === begin.line) return {
33
+ begin,
34
+ end
35
+ };
36
+ const previousLine = getLineBounds(sourceText, end.line - 1);
37
+ if (begin.line === previousLine.line && previousLine.text === "") {
38
+ const currentLine = getLineBounds(sourceText, end.line);
39
+ if (currentLine.text !== "") return {
40
+ begin: {
41
+ column: 0,
42
+ line: currentLine.line,
43
+ raw: currentLine.start
44
+ },
45
+ end: {
46
+ column: 1,
47
+ line: currentLine.line,
48
+ raw: currentLine.start + 1
49
+ }
50
+ };
51
+ }
52
+ return {
53
+ begin,
54
+ end: {
55
+ column: previousLine.text.length,
56
+ line: previousLine.line,
57
+ raw: previousLine.end
58
+ }
59
+ };
60
+ }
61
+ function getLineBounds(sourceText, line) {
62
+ const start = getPositionOfColumnAndLine(sourceText, {
63
+ column: 0,
64
+ line
65
+ });
66
+ let end = sourceText.indexOf("\n", start);
67
+ if (end < 0) end = sourceText.length;
68
+ return {
69
+ end,
70
+ line,
71
+ start,
72
+ text: sourceText.slice(start, end)
73
+ };
74
+ }
75
+ //#endregion
76
+ export { createReportSnapshot };
77
+
39
78
  //# sourceMappingURL=createReportSnapshot.js.map
package/lib/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { RuleTester } from "./RuleTester.js";
2
- export type * from "./types.js";
3
- //# sourceMappingURL=index.d.ts.map
1
+ import { InvalidTestCase, TestCase, TestSuggestion, TestSuggestionFileCase, TestSuggestionForFile, TestSuggestionForFiles, ValidTestCase, ValidTestCaseObject } from "./types.js";
2
+ import { RuleTester } from "./RuleTester.js";
3
+ export { InvalidTestCase, RuleTester, TestCase, TestSuggestion, TestSuggestionFileCase, TestSuggestionForFile, TestSuggestionForFiles, ValidTestCase, ValidTestCaseObject };
package/lib/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { RuleTester } from "./RuleTester.js";
2
- //# sourceMappingURL=index.js.map
1
+ import { RuleTester } from "./RuleTester.js";
2
+ export { RuleTester };
@@ -1,4 +1,4 @@
1
- import { TestCase } from "./types.js";
1
+ import type { TestCase } from "./types.ts";
2
2
  export interface TestCaseNormalized extends TestCase {
3
3
  fileName: string;
4
4
  }
@@ -1,8 +1,10 @@
1
- export function normalizeTestCase(testCase, fileName) {
2
- const rv = {
3
- ...testCase,
4
- };
5
- rv.fileName ??= fileName ?? "file.ts";
6
- return rv;
1
+ //#region src/normalizeTestCase.ts
2
+ function normalizeTestCase(testCase, fileName) {
3
+ const rv = { ...testCase };
4
+ rv.fileName ??= fileName ?? "file.ts";
5
+ return rv;
7
6
  }
7
+ //#endregion
8
+ export { normalizeTestCase };
9
+
8
10
  //# sourceMappingURL=normalizeTestCase.js.map
@@ -1,3 +1,3 @@
1
- import { TestSuggestion, TestSuggestionForFiles } from "./types.js";
1
+ import type { TestSuggestion, TestSuggestionForFiles } from "./types.ts";
2
2
  export declare function isTestSuggestionForFiles(suggestion: TestSuggestion): suggestion is TestSuggestionForFiles;
3
3
  //# sourceMappingURL=predicates.d.ts.map
package/lib/predicates.js CHANGED
@@ -1,4 +1,8 @@
1
- export function isTestSuggestionForFiles(suggestion) {
2
- return "files" in suggestion;
1
+ //#region src/predicates.ts
2
+ function isTestSuggestionForFiles(suggestion) {
3
+ return "files" in suggestion;
3
4
  }
5
+ //#endregion
6
+ export { isTestSuggestionForFiles };
7
+
4
8
  //# sourceMappingURL=predicates.js.map
@@ -1,15 +1,15 @@
1
- import { NormalizedReport } from "@flint.fyi/core";
2
- import { TestCaseNormalized } from "./normalizeTestCase.js";
3
- import { InvalidTestCase, TestSuggestionFileCase } from "./types.js";
1
+ import { type NormalizedReport } from "@flint.fyi/core";
2
+ import type { TestCaseNormalized } from "./normalizeTestCase.ts";
3
+ import type { InvalidTestCase, TestSuggestionFileCase } from "./types.ts";
4
4
  export declare function resolveReportedSuggestions(reports: NormalizedReport[], testCaseNormalized: InvalidTestCase & TestCaseNormalized): ({
5
5
  files: {
6
6
  [k: string]: TestSuggestionFileCase[];
7
7
  };
8
8
  id: string;
9
- updated?: undefined;
9
+ updated?: never;
10
10
  } | {
11
11
  id: string;
12
12
  updated: string;
13
- files?: undefined;
13
+ files?: never;
14
14
  })[] | undefined;
15
15
  //# sourceMappingURL=resolveReportedSuggestions.d.ts.map
@@ -1,47 +1,34 @@
1
- import { applyChangesToText, isSuggestionForFiles, } from "@flint.fyi/core";
2
- import { isTruthy } from "@flint.fyi/utils";
3
1
  import { isTestSuggestionForFiles } from "./predicates.js";
4
- export function resolveReportedSuggestions(reports, testCaseNormalized) {
5
- const suggestionsReported = reports
6
- .flatMap((report) => report.suggestions)
7
- .filter(isTruthy);
8
- if (!suggestionsReported.length) {
9
- return undefined;
10
- }
11
- return suggestionsReported.map((suggestionReported) => isSuggestionForFiles(suggestionReported)
12
- ? {
13
- files: resolveReportedSuggestionForFiles(suggestionReported, testCaseNormalized),
14
- id: suggestionReported.id,
15
- }
16
- : {
17
- id: suggestionReported.id,
18
- updated: applyChangesToText([suggestionReported], testCaseNormalized.code),
19
- });
2
+ import { applyChangesToText, isSuggestionForFiles } from "@flint.fyi/core";
3
+ import { isTruthy } from "@flint.fyi/utils";
4
+ //#region src/resolveReportedSuggestions.ts
5
+ function resolveReportedSuggestions(reports, testCaseNormalized) {
6
+ const suggestionsReported = reports.flatMap((report) => report.suggestions).filter(isTruthy);
7
+ if (!suggestionsReported.length) return;
8
+ return suggestionsReported.map((suggestionReported) => isSuggestionForFiles(suggestionReported) ? {
9
+ files: resolveReportedSuggestionForFiles(suggestionReported, testCaseNormalized),
10
+ id: suggestionReported.id
11
+ } : {
12
+ id: suggestionReported.id,
13
+ updated: applyChangesToText([suggestionReported], testCaseNormalized.code)
14
+ });
20
15
  }
21
16
  function resolveReportedSuggestionForFiles(suggestionReported, testCaseNormalized) {
22
- if (!testCaseNormalized.suggestions) {
23
- return {};
24
- }
25
- if (!testCaseNormalized.suggestions.every(isTestSuggestionForFiles)) {
26
- throw new Error("This test case describes suggestions across files, but the rule is only reporting changes to its own file.");
27
- }
28
- return Object.fromEntries(testCaseNormalized.suggestions
29
- .map((suggestionExpected) => {
30
- return Object.entries(suggestionExpected.files).map(([filePath, suggestionCasesExpected]) => {
31
- return [
32
- filePath,
33
- suggestionCasesExpected.map((suggestionCaseExpected) => {
34
- const changes = suggestionReported.files[filePath]?.(suggestionCaseExpected.original);
35
- return {
36
- original: suggestionCaseExpected.original,
37
- updated: changes
38
- ? applyChangesToText(changes, suggestionCaseExpected.original)
39
- : suggestionCaseExpected.original,
40
- };
41
- }),
42
- ];
43
- });
44
- })
45
- .flat());
17
+ if (!testCaseNormalized.suggestions) return {};
18
+ 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.");
19
+ return Object.fromEntries(testCaseNormalized.suggestions.flatMap((suggestionExpected) => {
20
+ return Object.entries(suggestionExpected.files).map(([filePath, suggestionCasesExpected]) => {
21
+ return [filePath, suggestionCasesExpected.map((suggestionCaseExpected) => {
22
+ const changes = suggestionReported.files[filePath];
23
+ return {
24
+ original: suggestionCaseExpected.original,
25
+ updated: changes ? applyChangesToText(changes, suggestionCaseExpected.original) : suggestionCaseExpected.original
26
+ };
27
+ })];
28
+ });
29
+ }));
46
30
  }
31
+ //#endregion
32
+ export { resolveReportedSuggestions };
33
+
47
34
  //# sourceMappingURL=resolveReportedSuggestions.js.map
@@ -1,10 +1,9 @@
1
- import type { PromiseOrSync } from "@flint.fyi/utils";
2
- import { AnyLanguage, AnyOptionalSchema, AnyRule, InferredObject, LanguageFileFactory, type NormalizedReport, RuleAbout } from "@flint.fyi/core";
3
- import { CachedFactory } from "cached-factory";
4
- import { TestCaseNormalized } from "./normalizeTestCase.js";
1
+ import { type AnyLanguage, type AnyLanguageFileFactory, type AnyOptionalSchema, type AnyRule, type InferredOutputObject, type NormalizedReport, type RuleAbout, type VFSLinterHost } from "@flint.fyi/core";
2
+ import type { CachedFactory } from "cached-factory";
3
+ import type { TestCaseNormalized } from "./normalizeTestCase.ts";
5
4
  export interface TestCaseRuleConfiguration<OptionsSchema extends AnyOptionalSchema | undefined> {
6
- options?: InferredObject<OptionsSchema>;
5
+ options?: InferredOutputObject<OptionsSchema | undefined>;
7
6
  rule: AnyRule<RuleAbout, OptionsSchema>;
8
7
  }
9
- export declare function runTestCaseRule<OptionsSchema extends AnyOptionalSchema | undefined>(fileFactories: CachedFactory<AnyLanguage, LanguageFileFactory>, { options, rule }: Required<TestCaseRuleConfiguration<OptionsSchema>>, { code, fileName }: TestCaseNormalized): PromiseOrSync<NormalizedReport[]>;
8
+ export declare function runTestCaseRule<OptionsSchema extends AnyOptionalSchema | undefined>(fileFactories: CachedFactory<AnyLanguage, AnyLanguageFileFactory>, linterHost: VFSLinterHost, { options, rule }: Required<TestCaseRuleConfiguration<OptionsSchema>>, { code, fileName, files }: TestCaseNormalized): Promise<NormalizedReport[]>;
10
9
  //# sourceMappingURL=runTestCaseRule.d.ts.map
@@ -1,10 +1,40 @@
1
- export function runTestCaseRule(fileFactories, { options, rule }, { code, fileName }) {
2
- using file = fileFactories
3
- // TODO: How to make types more permissive around assignability?
4
- // See AnyRule's any
5
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
6
- .get(rule.language)
7
- .prepareFromVirtual(fileName, code).file;
8
- return file.runRule(rule, options);
1
+ import { processRuleReport } from "@flint.fyi/core";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import { normalizePath, pathKey } from "@flint.fyi/utils";
5
+ //#region src/runTestCaseRule.ts
6
+ async function runTestCaseRule(fileFactories, linterHost, { options, rule }, { code, fileName, files }) {
7
+ const filePathAbsolute = normalizePath(path.resolve(linterHost.getCurrentDirectory(), fileName));
8
+ const caseSensitive = linterHost.isCaseSensitiveFS();
9
+ const targetKey = pathKey(filePathAbsolute, caseSensitive);
10
+ for (const oldFile of linterHost.vfsListFiles().keys()) if (pathKey(oldFile, caseSensitive) !== targetKey) linterHost.vfsDeleteFile(oldFile);
11
+ for (const [name, content] of Object.entries(files ?? {})) {
12
+ const filePath = normalizePath(path.resolve(linterHost.getCurrentDirectory(), name));
13
+ assert.notEqual(filePath, filePathAbsolute, `Expected 'files' not to shadow '${fileName}'`);
14
+ linterHost.vfsUpsertFile(filePath, content);
15
+ }
16
+ linterHost.vfsUpsertFile(filePathAbsolute, code);
17
+ using file = fileFactories.get(rule.language).createFile({
18
+ filePath: fileName,
19
+ filePathAbsolute,
20
+ sourceText: code
21
+ });
22
+ const reports = [];
23
+ const ruleRuntime = await rule.setup({
24
+ host: linterHost,
25
+ report(ruleReport) {
26
+ const processedReport = processRuleReport(file, rule, ruleReport);
27
+ if (processedReport == null) return;
28
+ reports.push(processedReport);
29
+ }
30
+ });
31
+ if (ruleRuntime) {
32
+ rule.language.runFileVisitors(file, options, ruleRuntime);
33
+ await ruleRuntime.teardown?.();
34
+ }
35
+ return reports;
9
36
  }
37
+ //#endregion
38
+ export { runTestCaseRule };
39
+
10
40
  //# sourceMappingURL=runTestCaseRule.js.map
package/lib/types.d.ts CHANGED
@@ -1,38 +1,43 @@
1
- export interface InvalidTestCase<Options extends object | undefined = object | undefined> extends TestCase<Options> {
2
- output?: string;
3
- snapshot: string;
4
- suggestions?: TestSuggestion[];
1
+ //#region src/types.d.ts
2
+ interface InvalidTestCase<Options extends object | undefined = object | undefined> extends TestCase<Options> {
3
+ output?: string;
4
+ snapshot: string;
5
+ suggestions?: TestSuggestion[];
5
6
  }
6
- export interface TestCase<Options extends object | undefined = object | undefined> {
7
- code: string;
8
- fileName?: string;
9
- /**
10
- * Run only this test case. Useful for debugging.
11
- *
12
- * Do not commit code with this flag set.
13
- */
14
- only?: boolean;
15
- options?: Options;
16
- /**
17
- * Skip running this test case. Useful for work-in-progress tests.
18
- *
19
- * Do not commit code with this flag set.
20
- */
21
- skip?: boolean;
7
+ interface TestCase<Options extends object | undefined = object | undefined> {
8
+ code: string;
9
+ fileName?: string | undefined;
10
+ files?: Record<string, string> | undefined;
11
+ name?: string | undefined;
12
+ /**
13
+ * Run only this test case. Useful for debugging.
14
+ *
15
+ * Do not commit code with this flag set.
16
+ */
17
+ only?: boolean;
18
+ options?: Options | undefined;
19
+ /**
20
+ * Skip running this test case. Useful for work-in-progress tests.
21
+ *
22
+ * Do not commit code with this flag set.
23
+ */
24
+ skip?: boolean;
22
25
  }
23
- export type TestSuggestion = TestSuggestionForFile | TestSuggestionForFiles;
24
- export interface TestSuggestionFileCase {
25
- original: string;
26
- updated: string;
26
+ type TestSuggestion = TestSuggestionForFile | TestSuggestionForFiles;
27
+ interface TestSuggestionFileCase {
28
+ original: string;
29
+ updated: string;
27
30
  }
28
- export interface TestSuggestionForFile {
29
- id: string;
30
- updated: string;
31
+ interface TestSuggestionForFile {
32
+ id: string;
33
+ updated: string;
31
34
  }
32
- export interface TestSuggestionForFiles {
33
- files: Record<string, TestSuggestionFileCase[]>;
34
- id: string;
35
+ interface TestSuggestionForFiles {
36
+ files: Record<string, TestSuggestionFileCase[]>;
37
+ id: string;
35
38
  }
36
- export type ValidTestCase<Options extends object | undefined> = string | ValidTestCaseObject<Options>;
37
- export type ValidTestCaseObject<Options extends object | undefined> = TestCase<Options>;
39
+ type ValidTestCase<Options extends object | undefined> = string | ValidTestCaseObject<Options>;
40
+ type ValidTestCaseObject<Options extends object | undefined> = TestCase<Options>;
41
+ //#endregion
42
+ export { InvalidTestCase, TestCase, TestSuggestion, TestSuggestionFileCase, TestSuggestionForFile, TestSuggestionForFiles, ValidTestCase, ValidTestCaseObject };
38
43
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@flint.fyi/rule-tester",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "[Experimental] Rule unit tests for Flint.",
5
+ "homepage": "https://flint.fyi",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "git+https://github.com/flint-fyi/flint.git",
@@ -22,17 +23,21 @@
22
23
  "!lib/**/*.map"
23
24
  ],
24
25
  "dependencies": {
25
- "@flint.fyi/core": "",
26
- "@flint.fyi/utils": "",
27
- "cached-factory": "^0.1.0"
26
+ "cached-factory": "^0.1.0",
27
+ "@flint.fyi/core": "^0.21.0",
28
+ "@flint.fyi/utils": "^0.14.1"
28
29
  },
29
30
  "devDependencies": {
30
- "vitest": "4.0.15"
31
+ "tsdown": "0.21.0",
32
+ "vitest": "4.1.0"
31
33
  },
32
34
  "engines": {
33
35
  "node": ">=24.0.0"
34
36
  },
35
37
  "publishConfig": {
36
38
  "access": "public"
39
+ },
40
+ "scripts": {
41
+ "test": "vitest --project rule-tester"
37
42
  }
38
43
  }
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=resolveReportedSuggestions.test.d.ts.map
@@ -1,173 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { resolveReportedSuggestions } from "./resolveReportedSuggestions.js";
3
- const mockReport = {
4
- message: { primary: "", secondary: [], suggestions: [] },
5
- range: {
6
- begin: { column: 0, line: 1, raw: 0 },
7
- end: { column: 3, line: 1, raw: 3 },
8
- },
9
- };
10
- const mockTestCaseNormalized = {
11
- code: "xyz",
12
- fileName: "file.ts",
13
- snapshot: "",
14
- };
15
- describe("resolveReportedSuggestions", () => {
16
- it("returns undefined when reports is empty", () => {
17
- const result = resolveReportedSuggestions([], mockTestCaseNormalized);
18
- expect(result).toEqual(undefined);
19
- });
20
- it("returns undefined when given one report with no suggestions", () => {
21
- const report = {
22
- ...mockReport,
23
- suggestions: [],
24
- };
25
- const result = resolveReportedSuggestions([report], mockTestCaseNormalized);
26
- expect(result).toEqual(undefined);
27
- });
28
- it("returns id and updated text when given a single file suggestion", () => {
29
- const suggestion = {
30
- id: "suggestion",
31
- range: { begin: 0, end: 3 },
32
- text: "abc",
33
- };
34
- const report = {
35
- ...mockReport,
36
- suggestions: [suggestion],
37
- };
38
- const result = resolveReportedSuggestions([report], mockTestCaseNormalized);
39
- expect(result).toEqual([
40
- {
41
- id: suggestion.id,
42
- updated: suggestion.text,
43
- },
44
- ]);
45
- });
46
- it("throws when given a test case with no suggestions", () => {
47
- const report = {
48
- ...mockReport,
49
- suggestions: [
50
- {
51
- files: {
52
- "file.ts": () => [{ range: { begin: 0, end: 3 }, text: "def" }],
53
- },
54
- id: "suggestion-report",
55
- },
56
- ],
57
- };
58
- expect(() => resolveReportedSuggestions([report], {
59
- ...mockTestCaseNormalized,
60
- suggestions: [
61
- {
62
- id: "suggestion-result",
63
- updated: "...",
64
- },
65
- ],
66
- })).toThrowErrorMatchingInlineSnapshot(`[Error: This test case describes suggestions across files, but the rule is only reporting changes to its own file.]`);
67
- });
68
- it("throws when given a test case that doesn't have cross-file suggestions", () => {
69
- const report = {
70
- ...mockReport,
71
- suggestions: [
72
- {
73
- files: {
74
- "file.ts": () => [{ range: { begin: 0, end: 3 }, text: "def" }],
75
- },
76
- id: "suggestion-report",
77
- },
78
- ],
79
- };
80
- expect(() => resolveReportedSuggestions([report], {
81
- ...mockTestCaseNormalized,
82
- suggestions: [
83
- {
84
- id: "suggestion-result",
85
- updated: "...",
86
- },
87
- ],
88
- })).toThrowErrorMatchingInlineSnapshot(`[Error: This test case describes suggestions across files, but the rule is only reporting changes to its own file.]`);
89
- });
90
- it("returns id and a files object when given multi-file suggestions with a single file", () => {
91
- const report = {
92
- ...mockReport,
93
- suggestions: [
94
- {
95
- files: {
96
- "file.ts": () => [{ range: { begin: 0, end: 3 }, text: "def" }],
97
- },
98
- id: "suggestion-report",
99
- },
100
- ],
101
- };
102
- const result = resolveReportedSuggestions([report], {
103
- ...mockTestCaseNormalized,
104
- suggestions: [
105
- {
106
- files: {
107
- "file.ts": [{ original: "abc", updated: "def" }],
108
- },
109
- id: "suggestion-result",
110
- },
111
- ],
112
- });
113
- expect(result).toEqual([
114
- {
115
- files: {
116
- "file.ts": [
117
- {
118
- original: "abc",
119
- updated: "def",
120
- },
121
- ],
122
- },
123
- id: "suggestion-report",
124
- },
125
- ]);
126
- });
127
- it("returns id and a files object when given multi-file suggestions with multiple file", () => {
128
- const report = {
129
- ...mockReport,
130
- suggestions: [
131
- {
132
- files: {
133
- "fileA.ts": () => [{ range: { begin: 0, end: 5 }, text: "def-A" }],
134
- "fileB.ts": () => [{ range: { begin: 0, end: 5 }, text: "def-B" }],
135
- },
136
- id: "suggestion-report",
137
- },
138
- ],
139
- };
140
- const result = resolveReportedSuggestions([report], {
141
- ...mockTestCaseNormalized,
142
- suggestions: [
143
- {
144
- files: {
145
- "fileA.ts": [{ original: "abc-A", updated: "def-A" }],
146
- "fileB.ts": [{ original: "abc-B", updated: "def-B" }],
147
- },
148
- id: "suggestion-result",
149
- },
150
- ],
151
- });
152
- expect(result).toEqual([
153
- {
154
- files: {
155
- "fileA.ts": [
156
- {
157
- original: "abc-A",
158
- updated: "def-A",
159
- },
160
- ],
161
- "fileB.ts": [
162
- {
163
- original: "abc-B",
164
- updated: "def-B",
165
- },
166
- ],
167
- },
168
- id: "suggestion-report",
169
- },
170
- ]);
171
- });
172
- });
173
- //# sourceMappingURL=resolveReportedSuggestions.test.js.map
package/lib/types.js DELETED
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=types.js.map