session-tab-switch-probe.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
  10. ) {
  11. await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
  12. const destination = new Set(destinationIDs)
  13. const source = new Set(sourceIDs)
  14. const samples: SessionSwitchSample[] = []
  15. let started: number | undefined
  16. let running = true
  17. const sample = () => {
  18. if (!running || started === undefined) return
  19. setTimeout(() => {
  20. if (!running || started === undefined) return
  21. const observedAtMs = performance.now() - started
  22. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  23. element.querySelector("[data-timeline-row]"),
  24. )
  25. if (root) {
  26. const view = root.getBoundingClientRect()
  27. const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
  28. .filter((element) => {
  29. const rect = element.getBoundingClientRect()
  30. return rect.bottom > view.top && rect.top < view.bottom
  31. })
  32. .map((element) => element.dataset.messageId!)
  33. const hasVisibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")].some((element) => {
  34. const rect = element.getBoundingClientRect()
  35. return rect.bottom > view.top && rect.top < view.bottom
  36. })
  37. const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
  38. samples.push({
  39. observedAtMs,
  40. destination: visible.filter((id) => destination.has(id)),
  41. source: visible.filter((id) => source.has(id)),
  42. hasVisibleRows,
  43. last: visible.includes(lastID),
  44. bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
  45. })
  46. } else {
  47. samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
  48. }
  49. requestAnimationFrame(sample)
  50. }, 0)
  51. }
  52. document.addEventListener(
  53. "click",
  54. (event) => {
  55. const link = event.target instanceof Element ? event.target.closest("a") : undefined
  56. if (link?.getAttribute("href") !== href) return
  57. started = performance.now()
  58. requestAnimationFrame(sample)
  59. },
  60. { capture: true, once: true },
  61. )
  62. ;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe = {
  63. samples,
  64. stop: () => {
  65. running = false
  66. },
  67. }
  68. }, input)
  69. }
  70. async function waitForStableSessionSwitch(page: Page) {
  71. await page.waitForFunction(() => {
  72. const samples = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.samples
  73. if (!samples) return false
  74. return samples.some((_, index) => {
  75. const stable = samples.slice(index, index + 3)
  76. return (
  77. stable.length === 3 &&
  78. stable.every(
  79. (sample) =>
  80. sample.destination.length > 0 &&
  81. sample.source.length === 0 &&
  82. sample.last &&
  83. Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
  84. )
  85. )
  86. })
  87. })
  88. }
  89. async function collectSessionSwitchResult(page: Page) {
  90. const samples = await page.evaluate(() => {
  91. const probe = (window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe!
  92. probe.stop()
  93. return probe.samples
  94. })
  95. return classifySessionSwitch(samples)
  96. }
  97. export async function measureSessionSwitch(
  98. page: Page,
  99. input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
  100. ) {
  101. const { switch: run, ...probe } = input
  102. await installSessionSwitchProbe(page, probe)
  103. await run()
  104. await waitForStableSessionSwitch(page)
  105. return collectSessionSwitchResult(page)
  106. }
  107. export async function waitForStableTimeline(page: Page, lastID: string) {
  108. const samples: Pick<SessionSwitchSample, "last" | "bottomErrorPx">[] = []
  109. await expect
  110. .poll(
  111. async () => {
  112. samples.push(
  113. await page.evaluate(
  114. (lastID) =>
  115. new Promise<Pick<SessionSwitchSample, "last" | "bottomErrorPx">>((resolve) => {
  116. requestAnimationFrame(() =>
  117. setTimeout(() => {
  118. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  119. element.querySelector("[data-timeline-row]"),
  120. )
  121. if (!root) {
  122. resolve({ last: false })
  123. return
  124. }
  125. const view = root.getBoundingClientRect()
  126. const last = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].some((element) => {
  127. if (element.dataset.messageId !== lastID) return false
  128. const rect = element.getBoundingClientRect()
  129. return rect.bottom > view.top && rect.top < view.bottom
  130. })
  131. const spacer = root
  132. .querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
  133. ?.getBoundingClientRect()
  134. resolve({ last, bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined })
  135. }, 0),
  136. )
  137. }),
  138. lastID,
  139. ),
  140. )
  141. return isStableDestination(samples.slice(-3))
  142. },
  143. { timeout: 30_000, intervals: [0] },
  144. )
  145. .toBe(true)
  146. }