feat: Add centralized filesystem module for Bun.file migration (#14117)

This commit is contained in:
Dax
2026-02-18 10:30:52 -05:00
committed by GitHub
parent 1bb8574179
commit 6b29896a35
9 changed files with 1888 additions and 35 deletions

View File

@@ -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=="],

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)

View 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")
},
})
})
})
})

View 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()
},
})
})
})
})

View 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)
},
})
})
})
})

View 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"))
},
})
})
})
})

View File

@@ -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")
})
}) })
}) })