From 2b0fdcc4497403eb649afc92380ec9b748846c9b Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 9 Apr 2026 14:22:39 -0400 Subject: [PATCH] Add some tests --- .../test/cli/tui/sync-provider.test.tsx | 293 ++++++++++++++++++ .../opencode/test/cli/tui/use-event.test.tsx | 175 +++++++++++ 2 files changed, 468 insertions(+) create mode 100644 packages/opencode/test/cli/tui/sync-provider.test.tsx create mode 100644 packages/opencode/test/cli/tui/use-event.test.tsx diff --git a/packages/opencode/test/cli/tui/sync-provider.test.tsx b/packages/opencode/test/cli/tui/sync-provider.test.tsx new file mode 100644 index 0000000000..ec686b3688 --- /dev/null +++ b/packages/opencode/test/cli/tui/sync-provider.test.tsx @@ -0,0 +1,293 @@ +/** @jsxImportSource @opentui/solid */ +import { afterEach, describe, expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { onMount } from "solid-js" +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" + +const sighup = new Set(process.listeners("SIGHUP")) + +afterEach(() => { + for (const fn of process.listeners("SIGHUP")) { + if (!sighup.has(fn)) process.off("SIGHUP", fn) + } +}) + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { + "content-type": "application/json", + }, + }) +} + +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 data(workspace?: string | null) { + const tag = workspace ?? "root" + return { + session: { + id: "ses_1", + title: `session-${tag}`, + workspaceID: workspace ?? undefined, + time: { + updated: 1, + }, + }, + message: { + info: { + id: "msg_1", + sessionID: "ses_1", + role: "assistant", + time: { + created: 1, + completed: 1, + }, + }, + parts: [ + { + id: "part_1", + messageID: "msg_1", + sessionID: "ses_1", + type: "text", + text: `part-${tag}`, + }, + ], + }, + todo: [ + { + id: `todo-${tag}`, + content: `todo-${tag}`, + status: "pending", + priority: "medium", + }, + ], + diff: [ + { + file: `${tag}.ts`, + patch: "", + additions: 0, + deletions: 0, + }, + ], + } +} + +type Hit = { + path: string + workspace?: string +} + +function createFetch(log: Hit[]) { + return Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init) + const url = new URL(req.url) + const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined + log.push({ + path: url.pathname, + workspace, + }) + + if (url.pathname === "/config/providers") { + return json({ providers: [], default: {} }) + } + if (url.pathname === "/provider") { + return json({ all: [], default: {}, connected: [] }) + } + if (url.pathname === "/experimental/console") { + return json({}) + } + if (url.pathname === "/agent") { + return json([]) + } + if (url.pathname === "/config") { + return json({}) + } + if (url.pathname === "/project/current") { + return json({ id: `proj-${workspace ?? "root"}` }) + } + if (url.pathname === "/path") { + return json({ + state: `/tmp/${workspace ?? "root"}/state`, + config: `/tmp/${workspace ?? "root"}/config`, + worktree: "/tmp/worktree", + directory: `/tmp/${workspace ?? "root"}`, + }) + } + if (url.pathname === "/session") { + return json([]) + } + if (url.pathname === "/command") { + return json([]) + } + if (url.pathname === "/lsp") { + return json([]) + } + if (url.pathname === "/mcp") { + return json({}) + } + if (url.pathname === "/experimental/resource") { + return json({}) + } + if (url.pathname === "/formatter") { + return json([]) + } + if (url.pathname === "/session/status") { + return json({}) + } + if (url.pathname === "/provider/auth") { + return json({}) + } + if (url.pathname === "/vcs") { + return json({ branch: "main" }) + } + if (url.pathname === "/experimental/workspace") { + return json([{ id: "ws_a" }, { id: "ws_b" }]) + } + if (url.pathname === "/session/ses_1") { + return json(data(workspace).session) + } + if (url.pathname === "/session/ses_1/message") { + return json([data(workspace).message]) + } + if (url.pathname === "/session/ses_1/todo") { + return json(data(workspace).todo) + } + if (url.pathname === "/session/ses_1/diff") { + return json(data(workspace).diff) + } + + throw new Error(`unexpected request: ${req.method} ${url.pathname}`) + }, + { preconnect: fetch.preconnect.bind(fetch) }, + ) satisfies typeof fetch +} + +async function mount(log: Hit[]) { + let project!: ReturnType + let sync!: ReturnType + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + () => {} }} + > + + + + + { + project = ctx.project + sync = ctx.sync + done() + }} + /> + + + + + + )) + + await ready + return { app, project, sync } +} + +async function waitBoot(log: Hit[], workspace?: string) { + await wait(() => log.some((item) => item.path === "/experimental/workspace")) + if (!workspace) return + await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace)) +} + +function Probe(props: { + onReady: (ctx: { project: ReturnType; sync: ReturnType }) => void +}) { + const project = useProject() + const sync = useSync() + + onMount(() => { + props.onReady({ project, sync }) + }) + + return +} + +describe("SyncProvider", () => { + test("re-runs bootstrap requests when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project } = await mount(log) + + try { + await waitBoot(log) + log.length = 0 + + project.workspace.set("ws_a") + + await waitBoot(log, "ws_a") + + expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true) + expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true) + } finally { + app.renderer.destroy() + } + }) + + test("clears full-sync cache when the active workspace changes", async () => { + const log: Hit[] = [] + const { app, project, sync } = await mount(log) + + try { + await waitBoot(log) + + log.length = 0 + project.workspace.set("ws_a") + await waitBoot(log, "ws_a") + expect(project.workspace.current()).toBe("ws_a") + + log.length = 0 + await sync.session.sync("ses_1") + + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1) + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a") + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" }) + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts") + + log.length = 0 + project.workspace.set("ws_b") + await waitBoot(log, "ws_b") + expect(project.workspace.current()).toBe("ws_b") + + log.length = 0 + await sync.session.sync("ses_1") + await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")) + + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1) + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b") + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" }) + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts") + } finally { + app.renderer.destroy() + } + }) +}) diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx new file mode 100644 index 0000000000..5b0fcad3c9 --- /dev/null +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -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 + let done!: () => void + const ready = new Promise((resolve) => { + done = resolve + }) + + const app = await testRender(() => ( + + + { + project = ctx.project + done() + }} + seen={seen} + /> + + + )) + + await ready + return { app, emit: source.emit, project, seen } +} + +function Probe(props: { seen: Event[]; onReady: (ctx: { project: ReturnType }) => void }) { + const project = useProject() + const event = useEvent() + + onMount(() => { + event.subscribe((evt) => { + props.seen.push(evt) + }) + props.onReady({ project }) + }) + + return +} + +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() + } + }) +})