| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492 |
- import { expect, spyOn, test } from "bun:test"
- import fs from "fs/promises"
- import path from "path"
- import { pathToFileURL } from "url"
- import { tmpdir } from "../../fixture/fixture"
- import { createTuiPluginApi } from "../../fixture/tui-plugin"
- import { TuiConfig } from "../../../src/config/tui"
- import { BunProc } from "../../../src/bun"
- const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
- test("loads npm tui plugin from package ./tui export", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const marker = path.join(dir, "tui-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" },
- }),
- )
- 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 {}\n")
- await Bun.write(
- path.join(mod, "tui.js"),
- `export default {
- id: "demo.tui.export",
- tui: async (_api, options) => {
- if (!options?.marker) return
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return { mod, marker, spec: "[email protected]" }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
- plugin_records: [
- {
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
- const hit = TuiPluginRuntime.list().find((item) => item.id === "demo.tui.export")
- expect(hit?.enabled).toBe(true)
- expect(hit?.active).toBe(true)
- expect(hit?.source).toBe("npm")
- } finally {
- await TuiPluginRuntime.dispose()
- install.mockRestore()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("does not use npm package exports dot for tui entry", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const marker = path.join(dir, "dot-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" },
- }),
- )
- await Bun.write(
- path.join(mod, "index.js"),
- `export default {
- id: "demo.dot",
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return { mod, marker, spec: "[email protected]" }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [tmp.extra.spec],
- plugin_records: [
- {
- item: tmp.extra.spec,
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
- } finally {
- await TuiPluginRuntime.dispose()
- install.mockRestore()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("rejects npm tui export that resolves outside plugin directory", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const outside = path.join(dir, "outside")
- const marker = path.join(dir, "outside-called.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", "./tui": "./escape/tui.js" },
- }),
- )
- await Bun.write(path.join(mod, "index.js"), "export default {}\n")
- await Bun.write(
- path.join(outside, "tui.js"),
- `export default {
- id: "demo.outside",
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "outside")
- },
- }
- `,
- )
- await fs.symlink(outside, path.join(mod, "escape"), process.platform === "win32" ? "junction" : "dir")
- return { mod, marker, spec: "[email protected]" }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [tmp.extra.spec],
- plugin_records: [
- {
- item: tmp.extra.spec,
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- // plugin code never ran
- await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
- // plugin not listed
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
- } finally {
- await TuiPluginRuntime.dispose()
- install.mockRestore()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("rejects npm tui plugin that exports server and tui together", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const marker = path.join(dir, "mixed-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", "./tui": "./tui.js" },
- }),
- )
- await Bun.write(path.join(mod, "index.js"), "export default {}\n")
- await Bun.write(
- path.join(mod, "tui.js"),
- `export default {
- id: "demo.mixed",
- server: async () => ({}),
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return { mod, marker, spec: "[email protected]" }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [tmp.extra.spec],
- plugin_records: [
- {
- item: tmp.extra.spec,
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
- } finally {
- await TuiPluginRuntime.dispose()
- install.mockRestore()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("does not use npm package main for tui entry", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const marker = path.join(dir, "main-called.txt")
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify({
- name: "acme-plugin",
- type: "module",
- main: "./index.js",
- }),
- )
- await Bun.write(
- path.join(mod, "index.js"),
- `export default {
- id: "demo.main",
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return { mod, marker, spec: "[email protected]" }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [tmp.extra.spec],
- plugin_records: [
- {
- item: tmp.extra.spec,
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
- const warn = spyOn(console, "warn").mockImplementation(() => {})
- const error = spyOn(console, "error").mockImplementation(() => {})
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
- expect(error).not.toHaveBeenCalled()
- expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
- } finally {
- await TuiPluginRuntime.dispose()
- install.mockRestore()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- warn.mockRestore()
- error.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("does not use directory package main for tui entry", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "dir-plugin")
- const spec = pathToFileURL(mod).href
- const marker = path.join(dir, "dir-main-called.txt")
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(
- path.join(mod, "package.json"),
- JSON.stringify({
- name: "dir-plugin",
- type: "module",
- main: "./main.js",
- }),
- )
- await Bun.write(
- path.join(mod, "main.js"),
- `export default {
- id: "demo.dir.main",
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return { marker, spec }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [tmp.extra.spec],
- plugin_records: [
- {
- item: tmp.extra.spec,
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
- expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
- } finally {
- await TuiPluginRuntime.dispose()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("uses directory index fallback for tui when package.json is missing", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "dir-index")
- const spec = pathToFileURL(mod).href
- const marker = path.join(dir, "dir-index-called.txt")
- await fs.mkdir(mod, { recursive: true })
- await Bun.write(
- path.join(mod, "index.ts"),
- `export default {
- id: "demo.dir.index",
- tui: async () => {
- await Bun.write(${JSON.stringify(marker)}, "called")
- },
- }
- `,
- )
- return { marker, spec }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [tmp.extra.spec],
- plugin_records: [
- {
- item: tmp.extra.spec,
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
- expect(TuiPluginRuntime.list().find((item) => item.id === "demo.dir.index")?.active).toBe(true)
- } finally {
- await TuiPluginRuntime.dispose()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
- test("uses npm package name when tui plugin id is omitted", async () => {
- await using tmp = await tmpdir({
- init: async (dir) => {
- const mod = path.join(dir, "mods", "acme-plugin")
- const marker = path.join(dir, "name-id-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", "./tui": "./tui.js" },
- }),
- )
- await Bun.write(path.join(mod, "index.js"), "export default {}\n")
- await Bun.write(
- path.join(mod, "tui.js"),
- `export default {
- tui: async (_api, options) => {
- if (!options?.marker) return
- await Bun.write(options.marker, "called")
- },
- }
- `,
- )
- return { mod, marker, spec: "[email protected]" }
- },
- })
- process.env.KILO_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
- const get = spyOn(TuiConfig, "get").mockResolvedValue({
- plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
- plugin_records: [
- {
- item: [tmp.extra.spec, { marker: tmp.extra.marker }],
- scope: "local",
- source: path.join(tmp.path, "tui.json"),
- },
- ],
- })
- const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
- const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
- const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
- try {
- await TuiPluginRuntime.init(createTuiPluginApi())
- await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
- expect(TuiPluginRuntime.list().find((item) => item.spec === tmp.extra.spec)?.id).toBe("acme-plugin")
- } finally {
- await TuiPluginRuntime.dispose()
- install.mockRestore()
- cwd.mockRestore()
- get.mockRestore()
- wait.mockRestore()
- delete process.env.KILO_PLUGIN_META_FILE
- }
- })
|