import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" 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.KILO_DISABLE_DEFAULT_PLUGINS process.env.KILO_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") const { Bus } = await import("../../src/bus") const { Session } = await import("../../src/session") afterAll(() => { if (disableDefault === undefined) { delete process.env.KILO_DISABLE_DEFAULT_PLUGINS return } process.env.KILO_DISABLE_DEFAULT_PLUGINS = disableDefault }) afterEach(async () => { await Instance.disposeAll() }) async function load(dir: string) { return Instance.provide({ directory: dir, fn: async () => { await Plugin.list() }, }) } async function errs(dir: string) { return Instance.provide({ directory: dir, fn: async () => { const errors: string[] = [] const off = Bus.subscribe(Session.Event.Error, (evt) => { const error = evt.properties.error if (!error || typeof error !== "object") return if (!("data" in error)) return if (!error.data || typeof error.data !== "object") return if (!("message" in error.data)) return if (typeof error.data.message !== "string") return errors.push(error.data.message) }) await Plugin.list() off() return errors }, }) } 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 } }, }) const errors = await errs(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) expect(errors.some((x) => x.includes("must export id"))).toBe(true) }) 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 } }, }) const errors = await errs(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true) }) 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 { const errors = await errs(tmp.path) expect(errors).toHaveLength(0) 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 { const errors = await errs(tmp.path) expect(errors).toHaveLength(0) 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 { const errors = await errs(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) expect(errors).toHaveLength(0) } 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 { const errors = await errs(tmp.path) const called = await Bun.file(tmp.extra.mark) .text() .then(() => true) .catch(() => false) expect(called).toBe(false) expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true) } 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("publishes session.error when install fails", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2)) }, }) const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) try { const errors = await errs(tmp.path) expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe( true, ) } finally { install.mockRestore() } }) test("publishes session.error when plugin init throws", async () => { await using tmp = await tmpdir({ init: async (dir) => { const file = pathToFileURL(path.join(dir, "throws.ts")).href 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, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) return { file } }, }) const errors = await errs(tmp.path) expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true) }) test("publishes session.error when plugin module has invalid export", async () => { await using tmp = await tmpdir({ init: async (dir) => { const file = pathToFileURL(path.join(dir, "invalid.ts")).href await Bun.write( path.join(dir, "invalid.ts"), ["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"), ) await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2)) return { file } }, }) const errors = await errs(tmp.path) expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true) }) test("publishes session.error when plugin import fails", async () => { await using tmp = await tmpdir({ init: async (dir) => { const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2)) return { missing } }, }) const errors = await errs(tmp.path) expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true) }) 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.KILO_PURE process.env.KILO_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.KILO_PURE } else { process.env.KILO_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>(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>(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() } }) })