mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-30 13:54:01 +00:00
- 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.
234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
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)
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
})
|
|
})
|