resolve merge conflicts

This commit is contained in:
Johnny Amancio
2026-04-15 22:51:15 +02:00
404 changed files with 25154 additions and 80487 deletions
@@ -1,10 +1,16 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
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"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const wintest = process.platform === "win32" ? test : test.skip
@@ -27,7 +33,7 @@ describe("file fsmonitor", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.status()
await status()
},
})
@@ -52,7 +58,7 @@ describe("file fsmonitor", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.read("tracked.txt")
await read("tracked.txt")
},
})
+88 -78
View File
@@ -1,18 +1,28 @@
import { afterEach, describe, test, expect } from "bun:test"
import { $ } from "bun"
import { Effect } from "effect"
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"
import { provideInstance, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
const init = () => run(File.Service.use((svc) => svc.init()))
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const status = () => run(File.Service.use((svc) => svc.status()))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
const search = (input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) =>
run(File.Service.use((svc) => svc.search(input)))
describe("file/index Filesystem patterns", () => {
describe("File.read() - text content", () => {
describe("read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.txt")
@@ -21,7 +31,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
const result = await read("test.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("Hello World")
},
@@ -35,7 +45,7 @@ describe("file/index Filesystem patterns", () => {
directory: tmp.path,
fn: async () => {
// Non-existent file should return empty content
const result = await File.read("nonexistent.txt")
const result = await read("nonexistent.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
@@ -50,7 +60,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
const result = await read("test.txt")
expect(result.content).toBe("content with spaces")
},
})
@@ -64,7 +74,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("empty.txt")
const result = await read("empty.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
@@ -79,14 +89,14 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("multiline.txt")
const result = await read("multiline.txt")
expect(result.content).toBe("line1\nline2\nline3")
},
})
})
})
describe("File.read() - binary content", () => {
describe("read() - binary content", () => {
test("reads binary file via Filesystem.readArrayBuffer()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "image.png")
@@ -96,7 +106,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("image.png")
const result = await 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")
@@ -113,7 +123,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("binary.so")
const result = await read("binary.so")
expect(result.type).toBe("binary")
expect(result.content).toBe("")
},
@@ -121,7 +131,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.read() - Filesystem.mimeType()", () => {
describe("read() - Filesystem.mimeType()", () => {
test("detects MIME type via Filesystem.mimeType()", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "test.json")
@@ -132,7 +142,7 @@ describe("file/index Filesystem patterns", () => {
fn: async () => {
expect(Filesystem.mimeType(filepath)).toContain("application/json")
const result = await File.read("test.json")
const result = await read("test.json")
expect(result.type).toBe("text")
},
})
@@ -161,7 +171,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.list() - Filesystem.exists() and readText()", () => {
describe("list() - Filesystem.exists() and readText()", () => {
test("reads .gitignore via Filesystem.exists() and readText()", async () => {
await using tmp = await tmpdir({ git: true })
@@ -171,7 +181,7 @@ describe("file/index Filesystem patterns", () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
// This is used internally in File.list()
// This is used internally in list()
expect(await Filesystem.exists(gitignorePath)).toBe(true)
const content = await Filesystem.readText(gitignorePath)
@@ -204,8 +214,8 @@ describe("file/index Filesystem patterns", () => {
const gitignorePath = path.join(tmp.path, ".gitignore")
expect(await Filesystem.exists(gitignorePath)).toBe(false)
// File.list() should still work
const nodes = await File.list()
// list() should still work
const nodes = await list()
expect(Array.isArray(nodes)).toBe(true)
},
})
@@ -244,8 +254,8 @@ describe("file/index Filesystem patterns", () => {
// 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")
// But read() handles this gracefully
const result = await read("does-not-exist.txt")
expect(result.content).toBe("")
},
})
@@ -272,8 +282,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
// File.read() handles missing images gracefully
const result = await File.read("broken.png")
// read() handles missing images gracefully
const result = await read("broken.png")
expect(result.type).toBe("text")
expect(result.content).toBe("")
},
@@ -290,7 +300,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.ts")
const result = await read("test.ts")
expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1")
},
@@ -305,7 +315,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.mts")
const result = await read("test.mts")
expect(result.type).toBe("text")
expect(result.content).toBe("export const value = 1")
},
@@ -320,7 +330,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.sh")
const result = await read("test.sh")
expect(result.type).toBe("text")
expect(result.content).toBe("#!/usr/bin/env bash\necho hello")
},
@@ -335,7 +345,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("Dockerfile")
const result = await read("Dockerfile")
expect(result.type).toBe("text")
expect(result.content).toBe("FROM alpine:3.20")
},
@@ -350,7 +360,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.txt")
const result = await read("test.txt")
expect(result.encoding).toBeUndefined()
expect(result.type).toBe("text")
},
@@ -365,7 +375,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("test.jpg")
const result = await read("test.jpg")
expect(result.encoding).toBe("base64")
expect(result.mimeType).toBe("image/jpeg")
},
@@ -380,7 +390,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
@@ -391,13 +401,13 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
await expect(read("../outside.txt")).rejects.toThrow("Access denied")
},
})
})
})
describe("File.status()", () => {
describe("status()", () => {
test("detects modified file", async () => {
await using tmp = await tmpdir({ git: true })
const filepath = path.join(tmp.path, "file.txt")
@@ -409,7 +419,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
const entry = result.find((f) => f.path === "file.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
@@ -426,7 +436,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
const entry = result.find((f) => f.path === "new.txt")
expect(entry).toBeDefined()
expect(entry!.status).toBe("added")
@@ -447,7 +457,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await 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)
@@ -470,7 +480,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await 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)
@@ -484,7 +494,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
expect(result).toEqual([])
},
})
@@ -496,7 +506,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
expect(result).toEqual([])
},
})
@@ -519,7 +529,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.status()
const result = await status()
const entry = result.find((f) => f.path === "data.bin")
expect(entry).toBeDefined()
expect(entry!.status).toBe("modified")
@@ -530,7 +540,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.list()", () => {
describe("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"))
@@ -540,7 +550,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
expect(nodes.length).toBeGreaterThanOrEqual(2)
for (const node of nodes) {
expect(node).toHaveProperty("name")
@@ -564,7 +574,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
const dirs = nodes.filter((n) => n.type === "directory")
const files = nodes.filter((n) => n.type === "file")
// Dirs come first
@@ -589,7 +599,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
const names = nodes.map((n) => n.name)
expect(names).not.toContain(".git")
expect(names).not.toContain(".DS_Store")
@@ -608,7 +618,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await 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")
@@ -628,7 +638,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list("sub")
const nodes = await 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)
@@ -643,7 +653,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.list("../outside")).rejects.toThrow("Access denied")
await expect(list("../outside")).rejects.toThrow("Access denied")
},
})
})
@@ -655,7 +665,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const nodes = await File.list()
const nodes = await list()
expect(nodes.length).toBeGreaterThanOrEqual(1)
// Without git, ignored should be false for all
for (const node of nodes) {
@@ -666,7 +676,7 @@ describe("file/index Filesystem patterns", () => {
})
})
describe("File.search()", () => {
describe("search()", () => {
async function setupSearchableRepo() {
const tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8")
@@ -685,9 +695,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "file" })
const result = await search({ query: "", type: "file" })
expect(result.length).toBeGreaterThan(0)
},
})
@@ -699,7 +709,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.search({ query: "main", type: "file" })
const result = await search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
@@ -711,9 +721,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "directory" })
const result = await 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))
@@ -731,9 +741,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "main", type: "file" })
const result = await search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
@@ -745,9 +755,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "file" })
const result = await search({ query: "", type: "file" })
// Files don't end with /
for (const f of result) {
expect(f.endsWith("/")).toBe(false)
@@ -762,9 +772,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "directory" })
const result = await search({ query: "", type: "directory" })
// Directories end with /
for (const d of result) {
expect(d.endsWith("/")).toBe(true)
@@ -779,9 +789,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: "", type: "file", limit: 2 })
const result = await search({ query: "", type: "file", limit: 2 })
expect(result.length).toBeLessThanOrEqual(2)
},
})
@@ -793,9 +803,9 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
await init()
const result = await File.search({ query: ".hidden", type: "directory" })
const result = await search({ query: ".hidden", type: "directory" })
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toContain(".hidden")
},
@@ -808,19 +818,19 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
await init()
expect(await 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" })
const result = await search({ query: "fresh", type: "file" })
expect(result).toContain("fresh.ts")
},
})
})
})
describe("File.read() - diff/patch", () => {
describe("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")
@@ -832,7 +842,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("file.txt")
const result = await read("file.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("modified content")
expect(result.diff).toBeDefined()
@@ -856,7 +866,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("staged.txt")
const result = await read("staged.txt")
expect(result.diff).toBeDefined()
expect(result.patch).toBeDefined()
},
@@ -873,7 +883,7 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("clean.txt")
const result = await read("clean.txt")
expect(result.type).toBe("text")
expect(result.content).toBe("unchanged")
expect(result.diff).toBeUndefined()
@@ -893,10 +903,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: one.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "a.ts", type: "file" })
await init()
const results = await search({ query: "a.ts", type: "file" })
expect(results).toContain("a.ts")
const results2 = await File.search({ query: "b.ts", type: "file" })
const results2 = await search({ query: "b.ts", type: "file" })
expect(results2).not.toContain("b.ts")
},
})
@@ -904,10 +914,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: two.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "b.ts", type: "file" })
await init()
const results = await search({ query: "b.ts", type: "file" })
expect(results).toContain("b.ts")
const results2 = await File.search({ query: "a.ts", type: "file" })
const results2 = await search({ query: "a.ts", type: "file" })
expect(results2).not.toContain("a.ts")
},
})
@@ -920,8 +930,8 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "before", type: "file" })
await init()
const results = await search({ query: "before", type: "file" })
expect(results).toContain("before.ts")
},
})
@@ -934,10 +944,10 @@ describe("file/index Filesystem patterns", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "after", type: "file" })
await init()
const results = await search({ query: "after", type: "file" })
expect(results).toContain("after.ts")
const stale = await File.search({ query: "before", type: "file" })
const stale = await search({ query: "before", type: "file" })
expect(stale).not.toContain("before.ts")
},
})
@@ -1,10 +1,16 @@
import { test, expect, describe } from "bun:test"
import { Effect } from "effect"
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"
import { provideInstance, tmpdir } from "../fixture/fixture"
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
Effect.runPromise(provideInstance(Instance.directory)(eff.pipe(Effect.provide(File.defaultLayer))))
const read = (file: string) => run(File.Service.use((svc) => svc.read(file)))
const list = (dir?: string) => run(File.Service.use((svc) => svc.list(dir)))
describe("Filesystem.contains", () => {
test("allows paths within project", () => {
@@ -32,10 +38,10 @@ describe("Filesystem.contains", () => {
})
/*
* Integration tests for File.read() and File.list() path traversal protection.
* Integration tests for read() and 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()
* in server.ts (GET /file/content, GET /file) call read()/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.
@@ -51,7 +57,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
@@ -62,7 +68,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow(
"Access denied: path escapes project directory",
)
},
@@ -79,7 +85,7 @@ describe("File.read path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.read("valid.txt")
const result = await read("valid.txt")
expect(result.content).toBe("valid content")
},
})
@@ -93,7 +99,7 @@ describe("File.list path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(File.list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory")
},
})
})
@@ -108,7 +114,7 @@ describe("File.list path traversal protection", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.list("subdir")
const result = await list("subdir")
expect(Array.isArray(result)).toBe(true)
},
})
+217 -14
View File
@@ -1,9 +1,26 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Ripgrep } from "../../src/file/ripgrep"
async function seed(dir: string, count: number, size = 16) {
const txt = "a".repeat(size)
await Promise.all(Array.from({ length: count }, (_, i) => Bun.write(path.join(dir, `file-${i}.txt`), `${txt}${i}\n`)))
}
function env(name: string, value: string | undefined) {
const prev = process.env[name]
if (value === undefined) delete process.env[name]
else process.env[name] = value
return () => {
if (prev === undefined) delete process.env[name]
else process.env[name] = prev
}
}
describe("file.ripgrep", () => {
test("defaults to include hidden", async () => {
await using tmp = await tmpdir({
@@ -14,11 +31,9 @@ describe("file.ripgrep", () => {
},
})
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)
const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path }))
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(true)
})
test("hidden false excludes hidden", async () => {
@@ -30,11 +45,9 @@ describe("file.ripgrep", () => {
},
})
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)
const files = await Array.fromAsync(await Ripgrep.files({ cwd: tmp.path, hidden: false }))
expect(files.includes("visible.txt")).toBe(true)
expect(files.includes(path.join(".opencode", "thing.json"))).toBe(false)
})
// kilocode_change start - .kilo directory should also be skipped in tree()
@@ -60,11 +73,201 @@ describe("file.ripgrep", () => {
},
})
const hits = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
expect(result.partial).toBe(false)
expect(result.items).toEqual([])
})
test("search returns match metadata with normalized path", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "src"), { recursive: true })
await Bun.write(path.join(dir, "src", "match.ts"), "const needle = 1\n")
},
})
expect(hits).toEqual([])
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(path.join("src", "match.ts"))
expect(result.items[0]?.line_number).toBe(1)
expect(result.items[0]?.lines.text).toContain("needle")
})
test("files returns empty when glob matches no files in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "packages", "console"), { recursive: true })
await Bun.write(path.join(dir, "packages", "console", "package.json"), "{}")
},
})
const ctl = new AbortController()
const files = await Array.fromAsync(
await Ripgrep.files({
cwd: tmp.path,
glob: ["packages/*"],
signal: ctl.signal,
}),
)
expect(files).toEqual([])
})
test("ignores RIPGREP_CONFIG_PATH in direct mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
try {
const result = await Ripgrep.search({ cwd: tmp.path, pattern: "needle" })
expect(result.items).toHaveLength(1)
} finally {
restore()
}
})
test("ignores RIPGREP_CONFIG_PATH in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const needle = 1\n")
},
})
const restore = env("RIPGREP_CONFIG_PATH", path.join(tmp.path, "missing-ripgreprc"))
try {
const ctl = new AbortController()
const result = await Ripgrep.search({
cwd: tmp.path,
pattern: "needle",
signal: ctl.signal,
})
expect(result.items).toHaveLength(1)
} finally {
restore()
}
})
test("aborts files scan in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await seed(dir, 4000)
},
})
const ctl = new AbortController()
const iter = await Ripgrep.files({ cwd: tmp.path, signal: ctl.signal })
const pending = Array.fromAsync(iter)
setTimeout(() => ctl.abort(), 0)
const err = await pending.catch((err) => err)
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe("AbortError")
}, 15_000)
test("aborts search in worker mode", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await seed(dir, 512, 64 * 1024)
},
})
const ctl = new AbortController()
const pending = Ripgrep.search({ cwd: tmp.path, pattern: "needle", signal: ctl.signal })
setTimeout(() => ctl.abort(), 0)
const err = await pending.catch((err) => err)
expect(err).toBeInstanceOf(Error)
expect(err.name).toBe("AbortError")
}, 15_000)
})
describe("Ripgrep.Service", () => {
test("search returns matched rows", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.txt"), "const value = 'other'\n")
},
})
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", glob: ["*.ts"] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toContain("match.ts")
expect(result.items[0]?.lines.text).toContain("needle")
})
test("search supports explicit file targets", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n")
await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n")
},
})
const file = path.join(tmp.path, "match.ts")
const result = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] })
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(result.partial).toBe(false)
expect(result.items).toHaveLength(1)
expect(result.items[0]?.path.text).toBe(file)
})
test("files returns stream of filenames", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "a.txt"), "hello")
await Bun.write(path.join(dir, "b.txt"), "world")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk].sort()),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["a.txt", "b.txt"])
})
test("files respects glob filter", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keep.ts"), "yes")
await Bun.write(path.join(dir, "skip.txt"), "no")
},
})
const files = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: tmp.path, glob: ["*.ts"] }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise)
expect(files).toEqual(["keep.ts"])
})
test("files dies on nonexistent directory", async () => {
const exit = await Effect.gen(function* () {
const rg = yield* Ripgrep.Service
return yield* rg.files({ cwd: "/tmp/nonexistent-dir-12345" }).pipe(Stream.runCollect)
}).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromiseExit)
expect(exit._tag).toBe("Failure")
})
})
+341 -364
View File
@@ -1,445 +1,422 @@
import { describe, test, expect, afterEach } from "bun:test"
import path from "path"
import { afterEach, describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
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"
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)
}
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
function gate() {
let open!: () => void
const wait = new Promise<void>((resolve) => {
open = resolve
const id = SessionID.make("ses_00000000000000000000000001")
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
const touch = (file: string, time: number) =>
Effect.promise(() => {
const date = new Date(time)
return fs.utimes(file, date, date)
})
return { open, wait }
}
const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
const exit = yield* self.pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
return err instanceof Error ? err : new Error(String(err))
}
throw new Error("expected file time effect to fail")
})
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")
it.live("stores read timestamp", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await FileTime.get(sessionID, filepath)
const before = yield* get(id, file)
expect(before).toBeUndefined()
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const after = await FileTime.get(sessionID, filepath)
const after = yield* get(id, file)
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")
it.live("tracks separate timestamps per session", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
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 one = SessionID.make("ses_00000000000000000000000002")
const two = SessionID.make("ses_00000000000000000000000003")
yield* read(one, file)
yield* read(two, file)
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
const first = yield* get(one, file)
const second = yield* get(two, file)
expect(time1).toBeDefined()
expect(time2).toBeDefined()
},
})
})
expect(first).toBeDefined()
expect(second).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")
it.live("updates timestamp on subsequent reads", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
const first = await FileTime.get(sessionID, filepath)
yield* read(id, file)
const first = yield* get(id, file)
await FileTime.read(sessionID, filepath)
const second = await FileTime.get(sessionID, filepath)
yield* read(id, file)
const second = yield* get(id, file)
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")
it.live("isolates reads by directory", () =>
Effect.gen(function* () {
const one = yield* tmpdirScoped()
const two = yield* tmpdirScoped()
const shared = yield* tmpdirScoped()
const file = path.join(shared, "file.txt")
yield* put(file, "content")
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()
},
})
})
yield* provideInstance(one)(read(id, file))
const result = yield* provideInstance(two)(get(id, file))
expect(result).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)
it.live("passes when file has not been modified", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
await FileTime.assert(sessionID, filepath)
},
})
})
yield* read(id, file)
yield* check(id, file)
}),
),
)
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")
it.live("throws when file was not read first", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
},
})
})
const err = yield* fail(check(id, file))
expect(err.message).toContain("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)
it.live("throws when file was modified after read", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 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")
},
})
})
yield* read(id, file)
yield* put(file, "modified content")
yield* touch(file, 2_000)
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)
const err = yield* fail(check(id, file))
expect(err.message).toContain("modified since it was last read")
}),
),
)
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)
it.live("includes timestamps in error message", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_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:")
},
})
})
yield* read(id, file)
yield* put(file, "modified")
yield* touch(file, 2_000)
const err = yield* fail(check(id, file))
expect(err.message).toContain("Last modification:")
expect(err.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")
it.live("executes function within lock", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
let hit = false
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")
yield* lock(file, () =>
Effect.sync(() => {
hit = true
return "result"
}),
).rejects.toThrow("Test error")
)
let executed = false
await FileTime.withLock(filepath, async () => {
executed = true
})
expect(executed).toBe(true)
},
})
})
expect(hit).toBe(true)
}),
),
)
it.live("returns function result", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const result = yield* lock(file, () => Effect.succeed("success"))
expect(result).toBe("success")
}),
),
)
it.live("serializes concurrent operations on same file", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const order: number[] = []
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const two = yield* lock(file, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
expect(order).toEqual([1, 2, 3, 4])
}),
),
)
it.live("allows concurrent operations on different files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const onefile = path.join(dir, "file1.txt")
const twofile = path.join(dir, "file2.txt")
let one = false
let two = false
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const a = yield* lock(onefile, () =>
Effect.gen(function* () {
one = true
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
expect(two).toBe(true)
}),
).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
const b = yield* lock(twofile, () =>
Effect.sync(() => {
two = true
}),
).pipe(Effect.forkScoped)
yield* Fiber.join(b)
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(a)
expect(one).toBe(true)
expect(two).toBe(true)
}),
),
)
it.live("releases lock even if function throws", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
expect(err.message).toContain("Test error")
let hit = false
yield* lock(file, () =>
Effect.sync(() => {
hit = true
}),
)
expect(hit).toBe(true)
}),
),
)
})
describe("path normalization", () => {
test("read with forward slashes, assert with backslashes", 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)
it.live("read with forward slashes, assert with backslashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
const forwardSlash = filepath.replaceAll("\\", "/")
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
yield* check(id, file)
}),
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, forwardSlash)
// assert with the native backslash path should still work
await FileTime.assert(sessionID, filepath)
},
})
})
it.live("read with backslashes, assert with forward slashes", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
test("read with backslashes, assert with forward slashes", 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)
const forward = file.replaceAll("\\", "/")
yield* read(id, file)
yield* check(id, forward)
}),
),
)
const forwardSlash = filepath.replaceAll("\\", "/")
it.live("get returns timestamp regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
// assert with forward slashes should still work
await FileTime.assert(sessionID, forwardSlash)
},
})
})
const forward = file.replaceAll("\\", "/")
yield* read(id, forward)
test("get returns timestamp regardless of slash direction", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
const forwardSlash = filepath.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, forwardSlash)
const result = await FileTime.get(sessionID, filepath)
const result = yield* get(id, file)
expect(result).toBeInstanceOf(Date)
},
})
})
}),
),
)
test("withLock serializes regardless of slash direction", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "file.txt")
const forwardSlash = filepath.replaceAll("\\", "/")
await Instance.provide({
directory: tmp.path,
fn: async () => {
it.live("withLock serializes regardless of slash direction", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
const forward = file.replaceAll("\\", "/")
const order: number[] = []
const hold = gate()
const ready = gate()
const hold = yield* Deferred.make<void>()
const ready = yield* Deferred.make<void>()
const op1 = FileTime.withLock(filepath, async () => {
order.push(1)
ready.open()
await hold.wait
order.push(2)
})
const one = yield* lock(file, () =>
Effect.gen(function* () {
order.push(1)
yield* Deferred.succeed(ready, void 0)
yield* Deferred.await(hold)
order.push(2)
}),
).pipe(Effect.forkScoped)
await ready.wait
yield* Deferred.await(ready)
// Use forward-slash variant -- should still serialize against op1
const op2 = FileTime.withLock(forwardSlash, async () => {
order.push(3)
order.push(4)
})
const two = yield* lock(forward, () =>
Effect.sync(() => {
order.push(3)
order.push(4)
}),
).pipe(Effect.forkScoped)
hold.open()
yield* Deferred.succeed(hold, void 0)
yield* Fiber.join(one)
yield* Fiber.join(two)
await Promise.all([op1, op2])
expect(order).toEqual([1, 2, 3, 4])
},
})
})
}),
),
)
})
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)
it.live("reads file modification time via Filesystem.stat()", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "content")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const stats = Filesystem.stat(filepath)
expect(stats?.mtime).toBeInstanceOf(Date)
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
const stat = Filesystem.stat(file)
expect(stat?.mtime).toBeInstanceOf(Date)
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
await FileTime.assert(sessionID, filepath)
},
})
})
yield* check(id, file)
}),
),
)
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)
it.live("detects modification via stat mtime", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "file.txt")
yield* put(file, "original")
yield* touch(file, 1_000)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
yield* read(id, file)
const originalStat = Filesystem.stat(filepath)
const first = Filesystem.stat(file)
await fs.writeFile(filepath, "modified", "utf-8")
await touch(filepath, 2_000)
yield* put(file, "modified")
yield* touch(file, 2_000)
const newStat = Filesystem.stat(filepath)
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
const second = Filesystem.stat(file)
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
},
})
})
yield* fail(check(id, file))
}),
),
)
})
})
@@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture"
import { Bus } from "../../src/bus"
import { Config } from "../../src/config/config"
import { FileWatcher } from "../../src/file/watcher"
import { Git } from "../../src/git"
import { Instance } from "../../src/project/instance"
// Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows)
@@ -32,6 +33,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
fn: async () => {
const layer: Layer.Layer<FileWatcher.Service, never, never> = FileWatcher.layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(watcherConfigLayer),
)
const rt = ManagedRuntime.make(layer)