session-timeline-stream-probe.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import type { Page } from "@playwright/test"
  2. const STREAM_MARKER_PATTERN = "stream-(\\d+)"
  3. const STREAM_FRAGMENT_COUNT = 18
  4. type TimelineProbeState = {
  5. started: number
  6. ended: number
  7. profileVisual: boolean
  8. minimal: boolean
  9. frames: number[]
  10. frameAt: number[]
  11. applied: { at: number; index: number }[]
  12. geometry: {
  13. scrollTop: number
  14. scrollHeight: number
  15. clientHeight: number
  16. distance: number
  17. virtualHeight: number
  18. headerHeight: number
  19. }[]
  20. blanks: number
  21. longTasks: number[]
  22. layoutShifts: number[]
  23. visibleMounts: number
  24. visibleUnmounts: number
  25. visibleRows: Set<Element>
  26. visibleSubtreeMounts: string[]
  27. visibleSubtreeUnmounts: string[]
  28. visibleSubtreeReplacements: number
  29. visibleSubtreeDropouts: string[]
  30. visibleSubtrees: Map<string, Element>
  31. subtreeKeys: WeakMap<Element, string>
  32. maxOverlap: number
  33. maxGap: number
  34. maxPartTopMovement: number
  35. previousPartTop: number
  36. slowFrames: {
  37. duration: number
  38. index: number
  39. phase: "stream" | "boundary" | "complete" | "unknown"
  40. tokenSpans: number
  41. blocks: number
  42. codeBlocks: number
  43. height: number
  44. distance: number
  45. }[]
  46. scroll: {
  47. calls: number
  48. callNoops: number
  49. sameFrameCalls: number
  50. assignments: number
  51. assignmentNoops: number
  52. lastCallFrame: number
  53. frame: number
  54. }
  55. row: HTMLElement
  56. markdown: HTMLElement
  57. running: boolean
  58. previous: number
  59. cleanup: () => void
  60. start: () => void
  61. }
  62. export async function installTimelineStreamProbe(
  63. page: Page,
  64. options: { textPartID: string; finalIndex: number; profileVisual: boolean; minimal: boolean },
  65. ) {
  66. await page.evaluate(
  67. ({ textPartID, finalIndex, profileVisual, minimal, markerPattern, fragmentCount }) => {
  68. const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
  69. const row = part?.closest<HTMLElement>("[data-timeline-row]")
  70. const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
  71. const root = part?.closest<HTMLElement>(".scroll-view__viewport")
  72. if (!part || !row || !markdown || !root) throw new Error("missing streaming benchmark nodes")
  73. const viewport = root.getBoundingClientRect()
  74. const state: TimelineProbeState = {
  75. started: 0,
  76. ended: Infinity,
  77. profileVisual,
  78. minimal,
  79. frames: [],
  80. frameAt: [],
  81. applied: [],
  82. geometry: [],
  83. blanks: 0,
  84. longTasks: [],
  85. layoutShifts: [],
  86. visibleMounts: 0,
  87. visibleUnmounts: 0,
  88. visibleRows: new Set(
  89. [...root.querySelectorAll("[data-timeline-key]")].filter((element) => {
  90. const rect = element.getBoundingClientRect()
  91. return rect.bottom > viewport.top && rect.top < viewport.bottom
  92. }),
  93. ),
  94. visibleSubtreeMounts: [],
  95. visibleSubtreeUnmounts: [],
  96. visibleSubtreeReplacements: 0,
  97. visibleSubtreeDropouts: [],
  98. visibleSubtrees: new Map<string, Element>(),
  99. subtreeKeys: new WeakMap<Element, string>(),
  100. maxOverlap: 0,
  101. maxGap: 0,
  102. maxPartTopMovement: 0,
  103. previousPartTop: part.getBoundingClientRect().top,
  104. slowFrames: [],
  105. scroll: {
  106. calls: 0,
  107. callNoops: 0,
  108. sameFrameCalls: 0,
  109. assignments: 0,
  110. assignmentNoops: 0,
  111. lastCallFrame: -1,
  112. frame: 0,
  113. },
  114. row,
  115. markdown,
  116. running: false,
  117. previous: 0,
  118. cleanup: () => {},
  119. start: () => {},
  120. }
  121. ;(window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark = state
  122. const scrollTo = Element.prototype.scrollTo
  123. const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
  124. if (profileVisual) {
  125. Element.prototype.scrollTo = function (...args) {
  126. state.scroll.calls += 1
  127. const top = typeof args[0] === "object" ? args[0]?.top : args[1]
  128. if (typeof top === "number") {
  129. const target = Math.min(top, this.scrollHeight - this.clientHeight)
  130. if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
  131. }
  132. if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
  133. state.scroll.lastCallFrame = state.scroll.frame
  134. return scrollTo.apply(this, args)
  135. }
  136. Object.defineProperty(Element.prototype, "scrollTop", {
  137. configurable: true,
  138. get: scrollTop.get,
  139. set(value) {
  140. state.scroll.assignments += 1
  141. if (Math.abs(this.scrollTop - value) < 1) state.scroll.assignmentNoops += 1
  142. scrollTop.set!.call(this, value)
  143. },
  144. })
  145. }
  146. const recordLongTasks = (entries: PerformanceEntry[]) => {
  147. if (!state.running) return
  148. state.longTasks.push(
  149. ...entries
  150. .filter((entry) => entry.startTime >= state.started && entry.startTime <= state.ended)
  151. .map((entry) => entry.duration),
  152. )
  153. }
  154. const longTaskObserver = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
  155. longTaskObserver.observe({ type: "longtask" })
  156. const recordLayoutShifts = (entries: PerformanceEntry[]) => {
  157. if (!state.running) return
  158. state.layoutShifts.push(
  159. ...entries
  160. .map((entry) => {
  161. const shift = entry as LayoutShiftEntry
  162. if (shift.startTime < state.started || shift.hadRecentInput) return
  163. return shift.value
  164. })
  165. .filter((value): value is number => value !== undefined),
  166. )
  167. }
  168. const layoutShiftObserver = profileVisual
  169. ? new PerformanceObserver((list) => recordLayoutShifts(list.getEntries()))
  170. : undefined
  171. layoutShiftObserver?.observe({ type: "layout-shift", buffered: true })
  172. const visible = (element: Element) => {
  173. const rect = element.getBoundingClientRect()
  174. const viewport = root.getBoundingClientRect()
  175. const style = getComputedStyle(element)
  176. return (
  177. element.isConnected &&
  178. rect.width > 0 &&
  179. rect.height > 0 &&
  180. rect.bottom > viewport.top &&
  181. rect.top < viewport.bottom &&
  182. style.display !== "none" &&
  183. style.visibility !== "hidden" &&
  184. Number(style.opacity) > 0
  185. )
  186. }
  187. const critical = [
  188. "[data-timeline-part-id]",
  189. '[data-component="edit-content"]',
  190. '[data-component="apply-patch-file-diff"]',
  191. '[data-component="file"]',
  192. '[data-component="markdown-code"]',
  193. "[data-markdown-block]",
  194. ].join(",")
  195. const describe = (element: Element) => {
  196. const cached = state.subtreeKeys.get(element)
  197. if (!element.isConnected && cached) return cached
  198. const part = element.closest<HTMLElement>("[data-timeline-part-id]")?.dataset.timelinePartId ?? "unknown"
  199. const block = element
  200. .closest<HTMLElement>("[data-markdown-key]")
  201. ?.dataset.markdownKey?.replace(/:(?:code|full|live)$/, "")
  202. const component =
  203. element.getAttribute("data-component") ?? element.getAttribute("data-markdown-block") ?? element.tagName
  204. const key = `${part}:${block ?? "root"}:${component}`
  205. state.subtreeKeys.set(element, key)
  206. return key
  207. }
  208. const recordMutations = (records: MutationRecord[]) => {
  209. if (!state.running) return
  210. records.forEach((record) => {
  211. record.addedNodes.forEach((node) => {
  212. if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && visible(node)) {
  213. state.visibleMounts += 1
  214. state.visibleRows.add(node)
  215. }
  216. if (!(node instanceof Element)) return
  217. const added = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
  218. added.forEach((element) => {
  219. if (visible(element)) state.visibleSubtreeMounts.push(describe(element))
  220. })
  221. })
  222. record.removedNodes.forEach((node) => {
  223. if (node instanceof HTMLElement && node.matches("[data-timeline-key]") && state.visibleRows.delete(node))
  224. state.visibleUnmounts += 1
  225. if (!(node instanceof Element)) return
  226. const removed = [node, ...node.querySelectorAll(critical)].filter((element) => element.matches(critical))
  227. removed.forEach((element) => {
  228. const key = describe(element)
  229. if (state.visibleSubtrees.get(key) === element) state.visibleSubtreeUnmounts.push(key)
  230. })
  231. })
  232. })
  233. }
  234. const mutationObserver = profileVisual ? new MutationObserver(recordMutations) : undefined
  235. mutationObserver?.observe(root, { childList: true, subtree: true })
  236. const currentPart = () => root.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
  237. const observeProgress = (at: number) => {
  238. if (!state.running) return
  239. const content = currentPart()?.textContent ?? ""
  240. const index = content.includes("benchmark-complete")
  241. ? finalIndex
  242. : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
  243. if (index >= 0 && index !== state.applied.at(-1)?.index) state.applied.push({ at, index })
  244. }
  245. const progressObserver = new MutationObserver(() => observeProgress(performance.now()))
  246. progressObserver.observe(root, { characterData: true, childList: true, subtree: true })
  247. state.cleanup = () => {
  248. recordLongTasks(longTaskObserver.takeRecords())
  249. recordLayoutShifts(layoutShiftObserver?.takeRecords() ?? [])
  250. recordMutations(mutationObserver?.takeRecords() ?? [])
  251. if (progressObserver.takeRecords().length) observeProgress(performance.now())
  252. longTaskObserver.disconnect()
  253. layoutShiftObserver?.disconnect()
  254. mutationObserver?.disconnect()
  255. progressObserver.disconnect()
  256. if (!profileVisual) return
  257. Element.prototype.scrollTo = scrollTo
  258. Object.defineProperty(Element.prototype, "scrollTop", scrollTop)
  259. }
  260. const sample = (now: number) => {
  261. if (!state.running) return
  262. state.frameAt.push(now)
  263. observeProgress(now)
  264. if (minimal) {
  265. state.frames.push(now - state.previous)
  266. state.previous = now
  267. requestAnimationFrame(sample)
  268. return
  269. }
  270. setTimeout(() => {
  271. if (!state.running) return
  272. state.scroll.frame += 1
  273. const duration = now - state.previous
  274. state.frames.push(duration)
  275. state.previous = now
  276. const virtualRoot = root.querySelector<HTMLElement>("[data-timeline-virtual-content]")
  277. const header = root.querySelector<HTMLElement>("[data-session-title]")
  278. state.geometry.push({
  279. scrollTop: root.scrollTop,
  280. scrollHeight: root.scrollHeight,
  281. clientHeight: root.clientHeight,
  282. distance: root.scrollHeight - root.clientHeight - root.scrollTop,
  283. virtualHeight: virtualRoot?.getBoundingClientRect().height ?? 0,
  284. headerHeight: header?.getBoundingClientRect().height ?? 0,
  285. })
  286. const viewport = root.getBoundingClientRect()
  287. if (profileVisual) {
  288. const visibleRows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
  289. .map((element) => ({ element, rect: element.getBoundingClientRect() }))
  290. .filter((item) => item.rect.bottom > viewport.top && item.rect.top < viewport.bottom)
  291. .sort((a, b) => a.rect.top - b.rect.top)
  292. state.visibleRows = new Set(visibleRows.map((item) => item.element))
  293. const rows = visibleRows.map((item) => item.rect)
  294. rows.slice(1).forEach((rect, index) => {
  295. const previous = rows[index]!
  296. state.maxOverlap = Math.max(state.maxOverlap, previous.bottom - rect.top)
  297. state.maxGap = Math.max(state.maxGap, rect.top - previous.bottom)
  298. })
  299. const partTop = part.getBoundingClientRect().top
  300. state.maxPartTopMovement = Math.max(state.maxPartTopMovement, Math.abs(partTop - state.previousPartTop))
  301. state.previousPartTop = partTop
  302. }
  303. const visibleRow = [...root.querySelectorAll<HTMLElement>("[data-timeline-row]")].some((element) => {
  304. const rect = element.getBoundingClientRect()
  305. return rect.bottom > viewport.top && rect.top < viewport.bottom
  306. })
  307. if (!visibleRow) state.blanks += 1
  308. if (profileVisual) {
  309. const subtrees = new Map<string, { element: Element; rendered: boolean }>()
  310. const visibleSubtrees = new Map<string, Element>()
  311. root.querySelectorAll(critical).forEach((element) => {
  312. const key = describe(element)
  313. const rect = element.getBoundingClientRect()
  314. const style = getComputedStyle(element)
  315. const rendered =
  316. element.isConnected &&
  317. rect.width > 0 &&
  318. rect.height > 0 &&
  319. style.display !== "none" &&
  320. style.visibility !== "hidden" &&
  321. Number(style.opacity) > 0
  322. subtrees.set(key, { element, rendered })
  323. if (rendered && rect.bottom > viewport.top && rect.top < viewport.bottom) {
  324. const previous = state.visibleSubtrees.get(key)
  325. if (previous && previous !== element && key.startsWith(`${textPartID}:`))
  326. state.visibleSubtreeReplacements += 1
  327. visibleSubtrees.set(key, element)
  328. }
  329. })
  330. state.visibleSubtrees.forEach((element, key) => {
  331. const current = subtrees.get(key)
  332. if (key.startsWith(`${textPartID}:`) && !current?.rendered) {
  333. const markdown = part.querySelector<HTMLElement>('[data-component="markdown"]')
  334. state.visibleSubtreeDropouts.push(
  335. `${key}:projection=${markdown?.dataset.markdownProjectionLength}/${markdown?.dataset.markdownProjectionBlocks}:result=${markdown?.dataset.markdownResultLength}/${markdown?.dataset.markdownResultBlocks}:applied=${markdown?.dataset.markdownAppliedBlocks}:dom=${markdown?.children.length}`,
  336. )
  337. }
  338. if (element.matches('[data-component="file"]')) {
  339. const hadLines = element.hasAttribute("data-profiler-had-lines")
  340. const hasLines = element.shadowRoot?.querySelector("[data-line]") != null
  341. if (hasLines) element.setAttribute("data-profiler-had-lines", "")
  342. if (hadLines && !hasLines) state.visibleSubtreeDropouts.push(`${key}:shadow-lines`)
  343. }
  344. })
  345. state.visibleSubtrees = visibleSubtrees
  346. }
  347. if (profileVisual && duration > 33.34) {
  348. const livePart = currentPart()
  349. const content = livePart?.textContent ?? ""
  350. const complete = content.includes("benchmark-complete")
  351. const index = complete
  352. ? finalIndex
  353. : Number(content.match(new RegExp(markerPattern, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
  354. state.slowFrames.push({
  355. duration,
  356. index,
  357. phase: complete
  358. ? "complete"
  359. : index >= 0 && index % fragmentCount === 0
  360. ? "boundary"
  361. : index >= 0
  362. ? "stream"
  363. : "unknown",
  364. tokenSpans: livePart?.querySelectorAll(".shiki span").length ?? 0,
  365. blocks: livePart?.querySelectorAll("[data-markdown-block]").length ?? 0,
  366. codeBlocks: livePart?.querySelectorAll('[data-component="markdown-code"]').length ?? 0,
  367. height: livePart?.getBoundingClientRect().height ?? 0,
  368. distance: root.scrollHeight - root.clientHeight - root.scrollTop,
  369. })
  370. }
  371. requestAnimationFrame(sample)
  372. }, 0)
  373. }
  374. state.start = () => {
  375. state.started = performance.now()
  376. state.previous = state.started
  377. state.running = true
  378. requestAnimationFrame(sample)
  379. }
  380. },
  381. { ...options, markerPattern: STREAM_MARKER_PATTERN, fragmentCount: STREAM_FRAGMENT_COUNT },
  382. )
  383. }
  384. export function startTimelineStreamProbe(page: Page) {
  385. return page.evaluate(() => {
  386. const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
  387. if (!state) throw new Error("missing streaming benchmark state")
  388. state.start()
  389. })
  390. }
  391. type LayoutShiftEntry = PerformanceEntry & { value: number; hadRecentInput?: boolean }
  392. export function layoutShiftValue(
  393. entry: Pick<LayoutShiftEntry, "startTime" | "value" | "hadRecentInput">,
  394. start: number,
  395. ) {
  396. if (entry.startTime < start || entry.hadRecentInput) return
  397. return entry.value
  398. }
  399. export function removeVisibleRow<T>(visible: Set<T>, row: T) {
  400. return visible.delete(row)
  401. }
  402. export function streamProgress(content: string) {
  403. const index = Number(content.match(new RegExp(STREAM_MARKER_PATTERN, "g"))?.at(-1)?.match(/\d+/)?.[0] ?? -1)
  404. return {
  405. index,
  406. phase: content.includes("benchmark-complete")
  407. ? ("complete" as const)
  408. : index >= 0 && index % STREAM_FRAGMENT_COUNT === 0
  409. ? ("boundary" as const)
  410. : index >= 0
  411. ? ("stream" as const)
  412. : ("unknown" as const),
  413. }
  414. }
  415. export async function collectTimelineStreamMetrics(
  416. page: Page,
  417. options: { textPartID: string; finalIndex: number; navigations: string[] },
  418. ) {
  419. return page.evaluate(({ textPartID, finalIndex, navigations }) => {
  420. const state = (window as Window & { __timelineStreamBenchmark?: TimelineProbeState }).__timelineStreamBenchmark
  421. if (!state) throw new Error(`missing streaming benchmark state after navigation: ${JSON.stringify(navigations)}`)
  422. state.ended = performance.now()
  423. state.cleanup()
  424. state.running = false
  425. const part = document.querySelector<HTMLElement>(`[data-timeline-part-id="${textPartID}"]`)
  426. const row = part?.closest<HTMLElement>("[data-timeline-row]")
  427. const markdown = part?.querySelector<HTMLElement>('[data-component="markdown"]')
  428. const sorted = state.frames.slice().sort((a, b) => a - b)
  429. const duration = state.frames.reduce((sum, value) => sum + value, 0)
  430. const longestSlowStreak = state.frames.reduce(
  431. (result, value) => {
  432. const current = value > 33.34 ? result.current + 1 : 0
  433. return { current, longest: Math.max(result.longest, current) }
  434. },
  435. { current: 0, longest: 0 },
  436. ).longest
  437. const busyStart = state.applied.at(0)?.at
  438. const completion = state.applied.find((value) => value.index === finalIndex)
  439. const busyEnd = completion?.at
  440. const busyFrames =
  441. busyStart === undefined || busyEnd === undefined
  442. ? []
  443. : state.frames.filter((_, index) => state.frameAt[index]! >= busyStart && state.frameAt[index]! <= busyEnd)
  444. const busySorted = busyFrames.slice().sort((a, b) => a - b)
  445. const busyDuration = busyFrames.reduce((sum, value) => sum + value, 0)
  446. const completionObservedMs = (completion?.at ?? NaN) - state.started
  447. const visual = state.profileVisual
  448. ? {
  449. layoutShiftValueSum: state.layoutShifts.reduce((sum, value) => sum + value, 0),
  450. maxLayoutShiftValue: Math.max(0, ...state.layoutShifts),
  451. visibleMounts: state.visibleMounts,
  452. visibleUnmounts: state.visibleUnmounts,
  453. visibleSubtreeMounts: state.visibleSubtreeMounts,
  454. visibleSubtreeUnmounts: [...new Set(state.visibleSubtreeUnmounts)],
  455. visibleSubtreeReplacements: state.visibleSubtreeReplacements,
  456. visibleSubtreeDropouts: [...new Set(state.visibleSubtreeDropouts)],
  457. maxOverlapPx: state.maxOverlap,
  458. maxGapPx: state.maxGap,
  459. maxPartTopMovementPx: state.maxPartTopMovement,
  460. slowestRafGaps: state.slowFrames
  461. .sort((a, b) => b.duration - a.duration)
  462. .slice(0, 20)
  463. .map((frame) => ({
  464. durationMs: frame.duration,
  465. index: frame.index,
  466. phase: frame.phase,
  467. tokenSpans: frame.tokenSpans,
  468. blocks: frame.blocks,
  469. codeBlocks: frame.codeBlocks,
  470. heightPx: frame.height,
  471. distancePx: frame.distance,
  472. })),
  473. slowRafGapPhases: Object.fromEntries(
  474. ["stream", "boundary", "complete", "unknown"].map((phase) => {
  475. const frames = state.slowFrames.filter((frame) => frame.phase === phase)
  476. return [
  477. phase,
  478. {
  479. count: frames.length,
  480. totalMs: frames.reduce((sum, frame) => sum + frame.duration, 0),
  481. maxMs: Math.max(0, ...frames.map((frame) => frame.duration)),
  482. },
  483. ]
  484. }),
  485. ),
  486. scroll: state.scroll,
  487. }
  488. : null
  489. const geometry = state.minimal
  490. ? null
  491. : {
  492. maxDistancePx: Math.max(0, ...state.geometry.map((sample) => sample.distance)),
  493. finalDistancePx: state.geometry.at(-1)?.distance ?? 0,
  494. final: state.geometry.at(-1),
  495. distanceTransitionsPx: state.geometry
  496. .map((sample) => Math.round(sample.distance))
  497. .filter((value, index, values) => index === 0 || value !== values[index - 1]),
  498. bottomDriftTransitions: state.geometry.slice(1).filter((value, index) => {
  499. const previous = state.geometry[index]?.distance ?? 0
  500. return previous <= 1 && value.distance > 1
  501. }).length,
  502. blankSamples: state.blanks,
  503. }
  504. return {
  505. capabilities: { visual: state.profileVisual, geometry: !state.minimal },
  506. completionObservedMs,
  507. deltasPerSecond: Number.isFinite(completionObservedMs) ? finalIndex / (completionObservedMs / 1_000) : null,
  508. rafGapSamples: state.frames.length,
  509. rafCallbackRate: duration ? (state.frames.length * 1000) / duration : 0,
  510. observedProgressWindowRafCallbackRate: busyDuration ? (busyFrames.length * 1000) / busyDuration : null,
  511. observedProgressWindowRafGapP95Ms: busySorted[Math.floor(busySorted.length * 0.95)] ?? null,
  512. observedProgressWindowRafGaps: busyFrames.length,
  513. maxObservedProgressIndex: Math.max(-1, ...state.applied.map((value) => value.index)),
  514. observedProgressTransitions: state.applied.length,
  515. rafGapP50Ms: sorted[Math.floor(sorted.length * 0.5)] ?? 0,
  516. rafGapP95Ms: sorted[Math.floor(sorted.length * 0.95)] ?? 0,
  517. rafGapP99Ms: sorted[Math.floor(sorted.length * 0.99)] ?? 0,
  518. maxRafGapMs: sorted.at(-1) ?? 0,
  519. rafGapsOver33Ms: state.frames.filter((value) => value > 33.34).length,
  520. rafGapsOver50Ms: state.frames.filter((value) => value > 50).length,
  521. missedFrameBudgetEquivalents: state.frames.reduce(
  522. (sum, value) => sum + Math.max(0, Math.round(value / 16.67) - 1),
  523. 0,
  524. ),
  525. longestRafGapOver33MsStreak: longestSlowStreak,
  526. longTaskCount: state.longTasks.length,
  527. longTaskTimeMs: state.longTasks.reduce((sum, value) => sum + value, 0),
  528. visual,
  529. geometry,
  530. rowReplaced: row !== state.row,
  531. markdownReplaced: markdown !== state.markdown,
  532. domTextCharacters: part?.textContent?.length ?? 0,
  533. }
  534. }, options)
  535. }