write.test.ts 9.8 KB

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