|
| 1 | +/** @jsxImportSource @opentui/solid */ |
| 2 | +import { afterEach, describe, expect, test } from "bun:test" |
| 3 | +import { testRender } from "@opentui/solid" |
| 4 | +import { onMount } from "solid-js" |
| 5 | +import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args" |
| 6 | +import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit" |
| 7 | +import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project" |
| 8 | +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" |
| 9 | +import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync" |
| 10 | + |
| 11 | +const sighup = new Set(process.listeners("SIGHUP")) |
| 12 | + |
| 13 | +afterEach(() => { |
| 14 | + for (const fn of process.listeners("SIGHUP")) { |
| 15 | + if (!sighup.has(fn)) process.off("SIGHUP", fn) |
| 16 | + } |
| 17 | +}) |
| 18 | + |
| 19 | +function json(data: unknown) { |
| 20 | + return new Response(JSON.stringify(data), { |
| 21 | + headers: { |
| 22 | + "content-type": "application/json", |
| 23 | + }, |
| 24 | + }) |
| 25 | +} |
| 26 | + |
| 27 | +async function wait(fn: () => boolean, timeout = 2000) { |
| 28 | + const start = Date.now() |
| 29 | + while (!fn()) { |
| 30 | + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") |
| 31 | + await Bun.sleep(10) |
| 32 | + } |
| 33 | +} |
| 34 | + |
| 35 | +function data(workspace?: string | null) { |
| 36 | + const tag = workspace ?? "root" |
| 37 | + return { |
| 38 | + session: { |
| 39 | + id: "ses_1", |
| 40 | + title: `session-${tag}`, |
| 41 | + workspaceID: workspace ?? undefined, |
| 42 | + time: { |
| 43 | + updated: 1, |
| 44 | + }, |
| 45 | + }, |
| 46 | + message: { |
| 47 | + info: { |
| 48 | + id: "msg_1", |
| 49 | + sessionID: "ses_1", |
| 50 | + role: "assistant", |
| 51 | + time: { |
| 52 | + created: 1, |
| 53 | + completed: 1, |
| 54 | + }, |
| 55 | + }, |
| 56 | + parts: [ |
| 57 | + { |
| 58 | + id: "part_1", |
| 59 | + messageID: "msg_1", |
| 60 | + sessionID: "ses_1", |
| 61 | + type: "text", |
| 62 | + text: `part-${tag}`, |
| 63 | + }, |
| 64 | + ], |
| 65 | + }, |
| 66 | + todo: [ |
| 67 | + { |
| 68 | + id: `todo-${tag}`, |
| 69 | + content: `todo-${tag}`, |
| 70 | + status: "pending", |
| 71 | + priority: "medium", |
| 72 | + }, |
| 73 | + ], |
| 74 | + diff: [ |
| 75 | + { |
| 76 | + file: `${tag}.ts`, |
| 77 | + patch: "", |
| 78 | + additions: 0, |
| 79 | + deletions: 0, |
| 80 | + }, |
| 81 | + ], |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +type Hit = { |
| 86 | + path: string |
| 87 | + workspace?: string |
| 88 | +} |
| 89 | + |
| 90 | +function createFetch(log: Hit[]) { |
| 91 | + return Object.assign( |
| 92 | + async (input: RequestInfo | URL, init?: RequestInit) => { |
| 93 | + const req = new Request(input, init) |
| 94 | + const url = new URL(req.url) |
| 95 | + const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined |
| 96 | + log.push({ |
| 97 | + path: url.pathname, |
| 98 | + workspace, |
| 99 | + }) |
| 100 | + |
| 101 | + if (url.pathname === "/config/providers") { |
| 102 | + return json({ providers: [], default: {} }) |
| 103 | + } |
| 104 | + if (url.pathname === "/provider") { |
| 105 | + return json({ all: [], default: {}, connected: [] }) |
| 106 | + } |
| 107 | + if (url.pathname === "/experimental/console") { |
| 108 | + return json({}) |
| 109 | + } |
| 110 | + if (url.pathname === "/agent") { |
| 111 | + return json([]) |
| 112 | + } |
| 113 | + if (url.pathname === "/config") { |
| 114 | + return json({}) |
| 115 | + } |
| 116 | + if (url.pathname === "/project/current") { |
| 117 | + return json({ id: `proj-${workspace ?? "root"}` }) |
| 118 | + } |
| 119 | + if (url.pathname === "/path") { |
| 120 | + return json({ |
| 121 | + state: `/tmp/${workspace ?? "root"}/state`, |
| 122 | + config: `/tmp/${workspace ?? "root"}/config`, |
| 123 | + worktree: "/tmp/worktree", |
| 124 | + directory: `/tmp/${workspace ?? "root"}`, |
| 125 | + }) |
| 126 | + } |
| 127 | + if (url.pathname === "/session") { |
| 128 | + return json([]) |
| 129 | + } |
| 130 | + if (url.pathname === "/command") { |
| 131 | + return json([]) |
| 132 | + } |
| 133 | + if (url.pathname === "/lsp") { |
| 134 | + return json([]) |
| 135 | + } |
| 136 | + if (url.pathname === "/mcp") { |
| 137 | + return json({}) |
| 138 | + } |
| 139 | + if (url.pathname === "/experimental/resource") { |
| 140 | + return json({}) |
| 141 | + } |
| 142 | + if (url.pathname === "/formatter") { |
| 143 | + return json([]) |
| 144 | + } |
| 145 | + if (url.pathname === "/session/status") { |
| 146 | + return json({}) |
| 147 | + } |
| 148 | + if (url.pathname === "/provider/auth") { |
| 149 | + return json({}) |
| 150 | + } |
| 151 | + if (url.pathname === "/vcs") { |
| 152 | + return json({ branch: "main" }) |
| 153 | + } |
| 154 | + if (url.pathname === "/experimental/workspace") { |
| 155 | + return json([{ id: "ws_a" }, { id: "ws_b" }]) |
| 156 | + } |
| 157 | + if (url.pathname === "/session/ses_1") { |
| 158 | + return json(data(workspace).session) |
| 159 | + } |
| 160 | + if (url.pathname === "/session/ses_1/message") { |
| 161 | + return json([data(workspace).message]) |
| 162 | + } |
| 163 | + if (url.pathname === "/session/ses_1/todo") { |
| 164 | + return json(data(workspace).todo) |
| 165 | + } |
| 166 | + if (url.pathname === "/session/ses_1/diff") { |
| 167 | + return json(data(workspace).diff) |
| 168 | + } |
| 169 | + |
| 170 | + throw new Error(`unexpected request: ${req.method} ${url.pathname}`) |
| 171 | + }, |
| 172 | + { preconnect: fetch.preconnect.bind(fetch) }, |
| 173 | + ) satisfies typeof fetch |
| 174 | +} |
| 175 | + |
| 176 | +async function mount(log: Hit[]) { |
| 177 | + let project!: ReturnType<typeof useProject> |
| 178 | + let sync!: ReturnType<typeof useSync> |
| 179 | + let done!: () => void |
| 180 | + const ready = new Promise<void>((resolve) => { |
| 181 | + done = resolve |
| 182 | + }) |
| 183 | + |
| 184 | + const app = await testRender(() => ( |
| 185 | + <SDKProvider |
| 186 | + url="http://test" |
| 187 | + directory="/tmp/root" |
| 188 | + fetch={createFetch(log)} |
| 189 | + events={{ subscribe: async () => () => {} }} |
| 190 | + > |
| 191 | + <ArgsProvider continue={false}> |
| 192 | + <ExitProvider> |
| 193 | + <ProjectProvider> |
| 194 | + <SyncProvider> |
| 195 | + <Probe |
| 196 | + onReady={(ctx) => { |
| 197 | + project = ctx.project |
| 198 | + sync = ctx.sync |
| 199 | + done() |
| 200 | + }} |
| 201 | + /> |
| 202 | + </SyncProvider> |
| 203 | + </ProjectProvider> |
| 204 | + </ExitProvider> |
| 205 | + </ArgsProvider> |
| 206 | + </SDKProvider> |
| 207 | + )) |
| 208 | + |
| 209 | + await ready |
| 210 | + return { app, project, sync } |
| 211 | +} |
| 212 | + |
| 213 | +async function waitBoot(log: Hit[], workspace?: string) { |
| 214 | + await wait(() => log.some((item) => item.path === "/experimental/workspace")) |
| 215 | + if (!workspace) return |
| 216 | + await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace)) |
| 217 | +} |
| 218 | + |
| 219 | +function Probe(props: { |
| 220 | + onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void |
| 221 | +}) { |
| 222 | + const project = useProject() |
| 223 | + const sync = useSync() |
| 224 | + |
| 225 | + onMount(() => { |
| 226 | + props.onReady({ project, sync }) |
| 227 | + }) |
| 228 | + |
| 229 | + return <box /> |
| 230 | +} |
| 231 | + |
| 232 | +describe("SyncProvider", () => { |
| 233 | + test("re-runs bootstrap requests when the active workspace changes", async () => { |
| 234 | + const log: Hit[] = [] |
| 235 | + const { app, project } = await mount(log) |
| 236 | + |
| 237 | + try { |
| 238 | + await waitBoot(log) |
| 239 | + log.length = 0 |
| 240 | + |
| 241 | + project.workspace.set("ws_a") |
| 242 | + |
| 243 | + await waitBoot(log, "ws_a") |
| 244 | + |
| 245 | + expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true) |
| 246 | + expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true) |
| 247 | + expect(log.some((item) => item.path === "/session" && item.workspace === "ws_a")).toBe(true) |
| 248 | + expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true) |
| 249 | + } finally { |
| 250 | + app.renderer.destroy() |
| 251 | + } |
| 252 | + }) |
| 253 | + |
| 254 | + test("clears full-sync cache when the active workspace changes", async () => { |
| 255 | + const log: Hit[] = [] |
| 256 | + const { app, project, sync } = await mount(log) |
| 257 | + |
| 258 | + try { |
| 259 | + await waitBoot(log) |
| 260 | + |
| 261 | + log.length = 0 |
| 262 | + project.workspace.set("ws_a") |
| 263 | + await waitBoot(log, "ws_a") |
| 264 | + expect(project.workspace.current()).toBe("ws_a") |
| 265 | + |
| 266 | + log.length = 0 |
| 267 | + await sync.session.sync("ses_1") |
| 268 | + |
| 269 | + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1) |
| 270 | + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a") |
| 271 | + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") |
| 272 | + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" }) |
| 273 | + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts") |
| 274 | + |
| 275 | + log.length = 0 |
| 276 | + project.workspace.set("ws_b") |
| 277 | + await waitBoot(log, "ws_b") |
| 278 | + expect(project.workspace.current()).toBe("ws_b") |
| 279 | + |
| 280 | + log.length = 0 |
| 281 | + await sync.session.sync("ses_1") |
| 282 | + await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")) |
| 283 | + |
| 284 | + expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1) |
| 285 | + expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b") |
| 286 | + expect(sync.data.message.ses_1[0]?.id).toBe("msg_1") |
| 287 | + expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" }) |
| 288 | + expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts") |
| 289 | + } finally { |
| 290 | + app.renderer.destroy() |
| 291 | + } |
| 292 | + }) |
| 293 | +}) |
0 commit comments