filesystem.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import { describe, test, expect } from "bun:test"
  2. import { Effect, Layer, FileSystem } from "effect"
  3. import { NodeFileSystem } from "@effect/platform-node"
  4. import { FSUtil } from "@opencode-ai/core/fs-util"
  5. import { testEffect } from "../lib/effect"
  6. import path from "path"
  7. const live = Layer.merge(FSUtil.defaultLayer, NodeFileSystem.layer)
  8. const { effect: it } = testEffect(live)
  9. describe("FSUtil", () => {
  10. describe("isDir", () => {
  11. it(
  12. "returns true for directories",
  13. Effect.gen(function* () {
  14. const fs = yield* FSUtil.Service
  15. const filesys = yield* FileSystem.FileSystem
  16. const tmp = yield* filesys.makeTempDirectoryScoped()
  17. expect(yield* fs.isDir(tmp)).toBe(true)
  18. }),
  19. )
  20. it(
  21. "returns false for files",
  22. Effect.gen(function* () {
  23. const fs = yield* FSUtil.Service
  24. const filesys = yield* FileSystem.FileSystem
  25. const tmp = yield* filesys.makeTempDirectoryScoped()
  26. const file = path.join(tmp, "test.txt")
  27. yield* filesys.writeFileString(file, "hello")
  28. expect(yield* fs.isDir(file)).toBe(false)
  29. }),
  30. )
  31. it(
  32. "returns false for non-existent paths",
  33. Effect.gen(function* () {
  34. const fs = yield* FSUtil.Service
  35. expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
  36. }),
  37. )
  38. })
  39. describe("isFile", () => {
  40. it(
  41. "returns true for files",
  42. Effect.gen(function* () {
  43. const fs = yield* FSUtil.Service
  44. const filesys = yield* FileSystem.FileSystem
  45. const tmp = yield* filesys.makeTempDirectoryScoped()
  46. const file = path.join(tmp, "test.txt")
  47. yield* filesys.writeFileString(file, "hello")
  48. expect(yield* fs.isFile(file)).toBe(true)
  49. }),
  50. )
  51. it(
  52. "returns false for directories",
  53. Effect.gen(function* () {
  54. const fs = yield* FSUtil.Service
  55. const filesys = yield* FileSystem.FileSystem
  56. const tmp = yield* filesys.makeTempDirectoryScoped()
  57. expect(yield* fs.isFile(tmp)).toBe(false)
  58. }),
  59. )
  60. })
  61. describe("readFileStringSafe", () => {
  62. it(
  63. "returns file contents when file exists",
  64. Effect.gen(function* () {
  65. const fs = yield* FSUtil.Service
  66. const filesys = yield* FileSystem.FileSystem
  67. const tmp = yield* filesys.makeTempDirectoryScoped()
  68. const file = path.join(tmp, "exists.txt")
  69. yield* filesys.writeFileString(file, "hello")
  70. const result = yield* fs.readFileStringSafe(file)
  71. expect(result).toBe("hello")
  72. }),
  73. )
  74. it(
  75. "returns undefined for missing file (NotFound)",
  76. Effect.gen(function* () {
  77. const fs = yield* FSUtil.Service
  78. const filesys = yield* FileSystem.FileSystem
  79. const tmp = yield* filesys.makeTempDirectoryScoped()
  80. const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt"))
  81. expect(result).toBeUndefined()
  82. }),
  83. )
  84. })
  85. describe("readJson / writeJson", () => {
  86. it(
  87. "round-trips JSON data",
  88. Effect.gen(function* () {
  89. const fs = yield* FSUtil.Service
  90. const filesys = yield* FileSystem.FileSystem
  91. const tmp = yield* filesys.makeTempDirectoryScoped()
  92. const file = path.join(tmp, "data.json")
  93. const data = { name: "test", count: 42, nested: { ok: true } }
  94. yield* fs.writeJson(file, data)
  95. const result = yield* fs.readJson(file)
  96. expect(result).toEqual(data)
  97. }),
  98. )
  99. it(
  100. "fails invalid JSON through the error channel",
  101. Effect.gen(function* () {
  102. const fs = yield* FSUtil.Service
  103. const filesys = yield* FileSystem.FileSystem
  104. const tmp = yield* filesys.makeTempDirectoryScoped()
  105. const file = path.join(tmp, "broken.json")
  106. yield* filesys.writeFileString(file, "{")
  107. const result = yield* fs.readJson(file).pipe(Effect.catch((error) => Effect.succeed(error)))
  108. expect(result).toHaveProperty("_tag", "FileSystemError")
  109. }),
  110. )
  111. })
  112. describe("ensureDir", () => {
  113. it(
  114. "creates nested directories",
  115. Effect.gen(function* () {
  116. const fs = yield* FSUtil.Service
  117. const filesys = yield* FileSystem.FileSystem
  118. const tmp = yield* filesys.makeTempDirectoryScoped()
  119. const nested = path.join(tmp, "a", "b", "c")
  120. yield* fs.ensureDir(nested)
  121. const info = yield* filesys.stat(nested)
  122. expect(info.type).toBe("Directory")
  123. }),
  124. )
  125. it(
  126. "is idempotent",
  127. Effect.gen(function* () {
  128. const fs = yield* FSUtil.Service
  129. const filesys = yield* FileSystem.FileSystem
  130. const tmp = yield* filesys.makeTempDirectoryScoped()
  131. const dir = path.join(tmp, "existing")
  132. yield* filesys.makeDirectory(dir)
  133. yield* fs.ensureDir(dir)
  134. const info = yield* filesys.stat(dir)
  135. expect(info.type).toBe("Directory")
  136. }),
  137. )
  138. })
  139. describe("writeWithDirs", () => {
  140. it(
  141. "creates parent directories if missing",
  142. Effect.gen(function* () {
  143. const fs = yield* FSUtil.Service
  144. const filesys = yield* FileSystem.FileSystem
  145. const tmp = yield* filesys.makeTempDirectoryScoped()
  146. const file = path.join(tmp, "deep", "nested", "file.txt")
  147. yield* fs.writeWithDirs(file, "hello")
  148. expect(yield* filesys.readFileString(file)).toBe("hello")
  149. }),
  150. )
  151. it(
  152. "writes directly when parent exists",
  153. Effect.gen(function* () {
  154. const fs = yield* FSUtil.Service
  155. const filesys = yield* FileSystem.FileSystem
  156. const tmp = yield* filesys.makeTempDirectoryScoped()
  157. const file = path.join(tmp, "direct.txt")
  158. yield* fs.writeWithDirs(file, "world")
  159. expect(yield* filesys.readFileString(file)).toBe("world")
  160. }),
  161. )
  162. it(
  163. "writes Uint8Array content",
  164. Effect.gen(function* () {
  165. const fs = yield* FSUtil.Service
  166. const filesys = yield* FileSystem.FileSystem
  167. const tmp = yield* filesys.makeTempDirectoryScoped()
  168. const file = path.join(tmp, "binary.bin")
  169. const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
  170. yield* fs.writeWithDirs(file, content)
  171. const result = yield* filesys.readFile(file)
  172. expect(new Uint8Array(result)).toEqual(content)
  173. }),
  174. )
  175. })
  176. describe("findUp", () => {
  177. it(
  178. "finds target in start directory",
  179. Effect.gen(function* () {
  180. const fs = yield* FSUtil.Service
  181. const filesys = yield* FileSystem.FileSystem
  182. const tmp = yield* filesys.makeTempDirectoryScoped()
  183. yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
  184. const result = yield* fs.findUp("target.txt", tmp)
  185. expect(result).toEqual([path.join(tmp, "target.txt")])
  186. }),
  187. )
  188. it(
  189. "finds target in parent directories",
  190. Effect.gen(function* () {
  191. const fs = yield* FSUtil.Service
  192. const filesys = yield* FileSystem.FileSystem
  193. const tmp = yield* filesys.makeTempDirectoryScoped()
  194. yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
  195. const child = path.join(tmp, "a", "b")
  196. yield* filesys.makeDirectory(child, { recursive: true })
  197. const result = yield* fs.findUp("marker", child, tmp)
  198. expect(result).toEqual([path.join(tmp, "marker")])
  199. }),
  200. )
  201. it(
  202. "returns empty array when not found",
  203. Effect.gen(function* () {
  204. const fs = yield* FSUtil.Service
  205. const filesys = yield* FileSystem.FileSystem
  206. const tmp = yield* filesys.makeTempDirectoryScoped()
  207. const result = yield* fs.findUp("nonexistent", tmp, tmp)
  208. expect(result).toEqual([])
  209. }),
  210. )
  211. })
  212. describe("up", () => {
  213. it(
  214. "finds multiple targets walking up",
  215. Effect.gen(function* () {
  216. const fs = yield* FSUtil.Service
  217. const filesys = yield* FileSystem.FileSystem
  218. const tmp = yield* filesys.makeTempDirectoryScoped()
  219. yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
  220. yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b")
  221. const child = path.join(tmp, "sub")
  222. yield* filesys.makeDirectory(child)
  223. yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child")
  224. const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
  225. expect(result).toContain(path.join(child, "a.txt"))
  226. expect(result).toContain(path.join(tmp, "a.txt"))
  227. expect(result).toContain(path.join(tmp, "b.txt"))
  228. }),
  229. )
  230. })
  231. describe("glob", () => {
  232. it(
  233. "finds files matching pattern",
  234. Effect.gen(function* () {
  235. const fs = yield* FSUtil.Service
  236. const filesys = yield* FileSystem.FileSystem
  237. const tmp = yield* filesys.makeTempDirectoryScoped()
  238. yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
  239. yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b")
  240. yield* filesys.writeFileString(path.join(tmp, "c.json"), "c")
  241. const result = yield* fs.glob("*.ts", { cwd: tmp })
  242. expect(result.sort()).toEqual(["a.ts", "b.ts"])
  243. }),
  244. )
  245. it(
  246. "supports absolute paths",
  247. Effect.gen(function* () {
  248. const fs = yield* FSUtil.Service
  249. const filesys = yield* FileSystem.FileSystem
  250. const tmp = yield* filesys.makeTempDirectoryScoped()
  251. yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
  252. const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
  253. expect(result).toEqual([path.join(tmp, "file.txt")])
  254. }),
  255. )
  256. })
  257. describe("globMatch", () => {
  258. it(
  259. "matches patterns",
  260. Effect.gen(function* () {
  261. const fs = yield* FSUtil.Service
  262. expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
  263. expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
  264. expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
  265. }),
  266. )
  267. })
  268. describe("globUp", () => {
  269. it(
  270. "finds files walking up directories",
  271. Effect.gen(function* () {
  272. const fs = yield* FSUtil.Service
  273. const filesys = yield* FileSystem.FileSystem
  274. const tmp = yield* filesys.makeTempDirectoryScoped()
  275. yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
  276. const child = path.join(tmp, "a", "b")
  277. yield* filesys.makeDirectory(child, { recursive: true })
  278. yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf")
  279. const result = yield* fs.globUp("*.md", child, tmp)
  280. expect(result).toContain(path.join(child, "leaf.md"))
  281. expect(result).toContain(path.join(tmp, "root.md"))
  282. }),
  283. )
  284. })
  285. describe("built-in passthrough", () => {
  286. it(
  287. "exists works",
  288. Effect.gen(function* () {
  289. yield* FSUtil.Service
  290. const filesys = yield* FileSystem.FileSystem
  291. const tmp = yield* filesys.makeTempDirectoryScoped()
  292. const file = path.join(tmp, "exists.txt")
  293. yield* filesys.writeFileString(file, "yes")
  294. expect(yield* filesys.exists(file)).toBe(true)
  295. expect(yield* filesys.exists(file + ".nope")).toBe(false)
  296. }),
  297. )
  298. it(
  299. "remove works",
  300. Effect.gen(function* () {
  301. yield* FSUtil.Service
  302. const filesys = yield* FileSystem.FileSystem
  303. const tmp = yield* filesys.makeTempDirectoryScoped()
  304. const file = path.join(tmp, "delete-me.txt")
  305. yield* filesys.writeFileString(file, "bye")
  306. yield* filesys.remove(file)
  307. expect(yield* filesys.exists(file)).toBe(false)
  308. }),
  309. )
  310. })
  311. describe("pure helpers", () => {
  312. test("mimeType returns correct types", () => {
  313. expect(FSUtil.mimeType("file.json")).toBe("application/json")
  314. expect(FSUtil.mimeType("image.png")).toBe("image/png")
  315. expect(FSUtil.mimeType("unknown.qzx")).toBe("application/octet-stream")
  316. })
  317. test("contains checks path containment", () => {
  318. expect(FSUtil.contains("/a/b", "/a/b/c")).toBe(true)
  319. expect(FSUtil.contains("/a/b", "/a/b")).toBe(true)
  320. expect(FSUtil.contains("/a/b", "/a/c")).toBe(false)
  321. expect(FSUtil.contains("/a/b", "/a/bad")).toBe(false)
  322. if (process.platform === "win32") expect(FSUtil.contains("C:\\a", "D:\\b")).toBe(false)
  323. })
  324. test("overlaps detects overlapping paths", () => {
  325. expect(FSUtil.overlaps("/a/b", "/a/b/c")).toBe(true)
  326. expect(FSUtil.overlaps("/a/b/c", "/a/b")).toBe(true)
  327. expect(FSUtil.overlaps("/a", "/b")).toBe(false)
  328. expect(FSUtil.overlaps("/a/b", "/a/bad")).toBe(false)
  329. if (process.platform === "win32") expect(FSUtil.overlaps("C:\\a", "D:\\b")).toBe(false)
  330. })
  331. })
  332. })