| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- import { afterEach, describe, expect } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Effect, Layer } from "effect"
- import path from "path"
- import fs from "fs/promises"
- import { WriteTool } from "../../src/tool/write"
- import { LSP } from "@/lsp/lsp"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { EventV2Bridge } from "../../src/event-v2-bridge"
- import { Format } from "../../src/format"
- import { Truncate } from "@/tool/truncate"
- import { Tool } from "@/tool/tool"
- import { Agent } from "../../src/agent/agent"
- import { SessionID, MessageID } from "../../src/session/schema"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { disposeAllInstances, TestInstance } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const ctx = {
- sessionID: SessionID.make("ses_test-write-session"),
- messageID: MessageID.make("msg_test"),
- callID: "",
- agent: "build",
- abort: AbortSignal.any([]),
- messages: [],
- metadata: () => Effect.void,
- ask: () => Effect.void,
- }
- afterEach(async () => {
- await disposeAllInstances()
- })
- const it = testEffect(
- LayerNode.compile(
- LayerNode.group([
- LSP.node,
- FSUtil.node,
- EventV2Bridge.node,
- Format.node,
- CrossSpawnSpawner.node,
- Truncate.node,
- Agent.node,
- ]),
- ),
- )
- const init = Effect.fn("WriteToolTest.init")(function* () {
- const info = yield* WriteTool
- return yield* info.init()
- })
- const run = Effect.fn("WriteToolTest.run")(function* (
- args: Tool.InferParameters<typeof WriteTool>,
- next: Tool.Context = ctx,
- ) {
- const tool = yield* init()
- return yield* tool.execute(args, next)
- })
- describe("tool.write", () => {
- describe("new file creation", () => {
- it.instance("writes content to new file", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "newfile.txt")
- const result = yield* run({ filePath: filepath, content: "Hello, World!" })
- expect(result.output).toContain("Wrote file successfully")
- expect(result.metadata.exists).toBe(false)
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content).toBe("Hello, World!")
- }),
- )
- it.instance("creates parent directories if needed", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "nested", "deep", "file.txt")
- yield* run({ filePath: filepath, content: "nested content" })
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content).toBe("nested content")
- }),
- )
- it.instance("handles relative paths by resolving to instance directory", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- yield* run({ filePath: "relative.txt", content: "relative content" })
- const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8"))
- expect(content).toBe("relative content")
- }),
- )
- })
- describe("existing file overwrite", () => {
- it.instance("overwrites existing file content", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "existing.txt")
- yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
- const result = yield* run({ filePath: filepath, content: "new content" })
- expect(result.output).toContain("Wrote file successfully")
- expect(result.metadata.exists).toBe(true)
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content).toBe("new content")
- }),
- )
- it.instance("preserves BOM when overwriting existing files", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "existing.cs")
- const bom = String.fromCharCode(0xfeff)
- yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
- yield* run({ filePath: filepath, content: "using Up;\n" })
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content.charCodeAt(0)).toBe(0xfeff)
- expect(content.slice(1)).toBe("using Up;\n")
- }),
- )
- it.instance(
- "restores BOM after formatter strips it",
- () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "formatted.cs")
- const bom = String.fromCharCode(0xfeff)
- yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
- yield* run({ filePath: filepath, content: "using Up;\n" })
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content.charCodeAt(0)).toBe(0xfeff)
- expect(content.slice(1)).toBe("using Up;\n")
- }),
- {
- config: {
- formatter: {
- stripbom: {
- extensions: [".cs"],
- command: [
- "node",
- "-e",
- "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')",
- "$FILE",
- ],
- },
- },
- },
- },
- )
- it.instance("returns diff in metadata for existing files", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "file.txt")
- yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
- const result = yield* run({ filePath: filepath, content: "new" })
- expect(result.metadata).toHaveProperty("filepath", filepath)
- expect(result.metadata).toHaveProperty("exists", true)
- }),
- )
- })
- describe("file permissions", () => {
- it.instance("sets file permissions when writing sensitive data", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "sensitive.json")
- yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
- if (process.platform !== "win32") {
- const stats = yield* Effect.promise(() => fs.stat(filepath))
- expect(stats.mode & 0o777).toBe(0o644)
- }
- }),
- )
- })
- describe("content types", () => {
- it.instance("writes JSON content", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "data.json")
- const data = { key: "value", nested: { array: [1, 2, 3] } }
- yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(JSON.parse(content)).toEqual(data)
- }),
- )
- it.instance("writes binary-safe content", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "binary.bin")
- const content = "Hello\x00World\x01\x02\x03"
- yield* run({ filePath: filepath, content })
- const buf = yield* Effect.promise(() => fs.readFile(filepath))
- expect(buf.toString()).toBe(content)
- }),
- )
- it.instance("writes empty content", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "empty.txt")
- yield* run({ filePath: filepath, content: "" })
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content).toBe("")
- const stats = yield* Effect.promise(() => fs.stat(filepath))
- expect(stats.size).toBe(0)
- }),
- )
- it.instance("writes multi-line content", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "multiline.txt")
- const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
- yield* run({ filePath: filepath, content: lines })
- const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
- expect(content).toBe(lines)
- }),
- )
- it.instance("handles different line endings", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "crlf.txt")
- const content = "Line 1\r\nLine 2\r\nLine 3"
- yield* run({ filePath: filepath, content })
- const buf = yield* Effect.promise(() => fs.readFile(filepath))
- expect(buf.toString()).toBe(content)
- }),
- )
- })
- describe("error handling", () => {
- it.instance("throws error when OS denies write access", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const readonlyPath = path.join(test.directory, "readonly.txt")
- yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
- yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
- const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
- expect(exit._tag).toBe("Failure")
- }),
- )
- })
- describe("title generation", () => {
- it.instance("returns relative path as title", () =>
- Effect.gen(function* () {
- const test = yield* TestInstance
- const filepath = path.join(test.directory, "src", "components", "Button.tsx")
- yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
- const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
- expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
- }),
- )
- })
- })
|