mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-03 07:11:31 +08:00
Compare commits
29 Commits
kit/cli-ef
...
httpapi-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b111ceb88 | ||
|
|
f19be82bbe | ||
|
|
09415a8ff2 | ||
|
|
7d241ff0aa | ||
|
|
0b69b36c67 | ||
|
|
fa055f25bd | ||
|
|
c2a1ab4d9e | ||
|
|
c7a69e62db | ||
|
|
9341d69530 | ||
|
|
c3eb736ee8 | ||
|
|
959cb17295 | ||
|
|
f86371be2c | ||
|
|
d0fd39e473 | ||
|
|
c7a10ac38b | ||
|
|
baa6976a8d | ||
|
|
73406e786f | ||
|
|
76d490afeb | ||
|
|
5ad32d4e7b | ||
|
|
1477b38ea2 | ||
|
|
0e256a1de1 | ||
|
|
2e1f1c2af1 | ||
|
|
b5f391cd8c | ||
|
|
ed00ae267b | ||
|
|
eebb26aa7e | ||
|
|
895275eb1e | ||
|
|
913321e4b6 | ||
|
|
50d5c57193 | ||
|
|
e07f5fbb5d | ||
|
|
f21aa32367 |
1709
packages/opencode/script/httpapi-exercise.ts
Normal file
1709
packages/opencode/script/httpapi-exercise.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ import type { Argv } from "yargs"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { cmd } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
@@ -22,11 +21,6 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError(
|
||||
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
|
||||
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
|
||||
*
|
||||
* The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded
|
||||
* InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`)
|
||||
* on every Exit — success, typed failure, defect, or interruption. Matches the
|
||||
* legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate.
|
||||
*
|
||||
* Errors propagate to the existing top-level handler in `src/index.ts`; use
|
||||
* `fail("...")` for user-visible domain failures (clean exit, formatted message).
|
||||
*
|
||||
@@ -51,17 +45,6 @@ export const effectCmd = <Args, A>(opts: {
|
||||
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
|
||||
const args = rawArgs as unknown as Args
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
await AppRuntime.runPromise(
|
||||
InstanceStore.Service.use((store) =>
|
||||
store.provide(
|
||||
{ directory },
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
const body = opts.handler(args)
|
||||
return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -26,13 +26,17 @@ export function nextTuiRequest() {
|
||||
return request.next()
|
||||
}
|
||||
|
||||
export function submitTuiRequest(body: TuiRequest) {
|
||||
request.push(body)
|
||||
}
|
||||
|
||||
export function submitTuiResponse(body: unknown) {
|
||||
response.push(body)
|
||||
}
|
||||
|
||||
export async function callTui(ctx: Context) {
|
||||
const body = await ctx.req.json()
|
||||
request.push({
|
||||
submitTuiRequest({
|
||||
path: ctx.req.path,
|
||||
body,
|
||||
})
|
||||
|
||||
@@ -122,6 +122,7 @@ export const Client = lazy(() => {
|
||||
})
|
||||
|
||||
export function close() {
|
||||
if (!Client.loaded()) return
|
||||
Client().$client.close()
|
||||
Client.reset()
|
||||
}
|
||||
|
||||
@@ -14,5 +14,7 @@ export function lazy<T>(fn: () => T) {
|
||||
value = undefined
|
||||
}
|
||||
|
||||
result.loaded = () => loaded
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s
|
||||
```typescript
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||
|
||||
describe("my service", () => {
|
||||
it.live("does the thing", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
const out = yield* svc.run()
|
||||
expect(out).toEqual("ok")
|
||||
}),
|
||||
),
|
||||
it.instance("does the thing", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* MyService.Service
|
||||
const out = yield* svc.run()
|
||||
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.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(...)`.
|
||||
|
||||
### 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.
|
||||
- `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
|
||||
|
||||
@@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst
|
||||
- Keep the test body inside `Effect.gen(function* () { ... })`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
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"
|
||||
|
||||
const TestEvent = {
|
||||
@@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node)
|
||||
const it = testEffect(live)
|
||||
|
||||
describe("Bus (Effect-native)", () => {
|
||||
it.live("publish + subscribe stream delivers events", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
it.instance("publish + subscribe stream delivers events", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const received: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
received.push(evt.properties.value)
|
||||
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Deferred.await(done)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 2 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
),
|
||||
expect(received).toEqual([1, 2])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribe filters by event type", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
it.instance("subscribe filters by event type", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pings: number[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
pings.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Pong, { message: "ignored" })
|
||||
yield* bus.publish(TestEvent.Ping, { value: 42 })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(pings).toEqual([42])
|
||||
}),
|
||||
),
|
||||
expect(pings).toEqual([42])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribeAll receives all types", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const types: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
it.instance("subscribeAll receives all types", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const types: string[] = []
|
||||
const done = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||
Effect.sync(() => {
|
||||
types.push(evt.type)
|
||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
|
||||
Effect.sync(() => {
|
||||
types.push(evt.type)
|
||||
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 1 })
|
||||
yield* bus.publish(TestEvent.Pong, { message: "hi" })
|
||||
yield* Deferred.await(done)
|
||||
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain("test.effect.pong")
|
||||
}),
|
||||
),
|
||||
expect(types).toContain("test.effect.ping")
|
||||
expect(types).toContain("test.effect.pong")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("multiple subscribers each receive the event", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
it.instance("multiple subscribers each receive the event", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
const doneA = yield* Deferred.make<void>()
|
||||
const doneB = yield* Deferred.make<void>()
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
a.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneA, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
|
||||
Effect.sync(() => {
|
||||
b.push(evt.properties.value)
|
||||
Deferred.doneUnsafe(doneB, Effect.void)
|
||||
}),
|
||||
).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(doneA)
|
||||
yield* Deferred.await(doneB)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* bus.publish(TestEvent.Ping, { value: 99 })
|
||||
yield* Deferred.await(doneA)
|
||||
yield* Deferred.await(doneB)
|
||||
|
||||
expect(a).toEqual([99])
|
||||
expect(b).toEqual([99])
|
||||
}),
|
||||
),
|
||||
expect(a).toEqual([99])
|
||||
expect(b).toEqual([99])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "path"
|
||||
import { Effect, Context } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import type { Config } from "@/config/config"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
@@ -161,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>(
|
||||
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
|
||||
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
|
||||
|
||||
@@ -3,8 +3,24 @@ import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
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 InstanceOptions = { git?: boolean; config?: Partial<Config.Info> }
|
||||
|
||||
function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions {
|
||||
return !!options && typeof options === "object" && ("git" in options || "config" in options)
|
||||
}
|
||||
|
||||
function instanceArgs(
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
testOptions?: number | TestOptions,
|
||||
): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } {
|
||||
if (typeof options === "number") return { instanceOptions: undefined, testOptions: options }
|
||||
if (isInstanceOptions(options)) return { instanceOptions: options, testOptions }
|
||||
return { instanceOptions: undefined, testOptions: options }
|
||||
}
|
||||
|
||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||
|
||||
@@ -38,7 +54,37 @@ 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) =>
|
||||
test.skip(name, () => run(value, liveLayer), opts)
|
||||
|
||||
return { effect, live }
|
||||
const instance = <A, E2>(
|
||||
name: string,
|
||||
value: Body<A, E2, R | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
opts?: number | TestOptions,
|
||||
) => {
|
||||
const args = instanceArgs(options, opts)
|
||||
return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions)
|
||||
}
|
||||
|
||||
instance.only = <A, E2>(
|
||||
name: string,
|
||||
value: Body<A, E2, R | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
opts?: number | TestOptions,
|
||||
) => {
|
||||
const args = instanceArgs(options, opts)
|
||||
return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions)
|
||||
}
|
||||
|
||||
instance.skip = <A, E2>(
|
||||
name: string,
|
||||
value: Body<A, E2, R | TestInstance | Scope.Scope>,
|
||||
options?: InstanceOptions | number | TestOptions,
|
||||
opts?: number | TestOptions,
|
||||
) => {
|
||||
const args = instanceArgs(options, opts)
|
||||
return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions)
|
||||
}
|
||||
|
||||
return { effect, live, instance }
|
||||
}
|
||||
|
||||
// Test environment with TestClock and TestConsole
|
||||
|
||||
@@ -1,65 +1,64 @@
|
||||
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 { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
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 { 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 }) =>
|
||||
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
|
||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
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> }) =>
|
||||
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
|
||||
const listEffect = Question.Service.use((svc) => svc.list())
|
||||
|
||||
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 () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
||||
async function rejectAll() {
|
||||
const pending = await list()
|
||||
for (const req of pending) {
|
||||
await reject(req.id)
|
||||
}
|
||||
}
|
||||
|
||||
test("ask - returns pending promise", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(promise).toBeInstanceOf(Promise)
|
||||
await rejectAll()
|
||||
await promise.catch(() => {})
|
||||
},
|
||||
})
|
||||
const rejectAll = Effect.gen(function* () {
|
||||
yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true })
|
||||
})
|
||||
|
||||
test("ask - adds to pending list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const questions = [
|
||||
const waitForPending = (count: number) =>
|
||||
Effect.gen(function* () {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const pending = yield* listEffect
|
||||
if (pending.length === count) return pending
|
||||
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"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
@@ -68,30 +67,81 @@ test("ask - adds to pending list", async () => {
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions,
|
||||
})
|
||||
expect(yield* waitForPending(1)).toHaveLength(1)
|
||||
yield* rejectAll
|
||||
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
const pending = await list()
|
||||
expect(pending.length).toBe(1)
|
||||
expect(pending[0].questions).toEqual(questions)
|
||||
await rejectAll()
|
||||
await promise.catch(() => {})
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("ask - adds to pending list", () =>
|
||||
Effect.gen(function* () {
|
||||
const questions = [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions,
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = yield* waitForPending(1)
|
||||
expect(pending.length).toBe(1)
|
||||
expect(pending[0].questions).toEqual(questions)
|
||||
yield* rejectAll
|
||||
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
// reply tests
|
||||
|
||||
test("reply - resolves the pending ask with answers", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const questions = [
|
||||
it.instance("reply - resolves the pending ask with answers", () =>
|
||||
Effect.gen(function* () {
|
||||
const questions = [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions,
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = yield* waitForPending(1)
|
||||
const requestID = pending[0].id
|
||||
|
||||
yield* replyEffect({
|
||||
requestID,
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("reply - removes from pending list", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
@@ -100,366 +150,260 @@ test("reply - resolves the pending ask with answers", async () => {
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions,
|
||||
})
|
||||
const pending = yield* waitForPending(1)
|
||||
expect(pending.length).toBe(1)
|
||||
|
||||
const pending = await list()
|
||||
const requestID = pending[0].id
|
||||
yield* replyEffect({
|
||||
requestID: pending[0].id,
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
yield* Fiber.join(fiber)
|
||||
|
||||
await reply({
|
||||
requestID,
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
const after = yield* listEffect
|
||||
expect(after.length).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
const answers = await promise
|
||||
expect(answers).toEqual([["Option 1"]])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - removes from pending list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await list()
|
||||
expect(pending.length).toBe(1)
|
||||
|
||||
await reply({
|
||||
requestID: pending[0].id,
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
await promise
|
||||
|
||||
const after = await list()
|
||||
expect(after.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reply - does nothing for unknown requestID", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await reply({
|
||||
requestID: QuestionID.make("que_unknown"),
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
// Should not throw
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("reply - does nothing for unknown requestID", () =>
|
||||
replyEffect({
|
||||
requestID: QuestionID.make("que_unknown"),
|
||||
answers: [["Option 1"]],
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
// reject tests
|
||||
|
||||
test("reject - throws RejectedError", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await list()
|
||||
await reject(pending[0].id)
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reject - removes from pending list", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const pending = await list()
|
||||
expect(pending.length).toBe(1)
|
||||
|
||||
await reject(pending[0].id)
|
||||
promise.catch(() => {}) // Ignore rejection
|
||||
|
||||
const after = await list()
|
||||
expect(after.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("reject - does nothing for unknown requestID", async () => {
|
||||
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
|
||||
|
||||
test("ask - handles multiple questions", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const questions = [
|
||||
it.instance("reject - throws RejectedError", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Build", description: "Build the project" },
|
||||
{ label: "Test", description: "Run tests" },
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = yield* waitForPending(1)
|
||||
yield* rejectEffect(pending[0].id)
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("reject - removes from pending list", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
question: "Which environment?",
|
||||
header: "Env",
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Dev", description: "Development" },
|
||||
{ label: "Prod", description: "Production" },
|
||||
{ label: "Option 1", description: "First option" },
|
||||
{ label: "Option 2", description: "Second option" },
|
||||
],
|
||||
},
|
||||
]
|
||||
],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const promise = ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions,
|
||||
})
|
||||
const pending = yield* waitForPending(1)
|
||||
expect(pending.length).toBe(1)
|
||||
|
||||
const pending = await list()
|
||||
yield* rejectEffect(pending[0].id)
|
||||
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
|
||||
|
||||
await reply({
|
||||
requestID: pending[0].id,
|
||||
answers: [["Build"], ["Dev"]],
|
||||
})
|
||||
const after = yield* listEffect
|
||||
expect(after.length).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
const answers = await promise
|
||||
expect(answers).toEqual([["Build"], ["Dev"]])
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true })
|
||||
|
||||
// multiple questions tests
|
||||
|
||||
it.instance("ask - handles multiple questions", () =>
|
||||
Effect.gen(function* () {
|
||||
const questions = [
|
||||
{
|
||||
question: "What would you like to do?",
|
||||
header: "Action",
|
||||
options: [
|
||||
{ label: "Build", description: "Build the project" },
|
||||
{ label: "Test", description: "Run tests" },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: "Which environment?",
|
||||
header: "Env",
|
||||
options: [
|
||||
{ label: "Dev", description: "Development" },
|
||||
{ label: "Prod", description: "Production" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions,
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = yield* waitForPending(1)
|
||||
|
||||
yield* replyEffect({
|
||||
requestID: pending[0].id,
|
||||
answers: [["Build"], ["Dev"]],
|
||||
})
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
// list tests
|
||||
|
||||
test("list - returns all pending requests", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const p1 = ask({
|
||||
sessionID: SessionID.make("ses_test1"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 1?",
|
||||
header: "Q1",
|
||||
options: [{ label: "A", description: "A" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
it.instance("list - returns all pending requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber1 = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test1"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 1?",
|
||||
header: "Q1",
|
||||
options: [{ label: "A", description: "A" }],
|
||||
},
|
||||
],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const p2 = ask({
|
||||
sessionID: SessionID.make("ses_test2"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 2?",
|
||||
header: "Q2",
|
||||
options: [{ label: "B", description: "B" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
const fiber2 = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_test2"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 2?",
|
||||
header: "Q2",
|
||||
options: [{ label: "B", description: "B" }],
|
||||
},
|
||||
],
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const pending = await list()
|
||||
expect(pending.length).toBe(2)
|
||||
await rejectAll()
|
||||
p1.catch(() => {})
|
||||
p2.catch(() => {})
|
||||
},
|
||||
})
|
||||
})
|
||||
const pending = yield* waitForPending(2)
|
||||
expect(pending.length).toBe(2)
|
||||
yield* rejectAll
|
||||
expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
|
||||
expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("list - returns empty when no pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const pending = await list()
|
||||
expect(pending.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("list - returns empty when no pending", () =>
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* listEffect
|
||||
expect(pending.length).toBe(0)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("questions stay isolated by directory", async () => {
|
||||
await using one = await tmpdir({ git: true })
|
||||
await using two = await tmpdir({ git: true })
|
||||
it.live("questions stay isolated by directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const one = yield* tmpdirScoped({ git: true })
|
||||
const two = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const p1 = Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () =>
|
||||
ask({
|
||||
sessionID: SessionID.make("ses_one"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 1?",
|
||||
header: "Q1",
|
||||
options: [{ label: "A", description: "A" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
const fiber1 = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_one"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 1?",
|
||||
header: "Q1",
|
||||
options: [{ label: "A", description: "A" }],
|
||||
},
|
||||
],
|
||||
}).pipe(provideInstance(one), Effect.forkScoped)
|
||||
|
||||
const p2 = Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () =>
|
||||
ask({
|
||||
sessionID: SessionID.make("ses_two"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 2?",
|
||||
header: "Q2",
|
||||
options: [{ label: "B", description: "B" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
const fiber2 = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_two"),
|
||||
questions: [
|
||||
{
|
||||
question: "Question 2?",
|
||||
header: "Q2",
|
||||
options: [{ label: "B", description: "B" }],
|
||||
},
|
||||
],
|
||||
}).pipe(provideInstance(two), Effect.forkScoped)
|
||||
|
||||
const onePending = await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => list(),
|
||||
})
|
||||
const twoPending = await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => list(),
|
||||
})
|
||||
const onePending = yield* waitForPending(1).pipe(provideInstance(one))
|
||||
const twoPending = yield* waitForPending(1).pipe(provideInstance(two))
|
||||
|
||||
expect(onePending.length).toBe(1)
|
||||
expect(twoPending.length).toBe(1)
|
||||
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
|
||||
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
|
||||
expect(onePending.length).toBe(1)
|
||||
expect(twoPending.length).toBe(1)
|
||||
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
|
||||
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
|
||||
|
||||
await Instance.provide({
|
||||
directory: one.path,
|
||||
fn: () => reject(onePending[0].id),
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: two.path,
|
||||
fn: () => reject(twoPending[0].id),
|
||||
})
|
||||
yield* rejectEffect(onePending[0].id).pipe(provideInstance(one))
|
||||
yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two))
|
||||
|
||||
await p1.catch(() => {})
|
||||
await p2.catch(() => {})
|
||||
})
|
||||
expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
|
||||
expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
test("pending question rejects on instance dispose", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.live("pending question rejects on instance dispose", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_dispose"),
|
||||
questions: [
|
||||
{
|
||||
question: "Dispose me?",
|
||||
header: "Dispose",
|
||||
options: [{ label: "Yes", description: "Yes" }],
|
||||
},
|
||||
],
|
||||
}).pipe(provideInstance(dir), Effect.forkScoped)
|
||||
|
||||
const pending = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
return ask({
|
||||
sessionID: SessionID.make("ses_dispose"),
|
||||
questions: [
|
||||
{
|
||||
question: "Dispose me?",
|
||||
header: "Dispose",
|
||||
options: [{ label: "Yes", description: "Yes" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = pending.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
|
||||
yield* Effect.promise(() =>
|
||||
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const items = await list()
|
||||
expect(items).toHaveLength(1)
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
},
|
||||
})
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await result).toBeInstanceOf(Question.RejectedError)
|
||||
})
|
||||
it.live("pending question rejects on instance reload", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const fiber = yield* askEffect({
|
||||
sessionID: SessionID.make("ses_reload"),
|
||||
questions: [
|
||||
{
|
||||
question: "Reload me?",
|
||||
header: "Reload",
|
||||
options: [{ label: "Yes", description: "Yes" }],
|
||||
},
|
||||
],
|
||||
}).pipe(provideInstance(dir), Effect.forkScoped)
|
||||
|
||||
test("pending question rejects on instance reload", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
|
||||
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
|
||||
|
||||
const pending = Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: () => {
|
||||
return ask({
|
||||
sessionID: SessionID.make("ses_reload"),
|
||||
questions: [
|
||||
{
|
||||
question: "Reload me?",
|
||||
header: "Reload",
|
||||
options: [{ label: "Yes", description: "Yes" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
const result = pending.then(
|
||||
() => "resolved" as const,
|
||||
(err) => err,
|
||||
)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const items = await list()
|
||||
expect(items).toHaveLength(1)
|
||||
await InstanceStore.reloadInstance({ 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)
|
||||
}),
|
||||
)
|
||||
|
||||
34
packages/opencode/test/server/global-bus.ts
Normal file
34
packages/opencode/test/server/global-bus.ts
Normal file
@@ -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 path from "path"
|
||||
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 * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -18,20 +17,9 @@ function app() {
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
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)
|
||||
await waitGlobalBusEventPromise({
|
||||
message: "timed out waiting for instance disposal",
|
||||
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
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 { 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 { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -31,20 +31,9 @@ function createSession(input?: Session.CreateInput) {
|
||||
}
|
||||
|
||||
async function waitReady(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
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)
|
||||
await waitGlobalBusEventPromise({
|
||||
message: "timed out waiting for worktree.ready",
|
||||
predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
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 { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { waitGlobalBusEvent } from "./global-bus"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
@@ -97,24 +97,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
const waitDisposedEvent = Effect.promise(
|
||||
() =>
|
||||
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
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 waitDisposedEvent = waitGlobalBusEvent({
|
||||
message: "timed out waiting for instance disposal",
|
||||
predicate: (event) => event.payload.type === "server.instance.disposed",
|
||||
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
|
||||
|
||||
const serveDisposeProbe = () =>
|
||||
HttpRouter.serve(
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
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 { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -18,20 +17,9 @@ function app() {
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
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)
|
||||
await waitGlobalBusEventPromise({
|
||||
message: "timed out waiting for instance disposal",
|
||||
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,13 +105,9 @@ describe("instance HttpApi", () => {
|
||||
test("serves instance dispose through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const disposed = new Promise<string | undefined>((resolve) => {
|
||||
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
|
||||
if (event.payload.type !== "server.instance.disposed") return
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve(event.directory)
|
||||
}
|
||||
GlobalBus.on("event", onEvent)
|
||||
const disposed = waitGlobalBusEventPromise({
|
||||
message: "timed out waiting for instance disposal",
|
||||
predicate: (event) => event.payload.type === "server.instance.disposed",
|
||||
})
|
||||
|
||||
const response = await app().request(InstancePaths.dispose, {
|
||||
@@ -133,6 +117,6 @@ describe("instance HttpApi", () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
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 type { Context } from "hono"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { TuiEvent } from "../../src/cli/cmd/tui/event"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
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 { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -23,14 +23,9 @@ function app(experimental = true) {
|
||||
}
|
||||
|
||||
function nextCommandExecute() {
|
||||
return new Promise<unknown>((resolve) => {
|
||||
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
|
||||
if (event.payload.type !== TuiEvent.CommandExecute.type) return
|
||||
GlobalBus.off("event", listener)
|
||||
resolve(event.payload.properties?.command)
|
||||
}
|
||||
GlobalBus.on("event", listener)
|
||||
})
|
||||
return waitGlobalBusEventPromise({
|
||||
predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type,
|
||||
}).then((event) => event.payload.properties?.command)
|
||||
}
|
||||
|
||||
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
@@ -33,49 +33,47 @@ const ctx = {
|
||||
}
|
||||
|
||||
describe("tool.glob", () => {
|
||||
it.live("matches files from a directory path", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
|
||||
const info = yield* GlobTool
|
||||
const glob = yield* info.init()
|
||||
const result = yield* glob.execute(
|
||||
it.instance("matches files from a directory path", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
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 glob = yield* info.init()
|
||||
const result = yield* glob.execute(
|
||||
{
|
||||
pattern: "*.ts",
|
||||
path: test.directory,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.count).toBe(1)
|
||||
expect(result.output).toContain(path.join(test.directory, "a.ts"))
|
||||
expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects exact file paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "a.ts")
|
||||
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
|
||||
const info = yield* GlobTool
|
||||
const glob = yield* info.init()
|
||||
const exit = yield* glob
|
||||
.execute(
|
||||
{
|
||||
pattern: "*.ts",
|
||||
path: dir,
|
||||
path: file,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.count).toBe(1)
|
||||
expect(result.output).toContain(path.join(dir, "a.ts"))
|
||||
expect(result.output).not.toContain(path.join(dir, "b.txt"))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects exact file paths", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "a.ts")
|
||||
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
|
||||
const info = yield* GlobTool
|
||||
const glob = yield* info.init()
|
||||
const exit = yield* glob
|
||||
.execute(
|
||||
{
|
||||
pattern: "*.ts",
|
||||
path: file,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const err = Cause.squash(exit.cause)
|
||||
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
|
||||
}
|
||||
}),
|
||||
),
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const err = Cause.squash(exit.cause)
|
||||
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
@@ -54,61 +54,58 @@ describe("tool.grep", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("no matches returns correct output", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: dir,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBe(0)
|
||||
expect(result.output).toBe("No files found")
|
||||
}),
|
||||
),
|
||||
it.instance("no matches returns correct output", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: test.directory,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBe(0)
|
||||
expect(result.output).toBe("No files found")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("finds matches in tmp instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: dir,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
it.instance("finds matches in tmp instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: test.directory,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("supports exact file paths", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "test.txt")
|
||||
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "line2",
|
||||
path: file,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBe(1)
|
||||
expect(result.output).toContain(file)
|
||||
expect(result.output).toContain("Line 2: line2")
|
||||
}),
|
||||
),
|
||||
it.instance("supports exact file paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "test.txt")
|
||||
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
|
||||
const info = yield* GrepTool
|
||||
const grep = yield* info.init()
|
||||
const result = yield* grep.execute(
|
||||
{
|
||||
pattern: "line2",
|
||||
path: file,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBe(1)
|
||||
expect(result.output).toContain(file)
|
||||
expect(result.output).toContain("Line 2: line2")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const ctx = {
|
||||
@@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest
|
||||
})
|
||||
|
||||
describe("tool.question", () => {
|
||||
it.live("should successfully execute with valid question parameters", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const toolInfo = yield* QuestionTool
|
||||
const tool = yield* toolInfo.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite color?",
|
||||
header: "Color",
|
||||
options: [
|
||||
{ label: "Red", description: "The color of passion" },
|
||||
{ label: "Blue", description: "The color of sky" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
]
|
||||
it.instance("should successfully execute with valid question parameters", () =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const toolInfo = yield* QuestionTool
|
||||
const tool = yield* toolInfo.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite color?",
|
||||
header: "Color",
|
||||
options: [
|
||||
{ label: "Red", description: "The color of passion" },
|
||||
{ label: "Blue", description: "The color of sky" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
|
||||
|
||||
const result = yield* Fiber.join(fiber)
|
||||
expect(result.title).toBe("Asked 1 question")
|
||||
}),
|
||||
),
|
||||
const result = yield* Fiber.join(fiber)
|
||||
expect(result.title).toBe("Asked 1 question")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should now pass with a header longer than 12 but less than 30 chars", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const toolInfo = yield* QuestionTool
|
||||
const tool = yield* toolInfo.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite animal?",
|
||||
header: "This Header is Over 12",
|
||||
options: [{ label: "Dog", description: "Man's best friend" }],
|
||||
},
|
||||
]
|
||||
it.instance("should now pass with a header longer than 12 but less than 30 chars", () =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const toolInfo = yield* QuestionTool
|
||||
const tool = yield* toolInfo.init()
|
||||
const questions = [
|
||||
{
|
||||
question: "What is your favorite animal?",
|
||||
header: "This Header is Over 12",
|
||||
options: [{ label: "Dog", description: "Man's best friend" }],
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
|
||||
|
||||
const result = yield* Fiber.join(fiber)
|
||||
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
|
||||
}),
|
||||
),
|
||||
const result = yield* Fiber.join(fiber)
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
@@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => {
|
||||
})
|
||||
|
||||
describe("tool.read truncation", () => {
|
||||
it.live("truncates large file by bytes and sets truncated metadata", () =>
|
||||
it.instance("truncates large file by bytes and sets truncated metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const test = yield* TestInstance
|
||||
const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
|
||||
const target = 60 * 1024
|
||||
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
|
||||
yield* put(path.join(dir, "large.json"), content)
|
||||
yield* put(path.join(test.directory, "large.json"), content)
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
|
||||
const result = yield* run({ filePath: path.join(test.directory, "large.json") })
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("Output capped at")
|
||||
expect(result.output).toContain("Use offset=")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("truncates by line count when limit is specified", () =>
|
||||
it.instance("truncates by line count when limit is specified", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const test = yield* TestInstance
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||
yield* put(path.join(dir, "many-lines.txt"), lines)
|
||||
yield* put(path.join(test.directory, "many-lines.txt"), lines)
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
|
||||
const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 })
|
||||
expect(result.metadata.truncated).toBe(true)
|
||||
expect(result.output).toContain("Showing lines 1-10 of 100")
|
||||
expect(result.output).toContain("Use offset=11")
|
||||
@@ -286,12 +286,12 @@ describe("tool.read truncation", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not truncate small file", () =>
|
||||
it.instance("does not truncate small file", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* put(path.join(dir, "small.txt"), "hello world")
|
||||
const test = yield* TestInstance
|
||||
yield* put(path.join(test.directory, "small.txt"), "hello world")
|
||||
|
||||
const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
|
||||
const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).toContain("End of file")
|
||||
}),
|
||||
|
||||
@@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
@@ -17,136 +16,133 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("tool.registry", () => {
|
||||
it.live("loads tools from .opencode/tool (singular)", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".opencode")
|
||||
const tool = path.join(opencode, "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
),
|
||||
it.instance("loads tools from .opencode/tool (singular)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tool = path.join(opencode, "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads tools from .opencode/tools (plural)", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
),
|
||||
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads tools with external dependencies without crashing", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const opencode = path.join(dir, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
it.instance("loads tools with external dependencies without crashing", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const cowsay = path.join(opencode, "node_modules", "cowsay")
|
||||
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("cowsay")
|
||||
}),
|
||||
),
|
||||
const cowsay = path.join(opencode, "node_modules", "cowsay")
|
||||
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
expect(ids).toContain("cowsay")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
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"
|
||||
|
||||
const ctx = {
|
||||
@@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
|
||||
describe("tool.write", () => {
|
||||
describe("new file creation", () => {
|
||||
it.live("writes content to new file", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "newfile.txt")
|
||||
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
|
||||
it.instance("writes content to new file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "newfile.txt")
|
||||
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
|
||||
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
expect(result.metadata.exists).toBe(false)
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
expect(result.metadata.exists).toBe(false)
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("Hello, World!")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("Hello, World!")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates parent directories if needed", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "nested", "deep", "file.txt")
|
||||
yield* run({ filePath: filepath, content: "nested content" })
|
||||
it.instance("creates parent directories if needed", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "nested", "deep", "file.txt")
|
||||
yield* run({ filePath: filepath, content: "nested content" })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("nested content")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("nested content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("handles relative paths by resolving to instance directory", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run({ filePath: "relative.txt", content: "relative content" })
|
||||
it.instance("handles relative paths by resolving to instance directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* run({ filePath: "relative.txt", content: "relative content" })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8"))
|
||||
expect(content).toBe("relative content")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8"))
|
||||
expect(content).toBe("relative content")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("existing file overwrite", () => {
|
||||
it.live("overwrites existing file content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
|
||||
const result = yield* run({ filePath: filepath, content: "new content" })
|
||||
it.instance("overwrites existing file content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "existing.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
|
||||
const result = yield* run({ filePath: filepath, content: "new content" })
|
||||
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
expect(result.metadata.exists).toBe(true)
|
||||
expect(result.output).toContain("Wrote file successfully")
|
||||
expect(result.metadata.exists).toBe(true)
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("new content")
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("new content")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves BOM when overwriting existing files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
it.instance("preserves BOM when overwriting existing files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "existing.cs")
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
|
||||
|
||||
yield* run({ filePath: filepath, content: "using Up;\n" })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using Up;\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"restores BOM after formatter strips it",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "existing.cs")
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "formatted.cs")
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
|
||||
|
||||
@@ -127,165 +140,138 @@ describe("tool.write", () => {
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using Up;\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("restores BOM after formatter strips it", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "formatted.cs")
|
||||
const bom = String.fromCharCode(0xfeff)
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
|
||||
|
||||
yield* run({ filePath: filepath, content: "using Up;\n" })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content.charCodeAt(0)).toBe(0xfeff)
|
||||
expect(content.slice(1)).toBe("using Up;\n")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
stripbom: {
|
||||
extensions: [".cs"],
|
||||
command: [
|
||||
"node",
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
|
||||
"$FILE",
|
||||
],
|
||||
},
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
stripbom: {
|
||||
extensions: [".cs"],
|
||||
command: [
|
||||
"node",
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
|
||||
"$FILE",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
it.live("returns diff in metadata for existing files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
|
||||
const result = yield* run({ filePath: filepath, content: "new" })
|
||||
it.instance("returns diff in metadata for existing files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
|
||||
const result = yield* run({ filePath: filepath, content: "new" })
|
||||
|
||||
expect(result.metadata).toHaveProperty("filepath", filepath)
|
||||
expect(result.metadata).toHaveProperty("exists", true)
|
||||
}),
|
||||
),
|
||||
expect(result.metadata).toHaveProperty("filepath", filepath)
|
||||
expect(result.metadata).toHaveProperty("exists", true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("file permissions", () => {
|
||||
it.live("sets file permissions when writing sensitive data", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "sensitive.json")
|
||||
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
|
||||
it.instance("sets file permissions when writing sensitive data", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "sensitive.json")
|
||||
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(stats.mode & 0o777).toBe(0o644)
|
||||
}
|
||||
}),
|
||||
),
|
||||
if (process.platform !== "win32") {
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(stats.mode & 0o777).toBe(0o644)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("content types", () => {
|
||||
it.live("writes JSON content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "data.json")
|
||||
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
|
||||
it.instance("writes JSON content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "data.json")
|
||||
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("writes binary-safe content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "binary.bin")
|
||||
const content = "Hello\x00World\x01\x02\x03"
|
||||
yield* run({ filePath: filepath, content })
|
||||
it.instance("writes binary-safe content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "binary.bin")
|
||||
const content = "Hello\x00World\x01\x02\x03"
|
||||
yield* run({ filePath: filepath, content })
|
||||
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
),
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("writes empty content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "empty.txt")
|
||||
yield* run({ filePath: filepath, content: "" })
|
||||
it.instance("writes empty content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "empty.txt")
|
||||
yield* run({ filePath: filepath, content: "" })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("")
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe("")
|
||||
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(stats.size).toBe(0)
|
||||
}),
|
||||
),
|
||||
const stats = yield* Effect.promise(() => fs.stat(filepath))
|
||||
expect(stats.size).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("writes multi-line content", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "multiline.txt")
|
||||
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
|
||||
yield* run({ filePath: filepath, content: lines })
|
||||
it.instance("writes multi-line content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "multiline.txt")
|
||||
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
|
||||
yield* run({ filePath: filepath, content: lines })
|
||||
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe(lines)
|
||||
}),
|
||||
),
|
||||
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
|
||||
expect(content).toBe(lines)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("handles different line endings", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "crlf.txt")
|
||||
const content = "Line 1\r\nLine 2\r\nLine 3"
|
||||
yield* run({ filePath: filepath, content })
|
||||
it.instance("handles different line endings", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "crlf.txt")
|
||||
const content = "Line 1\r\nLine 2\r\nLine 3"
|
||||
yield* run({ filePath: filepath, content })
|
||||
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
),
|
||||
const buf = yield* Effect.promise(() => fs.readFile(filepath))
|
||||
expect(buf.toString()).toBe(content)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it.live("throws error when OS denies write access", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const readonlyPath = path.join(dir, "readonly.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
|
||||
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
|
||||
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
),
|
||||
it.instance("throws error when OS denies write access", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const readonlyPath = path.join(test.directory, "readonly.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
|
||||
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
|
||||
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
|
||||
expect(exit._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("title generation", () => {
|
||||
it.live("returns relative path as title", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const filepath = path.join(dir, "src", "components", "Button.tsx")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
|
||||
it.instance("returns relative path as title", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "src", "components", "Button.tsx")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
|
||||
|
||||
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
|
||||
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
|
||||
}),
|
||||
),
|
||||
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
|
||||
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user