mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 00:24:40 +00:00
192 lines
7.7 KiB
TypeScript
192 lines
7.7 KiB
TypeScript
import { expect } from "bun:test"
|
|
import { mkdir } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
|
import { Plugin } from "@opencode-ai/plugin/effect"
|
|
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
|
|
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
|
|
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
|
import { it } from "../../core/test/lib/effect"
|
|
import { createRoutes } from "../src/routes"
|
|
|
|
const fixture = Effect.fn(function* (plugin: Plugin.Plugin) {
|
|
const tmp = yield* tmpdirScoped("opencode-plugin-activation-")
|
|
const first = path.join(tmp.path, "first")
|
|
const second = path.join(tmp.path, "second")
|
|
const config = path.join(tmp.path, "config")
|
|
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
|
|
const context = yield* Layer.build(
|
|
createRoutes({
|
|
password: "secret",
|
|
database: { path: ":memory:" },
|
|
models: { fetch: false },
|
|
fs: { filewatcher: false },
|
|
config: {
|
|
directory: config,
|
|
project: false,
|
|
content: JSON.stringify({
|
|
providers: {
|
|
acme: {
|
|
models: {
|
|
reasoner: { name: "Configured Reasoner", limit: { context: 96_000, output: 8_000 } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
}).pipe(Layer.provide(HttpServer.layerServices)),
|
|
)
|
|
const sdk = Context.get(context, SdkPlugins.Service)
|
|
yield* sdk.register(plugin)
|
|
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
|
|
return {
|
|
first,
|
|
second,
|
|
request: (method: "GET" | "POST", route: string, directory = first, signal?: AbortSignal) =>
|
|
Effect.promise((interruption) => {
|
|
const url = new URL(route, "http://opencode.local")
|
|
url.searchParams.set("location[directory]", directory)
|
|
return handler(
|
|
new Request(url, {
|
|
method,
|
|
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
|
signal: signal ?? interruption,
|
|
}),
|
|
)
|
|
}),
|
|
}
|
|
})
|
|
|
|
it.live(
|
|
"awaits activation only for the requested location without blocking model or plugin snapshots",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const server = yield* fixture(
|
|
Plugin.define({
|
|
id: "slow-plugin",
|
|
effect: (ctx) =>
|
|
Effect.gen(function* () {
|
|
if (path.basename(ctx.location.directory) !== "first") return
|
|
yield* Deferred.succeed(started, undefined)
|
|
yield* Deferred.await(release)
|
|
}),
|
|
}),
|
|
)
|
|
const pending = yield* server.request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
|
|
yield* Deferred.await(started)
|
|
expect(pending.pollUnsafe()).toBeUndefined()
|
|
|
|
const models = yield* server.request("GET", "/api/model")
|
|
expect(models.status).toBe(200)
|
|
expect(yield* Effect.promise(() => models.json())).toMatchObject({
|
|
location: { directory: server.first },
|
|
data: expect.not.arrayContaining([expect.objectContaining({ providerID: "acme", id: "reasoner" })]),
|
|
})
|
|
const plugins = yield* server.request("GET", "/api/plugin")
|
|
expect(plugins.status).toBe(200)
|
|
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({ location: { directory: server.first } })
|
|
|
|
const second = yield* server.request("POST", "/api/plugin/await-activation", server.second)
|
|
expect(second.status).toBe(204)
|
|
expect(pending.pollUnsafe()).toBeUndefined()
|
|
|
|
yield* Deferred.succeed(release, undefined)
|
|
const response = yield* Fiber.join(pending)
|
|
expect(response.status).toBe(204)
|
|
expect(yield* Effect.promise(() => response.text())).toBe("")
|
|
const configured = yield* server.request("GET", "/api/model")
|
|
expect(configured.status).toBe(200)
|
|
expect(yield* Effect.promise(() => configured.json())).toMatchObject({
|
|
location: { directory: server.first },
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
providerID: "acme",
|
|
id: "reasoner",
|
|
name: "Configured Reasoner",
|
|
limit: { context: 96_000, output: 8_000 },
|
|
}),
|
|
]),
|
|
})
|
|
const active = yield* server.request("GET", "/api/plugin")
|
|
expect(active.status).toBe(200)
|
|
expect(yield* Effect.promise(() => active.json())).toMatchObject({
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({ id: "slow-plugin", source: { type: "sdk" }, state: { status: "active" } }),
|
|
]),
|
|
})
|
|
}).pipe(Effect.timeout("10 seconds")),
|
|
15_000,
|
|
)
|
|
|
|
it.live(
|
|
"aborting an activation wait does not cancel plugin setup",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const started = yield* Deferred.make<void>()
|
|
const release = yield* Deferred.make<void>()
|
|
const completed = yield* Deferred.make<void>()
|
|
const interrupted = yield* Deferred.make<void>()
|
|
const server = yield* fixture(
|
|
Plugin.define({
|
|
id: "slow-plugin",
|
|
effect: () =>
|
|
Effect.gen(function* () {
|
|
yield* Deferred.succeed(started, undefined)
|
|
yield* Deferred.await(release)
|
|
yield* Deferred.succeed(completed, undefined)
|
|
}).pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))),
|
|
}),
|
|
)
|
|
const controller = new AbortController()
|
|
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
|
|
const pending = yield* server
|
|
.request("POST", "/api/plugin/await-activation", server.first, controller.signal)
|
|
.pipe(Effect.forkScoped)
|
|
yield* Deferred.await(started)
|
|
controller.abort()
|
|
// HttpEffect resolves a cancelled Web request with 499 rather than rejecting its Promise.
|
|
expect((yield* Fiber.join(pending)).status).toBe(499)
|
|
expect(yield* Deferred.isDone(interrupted)).toBe(false)
|
|
expect(yield* Deferred.isDone(completed)).toBe(false)
|
|
|
|
yield* Deferred.succeed(release, undefined)
|
|
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
|
|
expect(yield* Deferred.isDone(completed)).toBe(true)
|
|
expect(yield* Deferred.isDone(interrupted)).toBe(false)
|
|
const plugins = yield* server.request("GET", "/api/plugin")
|
|
expect(plugins.status).toBe(200)
|
|
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
|
|
data: expect.arrayContaining([expect.objectContaining({ id: "slow-plugin", state: { status: "active" } })]),
|
|
})
|
|
}).pipe(Effect.timeout("10 seconds")),
|
|
15_000,
|
|
)
|
|
|
|
it.live(
|
|
"settles activation when plugin setup fails and exposes the failure in the inventory",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const server = yield* fixture(
|
|
Plugin.define({
|
|
id: "failing-plugin",
|
|
effect: () => Effect.die(new Error("fixture setup failed")),
|
|
}),
|
|
)
|
|
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
|
|
const plugins = yield* server.request("GET", "/api/plugin")
|
|
expect(plugins.status).toBe(200)
|
|
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
|
|
location: { directory: server.first },
|
|
data: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: "failing-plugin",
|
|
source: { type: "sdk" },
|
|
state: { status: "failed", error: expect.stringContaining("fixture setup failed") },
|
|
}),
|
|
]),
|
|
})
|
|
}).pipe(Effect.timeout("10 seconds")),
|
|
15_000,
|
|
)
|