session-tab-switch-probe.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import { expect, type Page } from "@playwright/test"
  2. import { classifySessionSwitch, isStableDestination, type SessionSwitchSample } from "./session-tab-switch-metrics"
  3. type SessionSwitchProbe = {
  4. samples: SessionSwitchSample[]
  5. stop: () => void
  6. }
  7. async function installSessionSwitchProbe(
  8. page: Page,
  9. input: {
  10. destinationIDs: string[]
  11. sourceIDs: string[]
  12. lastID: string
  13. requiredPartID?: string
  14. requireBottomAnchor?: boolean
  15. href: string
  16. },
  17. ) {
  18. await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
  19. const destination = new Set(destinationIDs)
  20. const source = new Set(sourceIDs)
  21. const samples: SessionSwitchSample[] = []
  22. let started: number | undefined
  23. let running = true
  24. const reviewLevels: Record<string, string> = {
  25. panel: "#review-panel",
  26. tabs: '#review-panel [data-component="tabs"]',
  27. body: '#review-panel [data-slot="session-review-v2-body"]',
  28. review: '#review-panel [data-component="session-review-v2"]',
  29. preview: '#review-panel [data-slot="session-review-v2-preview"]',
  30. scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
  31. file: '#review-panel [data-component="file"][data-mode="diff"]',
  32. }
  33. const initialReviewNodes: Record<string, Element | null> = {}
  34. const sample = () => {
  35. if (!running || started === undefined) return
  36. setTimeout(() => {
  37. if (!running || started === undefined) return
  38. const observedAtMs = performance.now() - started
  39. const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
  40. const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
  41. const initialReviewFile = initialReviewNodes.file
  42. const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
  43. const initial = initialReviewNodes[name]
  44. if (!initial) return []
  45. const current = document.querySelector(selector)
  46. return current && current !== initial ? [name] : []
  47. })
  48. const review = reviewPanel
  49. ? {
  50. fileHost: !!reviewFile,
  51. fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
  52. header:
  53. reviewPanel
  54. .querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
  55. ?.textContent?.trim() ?? "",
  56. replacedLevels,
  57. }
  58. : undefined
  59. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  60. element.querySelector("[data-timeline-row]"),
  61. )
  62. if (root) {
  63. const view = root.getBoundingClientRect()
  64. const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
  65. .filter((element) => {
  66. const rect = element.getBoundingClientRect()
  67. return rect.bottom > view.top && rect.top < view.bottom
  68. })
  69. .map((element) => element.dataset.messageId!)
  70. const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
  71. const rect = element.getBoundingClientRect()
  72. return rect.bottom > view.top && rect.top < view.bottom
  73. })
  74. const requiredPartVisible = requiredPartID
  75. ? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
  76. if (element.dataset.timelinePartId !== requiredPartID) return false
  77. const rect = element.getBoundingClientRect()
  78. return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
  79. })
  80. : undefined
  81. const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
  82. samples.push({
  83. observedAtMs,
  84. destination: visible.filter((id) => destination.has(id)),
  85. source: visible.filter((id) => source.has(id)),
  86. hasVisibleRows,
  87. last: visible.includes(lastID),
  88. requiredPartVisible,
  89. bottomAnchorRequired: requireBottomAnchor !== false,
  90. bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
  91. review,
  92. })
  93. } else {
  94. samples.push({
  95. observedAtMs,
  96. destination: [],
  97. source: [],
  98. hasVisibleRows: false,
  99. last: false,
  100. requiredPartVisible: requiredPartID ? false : undefined,
  101. bottomAnchorRequired: requireBottomAnchor !== false,
  102. review,
  103. })
  104. }
  105. requestAnimationFrame(sample)
  106. }, 0)
  107. }
  108. document.addEventListener(
  109. "click",
  110. (event) => {
  111. const link = event.target instanceof Element ? event.target.closest("a") : undefined
  112. if (link?.getAttribute("href") !== href) return
  113. started = performance.now()
  114. for (const [name, selector] of Object.entries(reviewLevels)) {
  115. initialReviewNodes[name] = document.querySelector(selector)
  116. }
  117. requestAnimationFrame(sample)
  118. },
  119. { capture: true, once: true },
  120. )
  121. ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
  122. samples,
  123. stop: () => {
  124. running = false
  125. },
  126. }
  127. }, input)
  128. }
  129. async function waitForStableSessionSwitch(page: Page) {
  130. await page.waitForFunction(() => {
  131. const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
  132. if (!samples) return false
  133. return samples.some((_, index) => {
  134. const stable = samples.slice(index, index + 3)
  135. return (
  136. stable.length === 3 &&
  137. stable.every(
  138. (sample) =>
  139. sample.destination.length > 0 &&
  140. sample.source.length === 0 &&
  141. sample.last &&
  142. sample.requiredPartVisible !== false &&
  143. (sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
  144. )
  145. )
  146. })
  147. })
  148. }
  149. async function collectSessionSwitchResult(page: Page) {
  150. const samples = await page.evaluate(() => {
  151. const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
  152. probe.stop()
  153. return probe.samples
  154. })
  155. return classifySessionSwitch(samples)
  156. }
  157. export async function measureSessionSwitch(
  158. page: Page,
  159. input: {
  160. destinationIDs: string[]
  161. sourceIDs: string[]
  162. lastID: string
  163. requiredPartID?: string
  164. requireBottomAnchor?: boolean
  165. href: string
  166. switch: () => Promise<void>
  167. },
  168. ) {
  169. const { switch: run, ...probe } = input
  170. await installSessionSwitchProbe(page, probe)
  171. try {
  172. await run()
  173. await waitForStableSessionSwitch(page)
  174. return await collectSessionSwitchResult(page)
  175. } finally {
  176. await page.evaluate(() => {
  177. ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
  178. })
  179. }
  180. }
  181. export async function waitForStableTimeline(page: Page, lastID: string) {
  182. const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
  183. await expect
  184. .poll(
  185. async () => {
  186. samples.push(
  187. await page.evaluate(
  188. (lastID) =>
  189. new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
  190. requestAnimationFrame(() =>
  191. setTimeout(() => {
  192. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  193. element.querySelector("[data-timeline-row]"),
  194. )
  195. if (!root) {
  196. resolve({ last: false })
  197. return
  198. }
  199. const view = root.getBoundingClientRect()
  200. const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
  201. if (element.dataset.messageId !== lastID) return false
  202. const rect = element.getBoundingClientRect()
  203. return rect.bottom > view.top && rect.top < view.bottom
  204. })
  205. const spacer = root
  206. .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
  207. ?.getBoundingClientRect()
  208. resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
  209. }, 0),
  210. )
  211. }),
  212. lastID,
  213. ),
  214. )
  215. return isStableDestination(samples.slice(-3))
  216. },
  217. { timeout: 30_000, intervals: [0] },
  218. )
  219. .toBe(true)
  220. }