+21
![opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)






![opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)
![opencode-agent[bot]](/assets/img/avatar_default.png)
opencode-agent[bot]
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
James Long
Aiden Cline
Dustin Deus
starptech
Frank
Luke Parker
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
Aiden Cline
Dax
usrnk1
Jay
Brendan Allan
Brendan Allan
runvip
Shoubhit Dash
Jack
opencode
Aarav Sareen
Julian Coy
Vladimir Glafirov
Adam
Kit Langton
Simon Klee
Jay
David Hill
James Long
冯基魁
Aiden Cline
687dbba6a3
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: runvip <164729189+runvip@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Julian Coy <julian@ex-machina.co> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Jay <air@live.ca> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { $ } from "bun"
|
|
import { describe, expect } from "bun:test"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } from "effect"
|
|
import { Config } from "@opencode-ai/core/config"
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|
import { EventV2 } from "@opencode-ai/core/event"
|
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
|
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
|
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
|
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { location } from "../fixture/location"
|
|
import { tmpdir } from "../fixture/tmpdir"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
|
|
|
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
|
|
|
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))
|
|
|
|
const configLayer = Layer.succeed(
|
|
Config.Service,
|
|
Config.Service.of({
|
|
entries: () => Effect.succeed([]),
|
|
}),
|
|
)
|
|
|
|
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
|
const locationLayer = Layer.succeed(
|
|
Location.Service,
|
|
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
|
)
|
|
return Effect.provide(
|
|
AppNodeBuilder.build(LocationWatcher.node, [
|
|
[Config.node, configLayer],
|
|
[Location.node, locationLayer],
|
|
]),
|
|
)
|
|
}
|
|
|
|
function withTmp<A, E, R>(
|
|
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
|
|
options?: { git?: boolean; init?: (directory: string) => Promise<void> },
|
|
) {
|
|
return Effect.acquireRelease(
|
|
Effect.promise(async () => {
|
|
const tmp = await tmpdir()
|
|
if (!options?.git) return { tmp, vcs: undefined }
|
|
await $`git init`.cwd(tmp.path).quiet()
|
|
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
|
|
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
|
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
|
|
await $`git config user.name Test`.cwd(tmp.path).quiet()
|
|
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
|
|
await options.init?.(tmp.path)
|
|
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
|
|
}),
|
|
({ tmp }) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
).pipe(Effect.flatMap(({ tmp, vcs }) => f(tmp.path, vcs).pipe(provide(tmp.path, vcs))))
|
|
}
|
|
|
|
function wait(check: (event: WatcherEvent) => boolean) {
|
|
return Effect.gen(function* () {
|
|
const events = yield* EventV2.Service
|
|
const deferred = yield* Deferred.make<WatcherEvent>()
|
|
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
|
|
Stream.runForEach((event) => {
|
|
if (!check(event.data)) return Effect.void
|
|
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
|
}),
|
|
Effect.forkScoped,
|
|
)
|
|
yield* Effect.yieldNow
|
|
return { deferred, fiber }
|
|
})
|
|
}
|
|
|
|
function maybeNextUpdate<E>(
|
|
check: (event: WatcherEvent) => boolean,
|
|
trigger: Effect.Effect<void, E>,
|
|
timeout: Duration.Input = "5 seconds",
|
|
) {
|
|
return Effect.acquireUseRelease(
|
|
wait(check),
|
|
({ deferred }) => trigger.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeoutOption(timeout)),
|
|
({ fiber }) => Fiber.interrupt(fiber),
|
|
)
|
|
}
|
|
|
|
function nextUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>) {
|
|
return Effect.gen(function* () {
|
|
const result = yield* maybeNextUpdate(check, trigger)
|
|
if (Option.isSome(result)) return result.value
|
|
return yield* Effect.fail(new Error("timed out waiting for file watcher update"))
|
|
})
|
|
}
|
|
|
|
function eventuallyUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: () => Effect.Effect<void, E>) {
|
|
return Effect.gen(function* () {
|
|
while (true) {
|
|
const result = yield* maybeNextUpdate(check, trigger(), "250 millis")
|
|
if (Option.isSome(result)) return result.value
|
|
}
|
|
}).pipe(
|
|
Effect.timeoutOrElse({
|
|
duration: "5 seconds",
|
|
orElse: () => Effect.fail(new Error("timed out waiting for file watcher readiness")),
|
|
}),
|
|
)
|
|
}
|
|
|
|
function noUpdate<E>(check: (event: WatcherEvent) => boolean, trigger: Effect.Effect<void, E>, timeout = 500) {
|
|
return Effect.acquireUseRelease(
|
|
wait(check),
|
|
({ deferred }) =>
|
|
trigger.pipe(
|
|
Effect.andThen(Deferred.await(deferred)),
|
|
Effect.timeoutOption(`${timeout} millis`),
|
|
Effect.tap((result) => Effect.sync(() => expect(result).toEqual(Option.none()))),
|
|
),
|
|
({ fiber }) => Fiber.interrupt(fiber),
|
|
)
|
|
}
|
|
|
|
function ready(directory: string) {
|
|
const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`)
|
|
return Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
yield* eventuallyUpdate(
|
|
(event) => event.file === file,
|
|
() => fs.writeFileString(file, `ready-${Math.random()}`),
|
|
).pipe(Effect.ensuring(fs.remove(file, { force: true }).pipe(Effect.ignore)), Effect.asVoid)
|
|
})
|
|
}
|
|
|
|
describeWatcher("LocationWatcher", () => {
|
|
it.live("limits file watches to the exact target", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const watcher = yield* Watcher.Service
|
|
const target = path.join(directory, "opencode.json")
|
|
const sibling = path.join(directory, "other.json")
|
|
const update = yield* watcher
|
|
.subscribe({ path: target, type: "file" })
|
|
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
|
yield* fs.writeFileString(sibling, "sibling")
|
|
const writes = yield* Effect.suspend(() => fs.writeFileString(target, `target-${Math.random()}`)).pipe(
|
|
Effect.repeat(Schedule.spaced("10 millis")),
|
|
Effect.forkScoped,
|
|
)
|
|
const event = yield* Fiber.join(update).pipe(Effect.ensuring(Fiber.interrupt(writes)))
|
|
|
|
expect(event.valueOrUndefined?.path).toBe(target)
|
|
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
|
),
|
|
)
|
|
|
|
it.live("publishes root create, update, and delete events", () =>
|
|
withTmp(
|
|
(directory) =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const file = path.join(directory, "watch.txt")
|
|
yield* ready(directory)
|
|
for (const item of [
|
|
{ event: "add" as const, trigger: fs.writeFileString(file, "a") },
|
|
{ event: "change" as const, trigger: fs.writeFileString(file, "b") },
|
|
{ event: "unlink" as const, trigger: fs.remove(file) },
|
|
]) {
|
|
expect(
|
|
yield* nextUpdate((event) => event.file === file && event.event === item.event, item.trigger),
|
|
).toEqual({
|
|
file,
|
|
event: item.event,
|
|
})
|
|
}
|
|
}),
|
|
{ git: true },
|
|
),
|
|
)
|
|
|
|
it.live("skips non-git roots", () =>
|
|
withTmp((directory) =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const file = path.join(directory, "plain.txt")
|
|
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "plain"))
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("ignores dependency, VCS, and build directories at any depth", () =>
|
|
withTmp(
|
|
(directory) =>
|
|
Effect.gen(function* () {
|
|
const afs = yield* FSUtil.Service
|
|
yield* ready(directory)
|
|
const roots = ["node_modules", ".git", "dist"].map((name) => path.join(directory, "nested", name))
|
|
const files = roots.map((root) => path.join(root, "package", "index.js"))
|
|
yield* noUpdate(
|
|
(event) => roots.some((root) => event.file === root || event.file.startsWith(`${root}${path.sep}`)),
|
|
Effect.forEach(files, (file) => afs.writeWithDirs(file, "ignored"), {
|
|
concurrency: "unbounded",
|
|
discard: true,
|
|
}),
|
|
)
|
|
}),
|
|
{ git: true },
|
|
),
|
|
)
|
|
|
|
it.live("cleanup stops publishing events", () =>
|
|
Effect.gen(function* () {
|
|
const events = yield* EventV2.Service
|
|
const fs = yield* FSUtil.Service
|
|
const tmp = yield* Effect.acquireRelease(
|
|
Effect.promise(() => tmpdir()),
|
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
)
|
|
yield* ready(tmp.path).pipe(
|
|
provide(tmp.path, { type: "git", store: AbsolutePath.make(path.join(tmp.path, ".git")) }),
|
|
Effect.scoped,
|
|
)
|
|
const file = path.join(tmp.path, "after-dispose.txt")
|
|
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
|
Effect.provideService(EventV2.Service, events),
|
|
)
|
|
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))),
|
|
)
|
|
|
|
it.live("ignores .git/index changes", () =>
|
|
withTmp(
|
|
(directory) =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const index = path.join(directory, ".git", "index")
|
|
yield* ready(directory)
|
|
yield* noUpdate(
|
|
(event) => event.file === index,
|
|
fs
|
|
.writeFileString(path.join(directory, "tracked.txt"), "a")
|
|
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
|
|
)
|
|
}),
|
|
{ git: true },
|
|
),
|
|
)
|
|
|
|
it.live("publishes .git/HEAD events", () =>
|
|
withTmp(
|
|
(directory) =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* FSUtil.Service
|
|
const head = path.join(directory, ".git", "HEAD")
|
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
|
yield* ready(directory)
|
|
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
|
expect(
|
|
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
|
).toMatchObject({ file: head })
|
|
}),
|
|
{ git: true },
|
|
),
|
|
)
|
|
|
|
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
|
|
describeSymlink("symlinked .git", () => {
|
|
it.live("publishes .git/HEAD events through a symlinked .git directory", () =>
|
|
withTmp(
|
|
(directory) =>
|
|
Effect.gen(function* () {
|
|
const afs = yield* FSUtil.Service
|
|
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
|
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(actual, { recursive: true, force: true })))
|
|
yield* ready(directory)
|
|
const head = path.join(directory, ".git", "HEAD")
|
|
const branch = `watch-${Math.random().toString(36).slice(2)}`
|
|
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
|
expect(
|
|
yield* nextUpdate(
|
|
(event) => event.file === path.join(actual, "HEAD"),
|
|
afs.writeFileString(head, `ref: refs/heads/${branch}\n`),
|
|
),
|
|
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
|
|
}),
|
|
{
|
|
git: true,
|
|
init: async (directory) => {
|
|
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
|
|
await fs.rename(path.join(directory, ".git"), actual)
|
|
await fs.symlink(actual, path.join(directory, ".git"))
|
|
},
|
|
},
|
|
),
|
|
)
|
|
})
|
|
})
|