mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-18 18:16:25 +00:00
1282 lines
38 KiB
TypeScript
1282 lines
38 KiB
TypeScript
import { afterAll, afterEach, describe, expect, spyOn } from "bun:test"
|
|
import { Effect, Layer } from "effect"
|
|
import fs from "fs/promises"
|
|
import path from "path"
|
|
import { pathToFileURL } from "url"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
import { Filesystem } from "@/util/filesystem"
|
|
|
|
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
|
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
|
|
|
const { Plugin } = await import("../../src/plugin/index")
|
|
const { PluginLoader } = await import("../../src/plugin/loader")
|
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
|
const { Bus } = await import("../../src/bus")
|
|
const { Npm } = await import("@opencode-ai/core/npm")
|
|
const { TestConfig } = await import("../fixture/config")
|
|
|
|
afterAll(() => {
|
|
if (disableDefault === undefined) {
|
|
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
|
return
|
|
}
|
|
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
})
|
|
|
|
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
|
|
|
function withTmp<T, A, E, R>(
|
|
init: (dir: string) => Promise<T>,
|
|
body: (tmp: { path: string; extra: T }) => Effect.Effect<A, E, R>,
|
|
) {
|
|
return Effect.gen(function* () {
|
|
const dir = yield* tmpdirScoped()
|
|
const extra = yield* Effect.promise(() => init(dir))
|
|
return yield* body({ path: dir, extra })
|
|
})
|
|
}
|
|
|
|
function load(dir: string) {
|
|
const source = path.join(dir, "opencode.json")
|
|
return Effect.gen(function* () {
|
|
const config = yield* Effect.promise(
|
|
() => Bun.file(source).json() as Promise<{ plugin?: Array<string | [string, Record<string, unknown>]> }>,
|
|
)
|
|
const plugins = config.plugin ?? []
|
|
return yield* Effect.gen(function* () {
|
|
const plugin = yield* Plugin.Service
|
|
yield* plugin.list()
|
|
}).pipe(
|
|
Effect.provide(
|
|
Plugin.layer.pipe(
|
|
Layer.provide(Bus.layer),
|
|
Layer.provide(
|
|
TestConfig.layer({
|
|
get: () =>
|
|
Effect.succeed({
|
|
plugin: plugins,
|
|
plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
|
|
}),
|
|
directories: () => Effect.succeed([dir]),
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
provideInstance(dir),
|
|
)
|
|
})
|
|
}
|
|
|
|
describe("plugin.loader.shared", () => {
|
|
it.live("loads a file:// plugin function export", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("deduplicates same function exported as default and named", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "count.txt")
|
|
await Bun.write(mark, "")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"const run = async () => {",
|
|
` const text = await Bun.file(${JSON.stringify(mark)}).text().catch(() => "")`,
|
|
` await Bun.write(${JSON.stringify(mark)}, text + "1")`,
|
|
" return {}",
|
|
"}",
|
|
"export default run",
|
|
"export const named = run",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("uses only default v1 server plugin when present", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "count.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
' id: "demo.v1-default",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "default")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"export const named = async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "named")`,
|
|
" return {}",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects v1 file server plugin without id", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
|
|
expect(called).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects v1 plugin that exports server and tui together", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
' id: "demo.mixed",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "server")`,
|
|
" return {}",
|
|
" },",
|
|
" tui: async () => {},",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
|
|
expect(called).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("resolves npm plugin specs with explicit and default versions", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const acme = path.join(dir, "node_modules", "acme-plugin")
|
|
const scope = path.join(dir, "node_modules", "scope-plugin")
|
|
await fs.mkdir(acme, { recursive: true })
|
|
await fs.mkdir(scope, { recursive: true })
|
|
await Bun.write(
|
|
path.join(acme, "package.json"),
|
|
JSON.stringify({ name: "acme-plugin", type: "module", main: "./index.js" }, null, 2),
|
|
)
|
|
await Bun.write(path.join(acme, "index.js"), "export default { server: async () => ({}) }\n")
|
|
await Bun.write(
|
|
path.join(scope, "package.json"),
|
|
JSON.stringify({ name: "scope-plugin", type: "module", main: "./index.js" }, null, 2),
|
|
)
|
|
await Bun.write(path.join(scope, "index.js"), "export default { server: async () => ({}) }\n")
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: ["acme-plugin", "scope-plugin@2.3.4"] }, null, 2),
|
|
)
|
|
|
|
return { acme, scope }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
|
|
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
|
|
return { directory: tmp.extra.scope, entrypoint: undefined }
|
|
})
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
|
|
expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
|
|
expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
|
|
} finally {
|
|
add.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads npm server plugin from package ./server export", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const mark = path.join(dir, "server-called.txt")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: {
|
|
".": "./index.js",
|
|
"./server": "./server.js",
|
|
"./tui": "./tui.js",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
|
await Bun.write(
|
|
path.join(mod, "server.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(path.join(mod, "tui.js"), "export default {}\n")
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads npm server plugin from package server export without leading dot", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const dist = path.join(mod, "dist")
|
|
const mark = path.join(dir, "server-called.txt")
|
|
await fs.mkdir(dist, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: {
|
|
".": "./index.js",
|
|
"./server": "dist/server.js",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
|
await Bun.write(
|
|
path.join(dist, "server.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads npm server plugin from package main without leading dot", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const dist = path.join(mod, "dist")
|
|
const mark = path.join(dir, "main-called.txt")
|
|
await fs.mkdir(dist, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
main: "dist/index.js",
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(
|
|
path.join(dist, "index.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not use npm package exports dot for server entry", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const mark = path.join(dir, "dot-server.txt")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify({
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: { ".": "./index.js" },
|
|
}),
|
|
)
|
|
await Bun.write(
|
|
path.join(mod, "index.js"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.dot.server",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
|
|
|
return { mod, mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
|
|
expect(called).toBe(false)
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects npm server export that resolves outside plugin directory", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
const outside = path.join(dir, "outside")
|
|
const mark = path.join(dir, "outside-server.txt")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
await fs.mkdir(outside, { recursive: true })
|
|
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
type: "module",
|
|
exports: {
|
|
".": "./index.js",
|
|
"./server": "./escape/server.js",
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "index.js"), "export default {}\n")
|
|
await Bun.write(
|
|
path.join(outside, "server.js"),
|
|
[
|
|
"export default {",
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "outside")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin"] }, null, 2))
|
|
|
|
return {
|
|
mod,
|
|
mark,
|
|
}
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
Bun.file(tmp.extra.mark)
|
|
.text()
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
expect(called).toBe(false)
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("skips legacy codex and copilot auth plugin specs", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify(
|
|
{
|
|
plugin: ["opencode-openai-codex-auth@1.0.0", "opencode-copilot-auth@1.0.0", "regular-plugin@1.0.0"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
},
|
|
(_tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
|
|
|
|
try {
|
|
yield* load(_tmp.path)
|
|
|
|
const pkgs = install.mock.calls.map((call) => call[0])
|
|
expect(pkgs).toContain("regular-plugin@1.0.0")
|
|
expect(pkgs).not.toContain("opencode-openai-codex-auth@1.0.0")
|
|
expect(pkgs).not.toContain("opencode-copilot-auth@1.0.0")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("skips broken plugin when install fails", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const ok = path.join(dir, "ok.ts")
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
ok,
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
|
|
)
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("continues loading plugins when plugin init throws", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
path.join(dir, "throws.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.throws",',
|
|
" server: async () => {",
|
|
' throw new Error("explode")',
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "ok.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("continues loading plugins when plugin module has invalid export", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
path.join(dir, "invalid.ts"),
|
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
|
)
|
|
await Bun.write(
|
|
path.join(dir, "ok.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("continues loading plugins when plugin import fails", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
const mark = path.join(dir, "ok.txt")
|
|
await Bun.write(
|
|
path.join(dir, "ok.ts"),
|
|
[
|
|
"export default {",
|
|
' id: "demo.ok",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("loads object plugin via plugin.server", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "object-plugin.ts")
|
|
const mark = path.join(dir, "object-called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"const plugin = {",
|
|
' id: "demo.object",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"export default plugin",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("passes tuple plugin options into server plugin", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "options-plugin.ts")
|
|
const mark = path.join(dir, "options.json")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"const plugin = {",
|
|
' id: "demo.options",',
|
|
" server: async (_input, options) => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(options ?? null))`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"export default plugin",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [[pathToFileURL(file).href, { source: "tuple", enabled: true }]] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
expect(
|
|
yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)),
|
|
).toEqual({
|
|
source: "tuple",
|
|
enabled: true,
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("initializes server plugins in config order", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const a = path.join(dir, "a-plugin.ts")
|
|
const b = path.join(dir, "b-plugin.ts")
|
|
const marker = path.join(dir, "server-order.txt")
|
|
const aSpec = pathToFileURL(a).href
|
|
const bSpec = pathToFileURL(b).href
|
|
|
|
await Bun.write(
|
|
a,
|
|
`import fs from "fs/promises"
|
|
|
|
export default {
|
|
id: "demo.order.a",
|
|
server: async () => {
|
|
await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
|
|
await Bun.sleep(25)
|
|
await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
|
|
return {}
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
await Bun.write(
|
|
b,
|
|
`import fs from "fs/promises"
|
|
|
|
export default {
|
|
id: "demo.order.b",
|
|
server: async () => {
|
|
await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
|
|
return {}
|
|
},
|
|
}
|
|
`,
|
|
)
|
|
|
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
|
|
|
|
return { marker }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
yield* load(tmp.path)
|
|
const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n")
|
|
expect(lines).toEqual(["a-start", "a-end", "b"])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("skips external plugins in pure mode", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const mark = path.join(dir, "called.txt")
|
|
await Bun.write(
|
|
file,
|
|
[
|
|
"export default {",
|
|
' id: "demo.pure",',
|
|
" server: async () => {",
|
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
|
" return {}",
|
|
" },",
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
)
|
|
|
|
await Bun.write(
|
|
path.join(dir, "opencode.json"),
|
|
JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
|
|
)
|
|
|
|
return { mark }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const pure = process.env.OPENCODE_PURE
|
|
process.env.OPENCODE_PURE = "1"
|
|
|
|
try {
|
|
yield* load(tmp.path)
|
|
const called = yield* Effect.promise(() =>
|
|
fs
|
|
.readFile(tmp.extra.mark, "utf8")
|
|
.then(() => true)
|
|
.catch(() => false),
|
|
)
|
|
expect(called).toBe(false)
|
|
} finally {
|
|
if (pure === undefined) {
|
|
delete process.env.OPENCODE_PURE
|
|
} else {
|
|
process.env.OPENCODE_PURE = pure
|
|
}
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("reads oc-themes from package manifest", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mod")
|
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
version: "1.0.0",
|
|
"oc-themes": ["themes/one.json", "./themes/one.json", "themes/two.json"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
return { mod }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const file = path.join(tmp.extra.mod, "package.json")
|
|
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(file))
|
|
const list = readPackageThemes("acme-plugin", {
|
|
dir: tmp.extra.mod,
|
|
pkg: file,
|
|
json,
|
|
})
|
|
|
|
expect(list).toEqual([
|
|
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
|
|
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
|
|
])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("handles no-entrypoint tui packages via missing callback", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
version: "1.0.0",
|
|
"oc-themes": ["themes/night.json"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
|
return { mod }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
const missing: string[] = []
|
|
|
|
try {
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
missing: async (item) => {
|
|
if (!item.pkg) return
|
|
const themes = readPackageThemes(item.spec, item.pkg)
|
|
if (!themes.length) return
|
|
return {
|
|
spec: item.spec,
|
|
target: item.target,
|
|
themes,
|
|
}
|
|
},
|
|
report: {
|
|
missing(_candidate, _retry, message) {
|
|
missing.push(message)
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
target: tmp.extra.mod,
|
|
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
|
},
|
|
])
|
|
expect(missing).toHaveLength(0)
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("passes package metadata for entrypoint tui plugins", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mods", "acme-plugin")
|
|
await fs.mkdir(path.join(mod, "themes"), { recursive: true })
|
|
await Bun.write(
|
|
path.join(mod, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "acme-plugin",
|
|
version: "1.0.0",
|
|
exports: {
|
|
"./tui": "./tui.js",
|
|
},
|
|
"oc-themes": ["themes/night.json"],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
await Bun.write(path.join(mod, "tui.js"), 'export default { id: "demo", tui: async () => {} }\n')
|
|
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
|
|
return { mod }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
|
|
|
try {
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
finish: async (item) => {
|
|
if (!item.pkg) return
|
|
return {
|
|
spec: item.spec,
|
|
themes: readPackageThemes(item.spec, item.pkg),
|
|
}
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
themes: [Filesystem.resolve(path.join(tmp.extra.mod, "themes", "night.json"))],
|
|
},
|
|
])
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("rejects oc-themes path traversal", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const mod = path.join(dir, "mod")
|
|
await fs.mkdir(mod, { recursive: true })
|
|
const file = path.join(mod, "package.json")
|
|
await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
|
|
return { mod, file }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(tmp.extra.file))
|
|
expect(() =>
|
|
readPackageThemes("acme", {
|
|
dir: tmp.extra.mod,
|
|
pkg: tmp.extra.file,
|
|
json,
|
|
}),
|
|
).toThrow("outside plugin directory")
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("retries failed file plugins once after wait and keeps order", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const a = path.join(dir, "a")
|
|
const b = path.join(dir, "b")
|
|
const aSpec = pathToFileURL(a).href
|
|
const bSpec = pathToFileURL(b).href
|
|
await fs.mkdir(a, { recursive: true })
|
|
await fs.mkdir(b, { recursive: true })
|
|
return { a, b, aSpec, bSpec }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
let wait = 0
|
|
const calls: Array<[string, boolean]> = []
|
|
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
|
|
spec,
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
})),
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
await Bun.write(path.join(tmp.extra.a, "index.ts"), "export default {}\n")
|
|
await Bun.write(path.join(tmp.extra.b, "index.ts"), "export default {}\n")
|
|
},
|
|
report: {
|
|
start(candidate, retry) {
|
|
calls.push([candidate.plan.spec, retry])
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(wait).toBe(1)
|
|
expect(calls).toEqual([
|
|
[tmp.extra.aSpec, false],
|
|
[tmp.extra.bSpec, false],
|
|
[tmp.extra.aSpec, true],
|
|
[tmp.extra.bSpec, true],
|
|
])
|
|
expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("retries file plugins when finish returns undefined", () =>
|
|
withTmp(
|
|
async (dir) => {
|
|
const file = path.join(dir, "plugin.ts")
|
|
const spec = pathToFileURL(file).href
|
|
await Bun.write(file, "export default {}\n")
|
|
return { spec }
|
|
},
|
|
(tmp) =>
|
|
Effect.gen(function* () {
|
|
let wait = 0
|
|
let count = 0
|
|
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: tmp.extra.spec,
|
|
scope: "local" as const,
|
|
source: tmp.path,
|
|
},
|
|
],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
},
|
|
finish: async (load, _item, retry) => {
|
|
count += 1
|
|
if (!retry) return
|
|
return {
|
|
retry,
|
|
spec: load.spec,
|
|
}
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(wait).toBe(1)
|
|
expect(count).toBe(2)
|
|
expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live("does not wait or retry npm plugin failures", () =>
|
|
Effect.gen(function* () {
|
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
|
let wait = 0
|
|
const errors: Array<[string, boolean]> = []
|
|
|
|
try {
|
|
const loaded = yield* Effect.promise(() =>
|
|
PluginLoader.loadExternal({
|
|
items: [
|
|
{
|
|
spec: "acme-plugin@1.0.0",
|
|
scope: "local" as const,
|
|
source: "test",
|
|
},
|
|
],
|
|
kind: "tui",
|
|
wait: async () => {
|
|
wait += 1
|
|
},
|
|
report: {
|
|
error(_candidate, retry, stage) {
|
|
errors.push([stage, retry])
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
|
|
expect(loaded).toEqual([])
|
|
expect(wait).toBe(0)
|
|
expect(errors).toEqual([["install", false]])
|
|
} finally {
|
|
install.mockRestore()
|
|
}
|
|
}),
|
|
)
|
|
})
|