test(plugin): migrate loader shared tests to Effect runner (#27129)
This commit is contained in:
@@ -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 fs from "fs/promises"
|
||||
import path from "path"
|
||||
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"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
@@ -28,11 +30,27 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
async function load(dir: string) {
|
||||
const source = path.join(dir, "opencode.json")
|
||||
const config = (await Bun.file(source).json()) as { plugin?: Array<string | [string, Record<string, unknown>]> }
|
||||
const plugins = config.plugin ?? []
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
|
||||
function withTmp<T, A, E, R>(
|
||||
init: (dir: string) => Promise<T>,
|
||||
body: (tmp: { path: string; extra: T }) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const extra = yield* Effect.promise(() => init(dir))
|
||||
return yield* body({ path: dir, extra })
|
||||
})
|
||||
}
|
||||
|
||||
function load(dir: string) {
|
||||
const source = path.join(dir, "opencode.json")
|
||||
return Effect.gen(function* () {
|
||||
const config = yield* Effect.promise(
|
||||
() => Bun.file(source).json() as Promise<{ plugin?: Array<string | [string, Record<string, unknown>]> }>,
|
||||
)
|
||||
const plugins = config.plugin ?? []
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
yield* plugin.list()
|
||||
}).pipe(
|
||||
@@ -52,14 +70,14 @@ async function load(dir: string) {
|
||||
),
|
||||
),
|
||||
provideInstance(dir),
|
||||
Effect.runPromise,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
describe("plugin.loader.shared", () => {
|
||||
test("loads a file:// plugin function export", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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(
|
||||
@@ -80,15 +98,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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, "")
|
||||
@@ -113,15 +133,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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(
|
||||
@@ -149,15 +171,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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(
|
||||
@@ -180,20 +204,24 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* load(tmp.path)
|
||||
const called = yield* Effect.promise(() =>
|
||||
Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.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) => {
|
||||
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(
|
||||
@@ -218,20 +246,24 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* load(tmp.path)
|
||||
const called = yield* Effect.promise(() =>
|
||||
Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.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) => {
|
||||
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 })
|
||||
@@ -254,26 +286,28 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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 {
|
||||
await load(tmp.path)
|
||||
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()
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("loads npm server plugin from package ./server export", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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 })
|
||||
@@ -317,21 +351,23 @@ describe("plugin.loader.shared", () => {
|
||||
mark,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||
yield* load(tmp.path)
|
||||
expect(yield* Effect.promise(() => 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) => {
|
||||
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")
|
||||
@@ -374,21 +410,23 @@ describe("plugin.loader.shared", () => {
|
||||
mark,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||
yield* load(tmp.path)
|
||||
expect(yield* Effect.promise(() => 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) => {
|
||||
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")
|
||||
@@ -426,21 +464,23 @@ describe("plugin.loader.shared", () => {
|
||||
mark,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||
yield* load(tmp.path)
|
||||
expect(yield* Effect.promise(() => 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) => {
|
||||
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 })
|
||||
@@ -471,26 +511,30 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
return { mod, mark }
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
yield* load(tmp.path)
|
||||
const called = yield* Effect.promise(() =>
|
||||
Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.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) => {
|
||||
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")
|
||||
@@ -534,25 +578,29 @@ describe("plugin.loader.shared", () => {
|
||||
mark,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const called = await Bun.file(tmp.extra.mark)
|
||||
yield* load(tmp.path)
|
||||
const called = yield* Effect.promise(() =>
|
||||
Bun.file(tmp.extra.mark)
|
||||
.text()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.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) => {
|
||||
it.live("skips legacy codex and copilot auth plugin specs", () =>
|
||||
withTmp(
|
||||
async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify(
|
||||
@@ -564,12 +612,12 @@ describe("plugin.loader.shared", () => {
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
(_tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: "", entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
yield* load(_tmp.path)
|
||||
|
||||
const pkgs = install.mock.calls.map((call) => call[0])
|
||||
expect(pkgs).toContain("regular-plugin@1.0.0")
|
||||
@@ -578,11 +626,13 @@ describe("plugin.loader.shared", () => {
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("skips broken plugin when install fails", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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(
|
||||
@@ -604,22 +654,24 @@ describe("plugin.loader.shared", () => {
|
||||
)
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
yield* load(tmp.path)
|
||||
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 {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("continues loading plugins when plugin init throws", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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")
|
||||
@@ -653,15 +705,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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")
|
||||
@@ -687,15 +741,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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")
|
||||
@@ -716,15 +772,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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(
|
||||
@@ -749,15 +807,17 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
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)
|
||||
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) => {
|
||||
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(
|
||||
@@ -782,18 +842,20 @@ describe("plugin.loader.shared", () => {
|
||||
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
expect(await Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark)).toEqual({
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* load(tmp.path)
|
||||
expect(yield* Effect.promise(() => Filesystem.readJson<{ source: string; enabled: boolean }>(tmp.extra.mark))).toEqual({
|
||||
source: "tuple",
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("initializes server plugins in config order", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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")
|
||||
@@ -833,16 +895,18 @@ export default {
|
||||
|
||||
return { marker }
|
||||
},
|
||||
})
|
||||
|
||||
await load(tmp.path)
|
||||
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
|
||||
(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"])
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("skips external plugins in pure mode", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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(
|
||||
@@ -866,17 +930,19 @@ export default {
|
||||
|
||||
return { mark }
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const pure = process.env.OPENCODE_PURE
|
||||
process.env.OPENCODE_PURE = "1"
|
||||
|
||||
try {
|
||||
await load(tmp.path)
|
||||
const called = await fs
|
||||
yield* load(tmp.path)
|
||||
const called = yield* Effect.promise(() =>
|
||||
fs
|
||||
.readFile(tmp.extra.mark, "utf8")
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
.catch(() => false),
|
||||
)
|
||||
expect(called).toBe(false)
|
||||
} finally {
|
||||
if (pure === undefined) {
|
||||
@@ -885,11 +951,13 @@ export default {
|
||||
process.env.OPENCODE_PURE = pure
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("reads oc-themes from package manifest", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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(
|
||||
@@ -907,10 +975,10 @@ export default {
|
||||
|
||||
return { mod }
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
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", {
|
||||
dir: tmp.extra.mod,
|
||||
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", "two.json")),
|
||||
])
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("handles no-entrypoint tui packages via missing callback", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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(
|
||||
@@ -943,13 +1013,14 @@ export default {
|
||||
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 = await PluginLoader.loadExternal({
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
@@ -973,7 +1044,8 @@ export default {
|
||||
missing.push(message)
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(loaded).toEqual([
|
||||
{
|
||||
@@ -986,11 +1058,13 @@ export default {
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("passes package metadata for entrypoint tui plugins", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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(
|
||||
@@ -1012,12 +1086,13 @@ export default {
|
||||
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 = await PluginLoader.loadExternal({
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
@@ -1033,7 +1108,8 @@ export default {
|
||||
themes: readPackageThemes(item.spec, item.pkg),
|
||||
}
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(loaded).toEqual([
|
||||
{
|
||||
@@ -1044,20 +1120,22 @@ export default {
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("rejects oc-themes path traversal", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
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 }
|
||||
},
|
||||
})
|
||||
|
||||
const json = await Filesystem.readJson<Record<string, unknown>>(tmp.extra.file)
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const json = yield* Effect.promise(() => Filesystem.readJson<Record<string, unknown>>(tmp.extra.file))
|
||||
expect(() =>
|
||||
readPackageThemes("acme", {
|
||||
dir: tmp.extra.mod,
|
||||
@@ -1065,11 +1143,13 @@ export default {
|
||||
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) => {
|
||||
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
|
||||
@@ -1078,12 +1158,13 @@ export default {
|
||||
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 = await PluginLoader.loadExternal({
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
PluginLoader.loadExternal({
|
||||
items: [tmp.extra.aSpec, tmp.extra.bSpec].map((spec) => ({
|
||||
spec,
|
||||
scope: "local" as const,
|
||||
@@ -1100,7 +1181,8 @@ export default {
|
||||
calls.push([candidate.plan.spec, retry])
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(wait).toBe(1)
|
||||
expect(calls).toEqual([
|
||||
@@ -1110,22 +1192,25 @@ export default {
|
||||
[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) => {
|
||||
it.live("retries file plugins when finish returns undefined", () =>
|
||||
withTmp(
|
||||
async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
await Bun.write(file, "export default {}\n")
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
let wait = 0
|
||||
let count = 0
|
||||
|
||||
const loaded = await PluginLoader.loadExternal({
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: tmp.extra.spec,
|
||||
@@ -1145,20 +1230,25 @@ export default {
|
||||
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 () => {
|
||||
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 = await PluginLoader.loadExternal({
|
||||
const loaded = yield* Effect.promise(() =>
|
||||
PluginLoader.loadExternal({
|
||||
items: [
|
||||
{
|
||||
spec: "acme-plugin@1.0.0",
|
||||
@@ -1175,7 +1265,8 @@ export default {
|
||||
errors.push([stage, retry])
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(loaded).toEqual([])
|
||||
expect(wait).toBe(0)
|
||||
@@ -1183,5 +1274,6 @@ export default {
|
||||
} finally {
|
||||
install.mockRestore()
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user