mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-04 16:13:11 +00:00
stack: effectify-file-watcher-service (#17827)
This commit is contained in:
250
packages/opencode/test/file/watcher.test.ts
Normal file
250
packages/opencode/test/file/watcher.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { ConfigProvider, Deferred, Effect, Fiber, Layer, ManagedRuntime, Option } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
|
||||
import { InstanceContext } from "../../src/effect/instances"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const configLayer = ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
|
||||
}),
|
||||
)
|
||||
|
||||
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
|
||||
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
||||
|
||||
/** Run `body` with a live FileWatcherService. Runtime is acquired/released via Effect.scoped. */
|
||||
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = Layer.sync(InstanceContext, () =>
|
||||
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
|
||||
)
|
||||
const layer = Layer.fresh(FileWatcherService.layer).pipe(Layer.provide(ctx), Layer.provide(configLayer))
|
||||
const rt = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => ManagedRuntime.make(layer)),
|
||||
(rt) => Effect.promise(() => rt.dispose()),
|
||||
)
|
||||
yield* Effect.promise(() => rt.runPromise(FileWatcherService.use((s) => s.init())))
|
||||
yield* ready(directory)
|
||||
yield* body
|
||||
}).pipe(Effect.scoped, Effect.runPromise),
|
||||
})
|
||||
}
|
||||
|
||||
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
|
||||
let done = false
|
||||
|
||||
function on(evt: BusUpdate) {
|
||||
if (done) return
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== FileWatcher.Event.Updated.type) return
|
||||
if (!check(evt.payload.properties)) return
|
||||
hit(evt.payload.properties)
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
done = true
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
|
||||
return Effect.callback<WatcherEvent>((resume) => {
|
||||
const cleanup = listen(directory, check, (evt) => {
|
||||
cleanup()
|
||||
resume(Effect.succeed(evt))
|
||||
})
|
||||
return Effect.sync(cleanup)
|
||||
}).pipe(Effect.timeout("5 seconds"))
|
||||
}
|
||||
|
||||
function nextUpdate<E>(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
||||
return Effect.acquireUseRelease(
|
||||
wait(directory, check).pipe(Effect.forkChild({ startImmediately: true })),
|
||||
(fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* trigger
|
||||
return yield* Fiber.join(fiber)
|
||||
}),
|
||||
Fiber.interrupt,
|
||||
)
|
||||
}
|
||||
|
||||
/** 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.gen(function* () {
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
listen(directory, check, (evt) => {
|
||||
Effect.runSync(Deferred.succeed(deferred, evt))
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
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("FileWatcherService", () => {
|
||||
afterEach(() => 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 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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -977,7 +977,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const ask = PermissionNext.ask({
|
||||
const err = await PermissionNext.ask({
|
||||
sessionID: SessionID.make("session_test"),
|
||||
permission: "bash",
|
||||
patterns: ["echo hello", "rm -rf /"],
|
||||
@@ -987,24 +987,12 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
|
||||
{ permission: "bash", pattern: "echo *", action: "ask" },
|
||||
{ permission: "bash", pattern: "rm *", action: "deny" },
|
||||
],
|
||||
})
|
||||
}).then(
|
||||
() => undefined,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
const out = await Promise.race([
|
||||
ask.then(
|
||||
() => ({ ok: true as const, err: undefined }),
|
||||
(err) => ({ ok: false as const, err }),
|
||||
),
|
||||
Bun.sleep(100).then(() => "timeout" as const),
|
||||
])
|
||||
|
||||
if (out === "timeout") {
|
||||
await rejectAll()
|
||||
await ask.catch(() => {})
|
||||
throw new Error("ask timed out instead of denying immediately")
|
||||
}
|
||||
|
||||
expect(out.ok).toBe(false)
|
||||
expect(out.err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
|
||||
expect(await PermissionNext.list()).toHaveLength(0)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { PtyID } from "../../src/pty/schema"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const wait = async (fn: () => boolean, ms = 2000) => {
|
||||
const wait = async (fn: () => boolean, ms = 5000) => {
|
||||
const end = Date.now() + ms
|
||||
while (Date.now() < end) {
|
||||
if (fn()) return
|
||||
@@ -20,7 +20,7 @@ const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>,
|
||||
}
|
||||
|
||||
describe("pty", () => {
|
||||
test("publishes created, exited, deleted in order for /bin/ls + remove", async () => {
|
||||
test("publishes created, exited, deleted in order for a short-lived process", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using dir = await tmpdir({ git: true })
|
||||
@@ -37,7 +37,11 @@ describe("pty", () => {
|
||||
|
||||
let id: PtyID | undefined
|
||||
try {
|
||||
const info = await Pty.create({ command: "/bin/ls", title: "ls" })
|
||||
const info = await Pty.create({
|
||||
command: "/usr/bin/env",
|
||||
args: ["sh", "-c", "sleep 0.1"],
|
||||
title: "sleep",
|
||||
})
|
||||
id = info.id
|
||||
|
||||
await wait(() => pick(log, id!).includes("exited"))
|
||||
|
||||
Reference in New Issue
Block a user