navigation-milestones.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. import type { Page } from "@playwright/test"
  2. export type NavigationMilestoneSample = {
  3. observedAtMs: number
  4. milestones: Record<string, boolean>
  5. }
  6. export function summarizeNavigationMilestones(samples: NavigationMilestoneSample[]) {
  7. const names = Object.keys(samples[0]?.milestones ?? {})
  8. const summarize = (matches: (sample: NavigationMilestoneSample) => boolean) => {
  9. const first = samples.find(matches)
  10. const stable = samples.findIndex(
  11. (sample, index) =>
  12. index + 2 < samples.length && matches(sample) && matches(samples[index + 1]!) && matches(samples[index + 2]!),
  13. )
  14. return {
  15. firstObservedMs: first?.observedAtMs ?? null,
  16. stableObservedMs: stable === -1 ? null : samples[stable + 2]!.observedAtMs,
  17. }
  18. }
  19. return {
  20. samples: samples.length,
  21. milestones: Object.fromEntries(
  22. names.map((name) => [name, summarize((sample) => sample.milestones[name] === true)]),
  23. ),
  24. all: summarize((sample) => names.every((name) => sample.milestones[name] === true)),
  25. }
  26. }
  27. type NavigationMilestoneProbe = {
  28. samples: NavigationMilestoneSample[]
  29. stop: () => void
  30. }
  31. export async function measureNavigationMilestones(
  32. page: Page,
  33. input: {
  34. triggerSelector: string
  35. milestones: Record<string, { selector: string; visible?: boolean }>
  36. navigate: () => Promise<void>
  37. },
  38. ) {
  39. await page.evaluate(
  40. ({ triggerSelector, milestones }) => {
  41. const samples: NavigationMilestoneSample[] = []
  42. const streaks = new Map<string, number>()
  43. const marked = new Set<string>()
  44. let started: number | undefined
  45. let running = true
  46. const visible = (selector: string) =>
  47. [...document.querySelectorAll<HTMLElement>(selector)].some((element) => {
  48. const rect = element.getBoundingClientRect()
  49. const style = getComputedStyle(element)
  50. return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none"
  51. })
  52. const sample = () => {
  53. if (!running || started === undefined) return
  54. requestAnimationFrame(() => {
  55. setTimeout(() => {
  56. if (!running || started === undefined) return
  57. const current = Object.fromEntries(
  58. Object.entries(milestones).map(([name, milestone]) => [
  59. name,
  60. milestone.visible === false ? !document.querySelector(milestone.selector) : visible(milestone.selector),
  61. ]),
  62. )
  63. samples.push({
  64. observedAtMs: performance.now() - started,
  65. milestones: current,
  66. })
  67. Object.entries(current).forEach(([name, value]) => {
  68. if (!value) {
  69. streaks.set(name, 0)
  70. return
  71. }
  72. if (!marked.has(`${name}.first`)) {
  73. performance.mark(`opencode.navigation.${name}.first`)
  74. marked.add(`${name}.first`)
  75. }
  76. const streak = (streaks.get(name) ?? 0) + 1
  77. streaks.set(name, streak)
  78. if (streak === 3) performance.mark(`opencode.navigation.${name}.stable`)
  79. })
  80. const all = Object.values(current).every(Boolean)
  81. const allStreak = all ? (streaks.get("all") ?? 0) + 1 : 0
  82. streaks.set("all", allStreak)
  83. if (all && !marked.has("all.first")) {
  84. performance.mark("opencode.navigation.all.first")
  85. marked.add("all.first")
  86. }
  87. if (allStreak === 3) performance.mark("opencode.navigation.all.stable")
  88. sample()
  89. }, 0)
  90. })
  91. }
  92. document.addEventListener(
  93. "click",
  94. (event) => {
  95. if (!(event.target instanceof Element) || !event.target.closest(triggerSelector)) return
  96. started = performance.now()
  97. performance.mark("opencode.navigation.click")
  98. sample()
  99. },
  100. { capture: true, once: true },
  101. )
  102. ;(window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones = {
  103. samples,
  104. stop: () => {
  105. running = false
  106. },
  107. }
  108. },
  109. { triggerSelector: input.triggerSelector, milestones: input.milestones },
  110. )
  111. await input.navigate()
  112. await page.waitForFunction(() => {
  113. const samples = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones
  114. ?.samples
  115. if (!samples || samples.length < 3) return false
  116. return samples.slice(-3).every((sample) => Object.values(sample.milestones).every(Boolean))
  117. })
  118. const samples = await page.evaluate(() => {
  119. const probe = (window as Window & { __navigationMilestones?: NavigationMilestoneProbe }).__navigationMilestones!
  120. probe.stop()
  121. return probe.samples
  122. })
  123. return { summary: summarizeNavigationMilestones(samples), samples }
  124. }