|
@@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
|
|
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
|
|
const { Instance } = await import("../../src/project/instance")
|
|
const { Instance } = await import("../../src/project/instance")
|
|
|
const { Npm } = await import("../../src/npm")
|
|
const { Npm } = await import("../../src/npm")
|
|
|
-const { Bus } = await import("../../src/bus")
|
|
|
|
|
-const { Session } = await import("../../src/session")
|
|
|
|
|
|
|
|
|
|
afterAll(() => {
|
|
afterAll(() => {
|
|
|
if (disableDefault === undefined) {
|
|
if (disableDefault === undefined) {
|
|
@@ -37,27 +35,6 @@ async function load(dir: string) {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function errs(dir: string) {
|
|
|
|
|
- return Instance.provide({
|
|
|
|
|
- directory: dir,
|
|
|
|
|
- fn: async () => {
|
|
|
|
|
- const errors: string[] = []
|
|
|
|
|
- const off = Bus.subscribe(Session.Event.Error, (evt) => {
|
|
|
|
|
- const error = evt.properties.error
|
|
|
|
|
- if (!error || typeof error !== "object") return
|
|
|
|
|
- if (!("data" in error)) return
|
|
|
|
|
- if (!error.data || typeof error.data !== "object") return
|
|
|
|
|
- if (!("message" in error.data)) return
|
|
|
|
|
- if (typeof error.data.message !== "string") return
|
|
|
|
|
- errors.push(error.data.message)
|
|
|
|
|
- })
|
|
|
|
|
- await Plugin.list()
|
|
|
|
|
- off()
|
|
|
|
|
- return errors
|
|
|
|
|
- },
|
|
|
|
|
- })
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
describe("plugin.loader.shared", () => {
|
|
describe("plugin.loader.shared", () => {
|
|
|
test("loads a file:// plugin function export", async () => {
|
|
test("loads a file:// plugin function export", async () => {
|
|
|
await using tmp = await tmpdir({
|
|
await using tmp = await tmpdir({
|
|
@@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => {
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
|
.text()
|
|
.text()
|
|
|
.then(() => true)
|
|
.then(() => true)
|
|
|
.catch(() => false)
|
|
.catch(() => false)
|
|
|
|
|
|
|
|
expect(called).toBe(false)
|
|
expect(called).toBe(false)
|
|
|
- expect(errors.some((x) => x.includes("must export id"))).toBe(true)
|
|
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
test("rejects v1 plugin that exports server and tui together", async () => {
|
|
test("rejects v1 plugin that exports server and tui together", async () => {
|
|
@@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => {
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
|
.text()
|
|
.text()
|
|
|
.then(() => true)
|
|
.then(() => true)
|
|
|
.catch(() => false)
|
|
.catch(() => false)
|
|
|
|
|
|
|
|
expect(called).toBe(false)
|
|
expect(called).toBe(false)
|
|
|
- expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
|
|
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
|
@@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
|
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
- expect(errors).toHaveLength(0)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
|
|
} finally {
|
|
} finally {
|
|
|
install.mockRestore()
|
|
install.mockRestore()
|
|
@@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
|
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
- expect(errors).toHaveLength(0)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
|
|
} finally {
|
|
} finally {
|
|
|
install.mockRestore()
|
|
install.mockRestore()
|
|
@@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
|
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
|
.text()
|
|
.text()
|
|
|
.then(() => true)
|
|
.then(() => true)
|
|
|
.catch(() => false)
|
|
.catch(() => false)
|
|
|
|
|
|
|
|
expect(called).toBe(false)
|
|
expect(called).toBe(false)
|
|
|
- expect(errors).toHaveLength(0)
|
|
|
|
|
} finally {
|
|
} finally {
|
|
|
install.mockRestore()
|
|
install.mockRestore()
|
|
|
}
|
|
}
|
|
@@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
|
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
const called = await Bun.file(tmp.extra.mark)
|
|
|
.text()
|
|
.text()
|
|
|
.then(() => true)
|
|
.then(() => true)
|
|
|
.catch(() => false)
|
|
.catch(() => false)
|
|
|
expect(called).toBe(false)
|
|
expect(called).toBe(false)
|
|
|
- expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
|
|
|
|
|
} finally {
|
|
} finally {
|
|
|
install.mockRestore()
|
|
install.mockRestore()
|
|
|
}
|
|
}
|
|
@@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => {
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- test("publishes session.error when install fails", async () => {
|
|
|
|
|
|
|
+ test("skips broken plugin when install fails", async () => {
|
|
|
await using tmp = await tmpdir({
|
|
await using tmp = await tmpdir({
|
|
|
init: async (dir) => {
|
|
init: async (dir) => {
|
|
|
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["[email protected]"] }, null, 2))
|
|
|
|
|
|
|
+ const ok = path.join(dir, "ok.ts")
|
|
|
|
|
+ const mark = path.join(dir, "ok.txt")
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ ok,
|
|
|
|
|
+ [
|
|
|
|
|
+ "export default {",
|
|
|
|
|
+ ' id: "demo.ok",',
|
|
|
|
|
+ " server: async () => {",
|
|
|
|
|
+ ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
|
|
|
+ " return {}",
|
|
|
|
|
+ " },",
|
|
|
|
|
+ "}",
|
|
|
|
|
+ "",
|
|
|
|
|
+ ].join("\n"),
|
|
|
|
|
+ )
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ path.join(dir, "opencode.json"),
|
|
|
|
|
+ JSON.stringify({ plugin: ["[email protected]", pathToFileURL(ok).href] }, null, 2),
|
|
|
|
|
+ )
|
|
|
|
|
+ return { mark }
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
-
|
|
|
|
|
- expect(errors.some((x) => x.includes("Failed to install plugin [email protected]") && x.includes("boom"))).toBe(
|
|
|
|
|
- true,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
|
|
+ expect(install).toHaveBeenCalledWith("[email protected]")
|
|
|
|
|
+ expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
|
|
} finally {
|
|
} finally {
|
|
|
install.mockRestore()
|
|
install.mockRestore()
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- test("publishes session.error when plugin init throws", async () => {
|
|
|
|
|
|
|
+ test("continues loading plugins when plugin init throws", async () => {
|
|
|
await using tmp = await tmpdir({
|
|
await using tmp = await tmpdir({
|
|
|
init: async (dir) => {
|
|
init: 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 mark = path.join(dir, "ok.txt")
|
|
|
await Bun.write(
|
|
await Bun.write(
|
|
|
path.join(dir, "throws.ts"),
|
|
path.join(dir, "throws.ts"),
|
|
|
[
|
|
[
|
|
@@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
|
|
|
"",
|
|
"",
|
|
|
].join("\n"),
|
|
].join("\n"),
|
|
|
)
|
|
)
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ path.join(dir, "ok.ts"),
|
|
|
|
|
+ [
|
|
|
|
|
+ "export default {",
|
|
|
|
|
+ ' id: "demo.ok",',
|
|
|
|
|
+ " server: async () => {",
|
|
|
|
|
+ ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
|
|
|
+ " return {}",
|
|
|
|
|
+ " },",
|
|
|
|
|
+ "}",
|
|
|
|
|
+ "",
|
|
|
|
|
+ ].join("\n"),
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
|
|
|
|
|
|
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
|
|
|
|
|
|
|
- return { file }
|
|
|
|
|
|
|
+ return { mark }
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
-
|
|
|
|
|
- expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
|
|
+ expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- test("publishes session.error when plugin module has invalid export", async () => {
|
|
|
|
|
|
|
+ test("continues loading plugins when plugin module has invalid export", async () => {
|
|
|
await using tmp = await tmpdir({
|
|
await using tmp = await tmpdir({
|
|
|
init: async (dir) => {
|
|
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 mark = path.join(dir, "ok.txt")
|
|
|
await Bun.write(
|
|
await Bun.write(
|
|
|
path.join(dir, "invalid.ts"),
|
|
path.join(dir, "invalid.ts"),
|
|
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
|
|
)
|
|
)
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ path.join(dir, "ok.ts"),
|
|
|
|
|
+ [
|
|
|
|
|
+ "export default {",
|
|
|
|
|
+ ' id: "demo.ok",',
|
|
|
|
|
+ " server: async () => {",
|
|
|
|
|
+ ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
|
|
|
+ " return {}",
|
|
|
|
|
+ " },",
|
|
|
|
|
+ "}",
|
|
|
|
|
+ "",
|
|
|
|
|
+ ].join("\n"),
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
|
|
|
|
|
|
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
|
|
|
|
|
|
|
- return { file }
|
|
|
|
|
|
|
+ return { mark }
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
-
|
|
|
|
|
- expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
|
|
+ expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- test("publishes session.error when plugin import fails", async () => {
|
|
|
|
|
|
|
+ test("continues loading plugins when plugin import fails", async () => {
|
|
|
await using tmp = await tmpdir({
|
|
await using tmp = await tmpdir({
|
|
|
init: async (dir) => {
|
|
init: async (dir) => {
|
|
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
|
|
- await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
|
|
|
|
|
|
|
+ const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
|
|
|
|
+ const mark = path.join(dir, "ok.txt")
|
|
|
|
|
+ await Bun.write(
|
|
|
|
|
+ path.join(dir, "ok.ts"),
|
|
|
|
|
+ [
|
|
|
|
|
+ "export default {",
|
|
|
|
|
+ ' id: "demo.ok",',
|
|
|
|
|
+ " server: async () => {",
|
|
|
|
|
+ ` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
|
|
|
|
+ " return {}",
|
|
|
|
|
+ " },",
|
|
|
|
|
+ "}",
|
|
|
|
|
+ "",
|
|
|
|
|
+ ].join("\n"),
|
|
|
|
|
+ )
|
|
|
|
|
+ await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
|
|
|
|
|
|
|
- return { missing }
|
|
|
|
|
|
|
+ return { mark }
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- const errors = await errs(tmp.path)
|
|
|
|
|
-
|
|
|
|
|
- expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
|
|
|
|
|
|
|
+ await load(tmp.path)
|
|
|
|
|
+ expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
test("loads object plugin via plugin.server", async () => {
|
|
test("loads object plugin via plugin.server", async () => {
|