session-tabs.test.tsx 10.0 KB

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