Compare commits

...

18 Commits

Author SHA1 Message Date
Kit Langton c13699e2c6 Convert question lifecycle tests to Effect 2026-05-02 18:01:41 -04:00
Kit Langton 1557f31415 Use Effect helpers in question tests 2026-05-02 17:18:27 -04:00
Kit Langton c7a10ac38b Add global bus wait helper for server tests (#25468) 2026-05-02 16:34:34 -04:00
Kit Langton baa6976a8d Use instance test helper in tool registry tests (#25461) 2026-05-02 16:16:00 -04:00
Kit Langton 73406e786f Use instance test helper in question tool tests (#25459) 2026-05-02 16:13:32 -04:00
Kit Langton 76d490afeb test(httpapi): cover remaining route scenarios 2026-05-02 15:42:41 -04:00
Kit Langton 5ad32d4e7b test(httpapi): cover workspace removal and sync replay 2026-05-02 15:29:07 -04:00
Kit Langton 1477b38ea2 test(httpapi): cover PTY routes 2026-05-02 15:25:01 -04:00
Kit Langton 0e256a1de1 test(httpapi): cover safe MCP routes 2026-05-02 15:20:07 -04:00
Kit Langton 2e1f1c2af1 test(httpapi): cover event streams and TUI control 2026-05-02 15:16:38 -04:00
Kit Langton b5f391cd8c test(httpapi): cover global writes and summarize 2026-05-02 15:11:23 -04:00
Kit Langton ed00ae267b Use instance test helper in glob tests (#25445) 2026-05-02 14:45:58 -04:00
Kit Langton eebb26aa7e Use instance test helper in grep tests (#25444) 2026-05-02 14:45:54 -04:00
Kit Langton 895275eb1e Document instance Effect test helper (#25443) 2026-05-02 13:27:29 -04:00
Kit Langton 913321e4b6 Add instance-aware Effect test helper (#25442) 2026-05-02 13:13:13 -04:00
Kit Langton 50d5c57193 test(httpapi): cover session LLM actions 2026-05-02 13:03:37 -04:00
Kit Langton e07f5fbb5d test(httpapi): exercise prompt route with fake llm 2026-05-02 12:52:03 -04:00
Kit Langton f21aa32367 test(httpapi): add route exerciser coverage 2026-05-02 12:39:23 -04:00
20 changed files with 2444 additions and 864 deletions
File diff suppressed because it is too large Load Diff
@@ -26,13 +26,17 @@ export function nextTuiRequest() {
return request.next() return request.next()
} }
export function submitTuiRequest(body: TuiRequest) {
request.push(body)
}
export function submitTuiResponse(body: unknown) { export function submitTuiResponse(body: unknown) {
response.push(body) response.push(body)
} }
export async function callTui(ctx: Context) { export async function callTui(ctx: Context) {
const body = await ctx.req.json() const body = await ctx.req.json()
request.push({ submitTuiRequest({
path: ctx.req.path, path: ctx.req.path,
body, body,
}) })
+1
View File
@@ -122,6 +122,7 @@ export const Client = lazy(() => {
}) })
export function close() { export function close() {
if (!Client.loaded()) return
Client().$client.close() Client().$client.close()
Client.reset() Client.reset()
} }
+2
View File
@@ -14,5 +14,7 @@ export function lazy<T>(fn: () => T) {
value = undefined value = undefined
} }
result.loaded = () => loaded
return result return result
} }
+17 -6
View File
@@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s
```typescript ```typescript
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => { describe("my service", () => {
it.live("does the thing", () => it.instance("does the thing", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* MyService.Service const svc = yield* MyService.Service
const out = yield* svc.run() const out = yield* svc.run()
expect(out).toEqual("ok") expect(out).toEqual("ok")
}), }),
),
) )
}) })
``` ```
@@ -111,6 +108,7 @@ describe("my service", () => {
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context.
- Most integration-style tests in this package use `it.live(...)`. - Most integration-style tests in this package use `it.live(...)`.
### Effect Fixtures ### Effect Fixtures
@@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path:
```typescript
import { TestInstance } from "../fixture/fixture"
it.instance("uses the temp directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
```
Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime.
### Style ### Style
@@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst
- Keep the test body inside `Effect.gen(function* () { ... })`. - Keep the test body inside `Effect.gen(function* () { ... })`.
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. - When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
+5 -14
View File
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const TestEvent = { const TestEvent = {
@@ -19,8 +18,7 @@ const live = Layer.mergeAll(Bus.layer, node)
const it = testEffect(live) const it = testEffect(live)
describe("Bus (Effect-native)", () => { describe("Bus (Effect-native)", () => {
it.live("publish + subscribe stream delivers events", () => it.instance("publish + subscribe stream delivers events", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const received: number[] = [] const received: number[] = []
@@ -40,11 +38,9 @@ describe("Bus (Effect-native)", () => {
expect(received).toEqual([1, 2]) expect(received).toEqual([1, 2])
}), }),
),
) )
it.live("subscribe filters by event type", () => it.instance("subscribe filters by event type", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const pings: number[] = [] const pings: number[] = []
@@ -64,11 +60,9 @@ describe("Bus (Effect-native)", () => {
expect(pings).toEqual([42]) expect(pings).toEqual([42])
}), }),
),
) )
it.live("subscribeAll receives all types", () => it.instance("subscribeAll receives all types", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const types: string[] = [] const types: string[] = []
@@ -89,11 +83,9 @@ describe("Bus (Effect-native)", () => {
expect(types).toContain("test.effect.ping") expect(types).toContain("test.effect.ping")
expect(types).toContain("test.effect.pong") expect(types).toContain("test.effect.pong")
}), }),
),
) )
it.live("multiple subscribers each receive the event", () => it.instance("multiple subscribers each receive the event", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const a: number[] = [] const a: number[] = []
@@ -123,7 +115,6 @@ describe("Bus (Effect-native)", () => {
expect(a).toEqual([99]) expect(a).toEqual([99])
expect(b).toEqual([99]) expect(b).toEqual([99])
}), }),
),
) )
it.live("subscribeAll stream sees InstanceDisposed on disposal", () => it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
+14
View File
@@ -5,10 +5,12 @@ import path from "path"
import { Effect, Context } from "effect" import { Effect, Context } from "effect"
import type * as PlatformError from "effect/PlatformError" import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope" import type * as Scope from "effect/Scope"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config" import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { TestLLMServer } from "../lib/llm-server" import { TestLLMServer } from "../lib/llm-server"
// Re-export for test ergonomics. The implementation lives next to the runtime // Re-export for test ergonomics. The implementation lives next to the runtime
@@ -160,6 +162,18 @@ export function provideTmpdirInstance<A, E, R>(
}) })
} }
export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
export const withTmpdirInstance =
(options?: { git?: boolean; config?: Partial<Config.Info> }) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped(options)
return yield* InstanceStore.Service.use((store) =>
store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))),
)
}).pipe(Effect.provide(InstanceStore.defaultLayer), Effect.provide(CrossSpawnSpawner.defaultLayer))
export function provideTmpdirServer<A, E, R>( export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>, self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> }, options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
+25 -1
View File
@@ -3,8 +3,11 @@ import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope" import type * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock" import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole" import * as TestConsole from "effect/testing/TestConsole"
import type { Config } from "@/config/config"
import { TestInstance, withTmpdirInstance } from "../fixture/fixture"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>) type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
type InstanceOptions = { git?: boolean; config?: Partial<Config.Info> }
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value)) const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
@@ -38,7 +41,28 @@ const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>)
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts) test.skip(name, () => run(value, liveLayer), opts)
return { effect, live } const instance = <A, E2>(
name: string,
value: Body<A, E2, R | TestInstance | Scope.Scope>,
instanceOptions?: InstanceOptions,
opts?: number | TestOptions,
) => test(name, () => run(body(value).pipe(withTmpdirInstance(instanceOptions)), liveLayer), opts)
instance.only = <A, E2>(
name: string,
value: Body<A, E2, R | TestInstance | Scope.Scope>,
instanceOptions?: InstanceOptions,
opts?: number | TestOptions,
) => test.only(name, () => run(body(value).pipe(withTmpdirInstance(instanceOptions)), liveLayer), opts)
instance.skip = <A, E2>(
name: string,
value: Body<A, E2, R | TestInstance | Scope.Scope>,
instanceOptions?: InstanceOptions,
opts?: number | TestOptions,
) => test.skip(name, () => run(body(value).pipe(withTmpdirInstance(instanceOptions)), liveLayer), opts)
return { effect, live, instance }
} }
// Test environment with TestClock and TestConsole // Test environment with TestClock and TestConsole
+180 -238
View File
@@ -1,39 +1,61 @@
import { afterEach, test, expect } from "bun:test" import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info>; tool?: Question.Tool }) => const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Question.Info>
tool?: Question.Tool
}) {
const question = yield* Question.Service
return yield* question.ask(input)
})
const reply = (input: { requestID: QuestionID; answers: ReadonlyArray<Question.Answer> }) => const listEffect = Question.Service.use((svc) => svc.list())
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Question.Answer>
}) {
const question = yield* Question.Service
yield* question.reply(input)
})
const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) {
const question = yield* Question.Service
yield* question.reject(id)
})
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
}) })
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */ /** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() { const rejectAll = Effect.gen(function* () {
const pending = await list() yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true })
for (const req of pending) { })
await reject(req.id)
}
}
test("ask - returns pending promise", async () => { const waitForPending = (count: number) =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ for (let i = 0; i < 100; i++) {
directory: tmp.path, const pending = yield* listEffect
fn: async () => { if (pending.length === count) return pending
const promise = ask({ yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`))
})
it.instance("ask - remains pending until answered", () =>
Effect.gen(function* () {
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -45,19 +67,17 @@ test("ask - returns pending promise", async () => {
], ],
}, },
], ],
}) }).pipe(Effect.forkScoped)
expect(promise).toBeInstanceOf(Promise)
await rejectAll()
await promise.catch(() => {})
},
})
})
test("ask - adds to pending list", async () => { expect(yield* waitForPending(1)).toHaveLength(1)
await using tmp = await tmpdir({ git: true }) yield* rejectAll
await Instance.provide({ expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
directory: tmp.path, }),
fn: async () => { { git: true },
)
it.instance("ask - adds to pending list", () =>
Effect.gen(function* () {
const questions = [ const questions = [
{ {
question: "What would you like to do?", question: "What would you like to do?",
@@ -69,27 +89,24 @@ test("ask - adds to pending list", async () => {
}, },
] ]
const promise = ask({ const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(1)
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions) expect(pending[0].questions).toEqual(questions)
await rejectAll() yield* rejectAll
await promise.catch(() => {}) expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
}, }),
}) { git: true },
}) )
// reply tests // reply tests
test("reply - resolves the pending ask with answers", async () => { it.instance("reply - resolves the pending ask with answers", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [ const questions = [
{ {
question: "What would you like to do?", question: "What would you like to do?",
@@ -101,31 +118,27 @@ test("reply - resolves the pending ask with answers", async () => {
}, },
] ]
const promise = ask({ const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(1)
const requestID = pending[0].id const requestID = pending[0].id
await reply({ yield* replyEffect({
requestID, requestID,
answers: [["Option 1"]], answers: [["Option 1"]],
}) })
const answers = await promise expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]])
expect(answers).toEqual([["Option 1"]]) }),
}, { git: true },
}) )
})
test("reply - removes from pending list", async () => { it.instance("reply - removes from pending list", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const fiber = yield* askEffect({
directory: tmp.path,
fn: async () => {
const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -137,45 +150,36 @@ test("reply - removes from pending list", async () => {
], ],
}, },
], ],
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(1)
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
await reply({ yield* replyEffect({
requestID: pending[0].id, requestID: pending[0].id,
answers: [["Option 1"]], answers: [["Option 1"]],
}) })
await promise yield* Fiber.join(fiber)
const after = await list() const after = yield* listEffect
expect(after.length).toBe(0) expect(after.length).toBe(0)
}, }),
}) { git: true },
}) )
test("reply - does nothing for unknown requestID", async () => { it.instance("reply - does nothing for unknown requestID", () =>
await using tmp = await tmpdir({ git: true }) replyEffect({
await Instance.provide({
directory: tmp.path,
fn: async () => {
await reply({
requestID: QuestionID.make("que_unknown"), requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]], answers: [["Option 1"]],
}) }),
// Should not throw { git: true },
}, )
})
})
// reject tests // reject tests
test("reject - throws RejectedError", async () => { it.instance("reject - throws RejectedError", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const fiber = yield* askEffect({
directory: tmp.path,
fn: async () => {
const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -187,22 +191,21 @@ test("reject - throws RejectedError", async () => {
], ],
}, },
], ],
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(1)
await reject(pending[0].id) yield* rejectEffect(pending[0].id)
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) const exit = yield* Fiber.await(fiber)
}, expect(exit._tag).toBe("Failure")
}) if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError")
}) }),
{ git: true },
)
test("reject - removes from pending list", async () => { it.instance("reject - removes from pending list", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const fiber = yield* askEffect({
directory: tmp.path,
fn: async () => {
const promise = ask({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions: [ questions: [
{ {
@@ -214,38 +217,26 @@ test("reject - removes from pending list", async () => {
], ],
}, },
], ],
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(1)
expect(pending.length).toBe(1) expect(pending.length).toBe(1)
await reject(pending[0].id) yield* rejectEffect(pending[0].id)
promise.catch(() => {}) // Ignore rejection expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
const after = await list() const after = yield* listEffect
expect(after.length).toBe(0) expect(after.length).toBe(0)
}, }),
}) { git: true },
}) )
test("reject - does nothing for unknown requestID", async () => { it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true })
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await reject(QuestionID.make("que_unknown"))
// Should not throw
},
})
})
// multiple questions tests // multiple questions tests
test("ask - handles multiple questions", async () => { it.instance("ask - handles multiple questions", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [ const questions = [
{ {
question: "What would you like to do?", question: "What would you like to do?",
@@ -265,32 +256,28 @@ test("ask - handles multiple questions", async () => {
}, },
] ]
const promise = ask({ const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"), sessionID: SessionID.make("ses_test"),
questions, questions,
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(1)
await reply({ yield* replyEffect({
requestID: pending[0].id, requestID: pending[0].id,
answers: [["Build"], ["Dev"]], answers: [["Build"], ["Dev"]],
}) })
const answers = await promise expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]])
expect(answers).toEqual([["Build"], ["Dev"]]) }),
}, { git: true },
}) )
})
// list tests // list tests
test("list - returns all pending requests", async () => { it.instance("list - returns all pending requests", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const fiber1 = yield* askEffect({
directory: tmp.path,
fn: async () => {
const p1 = ask({
sessionID: SessionID.make("ses_test1"), sessionID: SessionID.make("ses_test1"),
questions: [ questions: [
{ {
@@ -299,9 +286,9 @@ test("list - returns all pending requests", async () => {
options: [{ label: "A", description: "A" }], options: [{ label: "A", description: "A" }],
}, },
], ],
}) }).pipe(Effect.forkScoped)
const p2 = ask({ const fiber2 = yield* askEffect({
sessionID: SessionID.make("ses_test2"), sessionID: SessionID.make("ses_test2"),
questions: [ questions: [
{ {
@@ -310,36 +297,31 @@ test("list - returns all pending requests", async () => {
options: [{ label: "B", description: "B" }], options: [{ label: "B", description: "B" }],
}, },
], ],
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(2)
expect(pending.length).toBe(2) expect(pending.length).toBe(2)
await rejectAll() yield* rejectAll
p1.catch(() => {}) expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
p2.catch(() => {}) expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
}, }),
}) { git: true },
}) )
test("list - returns empty when no pending", async () => { it.instance("list - returns empty when no pending", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const pending = yield* listEffect
directory: tmp.path,
fn: async () => {
const pending = await list()
expect(pending.length).toBe(0) expect(pending.length).toBe(0)
}, }),
}) { git: true },
}) )
test("questions stay isolated by directory", async () => { it.live("questions stay isolated by directory", () =>
await using one = await tmpdir({ git: true }) Effect.gen(function* () {
await using two = await tmpdir({ git: true }) const one = yield* tmpdirScoped({ git: true })
const two = yield* tmpdirScoped({ git: true })
const p1 = Instance.provide({ const fiber1 = yield* askEffect({
directory: one.path,
fn: () =>
ask({
sessionID: SessionID.make("ses_one"), sessionID: SessionID.make("ses_one"),
questions: [ questions: [
{ {
@@ -348,13 +330,9 @@ test("questions stay isolated by directory", async () => {
options: [{ label: "A", description: "A" }], options: [{ label: "A", description: "A" }],
}, },
], ],
}), }).pipe(provideInstance(one), Effect.forkScoped)
})
const p2 = Instance.provide({ const fiber2 = yield* askEffect({
directory: two.path,
fn: () =>
ask({
sessionID: SessionID.make("ses_two"), sessionID: SessionID.make("ses_two"),
questions: [ questions: [
{ {
@@ -363,43 +341,28 @@ test("questions stay isolated by directory", async () => {
options: [{ label: "B", description: "B" }], options: [{ label: "B", description: "B" }],
}, },
], ],
}), }).pipe(provideInstance(two), Effect.forkScoped)
})
const onePending = await Instance.provide({ const onePending = yield* waitForPending(1).pipe(provideInstance(one))
directory: one.path, const twoPending = yield* waitForPending(1).pipe(provideInstance(two))
fn: () => list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => list(),
})
expect(onePending.length).toBe(1) expect(onePending.length).toBe(1)
expect(twoPending.length).toBe(1) expect(twoPending.length).toBe(1)
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
await Instance.provide({ yield* rejectEffect(onePending[0].id).pipe(provideInstance(one))
directory: one.path, yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two))
fn: () => reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => reject(twoPending[0].id),
})
await p1.catch(() => {}) expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
await p2.catch(() => {}) expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
}) }),
)
test("pending question rejects on instance dispose", async () => { it.live("pending question rejects on instance dispose", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const pending = Instance.provide({ const fiber = yield* askEffect({
directory: tmp.path,
fn: () => {
return ask({
sessionID: SessionID.make("ses_dispose"), sessionID: SessionID.make("ses_dispose"),
questions: [ questions: [
{ {
@@ -408,33 +371,21 @@ test("pending question rejects on instance dispose", async () => {
options: [{ label: "Yes", description: "Yes" }], options: [{ label: "Yes", description: "Yes" }],
}, },
], ],
}) }).pipe(provideInstance(dir), Effect.forkScoped)
},
})
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({ expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
directory: tmp.path, yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() }))
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Question.RejectedError) const exit = yield* Fiber.await(fiber)
}) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
}),
)
test("pending question rejects on instance reload", async () => { it.live("pending question rejects on instance reload", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const pending = Instance.provide({ const fiber = yield* askEffect({
directory: tmp.path,
fn: () => {
return ask({
sessionID: SessionID.make("ses_reload"), sessionID: SessionID.make("ses_reload"),
questions: [ questions: [
{ {
@@ -443,22 +394,13 @@ test("pending question rejects on instance reload", async () => {
options: [{ label: "Yes", description: "Yes" }], options: [{ label: "Yes", description: "Yes" }],
}, },
], ],
}) }).pipe(provideInstance(dir), Effect.forkScoped)
},
})
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({ expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
directory: tmp.path, yield* Effect.promise(() => Instance.reload({ directory: dir }))
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Question.RejectedError) const exit = yield* Fiber.await(fiber)
}) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
}),
)
@@ -0,0 +1,34 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Cause, Effect } from "effect"
export function waitGlobalBusEvent(input: {
timeout?: number
message?: string
predicate: (event: GlobalEvent) => boolean
}) {
return Effect.callback<GlobalEvent, unknown>((resume) => {
const cleanup = () => GlobalBus.off("event", handler)
const handler = (event: GlobalEvent) => {
try {
if (!input.predicate(event)) return
cleanup()
resume(Effect.succeed(event))
} catch (error) {
cleanup()
resume(Effect.fail(error))
}
}
GlobalBus.on("event", handler)
return Effect.sync(cleanup)
}).pipe(
Effect.timeout(input.timeout ?? 10_000),
Effect.mapError((error) =>
Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error,
),
)
}
export const waitGlobalBusEventPromise = (input: Parameters<typeof waitGlobalBusEvent>[0]) =>
Effect.runPromise(waitGlobalBusEvent(input))
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -18,20 +17,9 @@ function app() {
} }
async function waitDisposed(directory: string) { async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => { await waitGlobalBusEventPromise({
const timer = setTimeout(() => { message: "timed out waiting for instance disposal",
GlobalBus.off("event", onEvent) predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
}) })
} }
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
@@ -11,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Worktree } from "../../src/worktree" import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -31,20 +31,9 @@ function createSession(input?: Session.CreateInput) {
} }
async function waitReady(directory: string) { async function waitReady(directory: string) {
return await new Promise<void>((resolve, reject) => { await waitGlobalBusEventPromise({
const timer = setTimeout(() => { message: "timed out waiting for worktree.ready",
GlobalBus.off("event", onEvent) predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory,
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
}) })
} }
@@ -1,6 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect" import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -20,6 +19,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { waitGlobalBusEvent } from "./global-bus"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard( const testStateLayer = Layer.effectDiscard(
@@ -97,24 +97,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
Layer.build, Layer.build,
) )
const waitDisposedEvent = Effect.promise( const waitDisposedEvent = waitGlobalBusEvent({
() => message: "timed out waiting for instance disposal",
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { predicate: (event) => event.payload.type === "server.instance.disposed",
const timer = setTimeout(() => { }).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed") return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve({ directory: event.directory, workspace: event.workspace })
}
GlobalBus.on("event", onEvent)
}),
)
const serveDisposeProbe = () => const serveDisposeProbe = () =>
HttpRouter.serve( HttpRouter.serve(
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -18,20 +17,9 @@ function app() {
} }
async function waitDisposed(directory: string) { async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => { await waitGlobalBusEventPromise({
const timer = setTimeout(() => { message: "timed out waiting for instance disposal",
GlobalBus.off("event", onEvent) predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
}) })
} }
@@ -117,13 +105,9 @@ describe("instance HttpApi", () => {
test("serves instance dispose through Hono bridge", async () => { test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => { const disposed = waitGlobalBusEventPromise({
const onEvent = (event: { directory?: string; payload: { type?: string } }) => { message: "timed out waiting for instance disposal",
if (event.payload.type !== "server.instance.disposed") return predicate: (event) => event.payload.type === "server.instance.disposed",
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
}) })
const response = await app().request(InstancePaths.dispose, { const response = await app().request(InstancePaths.dispose, {
@@ -133,6 +117,6 @@ describe("instance HttpApi", () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(await response.json()).toBe(true) expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path) expect((await disposed).directory).toBe(tmp.path)
}) })
}) })
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono" import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event" import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi" import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -23,14 +23,9 @@ function app(experimental = true) {
} }
function nextCommandExecute() { function nextCommandExecute() {
return new Promise<unknown>((resolve) => { return waitGlobalBusEventPromise({
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type,
if (event.payload.type !== TuiEvent.CommandExecute.type) return }).then((event) => event.payload.properties?.command)
GlobalBus.off("event", listener)
resolve(event.payload.properties?.command)
}
GlobalBus.on("event", listener)
})
} }
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) { async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
+11 -13
View File
@@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { provideTmpdirInstance } from "../fixture/fixture" import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect( const it = testEffect(
@@ -33,31 +33,30 @@ const ctx = {
} }
describe("tool.glob", () => { describe("tool.glob", () => {
it.live("matches files from a directory path", () => it.instance("matches files from a directory path", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n"))
yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n"))
const info = yield* GlobTool const info = yield* GlobTool
const glob = yield* info.init() const glob = yield* info.init()
const result = yield* glob.execute( const result = yield* glob.execute(
{ {
pattern: "*.ts", pattern: "*.ts",
path: dir, path: test.directory,
}, },
ctx, ctx,
) )
expect(result.metadata.count).toBe(1) expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(dir, "a.ts")) expect(result.output).toContain(path.join(test.directory, "a.ts"))
expect(result.output).not.toContain(path.join(dir, "b.txt")) expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
}), }),
),
) )
it.live("rejects exact file paths", () => it.instance("rejects exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const file = path.join(dir, "a.ts") const test = yield* TestInstance
const file = path.join(test.directory, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool const info = yield* GlobTool
const glob = yield* info.init() const glob = yield* info.init()
@@ -76,6 +75,5 @@ describe("tool.glob", () => {
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
} }
}), }),
),
) )
}) })
+12 -15
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep" import { GrepTool } from "../../src/tool/grep"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { provideInstance, TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
@@ -54,47 +54,45 @@ describe("tool.grep", () => {
}), }),
) )
it.live("no matches returns correct output", () => it.instance("no matches returns correct output", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
const info = yield* GrepTool const info = yield* GrepTool
const grep = yield* info.init() const grep = yield* info.init()
const result = yield* grep.execute( const result = yield* grep.execute(
{ {
pattern: "xyznonexistentpatternxyz123", pattern: "xyznonexistentpatternxyz123",
path: dir, path: test.directory,
}, },
ctx, ctx,
) )
expect(result.metadata.matches).toBe(0) expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found") expect(result.output).toBe("No files found")
}), }),
),
) )
it.live("finds matches in tmp instance", () => it.instance("finds matches in tmp instance", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool const info = yield* GrepTool
const grep = yield* info.init() const grep = yield* info.init()
const result = yield* grep.execute( const result = yield* grep.execute(
{ {
pattern: "line", pattern: "line",
path: dir, path: test.directory,
}, },
ctx, ctx,
) )
expect(result.metadata.matches).toBeGreaterThan(0) expect(result.metadata.matches).toBeGreaterThan(0)
}), }),
),
) )
it.live("supports exact file paths", () => it.instance("supports exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const file = path.join(dir, "test.txt") const test = yield* TestInstance
const file = path.join(test.directory, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool const info = yield* GrepTool
const grep = yield* info.init() const grep = yield* info.init()
@@ -109,6 +107,5 @@ describe("tool.grep", () => {
expect(result.output).toContain(file) expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2") expect(result.output).toContain("Line 2: line2")
}), }),
),
) )
}) })
+2 -7
View File
@@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const ctx = { const ctx = {
@@ -34,8 +33,7 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest
}) })
describe("tool.question", () => { describe("tool.question", () => {
it.live("should successfully execute with valid question parameters", () => it.instance("should successfully execute with valid question parameters", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
const toolInfo = yield* QuestionTool const toolInfo = yield* QuestionTool
@@ -59,11 +57,9 @@ describe("tool.question", () => {
const result = yield* Fiber.join(fiber) const result = yield* Fiber.join(fiber)
expect(result.title).toBe("Asked 1 question") expect(result.title).toBe("Asked 1 question")
}), }),
),
) )
it.live("should now pass with a header longer than 12 but less than 30 chars", () => it.instance("should now pass with a header longer than 12 but less than 30 chars", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
const toolInfo = yield* QuestionTool const toolInfo = yield* QuestionTool
@@ -83,7 +79,6 @@ describe("tool.question", () => {
const result = yield* Fiber.join(fiber) const result = yield* Fiber.join(fiber)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
}), }),
),
) )
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
+10 -14
View File
@@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer const node = CrossSpawnSpawner.defaultLayer
@@ -17,10 +16,10 @@ afterEach(async () => {
}) })
describe("tool.registry", () => { describe("tool.registry", () => {
it.live("loads tools from .opencode/tool (singular)", () => it.instance("loads tools from .opencode/tool (singular)", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const opencode = path.join(dir, ".opencode") const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tool = path.join(opencode, "tool") const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
@@ -42,13 +41,12 @@ describe("tool.registry", () => {
const ids = yield* registry.ids() const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
),
) )
it.live("loads tools from .opencode/tools (plural)", () => it.instance("loads tools from .opencode/tools (plural)", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const opencode = path.join(dir, ".opencode") const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools") const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
@@ -70,13 +68,12 @@ describe("tool.registry", () => {
const ids = yield* registry.ids() const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
),
) )
it.live("loads tools with external dependencies without crashing", () => it.instance("loads tools with external dependencies without crashing", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const opencode = path.join(dir, ".opencode") const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools") const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
@@ -147,6 +144,5 @@ describe("tool.registry", () => {
const ids = yield* registry.ids() const ids = yield* registry.ids()
expect(ids).toContain("cowsay") expect(ids).toContain("cowsay")
}), }),
),
) )
}) })
+10 -13
View File
@@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const ctx = { const ctx = {
@@ -58,10 +58,10 @@ const run = Effect.fn("WriteToolTest.run")(function* (
describe("tool.write", () => { describe("tool.write", () => {
describe("new file creation", () => { describe("new file creation", () => {
it.live("writes content to new file", () => it.instance("writes content to new file", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const filepath = path.join(dir, "newfile.txt") const test = yield* TestInstance
const filepath = path.join(test.directory, "newfile.txt")
const result = yield* run({ filePath: filepath, content: "Hello, World!" }) const result = yield* run({ filePath: filepath, content: "Hello, World!" })
expect(result.output).toContain("Wrote file successfully") expect(result.output).toContain("Wrote file successfully")
@@ -70,30 +70,27 @@ describe("tool.write", () => {
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("Hello, World!") expect(content).toBe("Hello, World!")
}), }),
),
) )
it.live("creates parent directories if needed", () => it.instance("creates parent directories if needed", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const filepath = path.join(dir, "nested", "deep", "file.txt") const test = yield* TestInstance
const filepath = path.join(test.directory, "nested", "deep", "file.txt")
yield* run({ filePath: filepath, content: "nested content" }) yield* run({ filePath: filepath, content: "nested content" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("nested content") expect(content).toBe("nested content")
}), }),
),
) )
it.live("handles relative paths by resolving to instance directory", () => it.instance("handles relative paths by resolving to instance directory", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () { Effect.gen(function* () {
const test = yield* TestInstance
yield* run({ filePath: "relative.txt", content: "relative content" }) yield* run({ filePath: "relative.txt", content: "relative content" })
const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8"))
expect(content).toBe("relative content") expect(content).toBe("relative content")
}), }),
),
) )
}) })