probe.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import type { Page } from "@playwright/test"
  2. import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
  3. import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
  4. import type { VisualRegionDefinition } from "./regions"
  5. type ProbeWindow<RegionName extends string = string> = Window & {
  6. __visualStabilityProbe?: {
  7. startedAt: number
  8. markers: VisualMarker[]
  9. samples: VisualObservation<RegionName>[]
  10. stop: () => void
  11. }
  12. }
  13. const captures = new WeakMap<Page, VisualCapture>()
  14. export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
  15. page: Page,
  16. regions: Regions,
  17. ) {
  18. await stopCapture(page)
  19. await page.evaluate(() => {
  20. ;(window as ProbeWindow).__visualStabilityProbe?.stop()
  21. })
  22. const startedAtEpoch = await page.evaluate((regions) => {
  23. const samples: VisualObservation[] = []
  24. const markers: VisualMarker[] = []
  25. const startedAt = performance.now()
  26. const nodes = new WeakMap<Node, number>()
  27. const lastBounds = new Map<string, { top: number; bottom: number }>()
  28. let nextNode = 1
  29. let running = true
  30. const round = (value: number) => Math.round(value * 10) / 10
  31. const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
  32. const sample = () => {
  33. if (!running) return
  34. setTimeout(() => {
  35. if (!running) return
  36. const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  37. element.querySelector("[data-timeline-row]"),
  38. )
  39. const viewportRect = viewport?.getBoundingClientRect()
  40. samples.push({
  41. at: performance.now() - startedAt,
  42. viewport: viewport
  43. ? {
  44. top: round(viewportRect!.top),
  45. bottom: round(viewportRect!.bottom),
  46. scrollTop: round(viewport.scrollTop),
  47. scrollHeight: round(viewport.scrollHeight),
  48. clientHeight: round(viewport.clientHeight),
  49. distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
  50. }
  51. : undefined,
  52. regions: Object.fromEntries(
  53. Object.entries(regions).map(([name, config]) => {
  54. const found = document.querySelector<HTMLElement>(config.selector)
  55. const count = document.querySelectorAll(config.selector).length
  56. const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
  57. if (!element)
  58. return [
  59. name,
  60. {
  61. present: false,
  62. visible: false,
  63. inViewport: false,
  64. top: 0,
  65. bottom: 0,
  66. width: 0,
  67. height: 0,
  68. opacity: 0,
  69. count,
  70. node: 0,
  71. label: "",
  72. text: "",
  73. },
  74. ]
  75. const rect = element.getBoundingClientRect()
  76. const style = getComputedStyle(element)
  77. if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
  78. const known = rect.height > 0 ? rect : lastBounds.get(name)
  79. const painted = (() => {
  80. const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
  81. let parent = element.parentElement
  82. while (parent) {
  83. const parentStyle = getComputedStyle(parent)
  84. if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
  85. const parentRect = parent.getBoundingClientRect()
  86. result.top = Math.max(result.top, parentRect.top)
  87. result.bottom = Math.min(result.bottom, parentRect.bottom)
  88. }
  89. if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
  90. const parentRect = parent.getBoundingClientRect()
  91. result.left = Math.max(result.left, parentRect.left)
  92. result.right = Math.min(result.right, parentRect.right)
  93. }
  94. if (parent === viewport) break
  95. parent = parent.parentElement
  96. }
  97. if (viewportRect) {
  98. result.top = Math.max(result.top, viewportRect.top)
  99. result.bottom = Math.min(result.bottom, viewportRect.bottom)
  100. result.left = Math.max(result.left, viewportRect.left)
  101. result.right = Math.min(result.right, viewportRect.right)
  102. }
  103. return result
  104. })()
  105. const contentOpacity = config.opacitySelectors?.length
  106. ? Math.max(
  107. 0,
  108. ...config.opacitySelectors.flatMap((selector) =>
  109. [...element.querySelectorAll(selector)].map((node) => {
  110. let value = 1
  111. let current: Element | null = node
  112. while (current) {
  113. value *= opacity(current)
  114. if (current === element) break
  115. current = current.parentElement
  116. }
  117. return value
  118. }),
  119. ),
  120. )
  121. : opacity(element)
  122. let visibleOpacity = contentOpacity
  123. let ancestor = element.parentElement
  124. let ancestorHidden = false
  125. while (ancestor) {
  126. const ancestorStyle = getComputedStyle(ancestor)
  127. visibleOpacity *= Number(ancestorStyle.opacity)
  128. if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
  129. if (ancestor === viewport) break
  130. ancestor = ancestor.parentElement
  131. }
  132. const cssHidden =
  133. ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
  134. return [
  135. name,
  136. {
  137. present: true,
  138. visible:
  139. style.display !== "none" &&
  140. style.visibility !== "hidden" &&
  141. visibleOpacity > 0 &&
  142. painted.right > painted.left &&
  143. painted.bottom > painted.top,
  144. inViewport:
  145. !viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
  146. cssHidden,
  147. top: round(painted.top),
  148. bottom: round(painted.bottom),
  149. layoutTop: round(rect.top),
  150. layoutBottom: round(rect.bottom),
  151. width: round(painted.right - painted.left),
  152. height: round(painted.bottom - painted.top),
  153. opacity: round(visibleOpacity),
  154. count,
  155. node: (() => {
  156. const current = nodes.get(element)
  157. if (current) return current
  158. nodes.set(element, nextNode)
  159. return nextNode++
  160. })(),
  161. label: element.getAttribute("aria-label") ?? "",
  162. text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
  163. },
  164. ]
  165. }),
  166. ),
  167. })
  168. requestAnimationFrame(sample)
  169. }, 0)
  170. }
  171. ;(window as ProbeWindow).__visualStabilityProbe = {
  172. startedAt,
  173. markers,
  174. samples,
  175. stop: () => {
  176. running = false
  177. },
  178. }
  179. requestAnimationFrame(sample)
  180. return new Promise<number>((resolve) => {
  181. const ready = () => {
  182. if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
  183. requestAnimationFrame(ready)
  184. }
  185. ready()
  186. })
  187. }, regions)
  188. const capture = await startVisualCapture(page, startedAtEpoch)
  189. if (capture) captures.set(page, capture)
  190. }
  191. export async function stopVisualProbe<RegionName extends string = string>(
  192. page: Page,
  193. ): Promise<VisualProbeResult<RegionName>> {
  194. return page
  195. .evaluate(() => {
  196. const probe = (window as ProbeWindow).__visualStabilityProbe
  197. if (!probe) throw new Error("Visual stability probe is not running")
  198. probe.stop()
  199. return { markers: probe.markers, samples: probe.samples }
  200. })
  201. .then(
  202. async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
  203. async (error: unknown) => {
  204. await stopCapture(page)
  205. throw error
  206. },
  207. )
  208. }
  209. export async function markVisualProbe(page: Page, label: string) {
  210. await page.evaluate((label) => {
  211. const probe = (window as ProbeWindow).__visualStabilityProbe
  212. if (!probe) return
  213. probe.markers.push({ at: performance.now() - probe.startedAt, label })
  214. }, label)
  215. }
  216. async function stopCapture(page: Page) {
  217. const capture = captures.get(page)
  218. if (capture) captures.delete(page)
  219. return stopVisualCapture(capture)
  220. }