test(plugin): migrate loader shared tests to Effect runner (#27129)

This commit is contained in:
Kit Langton
2026-05-12 19:41:44 +00:00
committed by GitHub
parent fec78154b5
commit 71040c54aa
@@ -1,9 +1,11 @@
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" import { afterAll, afterEach, describe, expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" 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" import { Filesystem } from "@/util/filesystem"
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
@@ -28,11 +30,27 @@ afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
}) })
async function load(dir: string) { const it = testEffect(CrossSpawnSpawner.defaultLayer)
const source = path.join(dir, "opencode.json")
const config = (await Bun.file(source).json()) as { plugin?: Array<string | [string, Record<string, unknown>]> } function withTmp<T, A, E, R>(
const plugins = config.plugin ?? [] init: (dir: string) => Promise<T>,
body: (tmp: { path: string; extra: T }) => Effect.Effect<A, E, R>,
) {
return Effect.gen(function* () { 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 const plugin = yield* Plugin.Service
yield* plugin.list() yield* plugin.list()
}).pipe( }).pipe(
@@ -52,14 +70,14 @@ async function load(dir: string) {
), ),
), ),
provideInstance(dir), provideInstance(dir),
Effect.runPromise,
) )
})
} }
describe("plugin.loader.shared", () => { describe("plugin.loader.shared", () => {
test("loads a file:// plugin function export", async () => { it.live("loads a file:// plugin function export", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const file = path.join(dir, "plugin.ts") const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt") const mark = path.join(dir, "called.txt")
await Bun.write( await Bun.write(
@@ -80,15 +98,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
}),
),
)
await load(tmp.path) it.live("deduplicates same function exported as default and named", () =>
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") withTmp(
}) async (dir) => {
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 file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "count.txt") const mark = path.join(dir, "count.txt")
await Bun.write(mark, "") await Bun.write(mark, "")
@@ -113,15 +133,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("1")
}),
),
)
await load(tmp.path) it.live("uses only default v1 server plugin when present", () =>
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("1") withTmp(
}) async (dir) => {
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 file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "count.txt") const mark = path.join(dir, "count.txt")
await Bun.write( await Bun.write(
@@ -149,15 +171,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("default")
}),
),
)
await load(tmp.path) it.live("rejects v1 file server plugin without id", () =>
expect(await Bun.file(tmp.extra.mark).text()).toBe("default") withTmp(
}) async (dir) => {
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 file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt") const mark = path.join(dir, "called.txt")
await Bun.write( await Bun.write(
@@ -180,20 +204,24 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
await load(tmp.path) yield* load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false),
)
expect(called).toBe(false) expect(called).toBe(false)
}) }),
),
)
test("rejects v1 plugin that exports server and tui together", async () => { it.live("rejects v1 plugin that exports server and tui together", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const file = path.join(dir, "plugin.ts") const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt") const mark = path.join(dir, "called.txt")
await Bun.write( await Bun.write(
@@ -218,20 +246,24 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
await load(tmp.path) yield* load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false),
)
expect(called).toBe(false) expect(called).toBe(false)
}) }),
),
)
test("resolves npm plugin specs with explicit and default versions", async () => { it.live("resolves npm plugin specs with explicit and default versions", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const acme = path.join(dir, "node_modules", "acme-plugin") const acme = path.join(dir, "node_modules", "acme-plugin")
const scope = path.join(dir, "node_modules", "scope-plugin") const scope = path.join(dir, "node_modules", "scope-plugin")
await fs.mkdir(acme, { recursive: true }) await fs.mkdir(acme, { recursive: true })
@@ -254,26 +286,28 @@ describe("plugin.loader.shared", () => {
return { acme, scope } return { acme, scope }
}, },
}) (tmp) =>
Effect.gen(function* () {
const add = spyOn(Npm, "add").mockImplementation(async (pkg) => { const add = spyOn(Npm, "add").mockImplementation(async (pkg) => {
if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined } if (pkg === "acme-plugin") return { directory: tmp.extra.acme, entrypoint: undefined }
return { directory: tmp.extra.scope, entrypoint: undefined } return { directory: tmp.extra.scope, entrypoint: undefined }
}) })
try { try {
await load(tmp.path) yield* load(tmp.path)
expect(add.mock.calls).toContainEqual(["acme-plugin@latest"]) expect(add.mock.calls).toContainEqual(["acme-plugin@latest"])
expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"]) expect(add.mock.calls).toContainEqual(["scope-plugin@2.3.4"])
} finally { } finally {
add.mockRestore() add.mockRestore()
} }
}) }),
),
)
test("loads npm server plugin from package ./server export", async () => { it.live("loads npm server plugin from package ./server export", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
const mark = path.join(dir, "server-called.txt") const mark = path.join(dir, "server-called.txt")
await fs.mkdir(mod, { recursive: true }) await fs.mkdir(mod, { recursive: true })
@@ -317,21 +351,23 @@ describe("plugin.loader.shared", () => {
mark, mark,
} }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) yield* load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called") expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("loads npm server plugin from package server export without leading dot", async () => { it.live("loads npm server plugin from package server export without leading dot", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
const dist = path.join(mod, "dist") const dist = path.join(mod, "dist")
const mark = path.join(dir, "server-called.txt") const mark = path.join(dir, "server-called.txt")
@@ -374,21 +410,23 @@ describe("plugin.loader.shared", () => {
mark, mark,
} }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) yield* load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called") expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("loads npm server plugin from package main without leading dot", async () => { it.live("loads npm server plugin from package main without leading dot", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
const dist = path.join(mod, "dist") const dist = path.join(mod, "dist")
const mark = path.join(dir, "main-called.txt") const mark = path.join(dir, "main-called.txt")
@@ -426,21 +464,23 @@ describe("plugin.loader.shared", () => {
mark, mark,
} }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) yield* load(tmp.path)
expect(await Bun.file(tmp.extra.mark).text()).toBe("called") expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("called")
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("does not use npm package exports dot for server entry", async () => { it.live("does not use npm package exports dot for server entry", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
const mark = path.join(dir, "dot-server.txt") const mark = path.join(dir, "dot-server.txt")
await fs.mkdir(mod, { recursive: true }) await fs.mkdir(mod, { recursive: true })
@@ -471,26 +511,30 @@ describe("plugin.loader.shared", () => {
return { mod, mark } return { mod, mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) yield* load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false),
)
expect(called).toBe(false) expect(called).toBe(false)
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("rejects npm server export that resolves outside plugin directory", async () => { it.live("rejects npm server export that resolves outside plugin directory", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
const outside = path.join(dir, "outside") const outside = path.join(dir, "outside")
const mark = path.join(dir, "outside-server.txt") const mark = path.join(dir, "outside-server.txt")
@@ -534,25 +578,29 @@ describe("plugin.loader.shared", () => {
mark, mark,
} }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
await load(tmp.path) yield* load(tmp.path)
const called = await Bun.file(tmp.extra.mark) const called = yield* Effect.promise(() =>
Bun.file(tmp.extra.mark)
.text() .text()
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false),
)
expect(called).toBe(false) expect(called).toBe(false)
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("skips legacy codex and copilot auth plugin specs", async () => { it.live("skips legacy codex and copilot auth plugin specs", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
await Bun.write( await Bun.write(
path.join(dir, "opencode.json"), path.join(dir, "opencode.json"),
JSON.stringify( JSON.stringify(
@@ -564,12 +612,12 @@ describe("plugin.loader.shared", () => {
), ),
) )
}, },
}) (_tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
try { try {
await load(tmp.path) yield* load(_tmp.path)
const pkgs = install.mock.calls.map((call) => call[0]) const pkgs = install.mock.calls.map((call) => call[0])
expect(pkgs).toContain("regular-plugin@1.0.0") expect(pkgs).toContain("regular-plugin@1.0.0")
@@ -578,11 +626,13 @@ describe("plugin.loader.shared", () => {
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("skips broken plugin when install fails", async () => { it.live("skips broken plugin when install fails", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const ok = path.join(dir, "ok.ts") const ok = path.join(dir, "ok.ts")
const mark = path.join(dir, "ok.txt") const mark = path.join(dir, "ok.txt")
await Bun.write( await Bun.write(
@@ -604,22 +654,24 @@ describe("plugin.loader.shared", () => {
) )
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
try { try {
await load(tmp.path) yield* load(tmp.path)
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9") expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("continues loading plugins when plugin init throws", async () => { it.live("continues loading plugins when plugin init throws", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const file = pathToFileURL(path.join(dir, "throws.ts")).href const file = pathToFileURL(path.join(dir, "throws.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt") const mark = path.join(dir, "ok.txt")
@@ -653,15 +705,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
}),
),
)
await load(tmp.path) it.live("continues loading plugins when plugin module has invalid export", () =>
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") withTmp(
}) async (dir) => {
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 file = pathToFileURL(path.join(dir, "invalid.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt") const mark = path.join(dir, "ok.txt")
@@ -687,15 +741,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
}),
),
)
await load(tmp.path) it.live("continues loading plugins when plugin import fails", () =>
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") withTmp(
}) async (dir) => {
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 missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
const ok = pathToFileURL(path.join(dir, "ok.ts")).href const ok = pathToFileURL(path.join(dir, "ok.ts")).href
const mark = path.join(dir, "ok.txt") const mark = path.join(dir, "ok.txt")
@@ -716,15 +772,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => Bun.file(tmp.extra.mark).text())).toBe("ok")
}),
),
)
await load(tmp.path) it.live("loads object plugin via plugin.server", () =>
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok") withTmp(
}) async (dir) => {
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 file = path.join(dir, "object-plugin.ts")
const mark = path.join(dir, "object-called.txt") const mark = path.join(dir, "object-called.txt")
await Bun.write( await Bun.write(
@@ -749,15 +807,17 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
yield* load(tmp.path)
expect(yield* Effect.promise(() => fs.readFile(tmp.extra.mark, "utf8"))).toBe("called")
}),
),
)
await load(tmp.path) it.live("passes tuple plugin options into server plugin", () =>
expect(await fs.readFile(tmp.extra.mark, "utf8")).toBe("called") withTmp(
}) async (dir) => {
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 file = path.join(dir, "options-plugin.ts")
const mark = path.join(dir, "options.json") const mark = path.join(dir, "options.json")
await Bun.write( await Bun.write(
@@ -782,18 +842,20 @@ describe("plugin.loader.shared", () => {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
await load(tmp.path) yield* load(tmp.path)
expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({ expect(yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark))).toEqual({
source: "tuple", source: "tuple",
enabled: true, enabled: true,
}) })
}) }),
),
)
test("initializes server plugins in config order", async () => { it.live("initializes server plugins in config order", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const a = path.join(dir, "a-plugin.ts") const a = path.join(dir, "a-plugin.ts")
const b = path.join(dir, "b-plugin.ts") const b = path.join(dir, "b-plugin.ts")
const marker = path.join(dir, "server-order.txt") const marker = path.join(dir, "server-order.txt")
@@ -833,16 +895,18 @@ export default {
return { marker } return { marker }
}, },
}) (tmp) =>
Effect.gen(function* () {
await load(tmp.path) yield* load(tmp.path)
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n") const lines = (yield* Effect.promise(() => fs.readFile(tmp.extra.marker, "utf8"))).trim().split("\n")
expect(lines).toEqual(["a-start", "a-end", "b"]) expect(lines).toEqual(["a-start", "a-end", "b"])
}) }),
),
)
test("skips external plugins in pure mode", async () => { it.live("skips external plugins in pure mode", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const file = path.join(dir, "plugin.ts") const file = path.join(dir, "plugin.ts")
const mark = path.join(dir, "called.txt") const mark = path.join(dir, "called.txt")
await Bun.write( await Bun.write(
@@ -866,17 +930,19 @@ export default {
return { mark } return { mark }
}, },
}) (tmp) =>
Effect.gen(function* () {
const pure = process.env.OPENCODE_PURE const pure = process.env.OPENCODE_PURE
process.env.OPENCODE_PURE = "1" process.env.OPENCODE_PURE = "1"
try { try {
await load(tmp.path) yield* load(tmp.path)
const called = await fs const called = yield* Effect.promise(() =>
fs
.readFile(tmp.extra.mark, "utf8") .readFile(tmp.extra.mark, "utf8")
.then(() => true) .then(() => true)
.catch(() => false) .catch(() => false),
)
expect(called).toBe(false) expect(called).toBe(false)
} finally { } finally {
if (pure === undefined) { if (pure === undefined) {
@@ -885,11 +951,13 @@ export default {
process.env.OPENCODE_PURE = pure process.env.OPENCODE_PURE = pure
} }
} }
}) }),
),
)
test("reads oc-themes from package manifest", async () => { it.live("reads oc-themes from package manifest", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mod") const mod = path.join(dir, "mod")
await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await fs.mkdir(path.join(mod, "themes"), { recursive: true })
await Bun.write( await Bun.write(
@@ -907,10 +975,10 @@ export default {
return { mod } return { mod }
}, },
}) (tmp) =>
Effect.gen(function* () {
const file = path.join(tmp.extra.mod, "package.json") const file = path.join(tmp.extra.mod, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(file) const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(file))
const list = readPackageThemes("acme-plugin", { const list = readPackageThemes("acme-plugin", {
dir: tmp.extra.mod, dir: tmp.extra.mod,
pkg: file, pkg: file,
@@ -921,11 +989,13 @@ export default {
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")), Filesystem.resolve(path.join(tmp.extra.mod, "themes", "one.json")),
Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")), Filesystem.resolve(path.join(tmp.extra.mod, "themes", "two.json")),
]) ])
}) }),
),
)
test("handles no-entrypoint tui packages via missing callback", async () => { it.live("handles no-entrypoint tui packages via missing callback", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await fs.mkdir(path.join(mod, "themes"), { recursive: true })
await Bun.write( await Bun.write(
@@ -943,13 +1013,14 @@ export default {
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
return { mod } return { mod }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
const missing: string[] = [] const missing: string[] = []
try { try {
const loaded = await PluginLoader.loadExternal({ const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [ items: [
{ {
spec: "acme-plugin@1.0.0", spec: "acme-plugin@1.0.0",
@@ -973,7 +1044,8 @@ export default {
missing.push(message) missing.push(message)
}, },
}, },
}) }),
)
expect(loaded).toEqual([ expect(loaded).toEqual([
{ {
@@ -986,11 +1058,13 @@ export default {
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("passes package metadata for entrypoint tui plugins", async () => { it.live("passes package metadata for entrypoint tui plugins", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mods", "acme-plugin") const mod = path.join(dir, "mods", "acme-plugin")
await fs.mkdir(path.join(mod, "themes"), { recursive: true }) await fs.mkdir(path.join(mod, "themes"), { recursive: true })
await Bun.write( await Bun.write(
@@ -1012,12 +1086,13 @@ export default {
await Bun.write(path.join(mod, "themes", "night.json"), "{}\n") await Bun.write(path.join(mod, "themes", "night.json"), "{}\n")
return { mod } return { mod }
}, },
}) (tmp) =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined }) const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
try { try {
const loaded = await PluginLoader.loadExternal({ const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [ items: [
{ {
spec: "acme-plugin@1.0.0", spec: "acme-plugin@1.0.0",
@@ -1033,7 +1108,8 @@ export default {
themes: readPackageThemes(item.spec, item.pkg), themes: readPackageThemes(item.spec, item.pkg),
} }
}, },
}) }),
)
expect(loaded).toEqual([ expect(loaded).toEqual([
{ {
@@ -1044,20 +1120,22 @@ export default {
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
),
)
test("rejects oc-themes path traversal", async () => { it.live("rejects oc-themes path traversal", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const mod = path.join(dir, "mod") const mod = path.join(dir, "mod")
await fs.mkdir(mod, { recursive: true }) await fs.mkdir(mod, { recursive: true })
const file = path.join(mod, "package.json") const file = path.join(mod, "package.json")
await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2)) await Bun.write(file, JSON.stringify({ name: "acme", "oc-themes": ["../escape.json"] }, null, 2))
return { mod, file } return { mod, file }
}, },
}) (tmp) =>
Effect.gen(function* () {
const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file) const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(tmp.extra.file))
expect(() => expect(() =>
readPackageThemes("acme", { readPackageThemes("acme", {
dir: tmp.extra.mod, dir: tmp.extra.mod,
@@ -1065,11 +1143,13 @@ export default {
json, json,
}), }),
).toThrow("outside plugin directory") ).toThrow("outside plugin directory")
}) }),
),
)
test("retries failed file plugins once after wait and keeps order", async () => { it.live("retries failed file plugins once after wait and keeps order", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const a = path.join(dir, "a") const a = path.join(dir, "a")
const b = path.join(dir, "b") const b = path.join(dir, "b")
const aSpec = pathToFileURL(a).href const aSpec = pathToFileURL(a).href
@@ -1078,12 +1158,13 @@ export default {
await fs.mkdir(b, { recursive: true }) await fs.mkdir(b, { recursive: true })
return { a, b, aSpec, bSpec } return { a, b, aSpec, bSpec }
}, },
}) (tmp) =>
Effect.gen(function* () {
let wait = 0 let wait = 0
const calls: Array<[string, boolean]> = [] const calls: Array<[string, boolean]> = []
const loaded = await PluginLoader.loadExternal({ const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({ items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
spec, spec,
scope: "local" as const, scope: "local" as const,
@@ -1100,7 +1181,8 @@ export default {
calls.push([candidate.plan.spec, retry]) calls.push([candidate.plan.spec, retry])
}, },
}, },
}) }),
)
expect(wait).toBe(1) expect(wait).toBe(1)
expect(calls).toEqual([ expect(calls).toEqual([
@@ -1110,22 +1192,25 @@ export default {
[tmp.extra.bSpec, true], [tmp.extra.bSpec, true],
]) ])
expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec]) expect(loaded.map((item) => item.spec)).toEqual([tmp.extra.aSpec, tmp.extra.bSpec])
}) }),
),
)
test("retries file plugins when finish returns undefined", async () => { it.live("retries file plugins when finish returns undefined", () =>
await using tmp = await tmpdir({ withTmp(
init: async (dir) => { async (dir) => {
const file = path.join(dir, "plugin.ts") const file = path.join(dir, "plugin.ts")
const spec = pathToFileURL(file).href const spec = pathToFileURL(file).href
await Bun.write(file, "export default {}\n") await Bun.write(file, "export default {}\n")
return { spec } return { spec }
}, },
}) (tmp) =>
Effect.gen(function* () {
let wait = 0 let wait = 0
let count = 0 let count = 0
const loaded = await PluginLoader.loadExternal({ const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [ items: [
{ {
spec: tmp.extra.spec, spec: tmp.extra.spec,
@@ -1145,20 +1230,25 @@ export default {
spec: load.spec, spec: load.spec,
} }
}, },
}) }),
)
expect(wait).toBe(1) expect(wait).toBe(1)
expect(count).toBe(2) expect(count).toBe(2)
expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }]) expect(loaded).toEqual([{ retry: true, spec: tmp.extra.spec }])
}) }),
),
)
test("does not wait or retry npm plugin failures", async () => { it.live("does not wait or retry npm plugin failures", () =>
Effect.gen(function* () {
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom")) const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
let wait = 0 let wait = 0
const errors: Array<[string, boolean]> = [] const errors: Array<[string, boolean]> = []
try { try {
const loaded = await PluginLoader.loadExternal({ const loaded = yield* Effect.promise(() =>
PluginLoader.loadExternal({
items: [ items: [
{ {
spec: "acme-plugin@1.0.0", spec: "acme-plugin@1.0.0",
@@ -1175,7 +1265,8 @@ export default {
errors.push([stage, retry]) errors.push([stage, retry])
}, },
}, },
}) }),
)
expect(loaded).toEqual([]) expect(loaded).toEqual([])
expect(wait).toBe(0) expect(wait).toBe(0)
@@ -1183,5 +1274,6 @@ export default {
} finally { } finally {
install.mockRestore() install.mockRestore()
} }
}) }),
)
}) })