index.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import { describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { File } from "../../src/file"
  5. import { Instance } from "../../src/project/instance"
  6. import { Filesystem } from "../../src/util/filesystem"
  7. import { tmpdir } from "../fixture/fixture"
  8. describe("file/index Filesystem patterns", () => {
  9. describe("File.read() - text content", () => {
  10. test("reads text file via Filesystem.readText()", async () => {
  11. await using tmp = await tmpdir()
  12. const filepath = path.join(tmp.path, "test.txt")
  13. await fs.writeFile(filepath, "Hello World", "utf-8")
  14. await Instance.provide({
  15. directory: tmp.path,
  16. fn: async () => {
  17. const result = await File.read("test.txt")
  18. expect(result.type).toBe("text")
  19. expect(result.content).toBe("Hello World")
  20. },
  21. })
  22. })
  23. test("reads with Filesystem.exists() check", async () => {
  24. await using tmp = await tmpdir()
  25. await Instance.provide({
  26. directory: tmp.path,
  27. fn: async () => {
  28. // Non-existent file should return empty content
  29. const result = await File.read("nonexistent.txt")
  30. expect(result.type).toBe("text")
  31. expect(result.content).toBe("")
  32. },
  33. })
  34. })
  35. test("trims whitespace from text content", async () => {
  36. await using tmp = await tmpdir()
  37. const filepath = path.join(tmp.path, "test.txt")
  38. await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
  39. await Instance.provide({
  40. directory: tmp.path,
  41. fn: async () => {
  42. const result = await File.read("test.txt")
  43. expect(result.content).toBe("content with spaces")
  44. },
  45. })
  46. })
  47. test("handles empty text file", async () => {
  48. await using tmp = await tmpdir()
  49. const filepath = path.join(tmp.path, "empty.txt")
  50. await fs.writeFile(filepath, "", "utf-8")
  51. await Instance.provide({
  52. directory: tmp.path,
  53. fn: async () => {
  54. const result = await File.read("empty.txt")
  55. expect(result.type).toBe("text")
  56. expect(result.content).toBe("")
  57. },
  58. })
  59. })
  60. test("handles multi-line text files", async () => {
  61. await using tmp = await tmpdir()
  62. const filepath = path.join(tmp.path, "multiline.txt")
  63. await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
  64. await Instance.provide({
  65. directory: tmp.path,
  66. fn: async () => {
  67. const result = await File.read("multiline.txt")
  68. expect(result.content).toBe("line1\nline2\nline3")
  69. },
  70. })
  71. })
  72. })
  73. describe("File.read() - binary content", () => {
  74. test("reads binary file via Filesystem.readArrayBuffer()", async () => {
  75. await using tmp = await tmpdir()
  76. const filepath = path.join(tmp.path, "image.png")
  77. const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
  78. await fs.writeFile(filepath, binaryContent)
  79. await Instance.provide({
  80. directory: tmp.path,
  81. fn: async () => {
  82. const result = await File.read("image.png")
  83. expect(result.type).toBe("text") // Images return as text with base64 encoding
  84. expect(result.encoding).toBe("base64")
  85. expect(result.mimeType).toBe("image/png")
  86. expect(result.content).toBe(binaryContent.toString("base64"))
  87. },
  88. })
  89. })
  90. test("returns empty for binary non-image files", async () => {
  91. await using tmp = await tmpdir()
  92. const filepath = path.join(tmp.path, "binary.so")
  93. await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
  94. await Instance.provide({
  95. directory: tmp.path,
  96. fn: async () => {
  97. const result = await File.read("binary.so")
  98. expect(result.type).toBe("binary")
  99. expect(result.content).toBe("")
  100. },
  101. })
  102. })
  103. })
  104. describe("File.read() - Filesystem.mimeType()", () => {
  105. test("detects MIME type via Filesystem.mimeType()", async () => {
  106. await using tmp = await tmpdir()
  107. const filepath = path.join(tmp.path, "test.json")
  108. await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
  109. await Instance.provide({
  110. directory: tmp.path,
  111. fn: async () => {
  112. expect(Filesystem.mimeType(filepath)).toContain("application/json")
  113. const result = await File.read("test.json")
  114. expect(result.type).toBe("text")
  115. },
  116. })
  117. })
  118. test("handles various image MIME types", async () => {
  119. await using tmp = await tmpdir()
  120. const testCases = [
  121. { ext: "jpg", mime: "image/jpeg" },
  122. { ext: "png", mime: "image/png" },
  123. { ext: "gif", mime: "image/gif" },
  124. { ext: "webp", mime: "image/webp" },
  125. ]
  126. for (const { ext, mime } of testCases) {
  127. const filepath = path.join(tmp.path, `test.${ext}`)
  128. await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
  129. await Instance.provide({
  130. directory: tmp.path,
  131. fn: async () => {
  132. expect(Filesystem.mimeType(filepath)).toContain(mime)
  133. },
  134. })
  135. }
  136. })
  137. })
  138. describe("File.list() - Filesystem.exists() and readText()", () => {
  139. test("reads .gitignore via Filesystem.exists() and readText()", async () => {
  140. await using tmp = await tmpdir({ git: true })
  141. await Instance.provide({
  142. directory: tmp.path,
  143. fn: async () => {
  144. const gitignorePath = path.join(tmp.path, ".gitignore")
  145. await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
  146. // This is used internally in File.list()
  147. expect(await Filesystem.exists(gitignorePath)).toBe(true)
  148. const content = await Filesystem.readText(gitignorePath)
  149. expect(content).toContain("node_modules")
  150. },
  151. })
  152. })
  153. test("reads .ignore file similarly", async () => {
  154. await using tmp = await tmpdir({ git: true })
  155. await Instance.provide({
  156. directory: tmp.path,
  157. fn: async () => {
  158. const ignorePath = path.join(tmp.path, ".ignore")
  159. await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
  160. expect(await Filesystem.exists(ignorePath)).toBe(true)
  161. expect(await Filesystem.readText(ignorePath)).toContain("*.log")
  162. },
  163. })
  164. })
  165. test("handles missing .gitignore gracefully", async () => {
  166. await using tmp = await tmpdir({ git: true })
  167. await Instance.provide({
  168. directory: tmp.path,
  169. fn: async () => {
  170. const gitignorePath = path.join(tmp.path, ".gitignore")
  171. expect(await Filesystem.exists(gitignorePath)).toBe(false)
  172. // File.list() should still work
  173. const nodes = await File.list()
  174. expect(Array.isArray(nodes)).toBe(true)
  175. },
  176. })
  177. })
  178. })
  179. describe("File.changed() - Filesystem.readText() for untracked files", () => {
  180. test("reads untracked files via Filesystem.readText()", async () => {
  181. await using tmp = await tmpdir({ git: true })
  182. await Instance.provide({
  183. directory: tmp.path,
  184. fn: async () => {
  185. const untrackedPath = path.join(tmp.path, "untracked.txt")
  186. await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
  187. // This is how File.changed() reads untracked files
  188. const content = await Filesystem.readText(untrackedPath)
  189. const lines = content.split("\n").length
  190. expect(lines).toBe(2)
  191. },
  192. })
  193. })
  194. })
  195. describe("Error handling", () => {
  196. test("handles errors gracefully in Filesystem.readText()", async () => {
  197. await using tmp = await tmpdir()
  198. const filepath = path.join(tmp.path, "readonly.txt")
  199. await fs.writeFile(filepath, "content", "utf-8")
  200. await Instance.provide({
  201. directory: tmp.path,
  202. fn: async () => {
  203. const nonExistentPath = path.join(tmp.path, "does-not-exist.txt")
  204. // Filesystem.readText() on non-existent file throws
  205. await expect(Filesystem.readText(nonExistentPath)).rejects.toThrow()
  206. // But File.read() handles this gracefully
  207. const result = await File.read("does-not-exist.txt")
  208. expect(result.content).toBe("")
  209. },
  210. })
  211. })
  212. test("handles errors in Filesystem.readArrayBuffer()", async () => {
  213. await using tmp = await tmpdir()
  214. await Instance.provide({
  215. directory: tmp.path,
  216. fn: async () => {
  217. const nonExistentPath = path.join(tmp.path, "does-not-exist.bin")
  218. const buffer = await Filesystem.readArrayBuffer(nonExistentPath).catch(() => new ArrayBuffer(0))
  219. expect(buffer.byteLength).toBe(0)
  220. },
  221. })
  222. })
  223. test("returns empty array buffer on error for images", async () => {
  224. await using tmp = await tmpdir()
  225. const filepath = path.join(tmp.path, "broken.png")
  226. // Don't create the file
  227. await Instance.provide({
  228. directory: tmp.path,
  229. fn: async () => {
  230. // File.read() handles missing images gracefully
  231. const result = await File.read("broken.png")
  232. expect(result.type).toBe("text")
  233. expect(result.content).toBe("")
  234. },
  235. })
  236. })
  237. })
  238. describe("shouldEncode() logic", () => {
  239. test("treats .ts files as text", async () => {
  240. await using tmp = await tmpdir()
  241. const filepath = path.join(tmp.path, "test.ts")
  242. await fs.writeFile(filepath, "export const value = 1", "utf-8")
  243. await Instance.provide({
  244. directory: tmp.path,
  245. fn: async () => {
  246. const result = await File.read("test.ts")
  247. expect(result.type).toBe("text")
  248. expect(result.content).toBe("export const value = 1")
  249. },
  250. })
  251. })
  252. test("treats .mts files as text", async () => {
  253. await using tmp = await tmpdir()
  254. const filepath = path.join(tmp.path, "test.mts")
  255. await fs.writeFile(filepath, "export const value = 1", "utf-8")
  256. await Instance.provide({
  257. directory: tmp.path,
  258. fn: async () => {
  259. const result = await File.read("test.mts")
  260. expect(result.type).toBe("text")
  261. expect(result.content).toBe("export const value = 1")
  262. },
  263. })
  264. })
  265. test("treats .sh files as text", async () => {
  266. await using tmp = await tmpdir()
  267. const filepath = path.join(tmp.path, "test.sh")
  268. await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8")
  269. await Instance.provide({
  270. directory: tmp.path,
  271. fn: async () => {
  272. const result = await File.read("test.sh")
  273. expect(result.type).toBe("text")
  274. expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
  275. },
  276. })
  277. })
  278. test("treats Dockerfile as text", async () => {
  279. await using tmp = await tmpdir()
  280. const filepath = path.join(tmp.path, "Dockerfile")
  281. await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8")
  282. await Instance.provide({
  283. directory: tmp.path,
  284. fn: async () => {
  285. const result = await File.read("Dockerfile")
  286. expect(result.type).toBe("text")
  287. expect(result.content).toBe("FROM alpine:3.20")
  288. },
  289. })
  290. })
  291. test("returns encoding info for text files", async () => {
  292. await using tmp = await tmpdir()
  293. const filepath = path.join(tmp.path, "test.txt")
  294. await fs.writeFile(filepath, "simple text", "utf-8")
  295. await Instance.provide({
  296. directory: tmp.path,
  297. fn: async () => {
  298. const result = await File.read("test.txt")
  299. expect(result.encoding).toBeUndefined()
  300. expect(result.type).toBe("text")
  301. },
  302. })
  303. })
  304. test("returns base64 encoding for images", async () => {
  305. await using tmp = await tmpdir()
  306. const filepath = path.join(tmp.path, "test.jpg")
  307. await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
  308. await Instance.provide({
  309. directory: tmp.path,
  310. fn: async () => {
  311. const result = await File.read("test.jpg")
  312. expect(result.encoding).toBe("base64")
  313. expect(result.mimeType).toBe("image/jpeg")
  314. },
  315. })
  316. })
  317. })
  318. describe("Path security", () => {
  319. test("throws for paths outside project directory", async () => {
  320. await using tmp = await tmpdir()
  321. await Instance.provide({
  322. directory: tmp.path,
  323. fn: async () => {
  324. await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
  325. },
  326. })
  327. })
  328. test("throws for paths outside project directory", async () => {
  329. await using tmp = await tmpdir()
  330. await Instance.provide({
  331. directory: tmp.path,
  332. fn: async () => {
  333. await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
  334. },
  335. })
  336. })
  337. })
  338. })