write.test.ts 9.8 KB

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