session-tabs.test.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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 { mkdtempSync, 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. async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
  27. const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
  28. const events = createEventStream()
  29. const calls = createFetch((url) => {
  30. if (url.pathname !== `/api/session/${initialSessionID}`) return
  31. return json({
  32. data: {
  33. id: initialSessionID,
  34. title: options?.title,
  35. projectID: "project",
  36. location: { directory },
  37. cost: 0,
  38. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  39. time: { created: 0, updated: 0 },
  40. },
  41. })
  42. }, events)
  43. let tabs!: ReturnType<typeof useSessionTabs>
  44. let route!: ReturnType<typeof useRoute>
  45. let client!: ReturnType<typeof useClient>
  46. let data!: ReturnType<typeof useData>
  47. function Probe() {
  48. tabs = useSessionTabs()
  49. route = useRoute()
  50. client = useClient()
  51. data = useData()
  52. return <box />
  53. }
  54. const app = await testRender(() => (
  55. <TestTuiContexts paths={{ state }}>
  56. <TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
  57. <StorageProvider>
  58. <ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
  59. <RouteProvider initialRoute={{ type: "session", sessionID: initialSessionID }}>
  60. <ClientProvider api={createApi(calls.fetch)}>
  61. <DataProvider>
  62. <SessionTabsProvider>
  63. <Probe />
  64. </SessionTabsProvider>
  65. </DataProvider>
  66. </ClientProvider>
  67. </RouteProvider>
  68. </ConfigProvider>
  69. </StorageProvider>
  70. </TuiAppProvider>
  71. </TestTuiContexts>
  72. ))
  73. await wait(() => client.connection.status() === "connected")
  74. return {
  75. tabs,
  76. route,
  77. data,
  78. state,
  79. emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
  80. destroy() {
  81. app.renderer.destroy()
  82. if (!options?.state) rmSync(state, { recursive: true, force: true })
  83. },
  84. }
  85. }
  86. test("stores session tabs globally by default", async () => {
  87. const setup = await renderSessionTabs("first")
  88. try {
  89. const file = path.join(setup.state, "test", "tui", "tabs.json")
  90. await wait(() => Bun.file(file).size > 0)
  91. expect(await Bun.file(file).json()).toEqual({
  92. global: { tabs: [{ sessionID: "first" }], unread: {} },
  93. cwd: {},
  94. })
  95. } finally {
  96. setup.destroy()
  97. }
  98. })
  99. test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
  100. const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
  101. let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
  102. let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
  103. try {
  104. titled = await renderSessionTabs("shared", { state, title: "Generated title" })
  105. untitled = await renderSessionTabs("shared", { state })
  106. const file = path.join(state, "test", "tui", "tabs.json")
  107. await titled.data.session.sync("shared")
  108. await wait(async () => {
  109. if (!(await Bun.file(file).exists())) return false
  110. return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
  111. })
  112. const observed = ["Generated title"]
  113. const pending = new Set<Promise<void>>()
  114. const watcher = watch(path.dirname(file), (_, name) => {
  115. if (name !== path.basename(file)) return
  116. const read = Bun.file(file)
  117. .json()
  118. .then((value) => {
  119. const title = value.global.tabs[0]?.title
  120. if (title && observed.at(-1) !== title) observed.push(title)
  121. })
  122. .catch(() => undefined)
  123. .finally(() => pending.delete(read))
  124. pending.add(read)
  125. })
  126. try {
  127. await untitled.data.session.sync("shared")
  128. await Bun.sleep(500)
  129. } finally {
  130. watcher.close()
  131. await Promise.allSettled(pending)
  132. }
  133. expect(observed).toEqual(["Generated title"])
  134. } finally {
  135. titled?.destroy()
  136. untitled?.destroy()
  137. rmSync(state, { recursive: true, force: true })
  138. }
  139. })
  140. test("user prompt admissions pulse an already-busy background tab", async () => {
  141. const setup = await renderSessionTabs("background")
  142. const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
  143. id: `evt_${inputID}`,
  144. created: Date.now(),
  145. type: "session.input.admitted",
  146. durable: { aggregateID: sessionID, seq: Number(inputID.replace(/\D/g, "")), version: 1 },
  147. data: {
  148. sessionID,
  149. inputID,
  150. input: { type: "user", data: { text: inputID }, delivery: "steer" },
  151. },
  152. })
  153. try {
  154. await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
  155. setup.route.navigate({ type: "session", sessionID: "active" })
  156. await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2)
  157. setup.emit({
  158. id: "evt_context",
  159. created: Date.now(),
  160. type: "session.input.admitted",
  161. durable: { aggregateID: "background", seq: 0, version: 1 },
  162. data: {
  163. sessionID: "background",
  164. inputID: "msg_context",
  165. input: { type: "synthetic", data: { text: "editor context" }, delivery: "steer" },
  166. },
  167. })
  168. await Bun.sleep(20)
  169. expect(setup.tabs.status("background").promptPulse).toBe(0)
  170. setup.emit(admitted("background", "msg_1"))
  171. await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
  172. setup.emit(admitted("background", "msg_2"))
  173. await wait(() => setup.tabs.status("background").promptPulse === 2)
  174. setup.emit(admitted("active", "msg_3"))
  175. await Bun.sleep(20)
  176. expect(setup.tabs.status("active").promptPulse).toBe(0)
  177. expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
  178. } finally {
  179. setup.destroy()
  180. }
  181. })
  182. test("tracks a temporary new session tab across close and creation", async () => {
  183. const setup = await renderSessionTabs("first")
  184. try {
  185. await wait(() => setup.tabs.current() === "first")
  186. setup.route.navigate({ type: "session", sessionID: "second" })
  187. await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
  188. setup.route.navigate({ type: "session", sessionID: "first" })
  189. await wait(() => setup.tabs.current() === "first")
  190. setup.route.navigate({ type: "home" })
  191. await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined)
  192. expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
  193. setup.tabs.close()
  194. await wait(() => setup.route.data.type === "session")
  195. expect(setup.route.data).toEqual({ type: "session", sessionID: "first" })
  196. setup.route.navigate({ type: "home" })
  197. await wait(() => setup.tabs.newTab())
  198. setup.route.navigate({ type: "session", sessionID: "third" })
  199. expect(setup.tabs.newTab()).toBe(true)
  200. await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
  201. expect(setup.tabs.newTab()).toBe(false)
  202. expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
  203. } finally {
  204. setup.destroy()
  205. }
  206. })