2
0

write.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 when OS denies write access", async () => {
  257. await using tmp = await tmpdir()
  258. const readonlyPath = path.join(tmp.path, "readonly.txt")
  259. // Create a read-only file
  260. await fs.writeFile(readonlyPath, "test", "utf-8")
  261. await fs.chmod(readonlyPath, 0o444)
  262. await Instance.provide({
  263. directory: tmp.path,
  264. fn: async () => {
  265. const { FileTime } = await import("../../src/file/time")
  266. FileTime.read(ctx.sessionID, readonlyPath)
  267. const write = await WriteTool.init()
  268. await expect(
  269. write.execute(
  270. {
  271. filePath: readonlyPath,
  272. content: "new content",
  273. },
  274. ctx,
  275. ),
  276. ).rejects.toThrow()
  277. },
  278. })
  279. })
  280. })
  281. describe("title generation", () => {
  282. test("returns relative path as title", async () => {
  283. await using tmp = await tmpdir()
  284. const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
  285. await fs.mkdir(path.dirname(filepath), { recursive: true })
  286. await Instance.provide({
  287. directory: tmp.path,
  288. fn: async () => {
  289. const write = await WriteTool.init()
  290. const result = await write.execute(
  291. {
  292. filePath: filepath,
  293. content: "export const Button = () => {}",
  294. },
  295. ctx,
  296. )
  297. expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
  298. },
  299. })
  300. })
  301. })
  302. })