diff --git a/bun.lock b/bun.lock index 07e239a78..b2bd70b8e 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@tsconfig/bun": "catalog:", + "@types/mime-types": "3.0.1", "husky": "9.1.7", "prettier": "3.6.2", "semver": "^7.6.0", @@ -324,6 +325,7 @@ "hono-openapi": "catalog:", "ignore": "7.0.5", "jsonc-parser": "3.3.1", + "mime-types": "3.0.2", "minimatch": "10.0.3", "open": "10.1.2", "opentui-spinner": "0.0.6", @@ -356,6 +358,7 @@ "@tsconfig/bun": "catalog:", "@types/babel__core": "7.20.5", "@types/bun": "catalog:", + "@types/mime-types": "3.0.1", "@types/turndown": "5.0.5", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", @@ -1917,6 +1920,8 @@ "@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/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="], diff --git a/package.json b/package.json index 5d9320505..e0008d102 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "devDependencies": { "@actions/artifact": "5.0.1", "@tsconfig/bun": "catalog:", + "@types/mime-types": "3.0.1", "husky": "9.1.7", "prettier": "3.6.2", "semver": "^7.6.0", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index da5287db9..6e1288b28 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -40,6 +40,7 @@ "@tsconfig/bun": "catalog:", "@types/babel__core": "7.20.5", "@types/bun": "catalog:", + "@types/mime-types": "3.0.1", "@types/turndown": "5.0.5", "@types/yargs": "17.0.33", "@typescript/native-preview": "catalog:", @@ -110,6 +111,7 @@ "hono-openapi": "catalog:", "ignore": "7.0.5", "jsonc-parser": "3.3.1", + "mime-types": "3.0.2", "minimatch": "10.0.3", "open": "10.1.2", "opentui-spinner": "0.0.6", diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 7aff6bd1d..5c63af030 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -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 { dirname, join, relative } from "path" export namespace Filesystem { - export const exists = (p: string) => - Bun.file(p) - .stat() - .then(() => true) - .catch(() => false) + // Fast sync version for metadata checks + export async function exists(p: string): Promise { + return existsSync(p) + } + + export async function isDir(p: string): Promise { + try { + return statSync(p).isDirectory() + } catch { + return false + } + } + + export async function size(p: string): Promise { + try { + return statSync(p).size + } catch { + return 0 + } + } + + export async function readText(p: string): Promise { + return readFile(p, "utf-8") + } + + export async function readJson(p: string): Promise { + return JSON.parse(await readFile(p, "utf-8")) + } + + export async function readBytes(p: string): Promise { + 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 { + 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 { + 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. * This is needed because Windows paths are case-insensitive but LSP servers @@ -26,6 +84,7 @@ export namespace Filesystem { return p } } + export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts new file mode 100644 index 000000000..758886bd5 --- /dev/null +++ b/packages/opencode/test/file/index.test.ts @@ -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") + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/file/time.test.ts b/packages/opencode/test/file/time.test.ts new file mode 100644 index 000000000..ab7451276 --- /dev/null +++ b/packages/opencode/test/file/time.test.ts @@ -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((_, 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() + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts new file mode 100644 index 000000000..c3cf0404b --- /dev/null +++ b/packages/opencode/test/tool/edit.test.ts @@ -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) + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts new file mode 100644 index 000000000..4f1a7d28e --- /dev/null +++ b/packages/opencode/test/tool/write.test.ts @@ -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")) + }, + }) + }) + }) +}) diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 0e5f0ba38..3c3da0fc7 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -1,39 +1,288 @@ -import { describe, expect, test } from "bun:test" -import os from "node:os" -import path from "node:path" -import { mkdtemp, mkdir, rm } from "node:fs/promises" +import { describe, test, expect } from "bun:test" +import path from "path" +import fs from "fs/promises" import { Filesystem } from "../../src/util/filesystem" +import { tmpdir } from "../fixture/fixture" -describe("util.filesystem", () => { - test("exists() is true for files and directories", async () => { - const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-")) - const dir = path.join(tmp, "dir") - const file = path.join(tmp, "file.txt") - const missing = path.join(tmp, "missing") +describe("filesystem", () => { + describe("exists()", () => { + test("returns true for existing file", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "test.txt") + await fs.writeFile(filepath, "content", "utf-8") - await mkdir(dir, { recursive: true }) - await Bun.write(file, "hello") + expect(await Filesystem.exists(filepath)).toBe(true) + }) - 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 () => { - const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-")) - const dir = path.join(tmp, "dir") - const file = path.join(tmp, "file.txt") - const missing = path.join(tmp, "missing") + describe("isDir()", () => { + test("returns true for directory", async () => { + await using tmp = await tmpdir() + const dirpath = path.join(tmp.path, "testdir") + await fs.mkdir(dirpath) - await mkdir(dir, { recursive: true }) - await Bun.write(file, "hello") + expect(await Filesystem.isDir(dirpath)).toBe(true) + }) - 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(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") + }) }) })