session-parent-hydration-benchmark.spec.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import type { Page } from "@playwright/test"
  2. import { expectSessionTitle } from "../../utils/waits"
  3. import { mockKirinCodeServer } from "../../utils/mock-server"
  4. import { benchmark, expect, withBenchmarkPage } from "../benchmark"
  5. import { fixture } from "./session-timeline-stress.fixture"
  6. import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
  7. import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
  8. type ParentHydrationBenchmarkMode = "natural" | "candidate"
  9. const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
  10. if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
  11. const userID = "msg_parent_hydration_user"
  12. const user = {
  13. ...fixture.messages[fixture.targetID][0]!,
  14. info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
  15. parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
  16. ...part,
  17. id: `prt_parent_hydration_user_${index}`,
  18. messageID: userID,
  19. })),
  20. }
  21. const assistantSeed = fixture.messages[fixture.targetID][3]!
  22. const assistants = Array.from({ length: 14 }, (_, index) => {
  23. const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
  24. return {
  25. ...assistantSeed,
  26. info: {
  27. ...assistantSeed.info,
  28. id: messageID,
  29. parentID: userID,
  30. time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
  31. },
  32. parts: assistantSeed.parts.map((part, partIndex) => ({
  33. ...part,
  34. id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
  35. messageID,
  36. })),
  37. }
  38. })
  39. const messages = [user, ...assistants]
  40. const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
  41. const lastID = userID
  42. const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
  43. benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
  44. benchmark.setTimeout(180_000)
  45. const results = [] as Awaited<ReturnType<typeof trial>>[]
  46. for (let run = 0; run < 5; run++) {
  47. results.push(
  48. await withBenchmarkPage(
  49. browser,
  50. `session-parent-hydration-${mode}-${run}`,
  51. (page) => trial(page, mode),
  52. testInfo,
  53. ),
  54. )
  55. }
  56. const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
  57. report(
  58. {
  59. results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
  60. summary: {
  61. firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
  62. blankSamples: results.map((result) => result.metrics.blankSamples),
  63. requestCounts: {
  64. list: results.map((result) => result.requestCounts.list),
  65. parent: results.map((result) => result.requestCounts.parent),
  66. },
  67. historyGateCount: results.map((result) => result.historyGateCount),
  68. },
  69. },
  70. { mode },
  71. )
  72. })
  73. async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
  74. const requests: { type: "list" | "parent"; before?: string }[] = []
  75. const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
  76. let historyGates = 0
  77. await mockKirinCodeServer(page, {
  78. sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
  79. provider: fixture.provider,
  80. directory: fixture.directory,
  81. project: fixture.project,
  82. messageDelay: 50,
  83. onMessages: (request) => {
  84. if (request.sessionID === fixture.targetID && request.phase === "start")
  85. requests.push({ type: "list", before: request.before })
  86. },
  87. beforeMessagesResponse: (request) => {
  88. if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
  89. historyGates++
  90. return history!.promise
  91. },
  92. onMessage: (request) => {
  93. if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
  94. },
  95. message: (sessionID, messageID) => {
  96. if (sessionID !== fixture.targetID || messageID !== userID) return
  97. return user
  98. },
  99. pageMessages: (sessionID, limit, before) => {
  100. const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
  101. const end = before ? items.findIndex((message) => message.info.id === before) : items.length
  102. const start = Math.max(0, end - limit)
  103. return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
  104. },
  105. })
  106. await page.route(`**/session/${fixture.targetID}`, (route) =>
  107. route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
  108. )
  109. await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
  110. await page.goto(stressSessionHref(fixture.sourceID))
  111. await expectSessionTitle(page, fixture.expected.sourceTitle)
  112. await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
  113. const href = stressSessionHref(fixture.targetID)
  114. await page.evaluate(
  115. ({ href, title }) => {
  116. const link = document.createElement("a")
  117. link.id = "parent-hydration-target"
  118. link.href = href
  119. link.textContent = title
  120. document.body.append(link)
  121. },
  122. { href, title: target.title },
  123. )
  124. const metrics = await measureSessionSwitch(page, {
  125. destinationIDs: messages.map((message) => message.info.id),
  126. sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
  127. lastID,
  128. requiredPartID: lastPartID,
  129. requireBottomAnchor: false,
  130. href,
  131. switch: async () => {
  132. await page.locator("#parent-hydration-target").click()
  133. await expectSessionTitle(page, target.title)
  134. },
  135. }).finally(() => history?.resolve())
  136. expect(metrics.firstCorrectObservedMs).not.toBeNull()
  137. const requestCounts = {
  138. list: requests.filter((request) => request.type === "list").length,
  139. parent: requests.filter((request) => request.type === "parent").length,
  140. }
  141. if (mode === "candidate") {
  142. expect(requestCounts.parent).toBe(1)
  143. expect(historyGates).toBe(1)
  144. }
  145. return { metrics, requestCounts, historyGateCount: historyGates }
  146. }