Files
opencode/packages/opencode/test/plugin/loader-shared.test.ts

1170 lines
33 KiB
TypeScript

import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { tmpdir } from "../fixture/fixture"
import { Filesystem } from "../../src/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 { Instance } = await import("../../src/project/instance")
const { Npm } = await import("../../src/npm")
afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
return
}
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
})
afterEach(async () => {
await Instance.disposeAll()
})
async function load(dir: string) {
return Instance.provide({
directory: dir,
fn: async () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
yield* plugin.list()
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
})
}
describe("plugin.loader.shared", () => {
test("loads a file:// plugin function export", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
})
test("deduplicates same function exported as default and named", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1")
})
test("uses only default v1 server plugin when present", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("default")
})
test("rejects v1 file server plugin without id", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
})
test("rejects v1 plugin that exports server and tui together", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
})
test("resolves npm plugin specs with explicit and default versions", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: tmp.extra.acme }
return { directory: tmp.extra.scope, entrypoint: tmp.extra.scope }
})
try {
await load(tmp.path)
expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
} finally {
add.mockRestore()
}
})
test("loads npm server plugin from package ./server export", async () => {
await using tmp = await tmpdir({
init: 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,
}
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
}
})
test("loads npm server plugin from package server export without leading dot", async () => {
await using tmp = await tmpdir({
init: 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,
}
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
}
})
test("loads npm server plugin from package main without leading dot", async () => {
await using tmp = await tmpdir({
init: 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,
}
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
} finally {
install.mockRestore()
}
})
test("does not use npm package exports dot for server entry", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
} finally {
install.mockRestore()
}
})
test("rejects npm server export that resolves outside plugin directory", async () => {
await using tmp = await tmpdir({
init: 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,
}
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
await load(tmp.path)
const called = await Bun.file(tmp.extra.mark)
.text()
.then(() => true)
.catch(() => false)
expect(called).toBe(false)
} finally {
install.mockRestore()
}
})
test("skips legacy codex and copilot auth plugin specs", async () => {
await using tmp = await tmpdir({
init: 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,
),
)
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: "" })
try {
await 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()
}
})
test("skips broken plugin when install fails", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
try {
await load(tmp.path)
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
} finally {
install.mockRestore()
}
})
test("continues loading plugins when plugin init throws", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
})
test("continues loading plugins when plugin module has invalid export", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
})
test("continues loading plugins when plugin import fails", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
})
test("loads object plugin via plugin.server", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called")
})
test("passes tuple plugin options into server plugin", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
source: "tuple",
enabled: true,
})
})
test("initializes server plugins in config order", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
await load(tmp.path)
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
expect(lines).toEqual(["a-start", "a-end", "b"])
})
test("skips external plugins in pure mode", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const pure = process.env.OPENCODE_PURE
process.env.OPENCODE_PURE = "1"
try {
await load(tmp.path)
const called = await 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
}
}
})
test("reads oc-themes from package manifest", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const file = path.join(tmp.extra.mod, "package.json")
const json = await 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")),
])
})
test("handles no-entrypoint tui packages via missing callback", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
const missing: string[] = []
try {
const loaded = await 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()
}
})
test("passes package metadata for entrypoint tui plugins", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
try {
const loaded = await 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()
}
})
test("rejects oc-themes path traversal", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
expect(() =>
readPackageThemes("acme", {
dir: tmp.extra.mod,
pkg: tmp.extra.file,
json,
}),
).toThrow("outside plugin directory")
})
test("retries failed file plugins once after wait and keeps order", async () => {
await using tmp = await tmpdir({
init: 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 }
},
})
let wait = 0
const calls: Array<[string, boolean]> = []
const loaded = await 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])
})
test("retries file plugins when finish returns undefined", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href
await Bun.write(file, "export default {}\n")
return { spec }
},
})
let wait = 0
let count = 0
const loaded = await 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 }])
})
test("does not wait or retry npm plugin failures", async () => {
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
let wait = 0
const errors: Array<[string, boolean]> = []
try {
const loaded = await 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()
}
})
})