session-tabs.test.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
  1. /** @jsxImportSource @opentui/solid */
  2. import { afterAll, expect, test } from "bun:test"
  3. import type { OpenCodeEvent } from "@opencode-ai/client"
  4. import { testRender } from "@opentui/solid"
  5. import { mkdirSync, mkdtempSync, readdirSync, rmSync, watch } from "fs"
  6. import { tmpdir } from "os"
  7. import path from "path"
  8. import { ConfigProvider } from "../../src/config"
  9. import { ClientProvider, useClient } from "../../src/context/client"
  10. import { DataProvider, useData } from "../../src/context/data"
  11. import { RouteProvider, useRoute } from "../../src/context/route"
  12. import { TuiAppProvider } from "../../src/context/runtime"
  13. import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
  14. import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
  15. import { StorageProvider } from "../../src/context/storage"
  16. import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
  17. import { TestTuiContexts } from "../fixture/tui-environment"
  18. import { createTuiResolvedConfig } from "../fixture/tui-runtime"
  19. async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
  20. const start = Date.now()
  21. while (!(await fn())) {
  22. if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
  23. await Bun.sleep(10)
  24. }
  25. }
  26. // State directories are removed after the whole suite instead of per test: persistence writes are
  27. // fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
  28. const stateDirs: string[] = []
  29. afterAll(async () => {
  30. for (const dir of stateDirs) {
  31. // Drain any lock still held by a late write before deleting the tree beneath it.
  32. await wait(() => {
  33. try {
  34. return readdirSync(path.join(dir, "test", "locks")).length === 0
  35. } catch {
  36. return true
  37. }
  38. }).catch(() => undefined)
  39. rmSync(dir, { recursive: true, force: true })
  40. }
  41. })
  42. function stateDir(prefix: string) {
  43. const dir = mkdtempSync(path.join(tmpdir(), prefix))
  44. stateDirs.push(dir)
  45. return dir
  46. }
  47. async function renderSessionTabs(
  48. initialSessionID: string,
  49. options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
  50. ) {
  51. const state = options?.state ?? stateDir("opencode-session-tabs-")
  52. if (options?.persisted) {
  53. const file = path.join(state, "test", "tui", "tabs.json")
  54. mkdirSync(path.dirname(file), { recursive: true })
  55. await Bun.write(
  56. file,
  57. JSON.stringify({
  58. global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
  59. cwd: {},
  60. }),
  61. )
  62. }
  63. const events = createEventStream()
  64. const sessions: string[] = []
  65. const calls = createFetch(async (url) => {
  66. const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
  67. if (!sessionID) return undefined
  68. sessions.push(sessionID)
  69. await options?.sessionGate
  70. return json({
  71. data: {
  72. id: sessionID,
  73. title: sessionID === initialSessionID ? options?.title : undefined,
  74. projectID: "project",
  75. location: { directory },
  76. cost: 0,
  77. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  78. time: { created: 0, updated: 0 },
  79. },
  80. })
  81. }, events)
  82. let tabs!: ReturnType<typeof useSessionTabs>
  83. let route!: ReturnType<typeof useRoute>
  84. let client!: ReturnType<typeof useClient>
  85. let data!: ReturnType<typeof useData>
  86. function Probe() {
  87. tabs = useSessionTabs()
  88. route = useRoute()
  89. client = useClient()
  90. data = useData()
  91. return <box />
  92. }
  93. const app = await testRender(() => (
  94. <TestTuiContexts paths={{ state }}>
  95. <TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
  96. <StorageProvider>
  97. <ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
  98. <RouteProvider
  99. initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
  100. >
  101. <ClientProvider api={createApi(calls.fetch)}>
  102. <DataProvider>
  103. <SessionTabsProvider>
  104. <Probe />
  105. </SessionTabsProvider>
  106. </DataProvider>
  107. </ClientProvider>
  108. </RouteProvider>
  109. </ConfigProvider>
  110. </StorageProvider>
  111. </TuiAppProvider>
  112. </TestTuiContexts>
  113. ))
  114. await wait(() => client.connection.status() === "connected")
  115. return {
  116. tabs,
  117. route,
  118. data,
  119. sessions,
  120. state,
  121. emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
  122. destroy() {
  123. app.renderer.destroy()
  124. },
  125. }
  126. }
  127. test("loads persisted tab metadata concurrently on connect", async () => {
  128. let release!: () => void
  129. const sessionGate = new Promise<void>((resolve) => (release = resolve))
  130. const setup = await renderSessionTabs("first", {
  131. home: true,
  132. persisted: ["first", "second"],
  133. sessionGate,
  134. })
  135. try {
  136. await wait(() => setup.sessions.length === 2)
  137. expect(setup.sessions.toSorted()).toEqual(["first", "second"])
  138. release()
  139. await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined)
  140. } finally {
  141. release()
  142. setup.destroy()
  143. }
  144. })
  145. test("stores session tabs globally by default", async () => {
  146. const setup = await renderSessionTabs("first")
  147. try {
  148. const file = path.join(setup.state, "test", "tui", "tabs.json")
  149. await wait(() => Bun.file(file).size > 0)
  150. expect(await Bun.file(file).json()).toEqual({
  151. global: { tabs: [{ sessionID: "first" }], unread: {} },
  152. cwd: {},
  153. })
  154. } finally {
  155. setup.destroy()
  156. }
  157. })
  158. test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
  159. const state = stateDir("opencode-session-tabs-shared-")
  160. let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
  161. let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
  162. try {
  163. titled = await renderSessionTabs("shared", { state, title: "Generated title" })
  164. untitled = await renderSessionTabs("shared", { state })
  165. const file = path.join(state, "test", "tui", "tabs.json")
  166. await titled.data.session.sync("shared")
  167. await wait(async () => {
  168. if (!(await Bun.file(file).exists())) return false
  169. return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
  170. })
  171. const observed = ["Generated title"]
  172. const pending = new Set<Promise<void>>()
  173. const watcher = watch(path.dirname(file), (_, name) => {
  174. if (name !== path.basename(file)) return
  175. const read = Bun.file(file)
  176. .json()
  177. .then((value) => {
  178. const title = value.global.tabs[0]?.title
  179. if (title && observed.at(-1) !== title) observed.push(title)
  180. })
  181. .catch(() => undefined)
  182. .finally(() => pending.delete(read))
  183. pending.add(read)
  184. })
  185. try {
  186. await untitled.data.session.sync("shared")
  187. await Bun.sleep(500)
  188. } finally {
  189. watcher.close()
  190. await Promise.allSettled(pending)
  191. }
  192. expect(observed).toEqual(["Generated title"])
  193. } finally {
  194. titled?.destroy()
  195. untitled?.destroy()
  196. }
  197. })
  198. test("user prompt admissions pulse an already-busy background tab", async () => {
  199. const setup = await renderSessionTabs("background")
  200. const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
  201. id: `evt_${inputID}`,
  202. created: Date.now(),
  203. type: "session.input.admitted",
  204. durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 },
  205. data: {
  206. sessionID,
  207. inputID,
  208. input: { type: "user", data: { text: inputID }, delivery: "steer" },
  209. },
  210. })
  211. try {
  212. await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
  213. setup.route.navigate({ type: "session", sessionID: "active" })
  214. await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2)
  215. setup.emit({
  216. id: "evt_context",
  217. created: Date.now(),
  218. type: "session.input.admitted",
  219. durable: { aggregateID: "background", seq: 0, version: 1 },
  220. data: {
  221. sessionID: "background",
  222. inputID: "msg_context",
  223. input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" },
  224. },
  225. })
  226. await Bun.sleep(20)
  227. expect(setup.tabs.status("background").promptPulse).toBe(0)
  228. setup.emit(admitted("background", "msg_1"))
  229. await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
  230. setup.emit(admitted("background", "msg_2"))
  231. await wait(() => setup.tabs.status("background").promptPulse === 2)
  232. setup.emit(admitted("active", "msg_3"))
  233. await Bun.sleep(20)
  234. expect(setup.tabs.status("active").promptPulse).toBe(0)
  235. expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
  236. } finally {
  237. setup.destroy()
  238. }
  239. })
  240. test("tracks a temporary new session tab across close and creation", async () => {
  241. const setup = await renderSessionTabs("first")
  242. try {
  243. await wait(() => setup.tabs.current() === "first")
  244. setup.route.navigate({ type: "session", sessionID: "second" })
  245. await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
  246. setup.route.navigate({ type: "session", sessionID: "first" })
  247. await wait(() => setup.tabs.current() === "first")
  248. setup.route.navigate({ type: "home" })
  249. await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined)
  250. expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
  251. setup.tabs.close()
  252. await wait(() => setup.route.data.type === "session")
  253. expect(setup.route.data).toEqual({ type: "session", sessionID: "first" })
  254. setup.route.navigate({ type: "home" })
  255. await wait(() => setup.tabs.newTab())
  256. setup.route.navigate({ type: "session", sessionID: "third" })
  257. expect(setup.tabs.newTab()).toBe(true)
  258. await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
  259. expect(setup.tabs.newTab()).toBe(false)
  260. expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
  261. } finally {
  262. setup.destroy()
  263. }
  264. })