| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306 |
- import { afterEach, describe, expect, spyOn } from "bun:test"
- import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
- import { Effect, Layer } from "effect"
- import fs from "fs/promises"
- import path from "path"
- import { pathToFileURL } from "url"
- import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
- import { FSUtil } from "@kirincode-ai/core/fs-util"
- import { Config } from "@/config/config"
- import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture"
- import { testEffect } from "../lib/effect"
- const { Plugin } = await import("../../src/plugin/index")
- const { PluginLoader } = await import("../../src/plugin/loader")
- const { readPackageThemes } = await import("../../src/plugin/shared")
- const { Npm } = await import("@kirincode-ai/core/npm")
- const { TestConfig } = await import("../fixture/config")
- const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
- afterEach(async () => {
- await disposeAllInstances()
- })
- const it = testEffect(
- Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer),
- )
- 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, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
- const source = path.join(dir, "kirincode.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(
- LayerNode.compile(Plugin.node, [
- [
- Config.node,
- TestConfig.layer({
- get: () =>
- Effect.succeed({
- plugin: plugins,
- plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
- }),
- directories: () => Effect.succeed([dir]),
- }),
- ],
- [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })],
- ]),
- ),
- 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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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, "kirincode.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* (yield* FSUtil.Service).readJson(tmp.extra.mark)) as { source: string; enabled: boolean },
- ).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, "kirincode.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, "kirincode.json"),
- JSON.stringify({ plugin: [pathToFileURL(file).href] }, null, 2),
- )
- return { mark }
- },
- (tmp) =>
- Effect.gen(function* () {
- yield* load(tmp.path, { pure: true })
- const called = yield* Effect.promise(() =>
- fs
- .readFile(tmp.extra.mark, "utf8")
- .then(() => true)
- .catch(() => false),
- )
- expect(called).toBe(false)
- }),
- ),
- )
- 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 fsys = yield* FSUtil.Service
- const json = (yield* fsys.readJson(file)) as Record<string, unknown>
- const list = readPackageThemes("acme-plugin", {
- dir: tmp.extra.mod,
- pkg: file,
- json,
- })
- expect(list).toEqual([
- FSUtil.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
- FSUtil.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: [FSUtil.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: [FSUtil.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 fsys = yield* FSUtil.Service
- const json = (yield* fsys.readJson(tmp.extra.file)) as Record<string, unknown>
- 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("does not retry permanent file plugin entry errors", () =>
- withTmp(
- async (dir) => {
- const mod = path.join(dir, "bad-entry")
- const spec = pathToFileURL(mod).href
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify({ exports: { "./tui": "../outside.js" } }, null, 2),
- )
- return { spec }
- },
- (tmp) =>
- Effect.gen(function* () {
- let wait = 0
- const errors: Array<[string, boolean]> = []
- 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
- },
- report: {
- error(_candidate, retry, stage) {
- errors.push([stage, retry])
- },
- },
- }),
- )
- expect(loaded).toEqual([])
- expect(wait).toBe(0)
- expect(errors).toEqual([["entry", false]])
- }),
- ),
- )
- it.live("does not retry 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 () => {
- count += 1
- },
- }),
- )
- expect(wait).toBe(0)
- expect(count).toBe(1)
- expect(loaded).toEqual([])
- }),
- ),
- )
- 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()
- }
- }),
- )
- })
|