|
| 1 | +import { expect, test } from "bun:test" |
| 2 | +import type { ComposerTab } from "@opencode/plugin/tui/context" |
| 3 | +import { createPluginContext, type Registry, type RegistryKind, type usePluginHost } from "../src/plugin/api" |
| 4 | +import { createAppFixture } from "./fixture/app" |
| 5 | +import { copyPlugin } from "./fixture/fixture" |
| 6 | +import { directory, json } from "./fixture/tui-client" |
| 7 | + |
| 8 | +type Host = ReturnType<typeof usePluginHost> |
| 9 | + |
| 10 | +function setup() { |
| 11 | + const entries = new Map<string, unknown>() |
| 12 | + const registry: Registry = { |
| 13 | + has: (kind: RegistryKind, name: string) => entries.has(`${kind}:${name}`), |
| 14 | + set: (kind: RegistryKind, name: string, value: unknown) => void entries.set(`${kind}:${name}`, value), |
| 15 | + remove: (kind: RegistryKind, name: string) => void entries.delete(`${kind}:${name}`), |
| 16 | + active: () => true, |
| 17 | + } |
| 18 | + const host = { |
| 19 | + app: { version: "test", channel: "test" }, |
| 20 | + client: { api: {} }, |
| 21 | + keymap: {}, |
| 22 | + shortcuts: {}, |
| 23 | + keymapState: {}, |
| 24 | + sessionTabs: {}, |
| 25 | + } as unknown as Host |
| 26 | + const context = createPluginContext({ host, id: "test", options: undefined, owned: [], registry }) |
| 27 | + return { entries, composerTab: context.ui.composerTab } |
| 28 | +} |
| 29 | + |
| 30 | +const tab = (id: string): ComposerTab => ({ id, title: "Probe", render: () => null }) |
| 31 | + |
| 32 | +test("registers a tab under its id until the cleanup runs", () => { |
| 33 | + const harness = setup() |
| 34 | + const dispose = harness.composerTab(tab("probe")) |
| 35 | + expect(harness.entries.get("composer:probe")).toMatchObject({ id: "probe", title: "Probe" }) |
| 36 | + dispose() |
| 37 | + expect(harness.entries.has("composer:probe")).toBe(false) |
| 38 | +}) |
| 39 | + |
| 40 | +test.each(["subagents", "shell", "terminals"])("refuses the host's own tab id %j", (id) => { |
| 41 | + expect(() => setup().composerTab(tab(id))).toThrow("Composer tab id is the host's own") |
| 42 | +}) |
| 43 | + |
| 44 | +test.each(["", "a b", "x/y"])("refuses the id %j", (id) => { |
| 45 | + expect(() => setup().composerTab(tab(id))).toThrow("Composer tab id must be") |
| 46 | +}) |
| 47 | + |
| 48 | +test("refuses a second tab with one id from the same plugin", () => { |
| 49 | + const harness = setup() |
| 50 | + harness.composerTab(tab("probe")) |
| 51 | + expect(() => harness.composerTab(tab("probe"))).toThrow("Composer tab already registered: probe") |
| 52 | +}) |
| 53 | + |
| 54 | +const session = { |
| 55 | + id: "ses_composer_tab", |
| 56 | + title: "Composer tab", |
| 57 | + projectID: "proj_test", |
| 58 | + location: { directory }, |
| 59 | + cost: 0, |
| 60 | + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, |
| 61 | + time: { created: 0, updated: 0 }, |
| 62 | +} |
| 63 | +const messages = [{ type: "user", id: "msg_ask", text: "Fix the parser", time: { created: 1 } }] |
| 64 | + |
| 65 | +test("a plugin's tab follows the host's in the panel ↓ opens, its keys act while it is active, and it unmounts when the panel closes", async () => { |
| 66 | + const fixture = await copyPlugin("composer-tab") |
| 67 | + try { |
| 68 | + await using app = await createAppFixture({ |
| 69 | + width: 100, |
| 70 | + height: 30, |
| 71 | + config: { animations: false, tabs: { enabled: false }, plugins: [fixture.directory] }, |
| 72 | + args: { sessionID: session.id }, |
| 73 | + fetch: (url) => { |
| 74 | + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) |
| 75 | + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) |
| 76 | + if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages, cursor: {} }) |
| 77 | + if (/^\/api\/session\/[^/]+\/(inbox|permission)$/.test(url.pathname)) return json({ data: [] }) |
| 78 | + return undefined |
| 79 | + }, |
| 80 | + }) |
| 81 | + await app.ready |
| 82 | + await app.waitForFrame((frame) => frame.includes("Fix the parser")) |
| 83 | + app.mockInput.pressArrow("down") |
| 84 | + const panel = await app.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Probe")) |
| 85 | + const tabs = panel.split("\n").find((line) => line.includes("Subagents"))! |
| 86 | + expect(tabs.indexOf("Probe")).toBeGreaterThan(tabs.indexOf("Shell")) |
| 87 | + // Its content mounts only while it is the active tab. |
| 88 | + expect(panel).not.toContain("Probe for") |
| 89 | + for (let step = 0; step < 4 && !app.captureCharFrame().includes("Probe for"); step++) { |
| 90 | + app.mockInput.pressArrow("right") |
| 91 | + await Bun.sleep(50) |
| 92 | + } |
| 93 | + await app.waitForFrame((frame) => frame.includes(`Probe for ${session.id}: 0`) && frame.includes("count ctrl+d")) |
| 94 | + app.mockInput.pressKey("d", { ctrl: true }) |
| 95 | + await app.waitForFrame((frame) => frame.includes(`Probe for ${session.id}: 1`)) |
| 96 | + app.mockInput.pressEscape() |
| 97 | + await app.waitForFrame((frame) => !frame.includes("Probe for") && !frame.includes("Subagents")) |
| 98 | + } finally { |
| 99 | + await fixture[Symbol.asyncDispose]() |
| 100 | + } |
| 101 | +}) |
0 commit comments