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

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