Skip to content

Commit 4ba2da7

Browse files
committed
fix: add tests and fix typing for formatter trigger (#2768)
1 parent f95d3b1 commit 4ba2da7

File tree

4 files changed

+385
-7
lines changed

4 files changed

+385
-7
lines changed

src/plugin-handlers/config-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { applyMcpConfig } from "./mcp-config-handler";
77
import { applyProviderConfig } from "./provider-config-handler";
88
import { loadPluginComponents } from "./plugin-components-loader";
99
import { applyToolConfig } from "./tool-config-handler";
10+
import { clearFormatterCache } from "../tools/hashline-edit/formatter-trigger"
1011

1112
export { resolveCategoryConfig } from "./category-config-resolver";
1213

@@ -23,6 +24,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
2324
const formatterConfig = config.formatter;
2425

2526
applyProviderConfig({ config, modelCacheState });
27+
clearFormatterCache()
2628

2729
const pluginComponents = await loadPluginComponents({ pluginConfig });
2830

Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import { describe, it, expect, beforeEach, mock } from "bun:test"
2+
import {
3+
runFormattersForFile,
4+
clearFormatterCache,
5+
resolveFormatters,
6+
buildFormatterCommand,
7+
type FormatterClient,
8+
} from "./formatter-trigger"
9+
10+
function createMockClient(config: Record<string, unknown> = {}): FormatterClient {
11+
return {
12+
config: {
13+
get: mock(() => Promise.resolve({ data: config })),
14+
},
15+
}
16+
}
17+
18+
describe("buildFormatterCommand", () => {
19+
it("substitutes $FILE with the actual file path", () => {
20+
//#given
21+
const command = ["prettier", "--write", "$FILE"]
22+
const filePath = "/src/index.ts"
23+
24+
//#when
25+
const result = buildFormatterCommand(command, filePath)
26+
27+
//#then
28+
expect(result).toEqual(["prettier", "--write", "/src/index.ts"])
29+
})
30+
31+
it("substitutes multiple $FILE occurrences in the same arg", () => {
32+
//#given
33+
const command = ["echo", "$FILE:$FILE"]
34+
const filePath = "test.ts"
35+
36+
//#when
37+
const result = buildFormatterCommand(command, filePath)
38+
39+
//#then
40+
expect(result).toEqual(["echo", "test.ts:test.ts"])
41+
})
42+
43+
it("returns command unchanged when no $FILE present", () => {
44+
//#given
45+
const command = ["prettier", "--check", "."]
46+
47+
//#when
48+
const result = buildFormatterCommand(command, "/some/file.ts")
49+
50+
//#then
51+
expect(result).toEqual(["prettier", "--check", "."])
52+
})
53+
})
54+
55+
describe("resolveFormatters", () => {
56+
beforeEach(() => {
57+
clearFormatterCache()
58+
})
59+
60+
it("resolves formatters from config.formatter section", async () => {
61+
//#given
62+
const client = createMockClient({
63+
formatter: {
64+
prettier: {
65+
command: ["prettier", "--write", "$FILE"],
66+
extensions: [".ts", ".tsx"],
67+
},
68+
},
69+
})
70+
71+
//#when
72+
const result = await resolveFormatters(client, "/project")
73+
74+
//#then
75+
expect(result.get(".ts")).toEqual([{ command: ["prettier", "--write", "$FILE"], environment: {} }])
76+
expect(result.get(".tsx")).toEqual([{ command: ["prettier", "--write", "$FILE"], environment: {} }])
77+
})
78+
79+
it("resolves formatters from experimental.hook.file_edited section", async () => {
80+
//#given
81+
const client = createMockClient({
82+
experimental: {
83+
hook: {
84+
file_edited: {
85+
".go": [{ command: ["gofmt", "-w", "$FILE"], environment: { GOPATH: "/go" } }],
86+
},
87+
},
88+
},
89+
})
90+
91+
//#when
92+
const result = await resolveFormatters(client, "/project")
93+
94+
//#then
95+
expect(result.get(".go")).toEqual([{ command: ["gofmt", "-w", "$FILE"], environment: { GOPATH: "/go" } }])
96+
})
97+
98+
it("normalizes extensions without leading dot", async () => {
99+
//#given
100+
const client = createMockClient({
101+
formatter: {
102+
biome: {
103+
command: ["biome", "format", "$FILE"],
104+
extensions: ["ts", "js"],
105+
},
106+
},
107+
})
108+
109+
//#when
110+
const result = await resolveFormatters(client, "/project")
111+
112+
//#then
113+
expect(result.has(".ts")).toBe(true)
114+
expect(result.has(".js")).toBe(true)
115+
})
116+
117+
it("skips disabled formatters", async () => {
118+
//#given
119+
const client = createMockClient({
120+
formatter: {
121+
prettier: {
122+
disabled: true,
123+
command: ["prettier", "--write", "$FILE"],
124+
extensions: [".ts"],
125+
},
126+
},
127+
})
128+
129+
//#when
130+
const result = await resolveFormatters(client, "/project")
131+
132+
//#then
133+
expect(result.size).toBe(0)
134+
})
135+
136+
it("skips formatters without command", async () => {
137+
//#given
138+
const client = createMockClient({
139+
formatter: {
140+
prettier: {
141+
extensions: [".ts"],
142+
},
143+
},
144+
})
145+
146+
//#when
147+
const result = await resolveFormatters(client, "/project")
148+
149+
//#then
150+
expect(result.size).toBe(0)
151+
})
152+
153+
it("skips formatters without extensions", async () => {
154+
//#given
155+
const client = createMockClient({
156+
formatter: {
157+
prettier: {
158+
command: ["prettier", "--write", "$FILE"],
159+
},
160+
},
161+
})
162+
163+
//#when
164+
const result = await resolveFormatters(client, "/project")
165+
166+
//#then
167+
expect(result.size).toBe(0)
168+
})
169+
170+
it("returns cached result on subsequent calls", async () => {
171+
//#given
172+
const client = createMockClient({
173+
formatter: {
174+
prettier: {
175+
command: ["prettier", "--write", "$FILE"],
176+
extensions: [".ts"],
177+
},
178+
},
179+
})
180+
await resolveFormatters(client, "/project")
181+
182+
//#when
183+
const result = await resolveFormatters(client, "/project")
184+
185+
//#then
186+
expect(client.config.get).toHaveBeenCalledTimes(1)
187+
expect(result.get(".ts")).toHaveLength(1)
188+
})
189+
190+
it("returns fresh result after clearFormatterCache", async () => {
191+
//#given
192+
const client = createMockClient({
193+
formatter: {
194+
prettier: {
195+
command: ["prettier", "--write", "$FILE"],
196+
extensions: [".ts"],
197+
},
198+
},
199+
})
200+
await resolveFormatters(client, "/project")
201+
clearFormatterCache()
202+
203+
//#when
204+
await resolveFormatters(client, "/project")
205+
206+
//#then
207+
expect(client.config.get).toHaveBeenCalledTimes(2)
208+
})
209+
210+
it("handles config.get failure gracefully", async () => {
211+
//#given
212+
const client: FormatterClient = {
213+
config: {
214+
get: mock(() => Promise.reject(new Error("network error"))),
215+
},
216+
}
217+
218+
//#when
219+
const result = await resolveFormatters(client, "/project")
220+
221+
//#then
222+
expect(result.size).toBe(0)
223+
})
224+
225+
it("handles missing config data", async () => {
226+
//#given
227+
const client: FormatterClient = {
228+
config: {
229+
get: mock(() => Promise.resolve({ data: undefined })),
230+
},
231+
}
232+
233+
//#when
234+
const result = await resolveFormatters(client, "/project")
235+
236+
//#then
237+
expect(result.size).toBe(0)
238+
})
239+
240+
it("merges formatter and experimental.hook.file_edited for same extension", async () => {
241+
//#given
242+
const client = createMockClient({
243+
formatter: {
244+
prettier: {
245+
command: ["prettier", "--write", "$FILE"],
246+
extensions: [".ts"],
247+
},
248+
},
249+
experimental: {
250+
hook: {
251+
file_edited: {
252+
".ts": [{ command: ["eslint", "--fix", "$FILE"] }],
253+
},
254+
},
255+
},
256+
})
257+
258+
//#when
259+
const result = await resolveFormatters(client, "/project")
260+
261+
//#then
262+
expect(result.get(".ts")).toHaveLength(2)
263+
expect(result.get(".ts")![0].command).toEqual(["prettier", "--write", "$FILE"])
264+
expect(result.get(".ts")![1].command).toEqual(["eslint", "--fix", "$FILE"])
265+
})
266+
267+
it("defaults environment to empty object when not specified", async () => {
268+
//#given
269+
const client = createMockClient({
270+
experimental: {
271+
hook: {
272+
file_edited: {
273+
".py": [{ command: ["black", "$FILE"] }],
274+
},
275+
},
276+
},
277+
})
278+
279+
//#when
280+
const result = await resolveFormatters(client, "/project")
281+
282+
//#then
283+
expect(result.get(".py")![0].environment).toEqual({})
284+
})
285+
286+
it("preserves environment from formatter config", async () => {
287+
//#given
288+
const client = createMockClient({
289+
formatter: {
290+
biome: {
291+
command: ["biome", "format", "$FILE"],
292+
extensions: [".ts"],
293+
environment: { BIOME_LOG: "debug" },
294+
},
295+
},
296+
})
297+
298+
//#when
299+
const result = await resolveFormatters(client, "/project")
300+
301+
//#then
302+
expect(result.get(".ts")![0].environment).toEqual({ BIOME_LOG: "debug" })
303+
})
304+
305+
it("skips formatter=false config", async () => {
306+
//#given
307+
const client = createMockClient({
308+
formatter: false,
309+
})
310+
311+
//#when
312+
const result = await resolveFormatters(client, "/project")
313+
314+
//#then
315+
expect(result.size).toBe(0)
316+
})
317+
})
318+
319+
describe("runFormattersForFile", () => {
320+
beforeEach(() => {
321+
clearFormatterCache()
322+
})
323+
324+
it("skips files without extensions", async () => {
325+
//#given
326+
const client = createMockClient({
327+
formatter: {
328+
prettier: {
329+
command: ["prettier", "--write", "$FILE"],
330+
extensions: [".ts"],
331+
},
332+
},
333+
})
334+
335+
//#when
336+
await runFormattersForFile(client, "/project", "Makefile")
337+
338+
//#then
339+
expect(client.config.get).not.toHaveBeenCalled()
340+
})
341+
342+
it("skips when no matching formatters for extension", async () => {
343+
//#given
344+
const client = createMockClient({
345+
formatter: {
346+
prettier: {
347+
command: ["prettier", "--write", "$FILE"],
348+
extensions: [".ts"],
349+
},
350+
},
351+
})
352+
353+
//#when — run for a .go file, but only .ts formatters registered
354+
await runFormattersForFile(client, "/project", "/src/main.go")
355+
356+
//#then — no error thrown
357+
})
358+
359+
it("runs formatter for matching extension", async () => {
360+
//#given
361+
const client = createMockClient({
362+
formatter: {
363+
echo: {
364+
command: ["echo", "$FILE"],
365+
extensions: [".ts"],
366+
},
367+
},
368+
})
369+
370+
//#when — echo is a safe no-op command
371+
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
372+
373+
//#then — should complete without error
374+
expect(client.config.get).toHaveBeenCalledTimes(1)
375+
})
376+
})

0 commit comments

Comments
 (0)