session-timeline-history-root.spec.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { expect, test, type Page } from "@playwright/test"
  3. import {
  4. assistantMessage,
  5. directory,
  6. messageUpdated,
  7. project,
  8. session,
  9. sessionID,
  10. status,
  11. textPart,
  12. title,
  13. userID,
  14. userMessage,
  15. } from "../performance/timeline-stability/fixture"
  16. import { mockOpenCodeServer } from "../utils/mock-server"
  17. import { installSseTransport } from "../utils/sse-transport"
  18. import { expectSessionTitle } from "../utils/waits"
  19. const initialPageSize = 20
  20. const historyPageSize = 200
  21. const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
  22. assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
  23. id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
  24. parentID: userID,
  25. created: 1700000001000 + index * 1_000,
  26. completed: index < initialPageSize,
  27. }),
  28. )
  29. const messages = [userMessage(), ...assistants]
  30. const lastAssistant = assistants.at(-1)!
  31. const lastPartID = assistants.at(-1)!.parts[0]!.id
  32. const userPartID = `prt_${userID}_text`
  33. const completed = {
  34. ...lastAssistant.info,
  35. time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
  36. }
  37. const scenarios = [
  38. { name: "completion", info: completed, idleFirst: false, interrupted: false },
  39. {
  40. name: "interruption",
  41. info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
  42. idleFirst: true,
  43. interrupted: true,
  44. },
  45. ] as const
  46. test.use({ viewport: { width: 646, height: 1385 } })
  47. for (const scenario of scenarios) {
  48. test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
  49. const requests: { before?: string; phase: "start" | "end" }[] = []
  50. const pages: { before?: string; limit: number }[] = []
  51. const roots: { sessionID: string; messageID: string }[] = []
  52. const sequence: string[] = []
  53. const history = Promise.withResolvers<void>()
  54. const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
  55. server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
  56. retry: 20,
  57. })
  58. await mockOpenCodeServer(page, {
  59. directory,
  60. project: project(),
  61. provider: {
  62. all: [
  63. {
  64. id: "opencode",
  65. name: "OpenCode",
  66. models: {
  67. "claude-opus-4-6": {
  68. id: "claude-opus-4-6",
  69. name: "Claude Opus 4.6",
  70. limit: { context: 200_000 },
  71. },
  72. },
  73. },
  74. ],
  75. connected: ["opencode"],
  76. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  77. },
  78. sessions: [session()],
  79. sessionStatus: { [sessionID]: { type: "busy" } },
  80. beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
  81. onMessages: (request) => {
  82. requests.push(request)
  83. sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
  84. },
  85. onMessage: (request) => {
  86. roots.push(request)
  87. sequence.push(`message:${request.messageID}`)
  88. },
  89. message: (requestedSessionID, messageID) => {
  90. if (requestedSessionID !== sessionID) return
  91. return messages.find((item) => item.info.id === messageID)
  92. },
  93. pageMessages: (_, limit, before) => {
  94. pages.push({ before, limit })
  95. const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
  96. const start = Math.max(0, end - limit)
  97. return {
  98. items: messages.slice(start, end),
  99. cursor: start > 0 ? messages[start]!.info.id : undefined,
  100. }
  101. },
  102. })
  103. await page.addInitScript(() => {
  104. const visibleParts = () => {
  105. const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
  106. const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
  107. const view = viewport?.getBoundingClientRect()
  108. if (!viewport || !view) return []
  109. return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
  110. .filter((part) => {
  111. const rect = part.getBoundingClientRect()
  112. return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
  113. })
  114. .flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
  115. }
  116. const state = {
  117. armed: false,
  118. hidden: false,
  119. visibleParts: [] as string[],
  120. samples: 0,
  121. stop: false,
  122. arm() {
  123. state.visibleParts = visibleParts()
  124. state.armed = true
  125. },
  126. }
  127. ;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
  128. const sample = () => {
  129. if (state.armed) {
  130. const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
  131. const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
  132. const view = viewport?.getBoundingClientRect()
  133. const visible = (partID: string) => {
  134. const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
  135. const rect = part?.getBoundingClientRect()
  136. return (
  137. !!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
  138. )
  139. }
  140. if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
  141. state.hidden = true
  142. state.samples++
  143. }
  144. if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
  145. }
  146. requestAnimationFrame(() => setTimeout(sample, 0))
  147. })
  148. await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
  149. await transport.waitForConnection()
  150. await expectSessionTitle(page, title)
  151. await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
  152. await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
  153. await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
  154. expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
  155. expect(sequence.slice(0, 4)).toEqual([
  156. "messages:start:latest",
  157. "messages:end:latest",
  158. `message:${userID}`,
  159. `messages:start:${messages.at(-initialPageSize)!.info.id}`,
  160. ])
  161. await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
  162. await page.evaluate(() => {
  163. ;(
  164. window as Window & {
  165. __historyRootProbe?: { arm(): void }
  166. }
  167. ).__historyRootProbe!.arm()
  168. })
  169. await waitForProbeSamples(page, 0)
  170. expect(await visibleContentHidden(page)).toBe(false)
  171. const beforeHistory = await probeSamples(page)
  172. history.resolve()
  173. await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
  174. await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
  175. await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
  176. await waitForProbeSamples(page, beforeHistory)
  177. expect(pages).toEqual([
  178. { before: undefined, limit: initialPageSize },
  179. { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
  180. ])
  181. expect(roots).toEqual([{ sessionID, messageID: userID }])
  182. const message = messageUpdated(scenario.info)
  183. const idle = status("idle")
  184. for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
  185. const beforeEvent = await probeSamples(page)
  186. await transport.send(event)
  187. if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
  188. if (event === message && scenario.interrupted)
  189. await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
  190. await waitForProbeSamples(page, beforeEvent)
  191. const current = await timelineState(page)
  192. expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
  193. expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
  194. }
  195. expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
  196. expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
  197. await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
  198. await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
  199. if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
  200. expect(
  201. await page.evaluate(() => {
  202. const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
  203. .__historyRootProbe!
  204. state.stop = true
  205. return state.hidden
  206. }),
  207. ).toBe(false)
  208. })
  209. }
  210. function timelineState(page: Page) {
  211. return page.evaluate(() => ({
  212. virtual: !!document.querySelector("[data-timeline-virtual-content]"),
  213. rows: document.querySelectorAll("[data-timeline-key]").length,
  214. }))
  215. }
  216. function probeSamples(page: Page) {
  217. return page.evaluate(
  218. () => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
  219. )
  220. }
  221. async function waitForProbeSamples(page: Page, after: number) {
  222. await page.waitForFunction(
  223. (after) =>
  224. (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
  225. after,
  226. )
  227. }
  228. function visibleContentHidden(page: Page) {
  229. return page.evaluate(
  230. () => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
  231. )
  232. }