write.test.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import { describe, test, expect } from "bun:test"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { WriteTool } from "../../src/tool/write"
  5. import { Instance } from "../../src/project/instance"
  6. import { tmpdir } from "../fixture/fixture"
  7. const ctx = {
  8. sessionID: "test-write-session",
  9. messageID: "",
  10. callID: "",
  11. agent: "build",
  12. abort: AbortSignal.any([]),
  13. messages: [],
  14. metadata: () => {},
  15. ask: async () => {},
  16. }
  17. describe("tool.write", () => {
  18. describe("new file creation", () => {
  19. test("writes content to new file", async () => {
  20. await using tmp = await tmpdir()
  21. const filepath = path.join(tmp.path, "newfile.txt")
  22. await Instance.provide({
  23. directory: tmp.path,
  24. fn: async () => {
  25. const write = await WriteTool.init()
  26. const result = await write.execute(
  27. {
  28. filePath: filepath,
  29. content: "Hello, World!",
  30. },
  31. ctx,
  32. )
  33. expect(result.output).toContain("Wrote file successfully")
  34. expect(result.metadata.exists).toBe(false)
  35. const content = await fs.readFile(filepath, "utf-8")
  36. expect(content).toBe("Hello, World!")
  37. },
  38. })
  39. })
  40. test("creates parent directories if needed", async () => {
  41. await using tmp = await tmpdir()
  42. const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
  43. await Instance.provide({
  44. directory: tmp.path,
  45. fn: async () => {
  46. const write = await WriteTool.init()
  47. await write.execute(
  48. {
  49. filePath: filepath,
  50. content: "nested content",
  51. },
  52. ctx,
  53. )
  54. const content = await fs.readFile(filepath, "utf-8")
  55. expect(content).toBe("nested content")
  56. },
  57. })
  58. })
  59. test("handles relative paths by resolving to instance directory", async () => {
  60. await using tmp = await tmpdir()
  61. await Instance.provide({
  62. directory: tmp.path,
  63. fn: async () => {
  64. const write = await WriteTool.init()
  65. await write.execute(
  66. {
  67. filePath: "relative.txt",
  68. content: "relative content",
  69. },
  70. ctx,
  71. )
  72. const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
  73. expect(content).toBe("relative content")
  74. },
  75. })
  76. })
  77. })
  78. describe("existing file overwrite", () => {
  79. test("overwrites existing file content", async () => {
  80. await using tmp = await tmpdir()
  81. const filepath = path.join(tmp.path, "existing.txt")
  82. await fs.writeFile(filepath, "old content", "utf-8")
  83. // First read the file to satisfy FileTime requirement
  84. await Instance.provide({
  85. directory: tmp.path,
  86. fn: async () => {
  87. const { FileTime } = await import("../../src/file/time")
  88. FileTime.read(ctx.sessionID, filepath)
  89. const write = await WriteTool.init()
  90. const result = await write.execute(
  91. {
  92. filePath: filepath,
  93. content: "new content",
  94. },
  95. ctx,
  96. )
  97. expect(result.output).toContain("Wrote file successfully")
  98. expect(result.metadata.exists).toBe(true)
  99. const content = await fs.readFile(filepath, "utf-8")
  100. expect(content).toBe("new content")
  101. },
  102. })
  103. })
  104. test("returns diff in metadata for existing files", async () => {
  105. await using tmp = await tmpdir()
  106. const filepath = path.join(tmp.path, "file.txt")
  107. await fs.writeFile(filepath, "old", "utf-8")
  108. await Instance.provide({
  109. directory: tmp.path,
  110. fn: async () => {
  111. const { FileTime } = await import("../../src/file/time")
  112. FileTime.read(ctx.sessionID, filepath)
  113. const write = await WriteTool.init()
  114. const result = await write.execute(
  115. {
  116. filePath: filepath,
  117. content: "new",
  118. },
  119. ctx,
  120. )
  121. // Diff should be in metadata
  122. expect(result.metadata).toHaveProperty("filepath", filepath)
  123. expect(result.metadata).toHaveProperty("exists", true)
  124. },
  125. })
  126. })
  127. })
  128. describe("file permissions", () => {
  129. test("sets file permissions when writing sensitive data", async () => {
  130. await using tmp = await tmpdir()
  131. const filepath = path.join(tmp.path, "sensitive.json")
  132. await Instance.provide({
  133. directory: tmp.path,
  134. fn: async () => {
  135. const write = await WriteTool.init()
  136. await write.execute(
  137. {
  138. filePath: filepath,
  139. content: JSON.stringify({ secret: "data" }),
  140. },
  141. ctx,
  142. )
  143. // On Unix systems, check permissions
  144. if (process.platform !== "win32") {
  145. const stats = await fs.stat(filepath)
  146. expect(stats.mode & 0o777).toBe(0o644)
  147. }
  148. },
  149. })
  150. })
  151. })
  152. describe("content types", () => {
  153. test("writes JSON content", async () => {
  154. await using tmp = await tmpdir()
  155. const filepath = path.join(tmp.path, "data.json")
  156. const data = { key: "value", nested: { array: [1, 2, 3] } }
  157. await Instance.provide({
  158. directory: tmp.path,
  159. fn: async () => {
  160. const write = await WriteTool.init()
  161. await write.execute(
  162. {
  163. filePath: filepath,
  164. content: JSON.stringify(data, null, 2),
  165. },
  166. ctx,
  167. )
  168. const content = await fs.readFile(filepath, "utf-8")
  169. expect(JSON.parse(content)).toEqual(data)
  170. },
  171. })
  172. })
  173. test("writes binary-safe content", async () => {
  174. await using tmp = await tmpdir()
  175. const filepath = path.join(tmp.path, "binary.bin")
  176. const content = "Hello\x00World\x01\x02\x03"
  177. await Instance.provide({
  178. directory: tmp.path,
  179. fn: async () => {
  180. const write = await WriteTool.init()
  181. await write.execute(
  182. {
  183. filePath: filepath,
  184. content,
  185. },
  186. ctx,
  187. )
  188. const buf = await fs.readFile(filepath)
  189. expect(buf.toString()).toBe(content)
  190. },
  191. })
  192. })
  193. test("writes empty content", async () => {
  194. await using tmp = await tmpdir()
  195. const filepath = path.join(tmp.path, "empty.txt")
  196. await Instance.provide({
  197. directory: tmp.path,
  198. fn: async () => {
  199. const write = await WriteTool.init()
  200. await write.execute(
  201. {
  202. filePath: filepath,
  203. content: "",
  204. },
  205. ctx,
  206. )
  207. const content = await fs.readFile(filepath, "utf-8")
  208. expect(content).toBe("")
  209. const stats = await fs.stat(filepath)
  210. expect(stats.size).toBe(0)
  211. },
  212. })
  213. })
  214. test("writes multi-line content", async () => {
  215. await using tmp = await tmpdir()
  216. const filepath = path.join(tmp.path, "multiline.txt")
  217. const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
  218. await Instance.provide({
  219. directory: tmp.path,
  220. fn: async () => {
  221. const write = await WriteTool.init()
  222. await write.execute(
  223. {
  224. filePath: filepath,
  225. content: lines,
  226. },
  227. ctx,
  228. )
  229. const content = await fs.readFile(filepath, "utf-8")
  230. expect(content).toBe(lines)
  231. },
  232. })
  233. })
  234. test("handles different line endings", async () => {
  235. await using tmp = await tmpdir()
  236. const filepath = path.join(tmp.path, "crlf.txt")
  237. const content = "Line 1\r\nLine 2\r\nLine 3"
  238. await Instance.provide({
  239. directory: tmp.path,
  240. fn: async () => {
  241. const write = await WriteTool.init()
  242. await write.execute(
  243. {
  244. filePath: filepath,
  245. content,
  246. },
  247. ctx,
  248. )
  249. const buf = await fs.readFile(filepath)
  250. expect(buf.toString()).toBe(content)
  251. },
  252. })
  253. })
  254. })
  255. describe("error handling", () => {
  256. test("throws error for paths outside project", async () => {
  257. await using tmp = await tmpdir()
  258. const outsidePath = "/etc/passwd"
  259. await Instance.provide({
  260. directory: tmp.path,
  261. fn: async () => {
  262. const write = await WriteTool.init()
  263. await expect(
  264. write.execute(
  265. {
  266. filePath: outsidePath,
  267. content: "test",
  268. },
  269. ctx,
  270. ),
  271. ).rejects.toThrow()
  272. },
  273. })
  274. })
  275. })
  276. describe("title generation", () => {
  277. test("returns relative path as title", async () => {
  278. await using tmp = await tmpdir()
  279. const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
  280. await fs.mkdir(path.dirname(filepath), { recursive: true })
  281. await Instance.provide({
  282. directory: tmp.path,
  283. fn: async () => {
  284. const write = await WriteTool.init()
  285. const result = await write.execute(
  286. {
  287. filePath: filepath,
  288. content: "export const Button = () => {}",
  289. },
  290. ctx,
  291. )
  292. expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
  293. },
  294. })
  295. })
  296. })
  297. })