write.test.ts 8.6 KB

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