mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 00:23:10 +00:00
refactor: apply minimal tfcode branding
- Rename packages/opencode → packages/tfcode (directory only) - Rename bin/opencode → bin/tfcode (CLI binary) - Rename .opencode → .tfcode (config directory) - Update package.json name and bin field - Update config directory path references (.tfcode) - Keep internal code references as 'opencode' for easy upstream sync - Keep @opencode-ai/* workspace package names This minimal branding approach allows clean merges from upstream opencode repository while providing tfcode branding for users.
This commit is contained in:
62
packages/tfcode/test/file/fsmonitor.test.ts
Normal file
62
packages/tfcode/test/file/fsmonitor.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
|
||||
describe("file fsmonitor", () => {
|
||||
wintest("status does not start fsmonitor for readonly git checks", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const target = path.join(tmp.path, "tracked.txt")
|
||||
|
||||
await fs.writeFile(target, "base\n")
|
||||
await $`git add tracked.txt`.cwd(tmp.path).quiet()
|
||||
await $`git commit -m init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
|
||||
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
|
||||
await fs.writeFile(target, "next\n")
|
||||
await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n")
|
||||
|
||||
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.status()
|
||||
},
|
||||
})
|
||||
|
||||
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
})
|
||||
|
||||
wintest("read does not start fsmonitor for git diffs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const target = path.join(tmp.path, "tracked.txt")
|
||||
|
||||
await fs.writeFile(target, "base\n")
|
||||
await $`git add tracked.txt`.cwd(tmp.path).quiet()
|
||||
await $`git commit -m init`.cwd(tmp.path).quiet()
|
||||
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
|
||||
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
|
||||
await fs.writeFile(target, "next\n")
|
||||
|
||||
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(before.exitCode).not.toBe(0)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.read("tracked.txt")
|
||||
},
|
||||
})
|
||||
|
||||
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
|
||||
expect(after.exitCode).not.toBe(0)
|
||||
})
|
||||
})
|
||||
10
packages/tfcode/test/file/ignore.test.ts
Normal file
10
packages/tfcode/test/file/ignore.test.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { FileIgnore } from "../../src/file/ignore"
|
||||
|
||||
test("match nested and non-nested", () => {
|
||||
expect(FileIgnore.match("node_modules/index.js")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules/")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules/bar")).toBe(true)
|
||||
expect(FileIgnore.match("node_modules/bar/")).toBe(true)
|
||||
})
|
||||
946
packages/tfcode/test/file/index.test.ts
Normal file
946
packages/tfcode/test/file/index.test.ts
Normal file
@@ -0,0 +1,946 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
describe("file/index Filesystem patterns", () => {
|
||||
describe("File.read() - text content", () => {
|
||||
test("reads text file via Filesystem.readText()", 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 Filesystem.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 Filesystem.readArrayBuffer()", 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() - Filesystem.mimeType()", () => {
|
||||
test("detects MIME type via Filesystem.mimeType()", 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 () => {
|
||||
expect(Filesystem.mimeType(filepath)).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 () => {
|
||||
expect(Filesystem.mimeType(filepath)).toContain(mime)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list() - Filesystem.exists() and readText()", () => {
|
||||
test("reads .gitignore via Filesystem.exists() and readText()", 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()
|
||||
expect(await Filesystem.exists(gitignorePath)).toBe(true)
|
||||
|
||||
const content = await Filesystem.readText(gitignorePath)
|
||||
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")
|
||||
|
||||
expect(await Filesystem.exists(ignorePath)).toBe(true)
|
||||
expect(await Filesystem.readText(ignorePath)).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")
|
||||
expect(await Filesystem.exists(gitignorePath)).toBe(false)
|
||||
|
||||
// File.list() should still work
|
||||
const nodes = await File.list()
|
||||
expect(Array.isArray(nodes)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.changed() - Filesystem.readText() for untracked files", () => {
|
||||
test("reads untracked files via Filesystem.readText()", 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 content = await Filesystem.readText(untrackedPath)
|
||||
const lines = content.split("\n").length
|
||||
expect(lines).toBe(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("handles errors gracefully in Filesystem.readText()", 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 nonExistentPath = path.join(tmp.path, "does-not-exist.txt")
|
||||
// Filesystem.readText() on non-existent file throws
|
||||
await expect(Filesystem.readText(nonExistentPath)).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 Filesystem.readArrayBuffer()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nonExistentPath = path.join(tmp.path, "does-not-exist.bin")
|
||||
const buffer = await Filesystem.readArrayBuffer(nonExistentPath).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 () => {
|
||||
// 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("treats .ts files as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.ts")
|
||||
await fs.writeFile(filepath, "export const value = 1", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.ts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats .mts files as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.mts")
|
||||
await fs.writeFile(filepath, "export const value = 1", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.mts")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("export const value = 1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats .sh files as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.sh")
|
||||
await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("test.sh")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("treats Dockerfile as text", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "Dockerfile")
|
||||
await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("Dockerfile")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("FROM alpine:3.20")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.status()", () => {
|
||||
test("detects modified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified\nextra line\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "file.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBeGreaterThan(0)
|
||||
expect(entry!.removed).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects untracked file as added", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "new.txt")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("added")
|
||||
expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4
|
||||
expect(entry!.removed).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects deleted file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "gone.txt")
|
||||
await fs.writeFile(filepath, "content\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.rm(filepath)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
// Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted")
|
||||
const entries = result.filter((f) => f.path === "gone.txt")
|
||||
expect(entries.some((e) => e.status === "deleted")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("detects mixed changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet()
|
||||
|
||||
// Modify one, delete one, add one
|
||||
await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "remove.txt"))
|
||||
await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true)
|
||||
expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true)
|
||||
expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for non-git project", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty for clean repo", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
expect(result).toEqual([])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("parses binary numstat as 0", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "data.bin")
|
||||
// Write content with null bytes so git treats it as binary
|
||||
const binaryData = Buffer.alloc(256)
|
||||
for (let i = 0; i < 256; i++) binaryData[i] = i
|
||||
await fs.writeFile(filepath, binaryData)
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet()
|
||||
// Modify the binary
|
||||
const modified = Buffer.alloc(512)
|
||||
for (let i = 0; i < 512; i++) modified[i] = i % 256
|
||||
await fs.writeFile(filepath, modified)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.status()
|
||||
const entry = result.find((f) => f.path === "data.bin")
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.status).toBe("modified")
|
||||
expect(entry!.added).toBe(0)
|
||||
expect(entry!.removed).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list()", () => {
|
||||
test("returns files and directories with correct shape", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
for (const node of nodes) {
|
||||
expect(node).toHaveProperty("name")
|
||||
expect(node).toHaveProperty("path")
|
||||
expect(node).toHaveProperty("absolute")
|
||||
expect(node).toHaveProperty("type")
|
||||
expect(node).toHaveProperty("ignored")
|
||||
expect(["file", "directory"]).toContain(node.type)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("sorts directories before files, alphabetical within each", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "beta"))
|
||||
await fs.mkdir(path.join(tmp.path, "alpha"))
|
||||
await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const dirs = nodes.filter((n) => n.type === "directory")
|
||||
const files = nodes.filter((n) => n.type === "file")
|
||||
// Dirs come first
|
||||
const firstFile = nodes.findIndex((n) => n.type === "file")
|
||||
const lastDir = nodes.findLastIndex((n) => n.type === "directory")
|
||||
if (lastDir >= 0 && firstFile >= 0) {
|
||||
expect(lastDir).toBeLessThan(firstFile)
|
||||
}
|
||||
// Alphabetical within dirs
|
||||
expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted())
|
||||
// Alphabetical within files
|
||||
expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted())
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("excludes .git and .DS_Store", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const names = nodes.map((n) => n.name)
|
||||
expect(names).not.toContain(".git")
|
||||
expect(names).not.toContain(".DS_Store")
|
||||
expect(names).toContain("visible.txt")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("marks gitignored files as ignored", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "build"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
const logNode = nodes.find((n) => n.name === "app.log")
|
||||
const tsNode = nodes.find((n) => n.name === "main.ts")
|
||||
const buildNode = nodes.find((n) => n.name === "build")
|
||||
expect(logNode?.ignored).toBe(true)
|
||||
expect(tsNode?.ignored).toBe(false)
|
||||
expect(buildNode?.ignored).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("lists subdirectory contents", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.mkdir(path.join(tmp.path, "sub"))
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"])
|
||||
// Paths should be relative to project root (normalize for Windows)
|
||||
expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws for paths outside project directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.list("../outside")).rejects.toThrow("Access denied")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("works without git", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const nodes = await File.list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
// Without git, ignored should be false for all
|
||||
for (const node of nodes) {
|
||||
expect(node.ignored).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.search()", () => {
|
||||
async function setupSearchableRepo() {
|
||||
const tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8")
|
||||
await fs.mkdir(path.join(tmp.path, "src"))
|
||||
await fs.mkdir(path.join(tmp.path, ".hidden"))
|
||||
await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8")
|
||||
return tmp
|
||||
}
|
||||
|
||||
test("empty query returns files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("search works before explicit init", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("empty query returns dirs sorted with hidden last", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// Find first hidden dir index
|
||||
const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1))
|
||||
if (firstHidden >= 0 && lastVisible >= 0) {
|
||||
expect(firstHidden).toBeGreaterThan(lastVisible)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("fuzzy matches file names", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "main", type: "file" })
|
||||
expect(result.some((f) => f.includes("main"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("type filter returns only files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "file" })
|
||||
// Files don't end with /
|
||||
for (const f of result) {
|
||||
expect(f.endsWith("/")).toBe(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("type filter returns only directories", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "directory" })
|
||||
// Directories end with /
|
||||
for (const d of result) {
|
||||
expect(d.endsWith("/")).toBe(true)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("respects limit", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: "", type: "file", limit: 2 })
|
||||
expect(result.length).toBeLessThanOrEqual(2)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("query starting with dot prefers hidden files", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
|
||||
const result = await File.search({ query: ".hidden", type: "directory" })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
expect(result[0]).toContain(".hidden")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("search refreshes after init when files change", async () => {
|
||||
await using tmp = await setupSearchableRepo()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
|
||||
|
||||
const result = await File.search({ query: "fresh", type: "file" })
|
||||
expect(result).toContain("fresh.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.read() - diff/patch", () => {
|
||||
test("returns diff and patch for modified tracked file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "modified content\n", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("modified content")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.diff).toContain("original content")
|
||||
expect(result.diff).toContain("modified content")
|
||||
expect(result.patch).toBeDefined()
|
||||
expect(result.patch!.hunks.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns diff for staged changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "staged.txt")
|
||||
await fs.writeFile(filepath, "before\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(filepath, "after\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("staged.txt")
|
||||
expect(result.diff).toBeDefined()
|
||||
expect(result.patch).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns no diff for unmodified file", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const filepath = path.join(tmp.path, "clean.txt")
|
||||
await fs.writeFile(filepath, "unchanged\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("clean.txt")
|
||||
expect(result.type).toBe("text")
|
||||
expect(result.content).toBe("unchanged")
|
||||
expect(result.diff).toBeUndefined()
|
||||
expect(result.patch).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("InstanceState isolation", () => {
|
||||
test("two directories get independent file caches", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
|
||||
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results).toContain("a.ts")
|
||||
const results2 = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results2).not.toContain("b.ts")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "b.ts", type: "file" })
|
||||
expect(results).toContain("b.ts")
|
||||
const results2 = await File.search({ query: "a.ts", type: "file" })
|
||||
expect(results2).not.toContain("a.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disposal gives fresh state on next access", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "before", type: "file" })
|
||||
expect(results).toContain("before.ts")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.disposeAll()
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
|
||||
await fs.rm(path.join(tmp.path, "before.ts"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await File.init()
|
||||
const results = await File.search({ query: "after", type: "file" })
|
||||
expect(results).toContain("after.ts")
|
||||
const stale = await File.search({ query: "before", type: "file" })
|
||||
expect(stale).not.toContain("before.ts")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
198
packages/tfcode/test/file/path-traversal.test.ts
Normal file
198
packages/tfcode/test/file/path-traversal.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { File } from "../../src/file"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Filesystem.contains", () => {
|
||||
test("allows paths within project", () => {
|
||||
expect(Filesystem.contains("/project", "/project/src")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project/src/file.ts")).toBe(true)
|
||||
expect(Filesystem.contains("/project", "/project")).toBe(true)
|
||||
})
|
||||
|
||||
test("blocks ../ traversal", () => {
|
||||
expect(Filesystem.contains("/project", "/project/../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/project/src/../../etc")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
})
|
||||
|
||||
test("blocks absolute paths outside project", () => {
|
||||
expect(Filesystem.contains("/project", "/etc/passwd")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/tmp/file")).toBe(false)
|
||||
expect(Filesystem.contains("/home/user/project", "/home/user/other")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles prefix collision edge cases", () => {
|
||||
expect(Filesystem.contains("/project", "/project-other/file")).toBe(false)
|
||||
expect(Filesystem.contains("/project", "/projectfile")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Integration tests for File.read() and File.list() path traversal protection.
|
||||
*
|
||||
* These tests verify the HTTP API code path is protected. The HTTP endpoints
|
||||
* in server.ts (GET /file/content, GET /file) call File.read()/File.list()
|
||||
* directly - they do NOT go through ReadTool or the agent permission layer.
|
||||
*
|
||||
* This is a SEPARATE code path from ReadTool, which has its own checks.
|
||||
*/
|
||||
describe("File.read path traversal protection", () => {
|
||||
test("rejects ../ traversal attempting to read /etc/passwd", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "allowed.txt"), "allowed content")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects deeply nested traversal", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
|
||||
"Access denied: path escapes project directory",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("allows valid paths within project", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "valid.txt"), "valid content")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.read("valid.txt")
|
||||
expect(result.content).toBe("valid content")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("File.list path traversal protection", () => {
|
||||
test("rejects ../ traversal attempting to list /etc", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("allows valid subdirectory listing", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "subdir", "file.txt"), "content")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const result = await File.list("subdir")
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Instance.containsPath", () => {
|
||||
test("returns true for path inside directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns true for path inside worktree but outside directory (monorepo subdirectory scenario)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const subdir = path.join(tmp.path, "packages", "lib")
|
||||
await fs.mkdir(subdir, { recursive: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: subdir,
|
||||
fn: () => {
|
||||
// .opencode at worktree root, but we're running from packages/lib
|
||||
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true)
|
||||
// sibling package should also be accessible
|
||||
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true)
|
||||
// worktree root itself
|
||||
expect(Instance.containsPath(tmp.path)).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns false for path outside both directory and worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
||||
expect(Instance.containsPath("/tmp/other-project")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns false for path with .. escaping worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles directory === worktree (running from repo root)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
expect(Instance.directory).toBe(Instance.worktree)
|
||||
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
|
||||
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("non-git project does not allow arbitrary paths via worktree='/'", async () => {
|
||||
await using tmp = await tmpdir() // no git: true
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||
expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true)
|
||||
expect(Instance.containsPath("/etc/passwd")).toBe(false)
|
||||
expect(Instance.containsPath("/tmp/other")).toBe(false)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
54
packages/tfcode/test/file/ripgrep.test.ts
Normal file
54
packages/tfcode/test/file/ripgrep.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
|
||||
describe("file.ripgrep", () => {
|
||||
test("defaults to include hidden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
|
||||
const hasVisible = files.includes("visible.txt")
|
||||
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
|
||||
expect(hasVisible).toBe(true)
|
||||
expect(hasHidden).toBe(true)
|
||||
})
|
||||
|
||||
test("hidden false excludes hidden", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "visible.txt"), "hello")
|
||||
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
|
||||
await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
|
||||
},
|
||||
})
|
||||
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
|
||||
const hasVisible = files.includes("visible.txt")
|
||||
const hasHidden = files.includes(path.join(".opencode", "thing.json"))
|
||||
expect(hasVisible).toBe(true)
|
||||
expect(hasHidden).toBe(false)
|
||||
})
|
||||
|
||||
test("search returns empty when nothing matches", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
||||
},
|
||||
})
|
||||
|
||||
const hits = await Ripgrep.search({
|
||||
cwd: tmp.path,
|
||||
pattern: "needle",
|
||||
})
|
||||
|
||||
expect(hits).toEqual([])
|
||||
})
|
||||
})
|
||||
354
packages/tfcode/test/file/time.test.ts
Normal file
354
packages/tfcode/test/file/time.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { describe, test, expect, afterEach } 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 { SessionID } from "../../src/session/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
async function touch(file: string, time: number) {
|
||||
const date = new Date(time)
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
function gate() {
|
||||
let open!: () => void
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
open = resolve
|
||||
})
|
||||
return { open, wait }
|
||||
}
|
||||
|
||||
describe("file/time", () => {
|
||||
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
||||
|
||||
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 = await FileTime.get(sessionID, filepath)
|
||||
expect(before).toBeUndefined()
|
||||
|
||||
await FileTime.read(sessionID, filepath)
|
||||
|
||||
const after = await 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 () => {
|
||||
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
||||
|
||||
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
||||
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), 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 () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
const first = await FileTime.get(sessionID, filepath)
|
||||
|
||||
await FileTime.read(sessionID, filepath)
|
||||
const second = await FileTime.get(sessionID, filepath)
|
||||
|
||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("isolates reads by directory", async () => {
|
||||
await using one = await tmpdir()
|
||||
await using two = await tmpdir()
|
||||
await using shared = await tmpdir()
|
||||
const filepath = path.join(shared.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: async () => {
|
||||
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
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 touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
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 touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
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:")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 hold = gate()
|
||||
const ready = gate()
|
||||
|
||||
const op1 = FileTime.withLock(filepath, async () => {
|
||||
order.push(1)
|
||||
ready.open()
|
||||
await hold.wait
|
||||
order.push(2)
|
||||
})
|
||||
|
||||
await ready.wait
|
||||
|
||||
const op2 = FileTime.withLock(filepath, async () => {
|
||||
order.push(3)
|
||||
order.push(4)
|
||||
})
|
||||
|
||||
hold.open()
|
||||
|
||||
await Promise.all([op1, op2])
|
||||
expect(order).toEqual([1, 2, 3, 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 hold = gate()
|
||||
const ready = gate()
|
||||
|
||||
const op1 = FileTime.withLock(filepath1, async () => {
|
||||
started1 = true
|
||||
ready.open()
|
||||
await hold.wait
|
||||
expect(started2).toBe(true)
|
||||
})
|
||||
|
||||
await ready.wait
|
||||
|
||||
const op2 = FileTime.withLock(filepath2, async () => {
|
||||
started2 = true
|
||||
hold.open()
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
let executed = false
|
||||
await FileTime.withLock(filepath, async () => {
|
||||
executed = true
|
||||
})
|
||||
expect(executed).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("stat() Filesystem.stat pattern", () => {
|
||||
test("reads file modification time via Filesystem.stat()", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
await touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
|
||||
const stats = Filesystem.stat(filepath)
|
||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
||||
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
|
||||
|
||||
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 touch(filepath, 1_000)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(sessionID, filepath)
|
||||
|
||||
const originalStat = Filesystem.stat(filepath)
|
||||
|
||||
await fs.writeFile(filepath, "modified", "utf-8")
|
||||
await touch(filepath, 2_000)
|
||||
|
||||
const newStat = Filesystem.stat(filepath)
|
||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
||||
|
||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
233
packages/tfcode/test/file/watcher.test.ts
Normal file
233
packages/tfcode/test/file/watcher.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Option } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { watcherConfigLayer, withServices } from "../fixture/instance"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
||||
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
|
||||
const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcher service. */
|
||||
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
return withServices(
|
||||
directory,
|
||||
FileWatcher.layer,
|
||||
async (rt) => {
|
||||
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
|
||||
await Effect.runPromise(ready(directory))
|
||||
await Effect.runPromise(body)
|
||||
},
|
||||
{ provide: [watcherConfigLayer] },
|
||||
)
|
||||
}
|
||||
|
||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||
let done = false
|
||||
|
||||
const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => {
|
||||
if (done) return
|
||||
if (!check(evt.properties)) return
|
||||
hit(evt.properties)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (done) return
|
||||
done = true
|
||||
unsub()
|
||||
}
|
||||
}
|
||||
|
||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||
return Effect.gen(function* () {
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const cleanup = yield* Effect.sync(() => {
|
||||
let off = () => {}
|
||||
off = listen(directory, check, (evt) => {
|
||||
off()
|
||||
Deferred.doneUnsafe(deferred, Effect.succeed(evt))
|
||||
})
|
||||
return off
|
||||
})
|
||||
return { cleanup, deferred }
|
||||
})
|
||||
}
|
||||
|
||||
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(directory, check),
|
||||
({ deferred }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
|
||||
}),
|
||||
({ cleanup }) => Effect.sync(cleanup),
|
||||
)
|
||||
}
|
||||
|
||||
/** Effect that asserts no matching event arrives within `ms`. */
|
||||
function noUpdate<E>(
|
||||
directory: string,
|
||||
check: (evt: WatcherEvent) => boolean,
|
||||
trigger: Effect.Effect<void, E>,
|
||||
ms = 500,
|
||||
) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(directory, check),
|
||||
({ deferred }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
|
||||
}),
|
||||
({ cleanup }) => Effect.sync(cleanup),
|
||||
)
|
||||
}
|
||||
|
||||
function ready(directory: string) {
|
||||
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
||||
const head = path.join(directory, ".git", "HEAD")
|
||||
|
||||
return Effect.gen(function* () {
|
||||
yield* nextUpdate(
|
||||
directory,
|
||||
(evt) => evt.file === file && evt.event === "add",
|
||||
Effect.promise(() => fs.writeFile(file, "ready")),
|
||||
).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid)
|
||||
|
||||
const git = yield* Effect.promise(() =>
|
||||
fs
|
||||
.stat(head)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
if (!git) return
|
||||
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
const hash = yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(directory).quiet().text())
|
||||
yield* nextUpdate(
|
||||
directory,
|
||||
(evt) => evt.file === head && evt.event !== "unlink",
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n")
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
}),
|
||||
).pipe(Effect.asVoid)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeWatcher("FileWatcher", () => {
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
test("publishes root create, update, and delete events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const file = path.join(tmp.path, "watch.txt")
|
||||
const dir = tmp.path
|
||||
const cases = [
|
||||
{ event: "add" as const, trigger: Effect.promise(() => fs.writeFile(file, "a")) },
|
||||
{ event: "change" as const, trigger: Effect.promise(() => fs.writeFile(file, "b")) },
|
||||
{ event: "unlink" as const, trigger: Effect.promise(() => fs.unlink(file)) },
|
||||
]
|
||||
|
||||
await withWatcher(
|
||||
dir,
|
||||
Effect.forEach(cases, ({ event, trigger }) =>
|
||||
nextUpdate(dir, (evt) => evt.file === file && evt.event === event, trigger).pipe(
|
||||
Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("watches non-git roots", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "plain.txt")
|
||||
const dir = tmp.path
|
||||
|
||||
await withWatcher(
|
||||
dir,
|
||||
nextUpdate(
|
||||
dir,
|
||||
(e) => e.file === file && e.event === "add",
|
||||
Effect.promise(() => fs.writeFile(file, "plain")),
|
||||
).pipe(Effect.tap((evt) => Effect.sync(() => expect(evt).toEqual({ file, event: "add" })))),
|
||||
)
|
||||
})
|
||||
|
||||
test("cleanup stops publishing events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const file = path.join(tmp.path, "after-dispose.txt")
|
||||
|
||||
// Start and immediately stop the watcher (withWatcher disposes on exit)
|
||||
await withWatcher(tmp.path, Effect.void)
|
||||
|
||||
// Now write a file — no watcher should be listening
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
noUpdate(
|
||||
tmp.path,
|
||||
(e) => e.file === file,
|
||||
Effect.promise(() => fs.writeFile(file, "gone")),
|
||||
),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores .git/index changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const gitIndex = path.join(tmp.path, ".git", "index")
|
||||
const edit = path.join(tmp.path, "tracked.txt")
|
||||
|
||||
await withWatcher(
|
||||
tmp.path,
|
||||
noUpdate(
|
||||
tmp.path,
|
||||
(e) => e.file === gitIndex,
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(edit, "a")
|
||||
await $`git add .`.cwd(tmp.path).quiet().nothrow()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("publishes .git/HEAD events", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
||||
await $`git branch ${branch}`.cwd(tmp.path).quiet()
|
||||
|
||||
await withWatcher(
|
||||
tmp.path,
|
||||
nextUpdate(
|
||||
tmp.path,
|
||||
(evt) => evt.file === head && evt.event !== "unlink",
|
||||
Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)),
|
||||
).pipe(
|
||||
Effect.tap((evt) =>
|
||||
Effect.sync(() => {
|
||||
expect(evt.file).toBe(head)
|
||||
expect(["add", "change"]).toContain(evt.event)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user