Skip to content

Commit 2b0fdcc

Browse files
committed
Add some tests
1 parent 5d68c61 commit 2b0fdcc

2 files changed

Lines changed: 468 additions & 0 deletions

File tree

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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

Comments
 (0)