filesystem.test.ts 14 KB

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