index.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. import { afterEach, describe, test, expect } from "bun:test"
  2. import { $ } from "bun"
  3. import path from "path"
  4. import fs from "fs/promises"
  5. import { File } from "../../src/file"
  6. import { Instance } from "../../src/project/instance"
  7. import { Filesystem } from "../../src/util/filesystem"
  8. import { tmpdir } from "../fixture/fixture"
  9. afterEach(async () => {
  10. await Instance.disposeAll()
  11. })
  12. describe("file/index Filesystem patterns", () => {
  13. describe("File.read() - text content", () => {
  14. test("reads text file via Filesystem.readText()", async () => {
  15. await using tmp = await tmpdir()
  16. const filepath = path.join(tmp.path, "test.txt")
  17. await fs.writeFile(filepath, "Hello World", "utf-8")
  18. await Instance.provide({
  19. directory: tmp.path,
  20. fn: async () => {
  21. const result = await File.read("test.txt")
  22. expect(result.type).toBe("text")
  23. expect(result.content).toBe("Hello World")
  24. },
  25. })
  26. })
  27. test("reads with Filesystem.exists() check", async () => {
  28. await using tmp = await tmpdir()
  29. await Instance.provide({
  30. directory: tmp.path,
  31. fn: async () => {
  32. // Non-existent file should return empty content
  33. const result = await File.read("nonexistent.txt")
  34. expect(result.type).toBe("text")
  35. expect(result.content).toBe("")
  36. },
  37. })
  38. })
  39. test("trims whitespace from text content", async () => {
  40. await using tmp = await tmpdir()
  41. const filepath = path.join(tmp.path, "test.txt")
  42. await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
  43. await Instance.provide({
  44. directory: tmp.path,
  45. fn: async () => {
  46. const result = await File.read("test.txt")
  47. expect(result.content).toBe("content with spaces")
  48. },
  49. })
  50. })
  51. test("handles empty text file", async () => {
  52. await using tmp = await tmpdir()
  53. const filepath = path.join(tmp.path, "empty.txt")
  54. await fs.writeFile(filepath, "", "utf-8")
  55. await Instance.provide({
  56. directory: tmp.path,
  57. fn: async () => {
  58. const result = await File.read("empty.txt")
  59. expect(result.type).toBe("text")
  60. expect(result.content).toBe("")
  61. },
  62. })
  63. })
  64. test("handles multi-line text files", async () => {
  65. await using tmp = await tmpdir()
  66. const filepath = path.join(tmp.path, "multiline.txt")
  67. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  68. await Instance.provide({
  69. directory: tmp.path,
  70. fn: async () => {
  71. const result = await File.read("multiline.txt")
  72. expect(result.content).toBe("line1\nline2\nline3")
  73. },
  74. })
  75. })
  76. })
  77. describe("File.read() - binary content", () => {
  78. test("reads binary file via Filesystem.readArrayBuffer()", async () => {
  79. await using tmp = await tmpdir()
  80. const filepath = path.join(tmp.path, "image.png")
  81. const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  82. await fs.writeFile(filepath, binaryContent)
  83. await Instance.provide({
  84. directory: tmp.path,
  85. fn: async () => {
  86. const result = await File.read("image.png")
  87. expect(result.type).toBe("text") // Images return as text with base64 encoding
  88. expect(result.encoding).toBe("base64")
  89. expect(result.mimeType).toBe("image/png")
  90. expect(result.content).toBe(binaryContent.toString("base64"))
  91. },
  92. })
  93. })
  94. test("returns empty for binary non-image files", async () => {
  95. await using tmp = await tmpdir()
  96. const filepath = path.join(tmp.path, "binary.so")
  97. await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
  98. await Instance.provide({
  99. directory: tmp.path,
  100. fn: async () => {
  101. const result = await File.read("binary.so")
  102. expect(result.type).toBe("binary")
  103. expect(result.content).toBe("")
  104. },
  105. })
  106. })
  107. })
  108. describe("File.read() - Filesystem.mimeType()", () => {
  109. test("detects MIME type via Filesystem.mimeType()", async () => {
  110. await using tmp = await tmpdir()
  111. const filepath = path.join(tmp.path, "test.json")
  112. await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
  113. await Instance.provide({
  114. directory: tmp.path,
  115. fn: async () => {
  116. expect(Filesystem.mimeType(filepath)).toContain("application/json")
  117. const result = await File.read("test.json")
  118. expect(result.type).toBe("text")
  119. },
  120. })
  121. })
  122. test("handles various image MIME types", async () => {
  123. await using tmp = await tmpdir()
  124. const testCases = [
  125. { ext: "jpg", mime: "image/jpeg" },
  126. { ext: "png", mime: "image/png" },
  127. { ext: "gif", mime: "image/gif" },
  128. { ext: "webp", mime: "image/webp" },
  129. ]
  130. for (const { ext, mime } of testCases) {
  131. const filepath = path.join(tmp.path, `test.${ext}`)
  132. await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
  133. await Instance.provide({
  134. directory: tmp.path,
  135. fn: async () => {
  136. expect(Filesystem.mimeType(filepath)).toContain(mime)
  137. },
  138. })
  139. }
  140. })
  141. })
  142. describe("File.list() - Filesystem.exists() and readText()", () => {
  143. test("reads .gitignore via Filesystem.exists() and readText()", async () => {
  144. await using tmp = await tmpdir({ git: true })
  145. await Instance.provide({
  146. directory: tmp.path,
  147. fn: async () => {
  148. const gitignorePath = path.join(tmp.path, ".gitignore")
  149. await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
  150. // This is used internally in File.list()
  151. expect(await Filesystem.exists(gitignorePath)).toBe(true)
  152. const content = await Filesystem.readText(gitignorePath)
  153. expect(content).toContain("node_modules")
  154. },
  155. })
  156. })
  157. test("reads .ignore file similarly", async () => {
  158. await using tmp = await tmpdir({ git: true })
  159. await Instance.provide({
  160. directory: tmp.path,
  161. fn: async () => {
  162. const ignorePath = path.join(tmp.path, ".ignore")
  163. await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
  164. expect(await Filesystem.exists(ignorePath)).toBe(true)
  165. expect(await Filesystem.readText(ignorePath)).toContain("*.log")
  166. },
  167. })
  168. })
  169. test("handles missing .gitignore gracefully", async () => {
  170. await using tmp = await tmpdir({ git: true })
  171. await Instance.provide({
  172. directory: tmp.path,
  173. fn: async () => {
  174. const gitignorePath = path.join(tmp.path, ".gitignore")
  175. expect(await Filesystem.exists(gitignorePath)).toBe(false)
  176. // File.list() should still work
  177. const nodes = await File.list()
  178. expect(Array.isArray(nodes)).toBe(true)
  179. },
  180. })
  181. })
  182. })
  183. describe("File.changed() - Filesystem.readText() for untracked files", () => {
  184. test("reads untracked files via Filesystem.readText()", async () => {
  185. await using tmp = await tmpdir({ git: true })
  186. await Instance.provide({
  187. directory: tmp.path,
  188. fn: async () => {
  189. const untrackedPath = path.join(tmp.path, "untracked.txt")
  190. await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
  191. // This is how File.changed() reads untracked files
  192. const content = await Filesystem.readText(untrackedPath)
  193. const lines = content.split("\n").length
  194. expect(lines).toBe(2)
  195. },
  196. })
  197. })
  198. })
  199. describe("Error handling", () => {
  200. test("handles errors gracefully in Filesystem.readText()", async () => {
  201. await using tmp = await tmpdir()
  202. const filepath = path.join(tmp.path, "readonly.txt")
  203. await fs.writeFile(filepath, "content", "utf-8")
  204. await Instance.provide({
  205. directory: tmp.path,
  206. fn: async () => {
  207. const nonExistentPath = path.join(tmp.path, "does-not-exist.txt")
  208. // Filesystem.readText() on non-existent file throws
  209. await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
  210. // But File.read() handles this gracefully
  211. const result = await File.read("does-not-exist.txt")
  212. expect(result.content).toBe("")
  213. },
  214. })
  215. })
  216. test("handles errors in Filesystem.readArrayBuffer()", async () => {
  217. await using tmp = await tmpdir()
  218. await Instance.provide({
  219. directory: tmp.path,
  220. fn: async () => {
  221. const nonExistentPath = path.join(tmp.path, "does-not-exist.bin")
  222. const buffer = await Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))
  223. expect(buffer.byteLength).toBe(0)
  224. },
  225. })
  226. })
  227. test("returns empty array buffer on error for images", async () => {
  228. await using tmp = await tmpdir()
  229. const filepath = path.join(tmp.path, "broken.png")
  230. // Don't create the file
  231. await Instance.provide({
  232. directory: tmp.path,
  233. fn: async () => {
  234. // File.read() handles missing images gracefully
  235. const result = await File.read("broken.png")
  236. expect(result.type).toBe("text")
  237. expect(result.content).toBe("")
  238. },
  239. })
  240. })
  241. })
  242. describe("shouldEncode() logic", () => {
  243. test("treats .ts files as text", async () => {
  244. await using tmp = await tmpdir()
  245. const filepath = path.join(tmp.path, "test.ts")
  246. await fs.writeFile(filepath, "export const value = 1", "utf-8")
  247. await Instance.provide({
  248. directory: tmp.path,
  249. fn: async () => {
  250. const result = await File.read("test.ts")
  251. expect(result.type).toBe("text")
  252. expect(result.content).toBe("export const value = 1")
  253. },
  254. })
  255. })
  256. test("treats .mts files as text", async () => {
  257. await using tmp = await tmpdir()
  258. const filepath = path.join(tmp.path, "test.mts")
  259. await fs.writeFile(filepath, "export const value = 1", "utf-8")
  260. await Instance.provide({
  261. directory: tmp.path,
  262. fn: async () => {
  263. const result = await File.read("test.mts")
  264. expect(result.type).toBe("text")
  265. expect(result.content).toBe("export const value = 1")
  266. },
  267. })
  268. })
  269. test("treats .sh files as text", async () => {
  270. await using tmp = await tmpdir()
  271. const filepath = path.join(tmp.path, "test.sh")
  272. await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8")
  273. await Instance.provide({
  274. directory: tmp.path,
  275. fn: async () => {
  276. const result = await File.read("test.sh")
  277. expect(result.type).toBe("text")
  278. expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
  279. },
  280. })
  281. })
  282. test("treats Dockerfile as text", async () => {
  283. await using tmp = await tmpdir()
  284. const filepath = path.join(tmp.path, "Dockerfile")
  285. await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8")
  286. await Instance.provide({
  287. directory: tmp.path,
  288. fn: async () => {
  289. const result = await File.read("Dockerfile")
  290. expect(result.type).toBe("text")
  291. expect(result.content).toBe("FROM alpine:3.20")
  292. },
  293. })
  294. })
  295. test("returns encoding info for text files", async () => {
  296. await using tmp = await tmpdir()
  297. const filepath = path.join(tmp.path, "test.txt")
  298. await fs.writeFile(filepath, "simple text", "utf-8")
  299. await Instance.provide({
  300. directory: tmp.path,
  301. fn: async () => {
  302. const result = await File.read("test.txt")
  303. expect(result.encoding).toBeUndefined()
  304. expect(result.type).toBe("text")
  305. },
  306. })
  307. })
  308. test("returns base64 encoding for images", async () => {
  309. await using tmp = await tmpdir()
  310. const filepath = path.join(tmp.path, "test.jpg")
  311. await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
  312. await Instance.provide({
  313. directory: tmp.path,
  314. fn: async () => {
  315. const result = await File.read("test.jpg")
  316. expect(result.encoding).toBe("base64")
  317. expect(result.mimeType).toBe("image/jpeg")
  318. },
  319. })
  320. })
  321. })
  322. describe("Path security", () => {
  323. test("throws for paths outside project directory", async () => {
  324. await using tmp = await tmpdir()
  325. await Instance.provide({
  326. directory: tmp.path,
  327. fn: async () => {
  328. await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
  329. },
  330. })
  331. })
  332. test("throws for paths outside project directory", async () => {
  333. await using tmp = await tmpdir()
  334. await Instance.provide({
  335. directory: tmp.path,
  336. fn: async () => {
  337. await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
  338. },
  339. })
  340. })
  341. })
  342. describe("File.status()", () => {
  343. test("detects modified file", async () => {
  344. await using tmp = await tmpdir({ git: true })
  345. const filepath = path.join(tmp.path, "file.txt")
  346. await fs.writeFile(filepath, "original\n", "utf-8")
  347. await $`git add .`.cwd(tmp.path).quiet()
  348. await $`git commit -m "add file"`.cwd(tmp.path).quiet()
  349. await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
  350. await Instance.provide({
  351. directory: tmp.path,
  352. fn: async () => {
  353. const result = await File.status()
  354. const entry = result.find((f) => f.path === "file.txt")
  355. expect(entry).toBeDefined()
  356. expect(entry!.status).toBe("modified")
  357. expect(entry!.added).toBeGreaterThan(0)
  358. expect(entry!.removed).toBeGreaterThan(0)
  359. },
  360. })
  361. })
  362. test("detects untracked file as added", async () => {
  363. await using tmp = await tmpdir({ git: true })
  364. await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
  365. await Instance.provide({
  366. directory: tmp.path,
  367. fn: async () => {
  368. const result = await File.status()
  369. const entry = result.find((f) => f.path === "new.txt")
  370. expect(entry).toBeDefined()
  371. expect(entry!.status).toBe("added")
  372. expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4
  373. expect(entry!.removed).toBe(0)
  374. },
  375. })
  376. })
  377. test("detects deleted file", async () => {
  378. await using tmp = await tmpdir({ git: true })
  379. const filepath = path.join(tmp.path, "gone.txt")
  380. await fs.writeFile(filepath, "content\n", "utf-8")
  381. await $`git add .`.cwd(tmp.path).quiet()
  382. await $`git commit -m "add file"`.cwd(tmp.path).quiet()
  383. await fs.rm(filepath)
  384. await Instance.provide({
  385. directory: tmp.path,
  386. fn: async () => {
  387. const result = await File.status()
  388. // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
  389. const entries = result.filter((f) => f.path === "gone.txt")
  390. expect(entries.some((e) => e.status === "deleted")).toBe(true)
  391. },
  392. })
  393. })
  394. test("detects mixed changes", async () => {
  395. await using tmp = await tmpdir({ git: true })
  396. await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8")
  397. await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8")
  398. await $`git add .`.cwd(tmp.path).quiet()
  399. await $`git commit -m "initial"`.cwd(tmp.path).quiet()
  400. // Modify one, delete one, add one
  401. await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8")
  402. await fs.rm(path.join(tmp.path, "remove.txt"))
  403. await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
  404. await Instance.provide({
  405. directory: tmp.path,
  406. fn: async () => {
  407. const result = await File.status()
  408. expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
  409. expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
  410. expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
  411. },
  412. })
  413. })
  414. test("returns empty for non-git project", async () => {
  415. await using tmp = await tmpdir()
  416. await Instance.provide({
  417. directory: tmp.path,
  418. fn: async () => {
  419. const result = await File.status()
  420. expect(result).toEqual([])
  421. },
  422. })
  423. })
  424. test("returns empty for clean repo", async () => {
  425. await using tmp = await tmpdir({ git: true })
  426. await Instance.provide({
  427. directory: tmp.path,
  428. fn: async () => {
  429. const result = await File.status()
  430. expect(result).toEqual([])
  431. },
  432. })
  433. })
  434. test("parses binary numstat as 0", async () => {
  435. await using tmp = await tmpdir({ git: true })
  436. const filepath = path.join(tmp.path, "data.bin")
  437. // Write content with null bytes so git treats it as binary
  438. const binaryData = Buffer.alloc(256)
  439. for (let i = 0; i < 256; i++) binaryData[i] = i
  440. await fs.writeFile(filepath, binaryData)
  441. await $`git add .`.cwd(tmp.path).quiet()
  442. await $`git commit -m "add binary"`.cwd(tmp.path).quiet()
  443. // Modify the binary
  444. const modified = Buffer.alloc(512)
  445. for (let i = 0; i < 512; i++) modified[i] = i % 256
  446. await fs.writeFile(filepath, modified)
  447. await Instance.provide({
  448. directory: tmp.path,
  449. fn: async () => {
  450. const result = await File.status()
  451. const entry = result.find((f) => f.path === "data.bin")
  452. expect(entry).toBeDefined()
  453. expect(entry!.status).toBe("modified")
  454. expect(entry!.added).toBe(0)
  455. expect(entry!.removed).toBe(0)
  456. },
  457. })
  458. })
  459. })
  460. describe("File.list()", () => {
  461. test("returns files and directories with correct shape", async () => {
  462. await using tmp = await tmpdir({ git: true })
  463. await fs.mkdir(path.join(tmp.path, "subdir"))
  464. await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
  465. await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
  466. await Instance.provide({
  467. directory: tmp.path,
  468. fn: async () => {
  469. const nodes = await File.list()
  470. expect(nodes.length).toBeGreaterThanOrEqual(2)
  471. for (const node of nodes) {
  472. expect(node).toHaveProperty("name")
  473. expect(node).toHaveProperty("path")
  474. expect(node).toHaveProperty("absolute")
  475. expect(node).toHaveProperty("type")
  476. expect(node).toHaveProperty("ignored")
  477. expect(["file", "directory"]).toContain(node.type)
  478. }
  479. },
  480. })
  481. })
  482. test("sorts directories before files, alphabetical within each", async () => {
  483. await using tmp = await tmpdir({ git: true })
  484. await fs.mkdir(path.join(tmp.path, "beta"))
  485. await fs.mkdir(path.join(tmp.path, "alpha"))
  486. await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
  487. await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
  488. await Instance.provide({
  489. directory: tmp.path,
  490. fn: async () => {
  491. const nodes = await File.list()
  492. const dirs = nodes.filter((n) => n.type === "directory")
  493. const files = nodes.filter((n) => n.type === "file")
  494. // Dirs come first
  495. const firstFile = nodes.findIndex((n) => n.type === "file")
  496. const lastDir = nodes.findLastIndex((n) => n.type === "directory")
  497. if (lastDir >= 0 && firstFile >= 0) {
  498. expect(lastDir).toBeLessThan(firstFile)
  499. }
  500. // Alphabetical within dirs
  501. expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted())
  502. // Alphabetical within files
  503. expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted())
  504. },
  505. })
  506. })
  507. test("excludes .git and .DS_Store", async () => {
  508. await using tmp = await tmpdir({ git: true })
  509. await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
  510. await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
  511. await Instance.provide({
  512. directory: tmp.path,
  513. fn: async () => {
  514. const nodes = await File.list()
  515. const names = nodes.map((n) => n.name)
  516. expect(names).not.toContain(".git")
  517. expect(names).not.toContain(".DS_Store")
  518. expect(names).toContain("visible.txt")
  519. },
  520. })
  521. })
  522. test("marks gitignored files as ignored", async () => {
  523. await using tmp = await tmpdir({ git: true })
  524. await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8")
  525. await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8")
  526. await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
  527. await fs.mkdir(path.join(tmp.path, "build"))
  528. await Instance.provide({
  529. directory: tmp.path,
  530. fn: async () => {
  531. const nodes = await File.list()
  532. const logNode = nodes.find((n) => n.name === "app.log")
  533. const tsNode = nodes.find((n) => n.name === "main.ts")
  534. const buildNode = nodes.find((n) => n.name === "build")
  535. expect(logNode?.ignored).toBe(true)
  536. expect(tsNode?.ignored).toBe(false)
  537. expect(buildNode?.ignored).toBe(true)
  538. },
  539. })
  540. })
  541. test("lists subdirectory contents", async () => {
  542. await using tmp = await tmpdir({ git: true })
  543. await fs.mkdir(path.join(tmp.path, "sub"))
  544. await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
  545. await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
  546. await Instance.provide({
  547. directory: tmp.path,
  548. fn: async () => {
  549. const nodes = await File.list("sub")
  550. expect(nodes.length).toBe(2)
  551. expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
  552. // Paths should be relative to project root (normalize for Windows)
  553. expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
  554. },
  555. })
  556. })
  557. test("throws for paths outside project directory", async () => {
  558. await using tmp = await tmpdir({ git: true })
  559. await Instance.provide({
  560. directory: tmp.path,
  561. fn: async () => {
  562. await expect(File.list("../outside")).rejects.toThrow("Access denied")
  563. },
  564. })
  565. })
  566. test("works without git", async () => {
  567. await using tmp = await tmpdir()
  568. await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
  569. await Instance.provide({
  570. directory: tmp.path,
  571. fn: async () => {
  572. const nodes = await File.list()
  573. expect(nodes.length).toBeGreaterThanOrEqual(1)
  574. // Without git, ignored should be false for all
  575. for (const node of nodes) {
  576. expect(node.ignored).toBe(false)
  577. }
  578. },
  579. })
  580. })
  581. })
  582. describe("File.search()", () => {
  583. async function setupSearchableRepo() {
  584. const tmp = await tmpdir({ git: true })
  585. await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
  586. await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8")
  587. await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8")
  588. await fs.mkdir(path.join(tmp.path, "src"))
  589. await fs.mkdir(path.join(tmp.path, ".hidden"))
  590. await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8")
  591. await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8")
  592. return tmp
  593. }
  594. test("empty query returns files", async () => {
  595. await using tmp = await setupSearchableRepo()
  596. await Instance.provide({
  597. directory: tmp.path,
  598. fn: async () => {
  599. await File.init()
  600. const result = await File.search({ query: "", type: "file" })
  601. expect(result.length).toBeGreaterThan(0)
  602. },
  603. })
  604. })
  605. test("search works before explicit init", async () => {
  606. await using tmp = await setupSearchableRepo()
  607. await Instance.provide({
  608. directory: tmp.path,
  609. fn: async () => {
  610. const result = await File.search({ query: "main", type: "file" })
  611. expect(result.some((f) => f.includes("main"))).toBe(true)
  612. },
  613. })
  614. })
  615. test("empty query returns dirs sorted with hidden last", async () => {
  616. await using tmp = await setupSearchableRepo()
  617. await Instance.provide({
  618. directory: tmp.path,
  619. fn: async () => {
  620. await File.init()
  621. const result = await File.search({ query: "", type: "directory" })
  622. expect(result.length).toBeGreaterThan(0)
  623. // Find first hidden dir index
  624. const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
  625. const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1))
  626. if (firstHidden >= 0 && lastVisible >= 0) {
  627. expect(firstHidden).toBeGreaterThan(lastVisible)
  628. }
  629. },
  630. })
  631. })
  632. test("fuzzy matches file names", async () => {
  633. await using tmp = await setupSearchableRepo()
  634. await Instance.provide({
  635. directory: tmp.path,
  636. fn: async () => {
  637. await File.init()
  638. const result = await File.search({ query: "main", type: "file" })
  639. expect(result.some((f) => f.includes("main"))).toBe(true)
  640. },
  641. })
  642. })
  643. test("type filter returns only files", async () => {
  644. await using tmp = await setupSearchableRepo()
  645. await Instance.provide({
  646. directory: tmp.path,
  647. fn: async () => {
  648. await File.init()
  649. const result = await File.search({ query: "", type: "file" })
  650. // Files don't end with /
  651. for (const f of result) {
  652. expect(f.endsWith("/")).toBe(false)
  653. }
  654. },
  655. })
  656. })
  657. test("type filter returns only directories", async () => {
  658. await using tmp = await setupSearchableRepo()
  659. await Instance.provide({
  660. directory: tmp.path,
  661. fn: async () => {
  662. await File.init()
  663. const result = await File.search({ query: "", type: "directory" })
  664. // Directories end with /
  665. for (const d of result) {
  666. expect(d.endsWith("/")).toBe(true)
  667. }
  668. },
  669. })
  670. })
  671. test("respects limit", async () => {
  672. await using tmp = await setupSearchableRepo()
  673. await Instance.provide({
  674. directory: tmp.path,
  675. fn: async () => {
  676. await File.init()
  677. const result = await File.search({ query: "", type: "file", limit: 2 })
  678. expect(result.length).toBeLessThanOrEqual(2)
  679. },
  680. })
  681. })
  682. test("query starting with dot prefers hidden files", async () => {
  683. await using tmp = await setupSearchableRepo()
  684. await Instance.provide({
  685. directory: tmp.path,
  686. fn: async () => {
  687. await File.init()
  688. const result = await File.search({ query: ".hidden", type: "directory" })
  689. expect(result.length).toBeGreaterThan(0)
  690. expect(result[0]).toContain(".hidden")
  691. },
  692. })
  693. })
  694. test("search refreshes after init when files change", async () => {
  695. await using tmp = await setupSearchableRepo()
  696. await Instance.provide({
  697. directory: tmp.path,
  698. fn: async () => {
  699. await File.init()
  700. expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
  701. await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
  702. const result = await File.search({ query: "fresh", type: "file" })
  703. expect(result).toContain("fresh.ts")
  704. },
  705. })
  706. })
  707. })
  708. describe("File.read() - diff/patch", () => {
  709. test("returns diff and patch for modified tracked file", async () => {
  710. await using tmp = await tmpdir({ git: true })
  711. const filepath = path.join(tmp.path, "file.txt")
  712. await fs.writeFile(filepath, "original content\n", "utf-8")
  713. await $`git add .`.cwd(tmp.path).quiet()
  714. await $`git commit -m "add file"`.cwd(tmp.path).quiet()
  715. await fs.writeFile(filepath, "modified content\n", "utf-8")
  716. await Instance.provide({
  717. directory: tmp.path,
  718. fn: async () => {
  719. const result = await File.read("file.txt")
  720. expect(result.type).toBe("text")
  721. expect(result.content).toBe("modified content")
  722. expect(result.diff).toBeDefined()
  723. expect(result.diff).toContain("original content")
  724. expect(result.diff).toContain("modified content")
  725. expect(result.patch).toBeDefined()
  726. expect(result.patch!.hunks.length).toBeGreaterThan(0)
  727. },
  728. })
  729. })
  730. test("returns diff for staged changes", async () => {
  731. await using tmp = await tmpdir({ git: true })
  732. const filepath = path.join(tmp.path, "staged.txt")
  733. await fs.writeFile(filepath, "before\n", "utf-8")
  734. await $`git add .`.cwd(tmp.path).quiet()
  735. await $`git commit -m "add file"`.cwd(tmp.path).quiet()
  736. await fs.writeFile(filepath, "after\n", "utf-8")
  737. await $`git add .`.cwd(tmp.path).quiet()
  738. await Instance.provide({
  739. directory: tmp.path,
  740. fn: async () => {
  741. const result = await File.read("staged.txt")
  742. expect(result.diff).toBeDefined()
  743. expect(result.patch).toBeDefined()
  744. },
  745. })
  746. })
  747. test("returns no diff for unmodified file", async () => {
  748. await using tmp = await tmpdir({ git: true })
  749. const filepath = path.join(tmp.path, "clean.txt")
  750. await fs.writeFile(filepath, "unchanged\n", "utf-8")
  751. await $`git add .`.cwd(tmp.path).quiet()
  752. await $`git commit -m "add file"`.cwd(tmp.path).quiet()
  753. await Instance.provide({
  754. directory: tmp.path,
  755. fn: async () => {
  756. const result = await File.read("clean.txt")
  757. expect(result.type).toBe("text")
  758. expect(result.content).toBe("unchanged")
  759. expect(result.diff).toBeUndefined()
  760. expect(result.patch).toBeUndefined()
  761. },
  762. })
  763. })
  764. })
  765. describe("InstanceState isolation", () => {
  766. test("two directories get independent file caches", async () => {
  767. await using one = await tmpdir({ git: true })
  768. await using two = await tmpdir({ git: true })
  769. await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
  770. await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
  771. await Instance.provide({
  772. directory: one.path,
  773. fn: async () => {
  774. await File.init()
  775. const results = await File.search({ query: "a.ts", type: "file" })
  776. expect(results).toContain("a.ts")
  777. const results2 = await File.search({ query: "b.ts", type: "file" })
  778. expect(results2).not.toContain("b.ts")
  779. },
  780. })
  781. await Instance.provide({
  782. directory: two.path,
  783. fn: async () => {
  784. await File.init()
  785. const results = await File.search({ query: "b.ts", type: "file" })
  786. expect(results).toContain("b.ts")
  787. const results2 = await File.search({ query: "a.ts", type: "file" })
  788. expect(results2).not.toContain("a.ts")
  789. },
  790. })
  791. })
  792. test("disposal gives fresh state on next access", async () => {
  793. await using tmp = await tmpdir({ git: true })
  794. await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
  795. await Instance.provide({
  796. directory: tmp.path,
  797. fn: async () => {
  798. await File.init()
  799. const results = await File.search({ query: "before", type: "file" })
  800. expect(results).toContain("before.ts")
  801. },
  802. })
  803. await Instance.disposeAll()
  804. await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
  805. await fs.rm(path.join(tmp.path, "before.ts"))
  806. await Instance.provide({
  807. directory: tmp.path,
  808. fn: async () => {
  809. await File.init()
  810. const results = await File.search({ query: "after", type: "file" })
  811. expect(results).toContain("after.ts")
  812. const stale = await File.search({ query: "before", type: "file" })
  813. expect(stale).not.toContain("before.ts")
  814. },
  815. })
  816. })
  817. })
  818. })