import { expect, mock, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { Global } from "@opencode-ai/util/global" import { mkdir, readFile, symlink, writeFile } from "node:fs/promises" import path from "node:path" import { createEventStream, createFetch, json } from "./fixture/tui-client" import { tmpdir } from "./fixture/fixture" function lifecycleSource(marker: string, id: string, version: string) { return ` import { appendFile } from "node:fs/promises" export default { id: ${JSON.stringify(id)}, setup: async () => { await appendFile(${JSON.stringify(marker)}, "${version}:setup\\n") return () => appendFile(${JSON.stringify(marker)}, "${version}:cleanup\\n") }, } ` } async function until(read: () => Promise, expected: (value: string | undefined) => boolean) { let value: string | undefined for (let attempt = 0; attempt < 200; attempt++) { value = await read().catch(() => undefined) if (expected(value)) return value await Bun.sleep(50) } return value } async function bootApp(directory: string) { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const events = createEventStream() const calls = createFetch((url) => { if (url.pathname !== "/api/fs/list") return return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory }, }, data: [], }) }, events) const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) const cwd = process.cwd() process.chdir(directory) const { run } = await import("../src/app") const task = Effect.runPromise( run({ app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { get: async () => ({}), update: async () => ({}) }, packages: { resolve: async () => undefined }, args: {}, log: () => {}, }).pipe( Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })), Effect.provide(FileSystem.layerNoop({})), ), ) return { task, async [Symbol.asyncDispose]() { process.chdir(cwd) if (!setup.renderer.isDestroyed) setup.renderer.destroy() await server.stop() mock.restore() }, } } test("discovers an ancestor TUI plugin directory created after startup", async () => { await using tmp = await tmpdir() const cwd = path.join(tmp.path, "repo", "packages", "app") await mkdir(cwd, { recursive: true }) await mkdir(path.join(tmp.path, "repo", ".git")) const ready = path.join(tmp.path, "ready.txt") const marker = path.join(tmp.path, "marker.txt") const initial = path.join(cwd, ".opencode", "plugins", "tui") await mkdir(initial, { recursive: true }) await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready")) await using app = await bootApp(cwd) expect( await until( () => readFile(ready, "utf8"), (value) => value === "ready:setup\n", ), ).toBe("ready:setup\n") const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1")) expect( await until( () => readFile(marker, "utf8"), (value) => value === "v1:setup\n", ), ).toBe("v1:setup\n") process.emit("SIGHUP") await app.task }) test("editing a discovered TUI plugin hot-reloads its fresh module", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const marker = path.join(tmp.path, "marker.txt") const source = path.join(directory, "hot.ts") await writeFile(source, lifecycleSource(marker, "test.hot", "v1")) await using app = await bootApp(tmp.path) const read = () => readFile(marker, "utf8") expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n") await writeFile(source, lifecycleSource(marker, "test.hot", "v2")) expect(await until(read, (value) => value?.includes("v2:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv2:setup\n") process.emit("SIGHUP") await app.task }) test("a plugin whose slot render throws does not take down the TUI", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const markerA = path.join(tmp.path, "a.txt") const markerCrash = path.join(tmp.path, "crash.txt") const sourceA = path.join(directory, "a.ts") await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1")) await writeFile( path.join(directory, "crash.ts"), ` import { appendFile } from "node:fs/promises" export default { id: "test.crash", setup: async (context: any) => { context.ui.slot({ replace: "home.footer", render: () => { throw new Error("boom") }, }) await appendFile(${JSON.stringify(markerCrash)}, "setup\\n") }, } `, ) await using app = await bootApp(tmp.path) const readA = () => readFile(markerA, "utf8") expect(await until(readA, (value) => value === "a1:setup\n")).toBe("a1:setup\n") // The crashing plugin genuinely loaded and registered its slot; without // this the rest of the test would pass even if it never imported. expect( await until( () => readFile(markerCrash, "utf8"), (value) => value === "setup\n", ), ).toBe("setup\n") // The app survives the crashing slot: hot reload still works for others. // The render-time boundary itself (fallback + toast) is not exercisable // here: the test renderer never executes slot render bodies, so render // containment is verified in the real TUI (see PluginBoundary in // src/plugin/render.tsx and the demo runs on the PR). await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2")) expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n") process.emit("SIGHUP") await app.task }) test("editing one plugin leaves others untouched and a broken save keeps the last good version", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const markerA = path.join(tmp.path, "a.txt") const markerB = path.join(tmp.path, "b.txt") const sourceA = path.join(directory, "a.ts") const sourceB = path.join(directory, "b.ts") await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a1")) await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b1")) await using app = await bootApp(tmp.path) const readA = () => readFile(markerA, "utf8") const readB = () => readFile(markerB, "utf8") await until(readA, (value) => value === "a1:setup\n") await until(readB, (value) => value === "b1:setup\n") // Editing B restarts only B: A sees no cleanup and no second setup. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2")) expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n") expect(await readA()).toBe("a1:setup\n") // A broken save keeps the last good version running: b2 is never cleaned // up. Editing A afterwards provides a positive completion signal — once // A's swap lands, the serialized reconcile has processed the broken save. await writeFile(sourceB, "export default {") await writeFile(sourceA, lifecycleSource(markerA, "test.a", "a2")) expect(await until(readA, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n") expect(await readB()).toBe("b1:setup\nb1:cleanup\nb2:setup\n") // Fixing the file replaces the kept version and leaves A alone. await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b3")) expect(await until(readB, (value) => value?.includes("b3:setup") ?? false)).toBe( "b1:setup\nb1:cleanup\nb2:setup\nb2:cleanup\nb3:setup\n", ) expect(await readA()).toBe("a1:setup\na1:cleanup\na2:setup\n") process.emit("SIGHUP") await app.task }) test("a save whose setup throws restores the previous version", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const marker = path.join(tmp.path, "a.txt") const source = path.join(directory, "a.ts") await writeFile(source, lifecycleSource(marker, "test.a", "a1")) await using app = await bootApp(tmp.path) const read = () => readFile(marker, "utf8") expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n") // The module imports fine but its setup throws — unlike an import failure, // the swap has already torn down a1, so keep-last-good means restoring it. await writeFile( source, ` export default { id: "test.a", setup: async () => { throw new Error("setup boom") }, } `, ) expect(await until(read, (value) => value === "a1:setup\na1:cleanup\na1:setup\n")).toBe( "a1:setup\na1:cleanup\na1:setup\n", ) // Fixing the file swaps out the restored version normally. await writeFile(source, lifecycleSource(marker, "test.a", "a2")) expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe( "a1:setup\na1:cleanup\na1:setup\na1:cleanup\na2:setup\n", ) process.emit("SIGHUP") await app.task }) test("editing a symlinked plugin's target hot-reloads it", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const marker = path.join(tmp.path, "a.txt") // The real source lives outside the discovery directory; only a symlink // is discovered. Edits land at the target, which emits no event in the // plugin directory itself. const target = path.join(tmp.path, "elsewhere", "a.ts") await mkdir(path.dirname(target), { recursive: true }) await writeFile(target, lifecycleSource(marker, "test.a", "a1")) await symlink(target, path.join(directory, "a.ts")) await using app = await bootApp(tmp.path) const read = () => readFile(marker, "utf8") expect(await until(read, (value) => value === "a1:setup\n")).toBe("a1:setup\n") await writeFile(target, lifecycleSource(marker, "test.a", "a2")) expect(await until(read, (value) => value?.includes("a2:setup") ?? false)).toBe("a1:setup\na1:cleanup\na2:setup\n") process.emit("SIGHUP") await app.task }) test("memory storage survives hot reload while disk storage persists", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui") await mkdir(directory, { recursive: true }) const marker = path.join(tmp.path, "counter.txt") const source = path.join(directory, "counter.ts") const counterSource = (note: string) => ` import { appendFile } from "node:fs/promises" // ${note} export default { id: "test.counter", setup: async (context: any) => { const [state, update] = context.storage.memory("counter", { initial: { count: 0 } }) update((draft: any) => { draft.count += 1 }) await appendFile(${JSON.stringify(marker)}, "count:" + state.count + "\\n") }, } ` await writeFile(source, counterSource("v1")) await using app = await bootApp(tmp.path) const read = () => readFile(marker, "utf8") expect(await until(read, (value) => value === "count:1\n")).toBe("count:1\n") // The reloaded generation shares the same live store: the count continues. await writeFile(source, counterSource("v2")) expect(await until(read, (value) => value?.includes("count:2") ?? false)).toBe("count:1\ncount:2\n") process.emit("SIGHUP") await app.task })