| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- 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<string>, 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
- })
|