feat: Add centralized filesystem module for Bun.file migration (#14117)
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -14,6 +14,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
@@ -324,6 +325,7 @@
|
|||||||
"hono-openapi": "catalog:",
|
"hono-openapi": "catalog:",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
@@ -356,6 +358,7 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -1917,6 +1920,8 @@
|
|||||||
|
|
||||||
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
|
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
|
||||||
|
|
||||||
|
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
|
||||||
|
|
||||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||||
|
|
||||||
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
|
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/babel__core": "7.20.5",
|
"@types/babel__core": "7.20.5",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/mime-types": "3.0.1",
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
"hono-openapi": "catalog:",
|
"hono-openapi": "catalog:",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
|
"mime-types": "3.0.2",
|
||||||
"minimatch": "10.0.3",
|
"minimatch": "10.0.3",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
"opentui-spinner": "0.0.6",
|
"opentui-spinner": "0.0.6",
|
||||||
|
|||||||
@@ -1,18 +1,76 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "fs/promises"
|
||||||
|
import { existsSync, statSync } from "fs"
|
||||||
|
import { lookup } from "mime-types"
|
||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
import { dirname, join, relative } from "path"
|
import { dirname, join, relative } from "path"
|
||||||
|
|
||||||
export namespace Filesystem {
|
export namespace Filesystem {
|
||||||
export const exists = (p: string) =>
|
// Fast sync version for metadata checks
|
||||||
Bun.file(p)
|
export async function exists(p: string): Promise<boolean> {
|
||||||
.stat()
|
return existsSync(p)
|
||||||
.then(() => true)
|
}
|
||||||
.catch(() => false)
|
|
||||||
|
export async function isDir(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return statSync(p).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function size(p: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return statSync(p).size
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readText(p: string): Promise<string> {
|
||||||
|
return readFile(p, "utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson<T>(p: string): Promise<T> {
|
||||||
|
return JSON.parse(await readFile(p, "utf-8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readBytes(p: string): Promise<Buffer> {
|
||||||
|
return readFile(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEnoent(e: unknown): e is { code: "ENOENT" } {
|
||||||
|
return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (mode) {
|
||||||
|
await writeFile(p, content, { mode })
|
||||||
|
} else {
|
||||||
|
await writeFile(p, content)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (isEnoent(e)) {
|
||||||
|
await mkdir(dirname(p), { recursive: true })
|
||||||
|
if (mode) {
|
||||||
|
await writeFile(p, content, { mode })
|
||||||
|
} else {
|
||||||
|
await writeFile(p, content)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
|
||||||
|
return write(p, JSON.stringify(data, null, 2), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mimeType(p: string): string {
|
||||||
|
return lookup(p) || "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
export const isDir = (p: string) =>
|
|
||||||
Bun.file(p)
|
|
||||||
.stat()
|
|
||||||
.then((s) => s.isDirectory())
|
|
||||||
.catch(() => false)
|
|
||||||
/**
|
/**
|
||||||
* On Windows, normalize a path to its canonical casing using the filesystem.
|
* On Windows, normalize a path to its canonical casing using the filesystem.
|
||||||
* This is needed because Windows paths are case-insensitive but LSP servers
|
* This is needed because Windows paths are case-insensitive but LSP servers
|
||||||
@@ -26,6 +84,7 @@ export namespace Filesystem {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function overlaps(a: string, b: string) {
|
export function overlaps(a: string, b: string) {
|
||||||
const relA = relative(a, b)
|
const relA = relative(a, b)
|
||||||
const relB = relative(b, a)
|
const relB = relative(b, a)
|
||||||
|
|||||||
340
packages/opencode/test/file/index.test.ts
Normal file
340
packages/opencode/test/file/index.test.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { File } from "../../src/file"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
describe("file/index Bun.file patterns", () => {
|
||||||
|
describe("File.read() - text content", () => {
|
||||||
|
test("reads text file via Bun.file().text()", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
await fs.writeFile(filepath, "Hello World", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("test.txt")
|
||||||
|
expect(result.type).toBe("text")
|
||||||
|
expect(result.content).toBe("Hello World")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads with Bun.file().exists() check", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
// Non-existent file should return empty content
|
||||||
|
const result = await File.read("nonexistent.txt")
|
||||||
|
expect(result.type).toBe("text")
|
||||||
|
expect(result.content).toBe("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("trims whitespace from text content", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
await fs.writeFile(filepath, " content with spaces \n\n", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("test.txt")
|
||||||
|
expect(result.content).toBe("content with spaces")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles empty text file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "empty.txt")
|
||||||
|
await fs.writeFile(filepath, "", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("empty.txt")
|
||||||
|
expect(result.type).toBe("text")
|
||||||
|
expect(result.content).toBe("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles multi-line text files", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "multiline.txt")
|
||||||
|
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("multiline.txt")
|
||||||
|
expect(result.content).toBe("line1\nline2\nline3")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("File.read() - binary content", () => {
|
||||||
|
test("reads binary file via Bun.file().arrayBuffer()", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "image.png")
|
||||||
|
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
await fs.writeFile(filepath, binaryContent)
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("image.png")
|
||||||
|
expect(result.type).toBe("text") // Images return as text with base64 encoding
|
||||||
|
expect(result.encoding).toBe("base64")
|
||||||
|
expect(result.mimeType).toBe("image/png")
|
||||||
|
expect(result.content).toBe(binaryContent.toString("base64"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty for binary non-image files", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "binary.so")
|
||||||
|
await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("binary.so")
|
||||||
|
expect(result.type).toBe("binary")
|
||||||
|
expect(result.content).toBe("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("File.read() - Bun.file().type", () => {
|
||||||
|
test("detects MIME type via Bun.file().type", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.json")
|
||||||
|
await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const bunFile = Bun.file(filepath)
|
||||||
|
expect(bunFile.type).toContain("application/json")
|
||||||
|
|
||||||
|
const result = await File.read("test.json")
|
||||||
|
expect(result.type).toBe("text")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles various image MIME types", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const testCases = [
|
||||||
|
{ ext: "jpg", mime: "image/jpeg" },
|
||||||
|
{ ext: "png", mime: "image/png" },
|
||||||
|
{ ext: "gif", mime: "image/gif" },
|
||||||
|
{ ext: "webp", mime: "image/webp" },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { ext, mime } of testCases) {
|
||||||
|
const filepath = path.join(tmp.path, `test.${ext}`)
|
||||||
|
await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const bunFile = Bun.file(filepath)
|
||||||
|
expect(bunFile.type).toContain(mime)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("File.list() - Bun.file().exists() and .text()", () => {
|
||||||
|
test("reads .gitignore via Bun.file().exists() and .text()", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||||
|
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
|
||||||
|
|
||||||
|
// This is used internally in File.list()
|
||||||
|
const bunFile = Bun.file(gitignorePath)
|
||||||
|
expect(await bunFile.exists()).toBe(true)
|
||||||
|
|
||||||
|
const content = await bunFile.text()
|
||||||
|
expect(content).toContain("node_modules")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads .ignore file similarly", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const ignorePath = path.join(tmp.path, ".ignore")
|
||||||
|
await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
|
||||||
|
|
||||||
|
const bunFile = Bun.file(ignorePath)
|
||||||
|
expect(await bunFile.exists()).toBe(true)
|
||||||
|
expect(await bunFile.text()).toContain("*.log")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles missing .gitignore gracefully", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const gitignorePath = path.join(tmp.path, ".gitignore")
|
||||||
|
const bunFile = Bun.file(gitignorePath)
|
||||||
|
expect(await bunFile.exists()).toBe(false)
|
||||||
|
|
||||||
|
// File.list() should still work
|
||||||
|
const nodes = await File.list()
|
||||||
|
expect(Array.isArray(nodes)).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("File.changed() - Bun.file().text() for untracked files", () => {
|
||||||
|
test("reads untracked files via Bun.file().text()", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const untrackedPath = path.join(tmp.path, "untracked.txt")
|
||||||
|
await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
|
||||||
|
|
||||||
|
// This is how File.changed() reads untracked files
|
||||||
|
const bunFile = Bun.file(untrackedPath)
|
||||||
|
const content = await bunFile.text()
|
||||||
|
const lines = content.split("\n").length
|
||||||
|
expect(lines).toBe(2)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Error handling", () => {
|
||||||
|
test("handles errors gracefully in Bun.file().text()", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "readonly.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
|
||||||
|
// Bun.file().text() on non-existent file throws
|
||||||
|
await expect(nonExistentFile.text()).rejects.toThrow()
|
||||||
|
|
||||||
|
// But File.read() handles this gracefully
|
||||||
|
const result = await File.read("does-not-exist.txt")
|
||||||
|
expect(result.content).toBe("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles errors in Bun.file().arrayBuffer()", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
|
||||||
|
const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
|
||||||
|
expect(buffer.byteLength).toBe(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty array buffer on error for images", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "broken.png")
|
||||||
|
// Don't create the file
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const bunFile = Bun.file(filepath)
|
||||||
|
// File.read() handles missing images gracefully
|
||||||
|
const result = await File.read("broken.png")
|
||||||
|
expect(result.type).toBe("text")
|
||||||
|
expect(result.content).toBe("")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("shouldEncode() logic", () => {
|
||||||
|
test("returns encoding info for text files", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
await fs.writeFile(filepath, "simple text", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("test.txt")
|
||||||
|
expect(result.encoding).toBeUndefined()
|
||||||
|
expect(result.type).toBe("text")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns base64 encoding for images", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.jpg")
|
||||||
|
await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await File.read("test.jpg")
|
||||||
|
expect(result.encoding).toBe("base64")
|
||||||
|
expect(result.mimeType).toBe("image/jpeg")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Path security", () => {
|
||||||
|
test("throws for paths outside project directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws for paths outside project directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
360
packages/opencode/test/file/time.test.ts
Normal file
360
packages/opencode/test/file/time.test.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { FileTime } from "../../src/file/time"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
describe("file/time", () => {
|
||||||
|
const sessionID = "test-session-123"
|
||||||
|
|
||||||
|
describe("read() and get()", () => {
|
||||||
|
test("stores read timestamp", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const before = FileTime.get(sessionID, filepath)
|
||||||
|
expect(before).toBeUndefined()
|
||||||
|
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
|
||||||
|
const after = FileTime.get(sessionID, filepath)
|
||||||
|
expect(after).toBeInstanceOf(Date)
|
||||||
|
expect(after!.getTime()).toBeGreaterThan(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tracks separate timestamps per session", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read("session1", filepath)
|
||||||
|
FileTime.read("session2", filepath)
|
||||||
|
|
||||||
|
const time1 = FileTime.get("session1", filepath)
|
||||||
|
const time2 = FileTime.get("session2", filepath)
|
||||||
|
|
||||||
|
expect(time1).toBeDefined()
|
||||||
|
expect(time2).toBeDefined()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates timestamp on subsequent reads", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
const first = FileTime.get(sessionID, filepath)!
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
const second = FileTime.get(sessionID, filepath)!
|
||||||
|
|
||||||
|
expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("assert()", () => {
|
||||||
|
test("passes when file has not been modified", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
await FileTime.assert(sessionID, filepath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws when file was not read first", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws when file was modified after read", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
|
||||||
|
// Wait to ensure different timestamps
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
// Modify file after reading
|
||||||
|
await fs.writeFile(filepath, "modified content", "utf-8")
|
||||||
|
|
||||||
|
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes timestamps in error message", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
await fs.writeFile(filepath, "modified", "utf-8")
|
||||||
|
|
||||||
|
let error: Error | undefined
|
||||||
|
try {
|
||||||
|
await FileTime.assert(sessionID, filepath)
|
||||||
|
} catch (e) {
|
||||||
|
error = e as Error
|
||||||
|
}
|
||||||
|
expect(error).toBeDefined()
|
||||||
|
expect(error!.message).toContain("Last modification:")
|
||||||
|
expect(error!.message).toContain("Last read:")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const { Flag } = await import("../../src/flag/flag")
|
||||||
|
const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Should not throw even though file wasn't read
|
||||||
|
await FileTime.assert(sessionID, filepath)
|
||||||
|
} finally {
|
||||||
|
;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("withLock()", () => {
|
||||||
|
test("executes function within lock", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
let executed = false
|
||||||
|
await FileTime.withLock(filepath, async () => {
|
||||||
|
executed = true
|
||||||
|
return "result"
|
||||||
|
})
|
||||||
|
expect(executed).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns function result", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const result = await FileTime.withLock(filepath, async () => {
|
||||||
|
return "success"
|
||||||
|
})
|
||||||
|
expect(result).toBe("success")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes concurrent operations on same file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const order: number[] = []
|
||||||
|
|
||||||
|
const op1 = FileTime.withLock(filepath, async () => {
|
||||||
|
order.push(1)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
order.push(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const op2 = FileTime.withLock(filepath, async () => {
|
||||||
|
order.push(3)
|
||||||
|
order.push(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([op1, op2])
|
||||||
|
|
||||||
|
// Operations should be serialized
|
||||||
|
expect(order).toContain(1)
|
||||||
|
expect(order).toContain(2)
|
||||||
|
expect(order).toContain(3)
|
||||||
|
expect(order).toContain(4)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows concurrent operations on different files", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath1 = path.join(tmp.path, "file1.txt")
|
||||||
|
const filepath2 = path.join(tmp.path, "file2.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
let started1 = false
|
||||||
|
let started2 = false
|
||||||
|
|
||||||
|
const op1 = FileTime.withLock(filepath1, async () => {
|
||||||
|
started1 = true
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
expect(started2).toBe(true) // op2 should have started while op1 is running
|
||||||
|
})
|
||||||
|
|
||||||
|
const op2 = FileTime.withLock(filepath2, async () => {
|
||||||
|
started2 = true
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([op1, op2])
|
||||||
|
|
||||||
|
expect(started1).toBe(true)
|
||||||
|
expect(started2).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("releases lock even if function throws", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
await expect(
|
||||||
|
FileTime.withLock(filepath, async () => {
|
||||||
|
throw new Error("Test error")
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Test error")
|
||||||
|
|
||||||
|
// Lock should be released, subsequent operations should work
|
||||||
|
let executed = false
|
||||||
|
await FileTime.withLock(filepath, async () => {
|
||||||
|
executed = true
|
||||||
|
})
|
||||||
|
expect(executed).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("deadlocks on nested locks (expected behavior)", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
// Nested locks on same file cause deadlock - this is expected
|
||||||
|
// The outer lock waits for inner to complete, but inner waits for outer to release
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("Deadlock detected")), 100),
|
||||||
|
)
|
||||||
|
|
||||||
|
const nestedLock = FileTime.withLock(filepath, async () => {
|
||||||
|
return FileTime.withLock(filepath, async () => {
|
||||||
|
return "inner"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should timeout due to deadlock
|
||||||
|
await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("stat() Bun.file pattern", () => {
|
||||||
|
test("reads file modification time via Bun.file().stat()", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
|
||||||
|
const stats = await Bun.file(filepath).stat()
|
||||||
|
expect(stats.mtime).toBeInstanceOf(Date)
|
||||||
|
expect(stats.mtime.getTime()).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// FileTime.assert uses this stat internally
|
||||||
|
await FileTime.assert(sessionID, filepath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects modification via stat mtime", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "original", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(sessionID, filepath)
|
||||||
|
|
||||||
|
const originalStat = await Bun.file(filepath).stat()
|
||||||
|
|
||||||
|
// Wait and modify
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
await fs.writeFile(filepath, "modified", "utf-8")
|
||||||
|
|
||||||
|
const newStat = await Bun.file(filepath).stat()
|
||||||
|
expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
|
||||||
|
|
||||||
|
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
496
packages/opencode/test/tool/edit.test.ts
Normal file
496
packages/opencode/test/tool/edit.test.ts
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import { EditTool } from "../../src/tool/edit"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { FileTime } from "../../src/file/time"
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: "test-edit-session",
|
||||||
|
messageID: "",
|
||||||
|
callID: "",
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => {},
|
||||||
|
ask: async () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tool.edit", () => {
|
||||||
|
describe("creating new files", () => {
|
||||||
|
test("creates new file when oldString is empty", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "newfile.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
const result = await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "",
|
||||||
|
newString: "new content",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.diff).toContain("new content")
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toBe("new content")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates new file with nested directories", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "",
|
||||||
|
newString: "nested file",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toBe("nested file")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("emits add event for new files", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "new.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const { Bus } = await import("../../src/bus")
|
||||||
|
const { File } = await import("../../src/file")
|
||||||
|
const { FileWatcher } = await import("../../src/file/watcher")
|
||||||
|
|
||||||
|
const events: string[] = []
|
||||||
|
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||||
|
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "",
|
||||||
|
newString: "content",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events).toContain("edited")
|
||||||
|
expect(events).toContain("updated")
|
||||||
|
unsubEdited()
|
||||||
|
unsubUpdated()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("editing existing files", () => {
|
||||||
|
test("replaces text in existing file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "existing.txt")
|
||||||
|
await fs.writeFile(filepath, "old content here", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
const result = await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "old content",
|
||||||
|
newString: "new content",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.output).toContain("Edit applied successfully")
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toBe("new content here")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when file does not exist", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "nonexistent.txt")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "old",
|
||||||
|
newString: "new",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("not found")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when oldString equals newString", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "same",
|
||||||
|
newString: "same",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("identical")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when oldString not found in file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "actual content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "not in file",
|
||||||
|
newString: "replacement",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when file was not read first (FileTime)", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "content",
|
||||||
|
newString: "modified",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("You must read file")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when file has been modified since read", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "original content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
// Read first
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
// Wait a bit to ensure different timestamps
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
// Simulate external modification
|
||||||
|
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||||
|
|
||||||
|
// Try to edit with the new content
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "modified externally",
|
||||||
|
newString: "edited",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("modified since it was last read")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces all occurrences with replaceAll option", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "foo",
|
||||||
|
newString: "qux",
|
||||||
|
replaceAll: true,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toBe("qux bar qux baz qux")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("emits change event for existing files", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "original", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const { Bus } = await import("../../src/bus")
|
||||||
|
const { File } = await import("../../src/file")
|
||||||
|
const { FileWatcher } = await import("../../src/file/watcher")
|
||||||
|
|
||||||
|
const events: string[] = []
|
||||||
|
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||||
|
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "original",
|
||||||
|
newString: "modified",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events).toContain("edited")
|
||||||
|
expect(events).toContain("updated")
|
||||||
|
unsubEdited()
|
||||||
|
unsubUpdated()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
test("handles multiline replacements", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "line2",
|
||||||
|
newString: "new line 2\nextra line",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles CRLF line endings", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "old",
|
||||||
|
newString: "new",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toBe("line1\r\nnew\r\nline3")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when oldString equals newString", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "",
|
||||||
|
newString: "",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("identical")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws error when path is directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dirpath = path.join(tmp.path, "adir")
|
||||||
|
await fs.mkdir(dirpath)
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, dirpath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
await expect(
|
||||||
|
edit.execute(
|
||||||
|
{
|
||||||
|
filePath: dirpath,
|
||||||
|
oldString: "old",
|
||||||
|
newString: "new",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("directory")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tracks file diff statistics", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
const result = await edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "line2",
|
||||||
|
newString: "new line a\nnew line b",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.metadata.filediff).toBeDefined()
|
||||||
|
expect(result.metadata.filediff.file).toBe(filepath)
|
||||||
|
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("concurrent editing", () => {
|
||||||
|
test("serializes concurrent edits to same file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "file.txt")
|
||||||
|
await fs.writeFile(filepath, "0", "utf-8")
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const edit = await EditTool.init()
|
||||||
|
|
||||||
|
// Two concurrent edits
|
||||||
|
const promise1 = edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "0",
|
||||||
|
newString: "1",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Need to read again since FileTime tracks per-session
|
||||||
|
FileTime.read(ctx.sessionID, filepath)
|
||||||
|
|
||||||
|
const promise2 = edit.execute(
|
||||||
|
{
|
||||||
|
filePath: filepath,
|
||||||
|
oldString: "0",
|
||||||
|
newString: "2",
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both should complete without error (though one might fail due to content mismatch)
|
||||||
|
const results = await Promise.allSettled([promise1, promise2])
|
||||||
|
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
341
packages/opencode/test/tool/write.test.ts
Normal file
341
packages/opencode/test/tool/write.test.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { 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"
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
sessionID: "test-write-session",
|
||||||
|
messageID: "",
|
||||||
|
callID: "",
|
||||||
|
agent: "build",
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
messages: [],
|
||||||
|
metadata: () => {},
|
||||||
|
ask: async () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
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 for paths outside project", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const outsidePath = "/etc/passwd"
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const write = await WriteTool.init()
|
||||||
|
await expect(
|
||||||
|
write.execute(
|
||||||
|
{
|
||||||
|
filePath: outsidePath,
|
||||||
|
content: "test",
|
||||||
|
},
|
||||||
|
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"))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,39 +1,288 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import os from "node:os"
|
import path from "path"
|
||||||
import path from "node:path"
|
import fs from "fs/promises"
|
||||||
import { mkdtemp, mkdir, rm } from "node:fs/promises"
|
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
describe("util.filesystem", () => {
|
describe("filesystem", () => {
|
||||||
test("exists() is true for files and directories", async () => {
|
describe("exists()", () => {
|
||||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
|
test("returns true for existing file", async () => {
|
||||||
const dir = path.join(tmp, "dir")
|
await using tmp = await tmpdir()
|
||||||
const file = path.join(tmp, "file.txt")
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
const missing = path.join(tmp, "missing")
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
await mkdir(dir, { recursive: true })
|
expect(await Filesystem.exists(filepath)).toBe(true)
|
||||||
await Bun.write(file, "hello")
|
})
|
||||||
|
|
||||||
const cases = await Promise.all([Filesystem.exists(dir), Filesystem.exists(file), Filesystem.exists(missing)])
|
test("returns false for non-existent file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "does-not-exist.txt")
|
||||||
|
|
||||||
expect(cases).toEqual([true, true, false])
|
expect(await Filesystem.exists(filepath)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
await rm(tmp, { recursive: true, force: true })
|
test("returns true for existing directory", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dirpath = path.join(tmp.path, "subdir")
|
||||||
|
await fs.mkdir(dirpath)
|
||||||
|
|
||||||
|
expect(await Filesystem.exists(dirpath)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("isDir() is true only for directories", async () => {
|
describe("isDir()", () => {
|
||||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
|
test("returns true for directory", async () => {
|
||||||
const dir = path.join(tmp, "dir")
|
await using tmp = await tmpdir()
|
||||||
const file = path.join(tmp, "file.txt")
|
const dirpath = path.join(tmp.path, "testdir")
|
||||||
const missing = path.join(tmp, "missing")
|
await fs.mkdir(dirpath)
|
||||||
|
|
||||||
await mkdir(dir, { recursive: true })
|
expect(await Filesystem.isDir(dirpath)).toBe(true)
|
||||||
await Bun.write(file, "hello")
|
})
|
||||||
|
|
||||||
const cases = await Promise.all([Filesystem.isDir(dir), Filesystem.isDir(file), Filesystem.isDir(missing)])
|
test("returns false for file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
await fs.writeFile(filepath, "content", "utf-8")
|
||||||
|
|
||||||
expect(cases).toEqual([true, false, false])
|
expect(await Filesystem.isDir(filepath)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
await rm(tmp, { recursive: true, force: true })
|
test("returns false for non-existent path", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "does-not-exist")
|
||||||
|
|
||||||
|
expect(await Filesystem.isDir(filepath)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("size()", () => {
|
||||||
|
test("returns file size", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
const content = "Hello, World!"
|
||||||
|
await fs.writeFile(filepath, content, "utf-8")
|
||||||
|
|
||||||
|
expect(await Filesystem.size(filepath)).toBe(content.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns 0 for non-existent file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "does-not-exist.txt")
|
||||||
|
|
||||||
|
expect(await Filesystem.size(filepath)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns directory size", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const dirpath = path.join(tmp.path, "testdir")
|
||||||
|
await fs.mkdir(dirpath)
|
||||||
|
|
||||||
|
// Directories have size on some systems
|
||||||
|
const size = await Filesystem.size(dirpath)
|
||||||
|
expect(typeof size).toBe("number")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readText()", () => {
|
||||||
|
test("reads file content", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
const content = "Hello, World!"
|
||||||
|
await fs.writeFile(filepath, content, "utf-8")
|
||||||
|
|
||||||
|
expect(await Filesystem.readText(filepath)).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws for non-existent file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "does-not-exist.txt")
|
||||||
|
|
||||||
|
await expect(Filesystem.readText(filepath)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads UTF-8 content correctly", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "unicode.txt")
|
||||||
|
const content = "Hello 世界 🌍"
|
||||||
|
await fs.writeFile(filepath, content, "utf-8")
|
||||||
|
|
||||||
|
expect(await Filesystem.readText(filepath)).toBe(content)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readJson()", () => {
|
||||||
|
test("reads and parses JSON", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.json")
|
||||||
|
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||||
|
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
|
||||||
|
|
||||||
|
const result: typeof data = await Filesystem.readJson(filepath)
|
||||||
|
expect(result).toEqual(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws for invalid JSON", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "invalid.json")
|
||||||
|
await fs.writeFile(filepath, "{ invalid json", "utf-8")
|
||||||
|
|
||||||
|
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws for non-existent file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "does-not-exist.json")
|
||||||
|
|
||||||
|
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns typed data", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "typed.json")
|
||||||
|
interface Config {
|
||||||
|
name: string
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
const data: Config = { name: "test", version: 1 }
|
||||||
|
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
|
||||||
|
|
||||||
|
const result = await Filesystem.readJson<Config>(filepath)
|
||||||
|
expect(result.name).toBe("test")
|
||||||
|
expect(result.version).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("readBytes()", () => {
|
||||||
|
test("reads file as buffer", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
const content = "Hello, World!"
|
||||||
|
await fs.writeFile(filepath, content, "utf-8")
|
||||||
|
|
||||||
|
const buffer = await Filesystem.readBytes(filepath)
|
||||||
|
expect(buffer).toBeInstanceOf(Buffer)
|
||||||
|
expect(buffer.toString("utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("throws for non-existent file", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "does-not-exist.bin")
|
||||||
|
|
||||||
|
await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("write()", () => {
|
||||||
|
test("writes text content", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.txt")
|
||||||
|
const content = "Hello, World!"
|
||||||
|
|
||||||
|
await Filesystem.write(filepath, content)
|
||||||
|
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes buffer content", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "test.bin")
|
||||||
|
const content = Buffer.from([0x00, 0x01, 0x02, 0x03])
|
||||||
|
|
||||||
|
await Filesystem.write(filepath, content)
|
||||||
|
|
||||||
|
const read = await fs.readFile(filepath)
|
||||||
|
expect(read).toEqual(content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes with permissions", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "protected.txt")
|
||||||
|
const content = "secret"
|
||||||
|
|
||||||
|
await Filesystem.write(filepath, content, 0o600)
|
||||||
|
|
||||||
|
const stats = await fs.stat(filepath)
|
||||||
|
// Check permissions on Unix
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect(stats.mode & 0o777).toBe(0o600)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates parent directories", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
|
||||||
|
const content = "nested content"
|
||||||
|
|
||||||
|
await Filesystem.write(filepath, content)
|
||||||
|
|
||||||
|
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("writeJson()", () => {
|
||||||
|
test("writes JSON data", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "data.json")
|
||||||
|
const data = { key: "value", number: 42 }
|
||||||
|
|
||||||
|
await Filesystem.writeJson(filepath, data)
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(JSON.parse(content)).toEqual(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes formatted JSON", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "pretty.json")
|
||||||
|
const data = { key: "value" }
|
||||||
|
|
||||||
|
await Filesystem.writeJson(filepath, data)
|
||||||
|
|
||||||
|
const content = await fs.readFile(filepath, "utf-8")
|
||||||
|
expect(content).toContain("\n")
|
||||||
|
expect(content).toContain(" ")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("writes with permissions", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const filepath = path.join(tmp.path, "config.json")
|
||||||
|
const data = { secret: "data" }
|
||||||
|
|
||||||
|
await Filesystem.writeJson(filepath, data, 0o600)
|
||||||
|
|
||||||
|
const stats = await fs.stat(filepath)
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect(stats.mode & 0o777).toBe(0o600)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("mimeType()", () => {
|
||||||
|
test("returns correct MIME type for JSON", () => {
|
||||||
|
expect(Filesystem.mimeType("test.json")).toContain("application/json")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns correct MIME type for JavaScript", () => {
|
||||||
|
expect(Filesystem.mimeType("test.js")).toContain("javascript")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
|
||||||
|
const mime = Filesystem.mimeType("test.ts")
|
||||||
|
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
|
||||||
|
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns correct MIME type for images", () => {
|
||||||
|
expect(Filesystem.mimeType("test.png")).toContain("image/png")
|
||||||
|
expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns default for unknown extension", () => {
|
||||||
|
expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles files without extension", () => {
|
||||||
|
expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user