chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||
|
||||
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
|
||||
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import { createPluginKeybind } from "../../../src/cli/cmd/tui/context/plugin-keybinds"
|
||||
|
||||
describe("createPluginKeybind", () => {
|
||||
const defaults = {
|
||||
open: "ctrl+o",
|
||||
close: "escape",
|
||||
}
|
||||
|
||||
test("uses defaults when overrides are missing", () => {
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults)
|
||||
|
||||
expect(bind.all).toEqual(defaults)
|
||||
expect(bind.get("open")).toBe("ctrl+o")
|
||||
expect(bind.get("close")).toBe("escape")
|
||||
})
|
||||
|
||||
test("applies valid overrides", () => {
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: "ctrl+alt+o",
|
||||
close: "q",
|
||||
})
|
||||
|
||||
expect(bind.all).toEqual({
|
||||
open: "ctrl+alt+o",
|
||||
close: "q",
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores invalid overrides", () => {
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: " ",
|
||||
close: 1,
|
||||
extra: "ctrl+x",
|
||||
})
|
||||
|
||||
expect(bind.all).toEqual(defaults)
|
||||
expect(bind.get("extra")).toBe("extra")
|
||||
})
|
||||
|
||||
test("resolves names for match", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
match: (key: string) => {
|
||||
list.push(key)
|
||||
return true
|
||||
},
|
||||
print: (key: string) => key,
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
open: "ctrl+shift+o",
|
||||
})
|
||||
|
||||
bind.match("open", { name: "x" } as ParsedKey)
|
||||
bind.match("ctrl+k", { name: "x" } as ParsedKey)
|
||||
|
||||
expect(list).toEqual(["ctrl+shift+o", "ctrl+k"])
|
||||
})
|
||||
|
||||
test("resolves names for print", () => {
|
||||
const list: string[] = []
|
||||
const api = {
|
||||
match: () => false,
|
||||
print: (key: string) => {
|
||||
list.push(key)
|
||||
return `print:${key}`
|
||||
},
|
||||
}
|
||||
const bind = createPluginKeybind(api, defaults, {
|
||||
close: "q",
|
||||
})
|
||||
|
||||
expect(bind.print("close")).toBe("print:q")
|
||||
expect(bind.print("ctrl+p")).toBe("print:ctrl+p")
|
||||
expect(list).toEqual(["q", "ctrl+p"])
|
||||
})
|
||||
})
|
||||
111
qimingcode/packages/opencode/test/cli/tui/plugin-add.test.ts
Normal file
111
qimingcode/packages/opencode/test/cli/tui/plugin-add.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
test("adds tui plugin at runtime from spec", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "add-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "add.txt")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.add",
|
||||
tui: async () => {
|
||||
await Bun.write(${JSON.stringify(marker)}, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi(),
|
||||
config,
|
||||
})
|
||||
|
||||
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add")).toEqual({
|
||||
id: "demo.add",
|
||||
source: "file",
|
||||
spec: tmp.extra.spec,
|
||||
target: tmp.extra.spec,
|
||||
enabled: true,
|
||||
active: true,
|
||||
})
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
test("retries runtime add for file plugins after dependency wait", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const mod = path.join(dir, "retry-plugin")
|
||||
const spec = pathToFileURL(mod).href
|
||||
const marker = path.join(dir, "retry-add.txt")
|
||||
await fs.mkdir(mod, { recursive: true })
|
||||
return { mod, spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockImplementation(async () => {
|
||||
await Bun.write(
|
||||
path.join(tmp.extra.mod, "index.ts"),
|
||||
`export default {
|
||||
id: "demo.add.retry",
|
||||
tui: async () => {
|
||||
await Bun.write(${JSON.stringify(tmp.extra.marker)}, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
})
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi(),
|
||||
config,
|
||||
})
|
||||
|
||||
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("called")
|
||||
expect(wait).toHaveBeenCalledTimes(1)
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.add.retry")?.active).toBe(true)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
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/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
test("installs plugin without loading it", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "install-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "install.txt")
|
||||
|
||||
await Bun.write(
|
||||
path.join(dir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "demo-install-plugin",
|
||||
type: "module",
|
||||
exports: {
|
||||
"./tui": {
|
||||
import: "./install-plugin.ts",
|
||||
config: { marker },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.install",
|
||||
tui: async (_api, options) => {
|
||||
if (!options?.marker) return
|
||||
await Bun.write(options.marker, "loaded")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [],
|
||||
plugin_origins: undefined,
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const api = createTuiPluginApi({
|
||||
state: {
|
||||
path: {
|
||||
state: path.join(tmp.path, "state.json"),
|
||||
config: path.join(tmp.path, "tui.json"),
|
||||
worktree: tmp.path,
|
||||
directory: tmp.path,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api, config })
|
||||
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
||||
expect(out).toMatchObject({
|
||||
ok: true,
|
||||
tui: true,
|
||||
})
|
||||
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||
await expect(TuiPluginRuntime.addPlugin(tmp.extra.spec)).resolves.toBe(true)
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("loaded")
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
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 { mockTuiRuntime } from "../../fixture/tui-runtime"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
test("runs onDispose callbacks with aborted signal and is idempotent", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "marker.txt")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.lifecycle",
|
||||
tui: async (api, options) => {
|
||||
api.event.on("event.test", () => {})
|
||||
api.route.register([{ name: "lifecycle.route", render: () => null }])
|
||||
api.lifecycle.onDispose(async () => {
|
||||
const prev = await Bun.file(options.marker).text().catch(() => "")
|
||||
await Bun.write(options.marker, prev + "custom\\n")
|
||||
})
|
||||
api.lifecycle.onDispose(async () => {
|
||||
const prev = await Bun.file(options.marker).text().catch(() => "")
|
||||
await Bun.write(options.marker, prev + "aborted:" + String(api.lifecycle.signal.aborted) + "\\n")
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
const { config, restore } = mockTuiRuntime(tmp.path, [[tmp.extra.spec, { marker: tmp.extra.marker }]])
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
await TuiPluginRuntime.dispose()
|
||||
|
||||
const marker = await fs.readFile(tmp.extra.marker, "utf8")
|
||||
expect(marker).toContain("custom")
|
||||
expect(marker).toContain("aborted:true")
|
||||
|
||||
// second dispose is a no-op
|
||||
await TuiPluginRuntime.dispose()
|
||||
const after = await fs.readFile(tmp.extra.marker, "utf8")
|
||||
expect(after).toBe(marker)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("rolls back failed plugin and continues loading next", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const bad = path.join(dir, "bad-plugin.ts")
|
||||
const good = path.join(dir, "good-plugin.ts")
|
||||
const badSpec = pathToFileURL(bad).href
|
||||
const goodSpec = pathToFileURL(good).href
|
||||
const badMarker = path.join(dir, "bad-cleanup.txt")
|
||||
const goodMarker = path.join(dir, "good-called.txt")
|
||||
|
||||
await Bun.write(
|
||||
bad,
|
||||
`export default {
|
||||
id: "demo.bad",
|
||||
tui: async (api, options) => {
|
||||
api.route.register([{ name: "bad.route", render: () => null }])
|
||||
api.lifecycle.onDispose(async () => {
|
||||
await Bun.write(options.bad_marker, "cleaned")
|
||||
})
|
||||
throw new Error("bad plugin")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
good,
|
||||
`export default {
|
||||
id: "demo.good",
|
||||
tui: async (_api, options) => {
|
||||
await Bun.write(options.good_marker, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { badSpec, goodSpec, badMarker, goodMarker }
|
||||
},
|
||||
})
|
||||
|
||||
const { config, restore } = mockTuiRuntime(tmp.path, [
|
||||
[tmp.extra.badSpec, { bad_marker: tmp.extra.badMarker }],
|
||||
[tmp.extra.goodSpec, { good_marker: tmp.extra.goodMarker }],
|
||||
])
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
// bad plugin's onDispose ran during rollback
|
||||
await expect(fs.readFile(tmp.extra.badMarker, "utf8")).resolves.toBe("cleaned")
|
||||
// good plugin still loaded
|
||||
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("assigns sequential slot ids scoped to plugin", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "slot-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "slot-setup.txt")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`import fs from "fs"
|
||||
|
||||
const mark = (label) => {
|
||||
fs.appendFileSync(${JSON.stringify(marker)}, label + "\\n")
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "demo.slot",
|
||||
tui: async (api) => {
|
||||
const one = api.slots.register({
|
||||
id: 1,
|
||||
setup: () => { mark("one") },
|
||||
slots: { home_logo() { return null } },
|
||||
})
|
||||
const two = api.slots.register({
|
||||
id: 2,
|
||||
setup: () => { mark("two") },
|
||||
slots: { home_bottom() { return null } },
|
||||
})
|
||||
mark("id:" + one)
|
||||
mark("id:" + two)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec, marker }
|
||||
},
|
||||
})
|
||||
|
||||
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
||||
const err = spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
|
||||
const marker = await fs.readFile(tmp.extra.marker, "utf8")
|
||||
expect(marker).toContain("one")
|
||||
expect(marker).toContain("two")
|
||||
expect(marker).toContain("id:demo.slot")
|
||||
expect(marker).toContain("id:demo.slot:1")
|
||||
|
||||
// no initialization failures
|
||||
const hit = err.mock.calls.find(
|
||||
(item) => typeof item[0] === "string" && item[0].includes("failed to initialize tui plugin"),
|
||||
)
|
||||
expect(hit).toBeUndefined()
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
err.mockRestore()
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
test(
|
||||
"times out hanging plugin cleanup on dispose",
|
||||
async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "timeout-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.timeout",
|
||||
tui: async (api) => {
|
||||
api.lifecycle.onDispose(() => new Promise(() => {}))
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
const { config, restore } = mockTuiRuntime(tmp.path, [tmp.extra.spec])
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
|
||||
const done = await new Promise<string>((resolve) => {
|
||||
const timer = setTimeout(() => resolve("timeout"), 7000)
|
||||
void TuiPluginRuntime.dispose().then(() => {
|
||||
clearTimeout(timer)
|
||||
resolve("done")
|
||||
})
|
||||
})
|
||||
expect(done).toBe("done")
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
restore()
|
||||
}
|
||||
},
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
@@ -0,0 +1,484 @@
|
||||
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/cli/cmd/tui/config/tui"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
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: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_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: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: 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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_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: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: 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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
// 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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_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: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: 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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_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: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: 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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
||||
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
warn.mockRestore()
|
||||
error.mockRestore()
|
||||
delete process.env.OPENCODE_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.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: 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({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_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.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: 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({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_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: "acme-plugin@1.0.0" }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [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(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: undefined })
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
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()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
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/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
test("skips external tui plugins in pure mode", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "called.txt")
|
||||
const meta = path.join(dir, "plugin-meta.json")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.pure",
|
||||
tui: async (_api, options) => {
|
||||
if (!options?.marker) return
|
||||
await Bun.write(options.marker, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec, marker, meta }
|
||||
},
|
||||
})
|
||||
|
||||
const pure = process.env.OPENCODE_PURE
|
||||
const meta = process.env.OPENCODE_PLUGIN_META_FILE
|
||||
process.env.OPENCODE_PURE = "1"
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = tmp.extra.meta
|
||||
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [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)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
if (pure === undefined) {
|
||||
delete process.env.OPENCODE_PURE
|
||||
} else {
|
||||
process.env.OPENCODE_PURE = pure
|
||||
}
|
||||
if (meta === undefined) {
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
} else {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = meta
|
||||
}
|
||||
}
|
||||
})
|
||||
816
qimingcode/packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
816
qimingcode/packages/opencode/test/cli/tui/plugin-loader.test.ts
Normal file
@@ -0,0 +1,816 @@
|
||||
import { beforeAll, describe, 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 { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
import { Filesystem } from "../../../src/util/"
|
||||
|
||||
const { allThemes, addTheme } = await import("../../../src/cli/cmd/tui/context/theme")
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
|
||||
type Data = {
|
||||
local: Row
|
||||
global: Row
|
||||
invalid: Row
|
||||
preloaded: Row
|
||||
fn_called: boolean
|
||||
local_installed: string
|
||||
global_installed: string
|
||||
preloaded_installed: string
|
||||
leaked_local_to_global: boolean
|
||||
leaked_global_to_local: boolean
|
||||
local_theme: string
|
||||
global_theme: string
|
||||
}
|
||||
|
||||
async function row(file: string): Promise<Row> {
|
||||
return Filesystem.readJson<Row>(file)
|
||||
}
|
||||
|
||||
async function load(): Promise<Data> {
|
||||
const stamp = Date.now()
|
||||
const globalConfigPath = path.join(Global.Path.config, "tui.json")
|
||||
const backup = await Bun.file(globalConfigPath)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const localPluginPath = path.join(dir, "local-plugin.ts")
|
||||
const invalidPluginPath = path.join(dir, "invalid-plugin.ts")
|
||||
const preloadedPluginPath = path.join(dir, "preloaded-plugin.ts")
|
||||
const globalPluginPath = path.join(dir, "global-plugin.ts")
|
||||
const localSpec = pathToFileURL(localPluginPath).href
|
||||
const invalidSpec = pathToFileURL(invalidPluginPath).href
|
||||
const preloadedSpec = pathToFileURL(preloadedPluginPath).href
|
||||
const globalSpec = pathToFileURL(globalPluginPath).href
|
||||
const localThemeFile = `local-theme-${stamp}.json`
|
||||
const invalidThemeFile = `invalid-theme-${stamp}.json`
|
||||
const globalThemeFile = `global-theme-${stamp}.json`
|
||||
const preloadedThemeFile = `preloaded-theme-${stamp}.json`
|
||||
const localThemeName = localThemeFile.replace(/\.json$/, "")
|
||||
const invalidThemeName = invalidThemeFile.replace(/\.json$/, "")
|
||||
const globalThemeName = globalThemeFile.replace(/\.json$/, "")
|
||||
const preloadedThemeName = preloadedThemeFile.replace(/\.json$/, "")
|
||||
const localThemePath = path.join(dir, localThemeFile)
|
||||
const invalidThemePath = path.join(dir, invalidThemeFile)
|
||||
const globalThemePath = path.join(dir, globalThemeFile)
|
||||
const preloadedThemePath = path.join(dir, preloadedThemeFile)
|
||||
const localDest = path.join(dir, ".opencode", "themes", localThemeFile)
|
||||
const globalDest = path.join(Global.Path.config, "themes", globalThemeFile)
|
||||
const preloadedDest = path.join(dir, ".opencode", "themes", preloadedThemeFile)
|
||||
const fnMarker = path.join(dir, "function-called.txt")
|
||||
const localMarker = path.join(dir, "local-called.json")
|
||||
const invalidMarker = path.join(dir, "invalid-called.json")
|
||||
const globalMarker = path.join(dir, "global-called.json")
|
||||
const preloadedMarker = path.join(dir, "preloaded-called.json")
|
||||
const localConfigPath = path.join(dir, "tui.json")
|
||||
|
||||
await Bun.write(localThemePath, JSON.stringify({ theme: { primary: "#101010" } }, null, 2))
|
||||
await Bun.write(invalidThemePath, "{ invalid json }")
|
||||
await Bun.write(globalThemePath, JSON.stringify({ theme: { primary: "#202020" } }, null, 2))
|
||||
await Bun.write(preloadedThemePath, JSON.stringify({ theme: { primary: "#f0f0f0" } }, null, 2))
|
||||
await Bun.write(preloadedDest, JSON.stringify({ theme: { primary: "#303030" } }, null, 2))
|
||||
|
||||
await Bun.write(
|
||||
localPluginPath,
|
||||
`export const ignored = async (_input, options) => {
|
||||
if (!options?.fn_marker) return
|
||||
await Bun.write(options.fn_marker, "called")
|
||||
}
|
||||
|
||||
export default {
|
||||
id: "demo.local",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.marker) return
|
||||
const cfg_theme = api.tuiConfig.theme
|
||||
const cfg_diff = api.tuiConfig.diff_style
|
||||
const cfg_speed = api.tuiConfig.scroll_speed
|
||||
const cfg_accel = api.tuiConfig.scroll_acceleration?.enabled
|
||||
const cfg_submit = api.tuiConfig.keybinds?.input_submit
|
||||
const key = api.keybind.create(
|
||||
{ modal: "ctrl+shift+m", screen: "ctrl+shift+o", close: "escape" },
|
||||
options.keybinds,
|
||||
)
|
||||
const kv_before = api.kv.get(options.kv_key, "missing")
|
||||
api.kv.set(options.kv_key, "stored")
|
||||
const kv_after = api.kv.get(options.kv_key, "missing")
|
||||
const diff = api.state.session.diff(options.session_id)
|
||||
const todo = api.state.session.todo(options.session_id)
|
||||
const lsp = api.state.lsp()
|
||||
const mcp = api.state.mcp()
|
||||
const depth_before = api.ui.dialog.depth
|
||||
const open_before = api.ui.dialog.open
|
||||
const size_before = api.ui.dialog.size
|
||||
api.ui.dialog.setSize("large")
|
||||
const size_after = api.ui.dialog.size
|
||||
api.ui.dialog.replace(() => null)
|
||||
const depth_after = api.ui.dialog.depth
|
||||
const open_after = api.ui.dialog.open
|
||||
api.ui.dialog.clear()
|
||||
const open_clear = api.ui.dialog.open
|
||||
const before = api.theme.has(options.theme_name)
|
||||
const set_missing = api.theme.set(options.theme_name)
|
||||
await api.theme.install(options.theme_path)
|
||||
const after = api.theme.has(options.theme_name)
|
||||
const set_installed = api.theme.set(options.theme_name)
|
||||
const first = await Bun.file(options.dest).text()
|
||||
await Bun.write(options.source, JSON.stringify({ theme: { primary: "#fefefe" } }, null, 2))
|
||||
await api.theme.install(options.theme_path)
|
||||
const second = await Bun.file(options.dest).text()
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
before,
|
||||
set_missing,
|
||||
after,
|
||||
set_installed,
|
||||
selected: api.theme.selected,
|
||||
same: first === second,
|
||||
key_modal: key.get("modal"),
|
||||
key_close: key.get("close"),
|
||||
key_unknown: key.get("ctrl+k"),
|
||||
key_print: key.print("modal"),
|
||||
kv_before,
|
||||
kv_after,
|
||||
kv_ready: api.kv.ready,
|
||||
diff_count: diff.length,
|
||||
diff_file: diff[0]?.file,
|
||||
todo_count: todo.length,
|
||||
todo_first: todo[0]?.content,
|
||||
lsp_count: lsp.length,
|
||||
mcp_count: mcp.length,
|
||||
mcp_first: mcp[0]?.name,
|
||||
depth_before,
|
||||
open_before,
|
||||
size_before,
|
||||
size_after,
|
||||
depth_after,
|
||||
open_after,
|
||||
open_clear,
|
||||
cfg_theme,
|
||||
cfg_diff,
|
||||
cfg_speed,
|
||||
cfg_accel,
|
||||
cfg_submit,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
invalidPluginPath,
|
||||
`export default {
|
||||
id: "demo.invalid",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.marker) return
|
||||
const before = api.theme.has(options.theme_name)
|
||||
const set_missing = api.theme.set(options.theme_name)
|
||||
await api.theme.install(options.theme_path)
|
||||
const after = api.theme.has(options.theme_name)
|
||||
const set_installed = api.theme.set(options.theme_name)
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
before,
|
||||
set_missing,
|
||||
after,
|
||||
set_installed,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
preloadedPluginPath,
|
||||
`export default {
|
||||
id: "demo.preloaded",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.marker) return
|
||||
const before = api.theme.has(options.theme_name)
|
||||
await api.theme.install(options.theme_path)
|
||||
const after = api.theme.has(options.theme_name)
|
||||
const text = await Bun.file(options.dest).text()
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
before,
|
||||
after,
|
||||
text,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
globalPluginPath,
|
||||
`export default {
|
||||
id: "demo.global",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.marker) return
|
||||
await api.theme.install(options.theme_path)
|
||||
const has = api.theme.has(options.theme_name)
|
||||
const set_installed = api.theme.set(options.theme_name)
|
||||
await Bun.write(
|
||||
options.marker,
|
||||
JSON.stringify({
|
||||
has,
|
||||
set_installed,
|
||||
selected: api.theme.selected,
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
globalConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [
|
||||
[globalSpec, { marker: globalMarker, theme_path: `./${globalThemeFile}`, theme_name: globalThemeName }],
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.write(
|
||||
localConfigPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [
|
||||
[
|
||||
localSpec,
|
||||
{
|
||||
fn_marker: fnMarker,
|
||||
marker: localMarker,
|
||||
source: localThemePath,
|
||||
dest: localDest,
|
||||
theme_path: `./${localThemeFile}`,
|
||||
theme_name: localThemeName,
|
||||
kv_key: "plugin_state_key",
|
||||
session_id: "ses_test",
|
||||
keybinds: {
|
||||
modal: "ctrl+alt+m",
|
||||
close: "q",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
invalidSpec,
|
||||
{
|
||||
marker: invalidMarker,
|
||||
theme_path: `./${invalidThemeFile}`,
|
||||
theme_name: invalidThemeName,
|
||||
},
|
||||
],
|
||||
[
|
||||
preloadedSpec,
|
||||
{
|
||||
marker: preloadedMarker,
|
||||
dest: preloadedDest,
|
||||
theme_path: `./${preloadedThemeFile}`,
|
||||
theme_name: preloadedThemeName,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
localThemeFile,
|
||||
invalidThemeFile,
|
||||
globalThemeFile,
|
||||
preloadedThemeFile,
|
||||
localThemeName,
|
||||
invalidThemeName,
|
||||
globalThemeName,
|
||||
preloadedThemeName,
|
||||
localDest,
|
||||
globalDest,
|
||||
preloadedDest,
|
||||
localPluginPath,
|
||||
invalidPluginPath,
|
||||
globalPluginPath,
|
||||
preloadedPluginPath,
|
||||
localSpec,
|
||||
invalidSpec,
|
||||
globalSpec,
|
||||
preloadedSpec,
|
||||
fnMarker,
|
||||
localMarker,
|
||||
invalidMarker,
|
||||
globalMarker,
|
||||
preloadedMarker,
|
||||
}
|
||||
},
|
||||
})
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
|
||||
try {
|
||||
expect(addTheme(tmp.extra.preloadedThemeName, { theme: { primary: "#303030" } })).toBe(true)
|
||||
|
||||
const localOpts = {
|
||||
fn_marker: tmp.extra.fnMarker,
|
||||
marker: tmp.extra.localMarker,
|
||||
source: path.join(tmp.path, tmp.extra.localThemeFile),
|
||||
dest: tmp.extra.localDest,
|
||||
theme_path: `./${tmp.extra.localThemeFile}`,
|
||||
theme_name: tmp.extra.localThemeName,
|
||||
kv_key: "plugin_state_key",
|
||||
session_id: "ses_test",
|
||||
keybinds: { modal: "ctrl+alt+m", close: "q" },
|
||||
}
|
||||
const invalidOpts = {
|
||||
marker: tmp.extra.invalidMarker,
|
||||
theme_path: `./${tmp.extra.invalidThemeFile}`,
|
||||
theme_name: tmp.extra.invalidThemeName,
|
||||
}
|
||||
const preloadedOpts = {
|
||||
marker: tmp.extra.preloadedMarker,
|
||||
dest: tmp.extra.preloadedDest,
|
||||
theme_path: `./${tmp.extra.preloadedThemeFile}`,
|
||||
theme_name: tmp.extra.preloadedThemeName,
|
||||
}
|
||||
const globalOpts = {
|
||||
marker: tmp.extra.globalMarker,
|
||||
theme_path: `./${tmp.extra.globalThemeFile}`,
|
||||
theme_name: tmp.extra.globalThemeName,
|
||||
}
|
||||
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [
|
||||
[tmp.extra.localSpec, localOpts],
|
||||
[tmp.extra.invalidSpec, invalidOpts],
|
||||
[tmp.extra.preloadedSpec, preloadedOpts],
|
||||
[tmp.extra.globalSpec, globalOpts],
|
||||
],
|
||||
plugin_origins: [
|
||||
{ spec: [tmp.extra.localSpec, localOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
{ spec: [tmp.extra.invalidSpec, invalidOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
{ spec: [tmp.extra.preloadedSpec, preloadedOpts], scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
{
|
||||
spec: [tmp.extra.globalSpec, globalOpts],
|
||||
scope: "global",
|
||||
source: path.join(Global.Path.config, "tui.json"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({
|
||||
tuiConfig: {
|
||||
theme: "smoke",
|
||||
diff_style: "stacked",
|
||||
scroll_speed: 1.5,
|
||||
scroll_acceleration: { enabled: true },
|
||||
keybinds: {
|
||||
input_submit: "ctrl+enter",
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
print: (key) => `print:${key}`,
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
diff(sessionID) {
|
||||
if (sessionID !== "ses_test") return []
|
||||
return [{ file: "src/app.ts", additions: 3, deletions: 1 }]
|
||||
},
|
||||
todo(sessionID) {
|
||||
if (sessionID !== "ses_test") return []
|
||||
return [{ content: "ship it", status: "pending" }]
|
||||
},
|
||||
},
|
||||
lsp() {
|
||||
return [{ id: "ts", root: "/tmp/project", status: "connected" }]
|
||||
},
|
||||
mcp() {
|
||||
return [{ name: "github", status: "connected" }]
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
has(name) {
|
||||
return allThemes()[name] !== undefined
|
||||
},
|
||||
},
|
||||
}),
|
||||
config,
|
||||
})
|
||||
const local = await row(tmp.extra.localMarker)
|
||||
const global = await row(tmp.extra.globalMarker)
|
||||
const invalid = await row(tmp.extra.invalidMarker)
|
||||
const preloaded = await row(tmp.extra.preloadedMarker)
|
||||
const fn_called = await fs
|
||||
.readFile(tmp.extra.fnMarker, "utf8")
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
const local_installed = await fs.readFile(tmp.extra.localDest, "utf8")
|
||||
const global_installed = await fs.readFile(tmp.extra.globalDest, "utf8")
|
||||
const preloaded_installed = await fs.readFile(tmp.extra.preloadedDest, "utf8")
|
||||
const leaked_local_to_global = await fs
|
||||
.stat(path.join(Global.Path.config, "themes", tmp.extra.localThemeFile))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
const leaked_global_to_local = await fs
|
||||
.stat(path.join(tmp.path, ".opencode", "themes", tmp.extra.globalThemeFile))
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
return {
|
||||
local,
|
||||
global,
|
||||
invalid,
|
||||
preloaded,
|
||||
fn_called,
|
||||
local_installed,
|
||||
global_installed,
|
||||
preloaded_installed,
|
||||
leaked_local_to_global,
|
||||
leaked_global_to_local,
|
||||
local_theme: tmp.extra.localThemeName,
|
||||
global_theme: tmp.extra.globalThemeName,
|
||||
}
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
if (backup === undefined) {
|
||||
await fs.rm(globalConfigPath, { force: true })
|
||||
} else {
|
||||
await Bun.write(globalConfigPath, backup)
|
||||
}
|
||||
await fs.rm(tmp.extra.globalDest, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
test("continues loading when a plugin is missing config metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const bad = path.join(dir, "missing-meta-plugin.ts")
|
||||
const good = path.join(dir, "next-plugin.ts")
|
||||
const bare = path.join(dir, "plain-plugin.ts")
|
||||
const badSpec = pathToFileURL(bad).href
|
||||
const goodSpec = pathToFileURL(good).href
|
||||
const bareSpec = pathToFileURL(bare).href
|
||||
const goodMarker = path.join(dir, "next-called.txt")
|
||||
const bareMarker = path.join(dir, "plain-called.txt")
|
||||
|
||||
for (const [file, id] of [
|
||||
[bad, "demo.missing-meta"],
|
||||
[good, "demo.next"],
|
||||
] as const) {
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "${id}",
|
||||
tui: async (_api, options) => {
|
||||
if (!options?.marker) return
|
||||
await Bun.write(options.marker, "called")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
await Bun.write(
|
||||
bare,
|
||||
`export default {
|
||||
id: "demo.plain",
|
||||
tui: async (_api, options) => {
|
||||
await Bun.write(${JSON.stringify(bareMarker)}, options === undefined ? "undefined" : "value")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { badSpec, goodSpec, bareSpec, goodMarker, bareMarker }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [
|
||||
[tmp.extra.badSpec, { marker: path.join(tmp.path, "bad.txt") }],
|
||||
[tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||
tmp.extra.bareSpec,
|
||||
],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [tmp.extra.goodSpec, { marker: tmp.extra.goodMarker }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
{
|
||||
spec: tmp.extra.bareSpec,
|
||||
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({ api: createTuiPluginApi(), config })
|
||||
// bad plugin was skipped (no metadata entry)
|
||||
await expect(fs.readFile(path.join(tmp.path, "bad.txt"), "utf8")).rejects.toThrow()
|
||||
// good plugin loaded fine
|
||||
await expect(fs.readFile(tmp.extra.goodMarker, "utf8")).resolves.toBe("called")
|
||||
// bare string spec gets undefined options
|
||||
await expect(fs.readFile(tmp.extra.bareMarker, "utf8")).resolves.toBe("undefined")
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
test("initializes external tui plugins in config order", async () => {
|
||||
const globalJson = path.join(Global.Path.config, "tui.json")
|
||||
const globalJsonc = path.join(Global.Path.config, "tui.jsonc")
|
||||
const backupJson = await Bun.file(globalJson)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
const backupJsonc = await Bun.file(globalJsonc)
|
||||
.text()
|
||||
.catch(() => undefined)
|
||||
|
||||
await fs.rm(globalJson, { force: true }).catch(() => {})
|
||||
await fs.rm(globalJsonc, { force: true }).catch(() => {})
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const a = path.join(dir, "order-a.ts")
|
||||
const b = path.join(dir, "order-b.ts")
|
||||
const aSpec = pathToFileURL(a).href
|
||||
const bSpec = pathToFileURL(b).href
|
||||
const marker = path.join(dir, "tui-order.txt")
|
||||
|
||||
await Bun.write(
|
||||
a,
|
||||
`import fs from "fs/promises"
|
||||
|
||||
export default {
|
||||
id: "demo.tui.order.a",
|
||||
tui: async () => {
|
||||
await fs.appendFile(${JSON.stringify(marker)}, "a-start\\n")
|
||||
await Bun.sleep(25)
|
||||
await fs.appendFile(${JSON.stringify(marker)}, "a-end\\n")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(
|
||||
b,
|
||||
`import fs from "fs/promises"
|
||||
|
||||
export default {
|
||||
id: "demo.tui.order.b",
|
||||
tui: async () => {
|
||||
await fs.appendFile(${JSON.stringify(marker)}, "b\\n")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: [aSpec, bSpec] }, null, 2))
|
||||
|
||||
return { marker }
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
const a = path.join(tmp.path, "order-a.ts")
|
||||
const b = path.join(tmp.path, "order-b.ts")
|
||||
const aSpec = pathToFileURL(a).href
|
||||
const bSpec = pathToFileURL(b).href
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [aSpec, bSpec],
|
||||
plugin_origins: [
|
||||
{ spec: aSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
{ spec: bSpec, scope: "local", source: path.join(tmp.path, "tui.json") },
|
||||
],
|
||||
}
|
||||
await TuiPluginRuntime.init({ api: createTuiPluginApi(), config })
|
||||
const lines = (await fs.readFile(tmp.extra.marker, "utf8")).trim().split("\n")
|
||||
expect(lines).toEqual(["a-start", "a-end", "b"])
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
|
||||
if (backupJson === undefined) {
|
||||
await fs.rm(globalJson, { force: true }).catch(() => {})
|
||||
} else {
|
||||
await Bun.write(globalJson, backupJson)
|
||||
}
|
||||
if (backupJsonc === undefined) {
|
||||
await fs.rm(globalJsonc, { force: true }).catch(() => {})
|
||||
} else {
|
||||
await Bun.write(globalJsonc, backupJsonc)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("tui.plugin.loader", () => {
|
||||
let data: Data
|
||||
|
||||
beforeAll(async () => {
|
||||
data = await load()
|
||||
})
|
||||
|
||||
test("passes keybind, kv, state, and dialog APIs to v1 plugins", () => {
|
||||
expect(data.local.key_modal).toBe("ctrl+alt+m")
|
||||
expect(data.local.key_close).toBe("q")
|
||||
expect(data.local.key_unknown).toBe("ctrl+k")
|
||||
expect(data.local.key_print).toBe("print:ctrl+alt+m")
|
||||
expect(data.local.kv_before).toBe("missing")
|
||||
expect(data.local.kv_after).toBe("stored")
|
||||
expect(data.local.kv_ready).toBe(true)
|
||||
expect(data.local.diff_count).toBe(1)
|
||||
expect(data.local.diff_file).toBe("src/app.ts")
|
||||
expect(data.local.todo_count).toBe(1)
|
||||
expect(data.local.todo_first).toBe("ship it")
|
||||
expect(data.local.lsp_count).toBe(1)
|
||||
expect(data.local.mcp_count).toBe(1)
|
||||
expect(data.local.mcp_first).toBe("github")
|
||||
expect(data.local.depth_before).toBe(0)
|
||||
expect(data.local.open_before).toBe(false)
|
||||
expect(data.local.size_before).toBe("medium")
|
||||
expect(data.local.size_after).toBe("large")
|
||||
expect(data.local.depth_after).toBe(1)
|
||||
expect(data.local.open_after).toBe(true)
|
||||
expect(data.local.open_clear).toBe(false)
|
||||
expect(data.local.cfg_theme).toBe("smoke")
|
||||
expect(data.local.cfg_diff).toBe("stacked")
|
||||
expect(data.local.cfg_speed).toBe(1.5)
|
||||
expect(data.local.cfg_accel).toBe(true)
|
||||
expect(data.local.cfg_submit).toBe("ctrl+enter")
|
||||
})
|
||||
|
||||
test("installs themes in the correct scope and remains resilient", () => {
|
||||
expect(data.local.before).toBe(false)
|
||||
expect(data.local.set_missing).toBe(false)
|
||||
expect(data.local.after).toBe(true)
|
||||
expect(data.local.set_installed).toBe(true)
|
||||
expect(data.local.selected).toBe(data.local_theme)
|
||||
expect(data.local.same).toBe(true)
|
||||
|
||||
expect(data.global.has).toBe(true)
|
||||
expect(data.global.set_installed).toBe(true)
|
||||
expect(data.global.selected).toBe(data.global_theme)
|
||||
|
||||
expect(data.invalid.before).toBe(false)
|
||||
expect(data.invalid.set_missing).toBe(false)
|
||||
expect(data.invalid.after).toBe(false)
|
||||
expect(data.invalid.set_installed).toBe(false)
|
||||
|
||||
expect(data.preloaded.before).toBe(true)
|
||||
expect(data.preloaded.after).toBe(true)
|
||||
expect(data.preloaded.text).toContain("#303030")
|
||||
expect(data.preloaded.text).not.toContain("#f0f0f0")
|
||||
|
||||
expect(data.fn_called).toBe(false)
|
||||
expect(data.local_installed).toContain("#101010")
|
||||
expect(data.local_installed).not.toContain("#fefefe")
|
||||
expect(data.global_installed).toContain("#202020")
|
||||
expect(data.preloaded_installed).toContain("#303030")
|
||||
expect(data.preloaded_installed).not.toContain("#f0f0f0")
|
||||
expect(data.leaked_local_to_global).toBe(false)
|
||||
expect(data.leaked_global_to_local).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("updates installed theme when plugin metadata changes", async () => {
|
||||
await using tmp = await tmpdir<{
|
||||
spec: string
|
||||
pluginPath: string
|
||||
themePath: string
|
||||
dest: string
|
||||
themeName: string
|
||||
}>({
|
||||
init: async (dir) => {
|
||||
const pluginPath = path.join(dir, "theme-update-plugin.ts")
|
||||
const spec = pathToFileURL(pluginPath).href
|
||||
const themeFile = "theme-update.json"
|
||||
const themePath = path.join(dir, themeFile)
|
||||
const dest = path.join(dir, ".opencode", "themes", themeFile)
|
||||
const themeName = themeFile.replace(/\.json$/, "")
|
||||
const configPath = path.join(dir, "tui.json")
|
||||
|
||||
await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2))
|
||||
await Bun.write(
|
||||
pluginPath,
|
||||
`export default {
|
||||
id: "demo.theme-update",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.theme_path) return
|
||||
await api.theme.install(options.theme_path)
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
await Bun.write(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: [[spec, { theme_path: `./${themeFile}` }]],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
spec,
|
||||
pluginPath,
|
||||
themePath,
|
||||
dest,
|
||||
themeName,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
|
||||
const mkApi = () =>
|
||||
createTuiPluginApi({
|
||||
theme: {
|
||||
has(name) {
|
||||
return allThemes()[name] !== undefined
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const mkConfig = (): TuiConfig.Info => ({
|
||||
plugin: [[tmp.extra.spec, { theme_path: `./theme-update.json` }]],
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [tmp.extra.spec, { theme_path: `./theme-update.json` }],
|
||||
scope: "local",
|
||||
source: path.join(tmp.path, "tui.json"),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
|
||||
await TuiPluginRuntime.dispose()
|
||||
await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111")
|
||||
|
||||
await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2))
|
||||
await Bun.write(
|
||||
tmp.extra.pluginPath,
|
||||
`export default {
|
||||
id: "demo.theme-update",
|
||||
tui: async (api, options) => {
|
||||
if (!options?.theme_path) return
|
||||
await api.theme.install(options.theme_path)
|
||||
},
|
||||
}
|
||||
// v2
|
||||
`,
|
||||
)
|
||||
const stamp = new Date(Date.now() + 10_000)
|
||||
await fs.utimes(tmp.extra.pluginPath, stamp, stamp)
|
||||
await fs.utimes(tmp.extra.themePath, stamp, stamp)
|
||||
|
||||
await TuiPluginRuntime.init({ api: mkApi(), config: mkConfig() })
|
||||
const text = await fs.readFile(tmp.extra.dest, "utf8")
|
||||
expect(text).toContain("#222222")
|
||||
expect(text).not.toContain("#111111")
|
||||
const list = await Filesystem.readJson<Record<string, { themes?: Record<string, { dest: string }> }>>(
|
||||
process.env.OPENCODE_PLUGIN_META_FILE!,
|
||||
)
|
||||
expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
157
qimingcode/packages/opencode/test/cli/tui/plugin-toggle.test.ts
Normal file
157
qimingcode/packages/opencode/test/cli/tui/plugin-toggle.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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/cli/cmd/tui/config/tui"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
test("toggles plugin runtime state by exported id", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "toggle-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "toggle.txt")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.toggle",
|
||||
tui: async (api, options) => {
|
||||
const text = await Bun.file(options.marker).text().catch(() => "")
|
||||
await Bun.write(options.marker, text + "start\\n")
|
||||
api.lifecycle.onDispose(async () => {
|
||||
const next = await Bun.file(options.marker).text().catch(() => "")
|
||||
await Bun.write(options.marker, next + "stop\\n")
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
spec,
|
||||
marker,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_enabled: {
|
||||
"demo.toggle": false,
|
||||
},
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [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 api = createTuiPluginApi()
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api, config })
|
||||
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.toggle")).toEqual({
|
||||
id: "demo.toggle",
|
||||
source: "file",
|
||||
spec: tmp.extra.spec,
|
||||
target: tmp.extra.spec,
|
||||
enabled: false,
|
||||
active: false,
|
||||
})
|
||||
|
||||
await expect(TuiPluginRuntime.activatePlugin("demo.toggle")).resolves.toBe(true)
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\n")
|
||||
expect(api.kv.get("plugin_enabled", {})).toEqual({
|
||||
"demo.toggle": true,
|
||||
})
|
||||
|
||||
await expect(TuiPluginRuntime.deactivatePlugin("demo.toggle")).resolves.toBe(true)
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("start\nstop\n")
|
||||
expect(api.kv.get("plugin_enabled", {})).toEqual({
|
||||
"demo.toggle": false,
|
||||
})
|
||||
|
||||
await expect(TuiPluginRuntime.activatePlugin("missing.id")).resolves.toBe(false)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
|
||||
test("kv plugin_enabled overrides tui config on startup", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "startup-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const marker = path.join(dir, "startup.txt")
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.startup",
|
||||
tui: async (_api, options) => {
|
||||
await Bun.write(options.marker, "on")
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return {
|
||||
spec,
|
||||
marker,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||
const config: TuiConfig.Info = {
|
||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
||||
plugin_enabled: {
|
||||
"demo.startup": false,
|
||||
},
|
||||
plugin_origins: [
|
||||
{
|
||||
spec: [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 api = createTuiPluginApi()
|
||||
api.kv.set("plugin_enabled", {
|
||||
"demo.startup": true,
|
||||
})
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({ api, config })
|
||||
|
||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).resolves.toBe("on")
|
||||
expect(TuiPluginRuntime.list().find((item) => item.id === "demo.startup")).toEqual({
|
||||
id: "demo.startup",
|
||||
source: "file",
|
||||
spec: tmp.extra.spec,
|
||||
target: tmp.extra.spec,
|
||||
enabled: true,
|
||||
active: true,
|
||||
})
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRevertDiffFiles } from "../../../src/cli/cmd/tui/util/revert-diff"
|
||||
|
||||
describe("revert diff", () => {
|
||||
test("prefers the actual file path over /dev/null for added and deleted files", () => {
|
||||
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 0000000..3b18e51
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1 @@
|
||||
+new content
|
||||
diff --git a/old.txt b/old.txt
|
||||
deleted file mode 100644
|
||||
index 3b18e51..0000000
|
||||
--- a/old.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-old content
|
||||
`)
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
filename: "new.txt",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
{
|
||||
filename: "old.txt",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
type Slots = {
|
||||
prompt: {}
|
||||
}
|
||||
|
||||
test("replace slot mounts plugin content once", async () => {
|
||||
let mounts = 0
|
||||
|
||||
const Probe = () => {
|
||||
onMount(() => {
|
||||
mounts += 1
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
|
||||
const App = () => {
|
||||
const renderer = useRenderer()
|
||||
const reg = createSolidSlotRegistry<Slots>(renderer, {})
|
||||
const Slot = createSlot(reg)
|
||||
|
||||
reg.register({
|
||||
id: "plugin",
|
||||
slots: {
|
||||
prompt() {
|
||||
return <Probe />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<box>
|
||||
<Slot name="prompt" mode="replace">
|
||||
<box />
|
||||
</Slot>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
await testRender(() => <App />)
|
||||
|
||||
expect(mounts).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
const { DEFAULT_THEMES, allThemes, addTheme, hasTheme, resolveTheme } = await import(
|
||||
"../../../src/cli/cmd/tui/context/theme"
|
||||
)
|
||||
|
||||
test("addTheme writes into module theme store", () => {
|
||||
const name = `plugin-theme-${Date.now()}`
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
|
||||
expect(allThemes()[name]).toBeDefined()
|
||||
})
|
||||
|
||||
test("addTheme keeps first theme for duplicate names", () => {
|
||||
const name = `plugin-theme-keep-${Date.now()}`
|
||||
const one = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const two = structuredClone(DEFAULT_THEMES.opencode)
|
||||
one.theme.primary = "#101010"
|
||||
two.theme.primary = "#fefefe"
|
||||
|
||||
expect(addTheme(name, one)).toBe(true)
|
||||
expect(addTheme(name, two)).toBe(false)
|
||||
|
||||
expect(allThemes()[name]).toBeDefined()
|
||||
expect(allThemes()[name]!.theme.primary).toBe("#101010")
|
||||
})
|
||||
|
||||
test("addTheme ignores entries without a theme object", () => {
|
||||
const name = `plugin-theme-invalid-${Date.now()}`
|
||||
expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false)
|
||||
expect(allThemes()[name]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("hasTheme checks theme presence", () => {
|
||||
const name = `plugin-theme-has-${Date.now()}`
|
||||
expect(hasTheme(name)).toBe(false)
|
||||
expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true)
|
||||
expect(hasTheme(name)).toBe(true)
|
||||
})
|
||||
|
||||
test("resolveTheme rejects circular color refs", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
item.defs = {
|
||||
...item.defs,
|
||||
one: "two",
|
||||
two: "one",
|
||||
}
|
||||
item.theme.primary = "one"
|
||||
|
||||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||
})
|
||||
119
qimingcode/packages/opencode/test/cli/tui/thread.test.ts
Normal file
119
qimingcode/packages/opencode/test/cli/tui/thread.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import * as App from "../../../src/cli/cmd/tui/app"
|
||||
import { Rpc } from "../../../src/util"
|
||||
import { UI } from "../../../src/cli/ui"
|
||||
import * as Timeout from "../../../src/util/timeout"
|
||||
import * as Network from "../../../src/cli/network"
|
||||
import * as Win32 from "../../../src/cli/cmd/tui/win32"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
|
||||
const stop = new Error("stop")
|
||||
const seen = {
|
||||
tui: [] as string[],
|
||||
}
|
||||
|
||||
function setup() {
|
||||
// Intentionally avoid mock.module() here: Bun keeps module overrides in cache
|
||||
// and mock.restore() does not reset mock.module values. If this switches back
|
||||
// to module mocks, later suites can see mocked @/config/tui and fail (e.g.
|
||||
// plugin-loader tests expecting real TuiConfig.waitForDependencies). See:
|
||||
// https://github.com/oven-sh/bun/issues/7823 and #12823.
|
||||
spyOn(App, "tui").mockImplementation(async (input) => {
|
||||
if (input.directory) seen.tui.push(input.directory)
|
||||
throw stop
|
||||
})
|
||||
spyOn(Rpc, "client").mockImplementation(() => ({
|
||||
call: async () => ({ url: "http://127.0.0.1" }) as never,
|
||||
on: () => () => {},
|
||||
}))
|
||||
spyOn(UI, "error").mockImplementation(() => {})
|
||||
spyOn(Timeout, "withTimeout").mockImplementation((input) => input)
|
||||
spyOn(Network, "resolveNetworkOptions").mockResolvedValue({
|
||||
mdns: false,
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
mdnsDomain: "opencode.local",
|
||||
cors: [],
|
||||
})
|
||||
spyOn(Win32, "win32DisableProcessedInput").mockImplementation(() => {})
|
||||
spyOn(Win32, "win32InstallCtrlCGuard").mockReturnValue(undefined)
|
||||
}
|
||||
|
||||
describe("tui thread", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
async function call(project?: string) {
|
||||
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
|
||||
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
|
||||
_: [],
|
||||
$0: "opencode",
|
||||
project,
|
||||
prompt: "hi",
|
||||
model: undefined,
|
||||
agent: undefined,
|
||||
session: undefined,
|
||||
continue: false,
|
||||
fork: false,
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
mdns: false,
|
||||
"mdns-domain": "opencode.local",
|
||||
mdnsDomain: "opencode.local",
|
||||
cors: [],
|
||||
}
|
||||
return TuiThreadCommand.handler(args)
|
||||
}
|
||||
|
||||
async function check(project?: string) {
|
||||
setup()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const cwd = process.cwd()
|
||||
const pwd = process.env.PWD
|
||||
const worker = globalThis.Worker
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
|
||||
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
|
||||
const type = process.platform === "win32" ? "junction" : "dir"
|
||||
seen.tui.length = 0
|
||||
await fs.symlink(tmp.path, link, type)
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
globalThis.Worker = class extends EventTarget {
|
||||
onerror = null
|
||||
onmessage = null
|
||||
onmessageerror = null
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
} as unknown as typeof Worker
|
||||
|
||||
try {
|
||||
process.chdir(tmp.path)
|
||||
process.env.PWD = link
|
||||
await expect(call(project)).rejects.toBe(stop)
|
||||
expect(seen.tui[0]).toBe(tmp.path)
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
if (pwd === undefined) delete process.env.PWD
|
||||
else process.env.PWD = pwd
|
||||
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY
|
||||
globalThis.Worker = worker
|
||||
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
test("uses the real cwd when PWD points at a symlink", async () => {
|
||||
await check()
|
||||
})
|
||||
|
||||
test("uses the real cwd after resolving a relative project from PWD", async () => {
|
||||
await check(".")
|
||||
})
|
||||
})
|
||||
426
qimingcode/packages/opencode/test/cli/tui/transcript.test.ts
Normal file
426
qimingcode/packages/opencode/test/cli/tui/transcript.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
formatAssistantHeader,
|
||||
formatMessage,
|
||||
formatPart,
|
||||
formatTranscript,
|
||||
} from "../../../src/cli/cmd/tui/util/transcript"
|
||||
import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
|
||||
const providers: Provider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
source: "api",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-sonnet-4-20250514",
|
||||
url: "https://example.com/claude-sonnet-4-20250514",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
name: "Claude Sonnet 4",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: true,
|
||||
video: false,
|
||||
pdf: true,
|
||||
},
|
||||
output: {
|
||||
text: true,
|
||||
audio: false,
|
||||
image: false,
|
||||
video: false,
|
||||
pdf: false,
|
||||
},
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
context: 200_000,
|
||||
output: 8_192,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2025-05-14",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
describe("transcript", () => {
|
||||
describe("formatAssistantHeader", () => {
|
||||
const baseMsg: AssistantMessage = {
|
||||
id: "msg_123",
|
||||
sessionID: "ses_123",
|
||||
role: "assistant",
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_parent",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000, completed: 1005400 },
|
||||
}
|
||||
|
||||
test("includes metadata when enabled", () => {
|
||||
const result = formatAssistantHeader(baseMsg, true)
|
||||
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514 · 5.4s)\n\n")
|
||||
})
|
||||
|
||||
test("uses model display name when available", () => {
|
||||
const result = formatAssistantHeader(baseMsg, true, providers)
|
||||
expect(result).toBe("## Assistant (Build · Claude Sonnet 4 · 5.4s)\n\n")
|
||||
})
|
||||
|
||||
test("excludes metadata when disabled", () => {
|
||||
const result = formatAssistantHeader(baseMsg, false)
|
||||
expect(result).toBe("## Assistant\n\n")
|
||||
})
|
||||
|
||||
test("handles missing completed time", () => {
|
||||
const msg = { ...baseMsg, time: { created: 1000000 } }
|
||||
const result = formatAssistantHeader(msg as AssistantMessage, true)
|
||||
expect(result).toBe("## Assistant (Build · claude-sonnet-4-20250514)\n\n")
|
||||
})
|
||||
|
||||
test("titlecases agent name", () => {
|
||||
const msg = { ...baseMsg, agent: "plan" }
|
||||
const result = formatAssistantHeader(msg, true)
|
||||
expect(result).toContain("Plan")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatPart", () => {
|
||||
const options = { thinking: true, toolDetails: true, assistantMetadata: true }
|
||||
|
||||
test("formats text part", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "text",
|
||||
text: "Hello world",
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toBe("Hello world\n\n")
|
||||
})
|
||||
|
||||
test("skips synthetic text parts", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "text",
|
||||
text: "Synthetic content",
|
||||
synthetic: true,
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("formats reasoning when thinking enabled", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "reasoning",
|
||||
text: "Let me think...",
|
||||
time: { start: 1000 },
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toBe("_Thinking:_\n\nLet me think...\n\n")
|
||||
})
|
||||
|
||||
test("skips reasoning when thinking disabled", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "reasoning",
|
||||
text: "Let me think...",
|
||||
time: { start: 1000 },
|
||||
}
|
||||
const result = formatPart(part, { ...options, thinking: false })
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
test("formats tool part with details", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "ls" },
|
||||
output: "file1.txt\nfile2.txt",
|
||||
title: "List files",
|
||||
metadata: {},
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toContain("**Tool: bash**")
|
||||
expect(result).toContain("**Input:**")
|
||||
expect(result).toContain('"command": "ls"')
|
||||
expect(result).toContain("**Output:**")
|
||||
expect(result).toContain("file1.txt")
|
||||
})
|
||||
|
||||
test("formats tool output containing triple backticks without breaking markdown", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "echo '```hello```'" },
|
||||
output: "```hello```",
|
||||
title: "Echo backticks",
|
||||
metadata: {},
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
// The tool header should not be inside a code block
|
||||
expect(result).toStartWith("**Tool: bash**\n")
|
||||
// Input and output should each be in their own code blocks
|
||||
expect(result).toContain("**Input:**\n```json")
|
||||
expect(result).toContain("**Output:**\n```\n```hello```\n```")
|
||||
})
|
||||
|
||||
test("formats tool part without details when disabled", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: "ls" },
|
||||
output: "file1.txt",
|
||||
title: "List files",
|
||||
metadata: {},
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, { ...options, toolDetails: false })
|
||||
expect(result).toContain("**Tool: bash**")
|
||||
expect(result).not.toContain("**Input:**")
|
||||
expect(result).not.toContain("**Output:**")
|
||||
})
|
||||
|
||||
test("formats tool error", () => {
|
||||
const part: Part = {
|
||||
id: "part_1",
|
||||
sessionID: "ses_123",
|
||||
messageID: "msg_123",
|
||||
type: "tool",
|
||||
callID: "call_1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: "invalid" },
|
||||
error: "Command failed",
|
||||
time: { start: 1000, end: 1100 },
|
||||
},
|
||||
}
|
||||
const result = formatPart(part, options)
|
||||
expect(result).toContain("**Error:**")
|
||||
expect(result).toContain("Command failed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatMessage", () => {
|
||||
const options = { thinking: true, toolDetails: true, assistantMetadata: true, providers }
|
||||
|
||||
test("formats user message", () => {
|
||||
const msg: UserMessage = {
|
||||
id: "msg_123",
|
||||
sessionID: "ses_123",
|
||||
role: "user",
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
time: { created: 1000000 },
|
||||
}
|
||||
const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hello" }]
|
||||
const result = formatMessage(msg, parts, options)
|
||||
expect(result).toContain("## User")
|
||||
expect(result).toContain("Hello")
|
||||
})
|
||||
|
||||
test("formats assistant message with metadata", () => {
|
||||
const msg: AssistantMessage = {
|
||||
id: "msg_123",
|
||||
sessionID: "ses_123",
|
||||
role: "assistant",
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_parent",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000, completed: 1005400 },
|
||||
}
|
||||
const parts: Part[] = [{ id: "p1", sessionID: "ses_123", messageID: "msg_123", type: "text", text: "Hi there" }]
|
||||
const result = formatMessage(msg, parts, options)
|
||||
expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 5.4s)")
|
||||
expect(result).toContain("Hi there")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTranscript", () => {
|
||||
test("formats complete transcript", () => {
|
||||
const session = {
|
||||
id: "ses_abc123",
|
||||
title: "Test Session",
|
||||
time: { created: 1000000000000, updated: 1000000001000 },
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_abc123",
|
||||
role: "user" as const,
|
||||
agent: "build",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" },
|
||||
time: { created: 1000000000000 },
|
||||
},
|
||||
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Hello" }],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_2",
|
||||
sessionID: "ses_abc123",
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_1",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000000100, completed: 1000000000600 },
|
||||
},
|
||||
parts: [{ id: "p2", sessionID: "ses_abc123", messageID: "msg_2", type: "text" as const, text: "Hi!" }],
|
||||
},
|
||||
]
|
||||
const options = {
|
||||
thinking: false,
|
||||
toolDetails: false,
|
||||
assistantMetadata: true,
|
||||
providers,
|
||||
}
|
||||
|
||||
const result = formatTranscript(session, messages, options)
|
||||
|
||||
expect(result).toContain("# Test Session")
|
||||
expect(result).toContain("**Session ID:** ses_abc123")
|
||||
expect(result).toContain("## User")
|
||||
expect(result).toContain("Hello")
|
||||
expect(result).toContain("## Assistant (Build · Claude Sonnet 4 · 0.5s)")
|
||||
expect(result).toContain("Hi!")
|
||||
expect(result).toContain("---")
|
||||
})
|
||||
|
||||
test("falls back to raw model id when provider data is missing", () => {
|
||||
const session = {
|
||||
id: "ses_abc123",
|
||||
title: "Test Session",
|
||||
time: { created: 1000000000000, updated: 1000000001000 },
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_abc123",
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_0",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000000100, completed: 1000000000600 },
|
||||
},
|
||||
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = formatTranscript(session, messages, {
|
||||
thinking: false,
|
||||
toolDetails: false,
|
||||
assistantMetadata: true,
|
||||
})
|
||||
|
||||
expect(result).toContain("## Assistant (Build · claude-sonnet-4-20250514 · 0.5s)")
|
||||
})
|
||||
|
||||
test("formats transcript without assistant metadata", () => {
|
||||
const session = {
|
||||
id: "ses_abc123",
|
||||
title: "Test Session",
|
||||
time: { created: 1000000000000, updated: 1000000001000 },
|
||||
}
|
||||
const messages = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_abc123",
|
||||
role: "assistant" as const,
|
||||
agent: "build",
|
||||
modelID: "claude-sonnet-4-20250514",
|
||||
providerID: "anthropic",
|
||||
mode: "",
|
||||
parentID: "msg_0",
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
cost: 0.001,
|
||||
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1000000000100, completed: 1000000000600 },
|
||||
},
|
||||
parts: [{ id: "p1", sessionID: "ses_abc123", messageID: "msg_1", type: "text" as const, text: "Response" }],
|
||||
},
|
||||
]
|
||||
const options = { thinking: false, toolDetails: false, assistantMetadata: false }
|
||||
|
||||
const result = formatTranscript(session, messages, options)
|
||||
|
||||
expect(result).toContain("## Assistant\n\n")
|
||||
expect(result).not.toContain("Build")
|
||||
expect(result).not.toContain("claude-sonnet-4-20250514")
|
||||
})
|
||||
})
|
||||
})
|
||||
175
qimingcode/packages/opencode/test/cli/tui/use-event.test.tsx
Normal file
175
qimingcode/packages/opencode/test/cli/tui/use-event.test.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { Event, GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { onMount } from "solid-js"
|
||||
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
|
||||
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
|
||||
import { useEvent } from "../../../src/cli/cmd/tui/context/event"
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
function event(payload: Event, input: { directory: string; workspace?: string }): GlobalEvent {
|
||||
return {
|
||||
directory: input.directory,
|
||||
workspace: input.workspace,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
function vcs(branch: string): Event {
|
||||
return {
|
||||
type: "vcs.branch.updated",
|
||||
properties: {
|
||||
branch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function update(version: string): Event {
|
||||
return {
|
||||
type: "installation.update-available",
|
||||
properties: {
|
||||
version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSource() {
|
||||
let fn: ((event: GlobalEvent) => void) | undefined
|
||||
|
||||
return {
|
||||
source: {
|
||||
subscribe: async (handler: (event: GlobalEvent) => void) => {
|
||||
fn = handler
|
||||
return () => {
|
||||
if (fn === handler) fn = undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
emit(evt: GlobalEvent) {
|
||||
if (!fn) throw new Error("event source not ready")
|
||||
fn(evt)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function mount() {
|
||||
const source = createSource()
|
||||
const seen: Event[] = []
|
||||
let project!: ReturnType<typeof useProject>
|
||||
let done!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
|
||||
const app = await testRender(() => (
|
||||
<SDKProvider url="http://test" directory="/tmp/root" events={source.source}>
|
||||
<ProjectProvider>
|
||||
<Probe
|
||||
onReady={(ctx) => {
|
||||
project = ctx.project
|
||||
done()
|
||||
}}
|
||||
seen={seen}
|
||||
/>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
))
|
||||
|
||||
await ready
|
||||
return { app, emit: source.emit, project, seen }
|
||||
}
|
||||
|
||||
function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType<typeof useProject> }) => void }) {
|
||||
const project = useProject()
|
||||
const event = useEvent()
|
||||
|
||||
onMount(() => {
|
||||
event.subscribe((evt) => {
|
||||
props.seen.push(evt)
|
||||
})
|
||||
props.onReady({ project })
|
||||
})
|
||||
|
||||
return <box />
|
||||
}
|
||||
|
||||
describe("useEvent", () => {
|
||||
test("delivers matching directory events without an active workspace", async () => {
|
||||
const { app, emit, seen } = await mount()
|
||||
|
||||
try {
|
||||
emit(event(vcs("main"), { directory: "/tmp/root" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("main")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores non-matching directory events without an active workspace", async () => {
|
||||
const { app, emit, seen } = await mount()
|
||||
|
||||
try {
|
||||
emit(event(vcs("other"), { directory: "/tmp/other" }))
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(seen).toHaveLength(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("delivers matching workspace events when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(vcs("ws"), { directory: "/tmp/other", workspace: "ws_a" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([vcs("ws")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores non-matching workspace events when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(vcs("ws"), { directory: "/tmp/root", workspace: "ws_b" }))
|
||||
await Bun.sleep(30)
|
||||
|
||||
expect(seen).toHaveLength(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("delivers truly global events even when a workspace is active", async () => {
|
||||
const { app, emit, project, seen } = await mount()
|
||||
|
||||
try {
|
||||
project.workspace.set("ws_a")
|
||||
emit(event(update("1.2.3"), { directory: "global" }))
|
||||
|
||||
await wait(() => seen.length === 1)
|
||||
|
||||
expect(seen).toEqual([update("1.2.3")])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user