opencode/packages/core/test/plugin.test.ts

259 lines
8.7 KiB
TypeScript

import { expect } from "bun:test"
import path from "path"
import { Clock, Effect } from "effect"
import { TestClock } from "effect/testing"
import { Command } from "@opencode-ai/core/command"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginModule } from "@opencode-ai/core/plugin/module"
import { Session } from "@opencode-ai/schema/session"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
for (const scenario of [
{
name: "starts only the appended plugin",
before: ["a", "b"],
after: ["a", "b", "c"],
expected: ["start:c:1"],
},
{
name: "restarts the suffix after an insertion",
before: ["a", "b", "c"],
after: ["a", "x", "b", "c"],
expected: ["stop:c:1", "stop:b:1", "start:x:1", "start:b:1", "start:c:1"],
},
{
name: "restarts the suffix after a revision changes",
before: ["a", "b", "c"],
after: ["a", "b", "c"],
updated: "b",
expected: ["stop:c:1", "stop:b:1", "start:b:2", "start:c:1"],
},
{
name: "stops only the removed trailing plugin",
before: ["a", "b", "c"],
after: ["a", "b"],
expected: ["stop:c:1"],
},
]) {
it.effect(scenario.name, () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const events: string[] = []
const plugin = (id: string, revision = "1"): Plugin.Generation => ({
id,
revision,
effect: () =>
Effect.gen(function* () {
events.push(`start:${id}:${revision}`)
yield* Effect.addFinalizer(() => Effect.sync(() => events.push(`stop:${id}:${revision}`)))
}),
})
yield* plugins.activate(scenario.before.map((id) => plugin(id)))
events.length = 0
yield* plugins.activate(scenario.after.map((id) => plugin(id, id === scenario.updated ? "2" : "1")))
expect(events).toEqual(scenario.expected)
expect((yield* plugins.list()).map((plugin) => plugin.id)).toEqual(scenario.after.map((id) => Plugin.ID.make(id)))
}),
)
}
it.effect("updates inventory metadata without restarting an unchanged generation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
let loads = 0
const plugin = {
id: "metadata",
revision: "1",
source: { type: "package" as const, target: "fixture" },
effect: () => Effect.sync(() => loads++),
}
yield* plugins.activate([plugin])
yield* plugins.activate([{ ...plugin, source: { ...plugin.source, outdated: true } }])
expect(loads).toBe(1)
expect((yield* plugins.list())[0]?.source).toEqual({ type: "package", target: "fixture", outdated: true })
}),
)
it.live("loads a local plugin with its configured options", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.awaitActivation
const definition = yield* PluginModule.load({
type: "add",
target: path.join(import.meta.dir, "plugin/fixtures/greeting.ts"),
options: { description: "Configured greeting" },
})
if ("pending" in definition) return yield* Effect.die("Local plugin was not loaded")
yield* plugins.activate([definition])
expect(yield* commands.get("greet")).toMatchObject({ description: "Configured greeting" })
}),
)
it.effect("unloading a plugin removes its commands and runs cleanup", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
let cleaned = false
yield* plugins.activate([
{
id: "greeting",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
cleaned = true
}),
)
yield* ctx.command.transform((draft) => draft.add({ name: "greet", execute: () => Effect.void }))
}),
},
])
expect(yield* commands.get("greet")).toBeDefined()
expect(cleaned).toBe(false)
yield* plugins.activate([])
expect(yield* commands.get("greet")).toBeUndefined()
expect(cleaned).toBe(true)
}),
)
it.effect("reports a failed plugin without blocking a healthy plugin", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
yield* plugins.activate([
{ id: "broken", revision: "1", effect: () => Effect.die(new Error("Setup failed")) },
{
id: "greeting",
revision: "1",
effect: (ctx) =>
ctx.command
.transform((draft) => draft.add({ name: "greet", execute: () => Effect.void }))
.pipe(Effect.asVoid),
},
])
expect((yield* plugins.list()).find((plugin) => plugin.id === "broken")?.state).toMatchObject({
status: "failed",
error: expect.stringContaining("Setup failed"),
})
expect(yield* commands.get("greet")).toBeDefined()
}),
)
it.effect("reloading a plugin replaces its command implementation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const commands = yield* Command.Service
const output: string[] = []
const load = (revision: string, text: string) =>
plugins.activate([
{
id: "greeting",
revision,
effect: (ctx) =>
ctx.command
.transform((draft) =>
draft.add({
name: "greet",
execute: () =>
Effect.sync(() => {
output.push(text)
}),
}),
)
.pipe(Effect.asVoid),
},
])
const request = {
name: "greet",
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
}
yield* load("1", "before")
yield* commands.execute(request)
expect(output).toEqual(["before"])
yield* load("2", "after")
yield* commands.execute(request)
expect(output).toEqual(["before", "after"])
}),
)
it.effect("refreshes expired OAuth credentials through the context during activation", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
const clock = yield* Clock.Clock
const integrationID = Integration.ID.make("refresh-fixture")
const methodID = Integration.MethodID.make("oauth")
const expired = Credential.OAuth.make({
type: "oauth",
methodID,
access: "expired-access",
refresh: "fixture-refresh",
expires: (yield* Clock.currentTimeMillis) + 60_000,
})
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
yield* TestClock.adjust("2 minutes")
const refreshed = Credential.OAuth.make({
...expired,
access: "fresh-access",
refresh: "rotated-refresh",
expires: (yield* Clock.currentTimeMillis) + 3_600_000,
})
const refreshes: Credential.OAuth[] = []
const resolved: Array<Credential.Value | undefined> = []
yield* plugins.activate([
{
id: "oauth-refresh",
revision: "1",
effect: (ctx) =>
Effect.gen(function* () {
yield* ctx.integration.transform((draft) =>
draft.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Fixture" },
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.sync(() => {
refreshes.push(value)
return refreshed
}),
}),
)
// The method registered above must be readable before the activation batch ends.
const connection = yield* ctx.integration.connection.active(integrationID)
if (!connection) return yield* Effect.die("fixture connection not found")
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
}).pipe(
// Plugin activation isolates ambient services, including the test clock.
Effect.provideService(Clock.Clock, clock),
),
},
])
expect(yield* plugins.list()).toMatchObject([{ id: "oauth-refresh", state: { status: "active" } }])
expect(resolved).toEqual([refreshed])
expect((yield* credentials.get(stored.id))?.value).toEqual(refreshed)
expect(yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: stored.label })).toEqual(
refreshed,
)
expect(refreshes).toEqual([expired])
}),
)