write.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import path from "path"
  4. import fs from "fs/promises"
  5. import { WriteTool } from "../../src/tool/write"
  6. import { Instance } from "../../src/project/instance"
  7. import { LSP } from "../../src/lsp"
  8. import { AppFileSystem } from "../../src/filesystem"
  9. import { FileTime } from "../../src/file/time"
  10. import { Bus } from "../../src/bus"
  11. import { Format } from "../../src/format"
  12. import { Tool } from "../../src/tool/tool"
  13. import { SessionID, MessageID } from "../../src/session/schema"
  14. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  15. import { provideTmpdirInstance } from "../fixture/fixture"
  16. import { testEffect } from "../lib/effect"
  17. const ctx = {
  18. sessionID: SessionID.make("ses_test-write-session"),
  19. messageID: MessageID.make(""),
  20. callID: "",
  21. agent: "build",
  22. abort: AbortSignal.any([]),
  23. messages: [],
  24. metadata: () => {},
  25. ask: () => Effect.void,
  26. }
  27. afterEach(async () => {
  28. await Instance.disposeAll()
  29. })
  30. const it = testEffect(
  31. Layer.mergeAll(
  32. LSP.defaultLayer,
  33. AppFileSystem.defaultLayer,
  34. FileTime.defaultLayer,
  35. Bus.layer,
  36. Format.defaultLayer,
  37. CrossSpawnSpawner.defaultLayer,
  38. ),
  39. )
  40. const init = Effect.fn("WriteToolTest.init")(function* () {
  41. const info = yield* WriteTool
  42. return yield* Effect.promise(() => info.init())
  43. })
  44. const run = Effect.fn("WriteToolTest.run")(function* (
  45. args: Tool.InferParameters<typeof WriteTool>,
  46. next: Tool.Context = ctx,
  47. ) {
  48. const tool = yield* init()
  49. return yield* tool.execute(args, next)
  50. })
  51. const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
  52. const ft = yield* FileTime.Service
  53. yield* ft.read(sessionID as any, filepath)
  54. })
  55. describe("tool.write", () => {
  56. describe("new file creation", () => {
  57. it.live("writes content to new file", () =>
  58. provideTmpdirInstance((dir) =>
  59. Effect.gen(function* () {
  60. const filepath = path.join(dir, "newfile.txt")
  61. const result = yield* run({ filePath: filepath, content: "Hello, World!" })
  62. expect(result.output).toContain("Wrote file successfully")
  63. expect(result.metadata.exists).toBe(false)
  64. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  65. expect(content).toBe("Hello, World!")
  66. }),
  67. ),
  68. )
  69. it.live("creates parent directories if needed", () =>
  70. provideTmpdirInstance((dir) =>
  71. Effect.gen(function* () {
  72. const filepath = path.join(dir, "nested", "deep", "file.txt")
  73. yield* run({ filePath: filepath, content: "nested content" })
  74. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  75. expect(content).toBe("nested content")
  76. }),
  77. ),
  78. )
  79. it.live("handles relative paths by resolving to instance directory", () =>
  80. provideTmpdirInstance((dir) =>
  81. Effect.gen(function* () {
  82. yield* run({ filePath: "relative.txt", content: "relative content" })
  83. const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8"))
  84. expect(content).toBe("relative content")
  85. }),
  86. ),
  87. )
  88. })
  89. describe("existing file overwrite", () => {
  90. it.live("overwrites existing file content", () =>
  91. provideTmpdirInstance((dir) =>
  92. Effect.gen(function* () {
  93. const filepath = path.join(dir, "existing.txt")
  94. yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
  95. yield* markRead(ctx.sessionID, filepath)
  96. const result = yield* run({ filePath: filepath, content: "new content" })
  97. expect(result.output).toContain("Wrote file successfully")
  98. expect(result.metadata.exists).toBe(true)
  99. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  100. expect(content).toBe("new content")
  101. }),
  102. ),
  103. )
  104. it.live("returns diff in metadata for existing files", () =>
  105. provideTmpdirInstance((dir) =>
  106. Effect.gen(function* () {
  107. const filepath = path.join(dir, "file.txt")
  108. yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
  109. yield* markRead(ctx.sessionID, filepath)
  110. const result = yield* run({ filePath: filepath, content: "new" })
  111. expect(result.metadata).toHaveProperty("filepath", filepath)
  112. expect(result.metadata).toHaveProperty("exists", true)
  113. }),
  114. ),
  115. )
  116. })
  117. describe("file permissions", () => {
  118. it.live("sets file permissions when writing sensitive data", () =>
  119. provideTmpdirInstance((dir) =>
  120. Effect.gen(function* () {
  121. const filepath = path.join(dir, "sensitive.json")
  122. yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
  123. if (process.platform !== "win32") {
  124. const stats = yield* Effect.promise(() => fs.stat(filepath))
  125. expect(stats.mode & 0o777).toBe(0o644)
  126. }
  127. }),
  128. ),
  129. )
  130. })
  131. describe("content types", () => {
  132. it.live("writes JSON content", () =>
  133. provideTmpdirInstance((dir) =>
  134. Effect.gen(function* () {
  135. const filepath = path.join(dir, "data.json")
  136. const data = { key: "value", nested: { array: [1, 2, 3] } }
  137. yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
  138. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  139. expect(JSON.parse(content)).toEqual(data)
  140. }),
  141. ),
  142. )
  143. it.live("writes binary-safe content", () =>
  144. provideTmpdirInstance((dir) =>
  145. Effect.gen(function* () {
  146. const filepath = path.join(dir, "binary.bin")
  147. const content = "Hello\x00World\x01\x02\x03"
  148. yield* run({ filePath: filepath, content })
  149. const buf = yield* Effect.promise(() => fs.readFile(filepath))
  150. expect(buf.toString()).toBe(content)
  151. }),
  152. ),
  153. )
  154. it.live("writes empty content", () =>
  155. provideTmpdirInstance((dir) =>
  156. Effect.gen(function* () {
  157. const filepath = path.join(dir, "empty.txt")
  158. yield* run({ filePath: filepath, content: "" })
  159. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  160. expect(content).toBe("")
  161. const stats = yield* Effect.promise(() => fs.stat(filepath))
  162. expect(stats.size).toBe(0)
  163. }),
  164. ),
  165. )
  166. it.live("writes multi-line content", () =>
  167. provideTmpdirInstance((dir) =>
  168. Effect.gen(function* () {
  169. const filepath = path.join(dir, "multiline.txt")
  170. const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
  171. yield* run({ filePath: filepath, content: lines })
  172. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  173. expect(content).toBe(lines)
  174. }),
  175. ),
  176. )
  177. it.live("handles different line endings", () =>
  178. provideTmpdirInstance((dir) =>
  179. Effect.gen(function* () {
  180. const filepath = path.join(dir, "crlf.txt")
  181. const content = "Line 1\r\nLine 2\r\nLine 3"
  182. yield* run({ filePath: filepath, content })
  183. const buf = yield* Effect.promise(() => fs.readFile(filepath))
  184. expect(buf.toString()).toBe(content)
  185. }),
  186. ),
  187. )
  188. })
  189. describe("error handling", () => {
  190. it.live("throws error when OS denies write access", () =>
  191. provideTmpdirInstance((dir) =>
  192. Effect.gen(function* () {
  193. const readonlyPath = path.join(dir, "readonly.txt")
  194. yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
  195. yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
  196. yield* markRead(ctx.sessionID, readonlyPath)
  197. const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
  198. expect(exit._tag).toBe("Failure")
  199. }),
  200. ),
  201. )
  202. })
  203. describe("title generation", () => {
  204. it.live("returns relative path as title", () =>
  205. provideTmpdirInstance((dir) =>
  206. Effect.gen(function* () {
  207. const filepath = path.join(dir, "src", "components", "Button.tsx")
  208. yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
  209. const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
  210. expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
  211. }),
  212. ),
  213. )
  214. })
  215. })