mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 13:54:01 +00:00
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
354 lines
9.8 KiB
TypeScript
354 lines
9.8 KiB
TypeScript
import { afterEach, describe, test, expect } from "bun:test"
|
|
import path from "path"
|
|
import fs from "fs/promises"
|
|
import { WriteTool } from "../../src/tool/write"
|
|
import { Instance } from "../../src/project/instance"
|
|
import { tmpdir } from "../fixture/fixture"
|
|
import { SessionID, MessageID } from "../../src/session/schema"
|
|
|
|
const ctx = {
|
|
sessionID: SessionID.make("ses_test-write-session"),
|
|
messageID: MessageID.make(""),
|
|
callID: "",
|
|
agent: "build",
|
|
abort: AbortSignal.any([]),
|
|
messages: [],
|
|
metadata: () => {},
|
|
ask: async () => {},
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Instance.disposeAll()
|
|
})
|
|
|
|
describe("tool.write", () => {
|
|
describe("new file creation", () => {
|
|
test("writes content to new file", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "newfile.txt")
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
const result = await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: "Hello, World!",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
expect(result.output).toContain("Wrote file successfully")
|
|
expect(result.metadata.exists).toBe(false)
|
|
|
|
const content = await fs.readFile(filepath, "utf-8")
|
|
expect(content).toBe("Hello, World!")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("creates parent directories if needed", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: "nested content",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const content = await fs.readFile(filepath, "utf-8")
|
|
expect(content).toBe("nested content")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("handles relative paths by resolving to instance directory", async () => {
|
|
await using tmp = await tmpdir()
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: "relative.txt",
|
|
content: "relative content",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
|
|
expect(content).toBe("relative content")
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("existing file overwrite", () => {
|
|
test("overwrites existing file content", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "existing.txt")
|
|
await fs.writeFile(filepath, "old content", "utf-8")
|
|
|
|
// First read the file to satisfy FileTime requirement
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const { FileTime } = await import("../../src/file/time")
|
|
await FileTime.read(ctx.sessionID, filepath)
|
|
|
|
const write = await WriteTool.init()
|
|
const result = await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: "new content",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
expect(result.output).toContain("Wrote file successfully")
|
|
expect(result.metadata.exists).toBe(true)
|
|
|
|
const content = await fs.readFile(filepath, "utf-8")
|
|
expect(content).toBe("new content")
|
|
},
|
|
})
|
|
})
|
|
|
|
test("returns diff in metadata for existing files", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "file.txt")
|
|
await fs.writeFile(filepath, "old", "utf-8")
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const { FileTime } = await import("../../src/file/time")
|
|
await FileTime.read(ctx.sessionID, filepath)
|
|
|
|
const write = await WriteTool.init()
|
|
const result = await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: "new",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
// Diff should be in metadata
|
|
expect(result.metadata).toHaveProperty("filepath", filepath)
|
|
expect(result.metadata).toHaveProperty("exists", true)
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("file permissions", () => {
|
|
test("sets file permissions when writing sensitive data", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "sensitive.json")
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: JSON.stringify({ secret: "data" }),
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
// On Unix systems, check permissions
|
|
if (process.platform !== "win32") {
|
|
const stats = await fs.stat(filepath)
|
|
expect(stats.mode & 0o777).toBe(0o644)
|
|
}
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("content types", () => {
|
|
test("writes JSON content", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "data.json")
|
|
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: JSON.stringify(data, null, 2),
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const content = await fs.readFile(filepath, "utf-8")
|
|
expect(JSON.parse(content)).toEqual(data)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("writes binary-safe content", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "binary.bin")
|
|
const content = "Hello\x00World\x01\x02\x03"
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content,
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const buf = await fs.readFile(filepath)
|
|
expect(buf.toString()).toBe(content)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("writes empty content", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "empty.txt")
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: "",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const content = await fs.readFile(filepath, "utf-8")
|
|
expect(content).toBe("")
|
|
|
|
const stats = await fs.stat(filepath)
|
|
expect(stats.size).toBe(0)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("writes multi-line content", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "multiline.txt")
|
|
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: lines,
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const content = await fs.readFile(filepath, "utf-8")
|
|
expect(content).toBe(lines)
|
|
},
|
|
})
|
|
})
|
|
|
|
test("handles different line endings", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "crlf.txt")
|
|
const content = "Line 1\r\nLine 2\r\nLine 3"
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content,
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
const buf = await fs.readFile(filepath)
|
|
expect(buf.toString()).toBe(content)
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("error handling", () => {
|
|
test("throws error when OS denies write access", async () => {
|
|
await using tmp = await tmpdir()
|
|
const readonlyPath = path.join(tmp.path, "readonly.txt")
|
|
|
|
// Create a read-only file
|
|
await fs.writeFile(readonlyPath, "test", "utf-8")
|
|
await fs.chmod(readonlyPath, 0o444)
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const { FileTime } = await import("../../src/file/time")
|
|
await FileTime.read(ctx.sessionID, readonlyPath)
|
|
|
|
const write = await WriteTool.init()
|
|
await expect(
|
|
write.execute(
|
|
{
|
|
filePath: readonlyPath,
|
|
content: "new content",
|
|
},
|
|
ctx,
|
|
),
|
|
).rejects.toThrow()
|
|
},
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("title generation", () => {
|
|
test("returns relative path as title", async () => {
|
|
await using tmp = await tmpdir()
|
|
const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
|
|
await fs.mkdir(path.dirname(filepath), { recursive: true })
|
|
|
|
await Instance.provide({
|
|
directory: tmp.path,
|
|
fn: async () => {
|
|
const write = await WriteTool.init()
|
|
const result = await write.execute(
|
|
{
|
|
filePath: filepath,
|
|
content: "export const Button = () => {}",
|
|
},
|
|
ctx,
|
|
)
|
|
|
|
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
|
|
},
|
|
})
|
|
})
|
|
})
|
|
})
|