review-pane-scaling-benchmark.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import type { Page } from "@playwright/test"
  2. import { benchmark, expect } from "../benchmark"
  3. import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
  4. const changedLinesPerFile = 100
  5. const linesPerSide = changedLinesPerFile / 2
  6. const fileCounts = [1, 10, 100, 1_000, 10_000]
  7. const filesPerDirectory = 100
  8. const readyFrames = 3
  9. const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
  10. type ReviewPaneScalingSample = {
  11. observedAtMs: number
  12. logicalRows: number
  13. treeRows: number
  14. fileRows: number
  15. diffLines: number
  16. header: string
  17. ready: boolean
  18. }
  19. type ReviewPaneScalingProbe = {
  20. startedAt?: number
  21. firstTreeRowMs?: number
  22. logicalTreeReadyMs?: number
  23. firstDiffRenderMs?: number
  24. stableReadyMs?: number
  25. samples: ReviewPaneScalingSample[]
  26. frameTimesMs: number[]
  27. longTasks: { startTime: number; duration: number }[]
  28. stop: () => void
  29. }
  30. benchmark.describe("performance: review pane scaling", () => {
  31. for (const fileCount of fileCounts) {
  32. const changedLines = fileCount * changedLinesPerFile
  33. benchmark(
  34. `${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
  35. async ({ page, report }) => {
  36. benchmark.setTimeout(1_200_000)
  37. await page.emulateMedia({ reducedMotion: "reduce" })
  38. const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
  39. if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
  40. throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
  41. const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
  42. await setupTimelineBenchmark(page, {
  43. historyTurns: 0,
  44. eventBatch: 1,
  45. newLayoutDesigns: true,
  46. })
  47. await page.route("**/vcs/diff**", (route) =>
  48. route.fulfill({
  49. status: 200,
  50. contentType: "application/json",
  51. headers: { "access-control-allow-origin": "*" },
  52. body: responseBody,
  53. }),
  54. )
  55. const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
  56. const metrics = await measureReviewPaneLoad(page, {
  57. expectedFile: reviewFile(0),
  58. expectedRows,
  59. })
  60. const search = await measureBroadReviewSearch(page, fileCount)
  61. expect(metrics.logicalRows).toBe(expectedRows)
  62. expect(metrics.fileRows).toBeGreaterThan(0)
  63. expect(metrics.treeRows).toBeGreaterThan(0)
  64. expect(metrics.diffLines).toBeGreaterThan(0)
  65. expect(search.logicalRows).toBe(fileCount)
  66. expect(search.renderedRows).toBeGreaterThan(0)
  67. report(
  68. { ...metrics, search },
  69. {
  70. fileCount,
  71. changedLinesPerFile,
  72. changedLines,
  73. additions: changedLines / 2,
  74. deletions: changedLines / 2,
  75. patchLines: changedLines,
  76. patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
  77. payloadBytes: new TextEncoder().encode(responseBody).byteLength,
  78. expectedRows,
  79. },
  80. )
  81. },
  82. )
  83. }
  84. })
  85. async function measureBroadReviewSearch(page: Page, expectedRows: number) {
  86. const filter = page.getByRole("searchbox", { name: "Filter files" })
  87. await filter.evaluate((element) => {
  88. element.addEventListener(
  89. "input",
  90. () => {
  91. ;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
  92. },
  93. { once: true, capture: true },
  94. )
  95. })
  96. await filter.fill("file-")
  97. return page.evaluate((expectedRows) => {
  98. const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
  99. return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
  100. let previous = -1
  101. let streak = 0
  102. const sample = () => {
  103. const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
  104. const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
  105. const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
  106. const ready =
  107. logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
  108. streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
  109. previous = rows.length
  110. if (streak >= 3) {
  111. resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
  112. return
  113. }
  114. requestAnimationFrame(sample)
  115. }
  116. requestAnimationFrame(sample)
  117. })
  118. }, expectedRows)
  119. }
  120. function createScalingDiffs(fileCount: number, patchByteLimit: number) {
  121. const changes = Array.from({ length: linesPerSide }, (_, index) => {
  122. const line = String(index).padStart(3, "0")
  123. return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
  124. }).join("\n")
  125. let patchBytes = 0
  126. let capped = false
  127. return Array.from({ length: fileCount }, (_, index) => {
  128. const file = reviewFile(index)
  129. const fullPatch = [
  130. `diff --git a/${file} b/${file}`,
  131. `--- a/${file}`,
  132. `+++ b/${file}`,
  133. `@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
  134. changes,
  135. ].join("\n")
  136. if (index === 0 && fullPatch.length > patchByteLimit)
  137. throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
  138. const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
  139. if (patch === fullPatch) patchBytes += fullPatch.length
  140. else capped = true
  141. return {
  142. file,
  143. patch,
  144. additions: linesPerSide,
  145. deletions: linesPerSide,
  146. status: "modified" as const,
  147. }
  148. })
  149. }
  150. function emptyReviewPatch(file: string) {
  151. return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
  152. }
  153. function reviewFile(index: number) {
  154. return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
  155. }
  156. async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
  157. const toggle = page.getByRole("button", { name: "Toggle review" })
  158. await expect(toggle).toBeVisible()
  159. await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
  160. await installReviewPaneScalingProbe(page, input)
  161. await toggle.click()
  162. await page.waitForFunction(
  163. () =>
  164. (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
  165. ?.stableReadyMs !== undefined,
  166. undefined,
  167. { timeout: completionTimeoutMs },
  168. )
  169. return page.evaluate(() => {
  170. const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
  171. probe.stop()
  172. const startedAt = probe.startedAt!
  173. const final = probe.samples.at(-1)!
  174. const resources = performance
  175. .getEntriesByType("resource")
  176. .filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
  177. const resource = resources.at(-1)
  178. const longTasks = probe.longTasks.filter(
  179. (entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
  180. )
  181. const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
  182. return {
  183. firstTreeRowMs: probe.firstTreeRowMs ?? null,
  184. logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
  185. firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
  186. stableReadyMs: probe.stableReadyMs ?? null,
  187. responseStartMs: resource ? resource.responseStart - startedAt : null,
  188. responseEndMs: resource ? resource.responseEnd - startedAt : null,
  189. responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
  190. treeRows: final.treeRows,
  191. logicalRows: final.logicalRows,
  192. fileRows: final.fileRows,
  193. diffLines: final.diffLines,
  194. samples: probe.samples.length,
  195. maxFrameGapMs: Math.max(0, ...frameGaps),
  196. longTaskCount: longTasks.length,
  197. longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
  198. maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
  199. }
  200. })
  201. }
  202. async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
  203. await page.evaluate(
  204. ({ expectedFile, expectedRows, stableFrames }) => {
  205. let running = true
  206. let readyStreak = 0
  207. const basename = expectedFile.split("/").at(-1)!
  208. const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
  209. ? new PerformanceObserver((list) => {
  210. probe.longTasks.push(
  211. ...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
  212. )
  213. })
  214. : undefined
  215. const probe: ReviewPaneScalingProbe = {
  216. samples: [],
  217. frameTimesMs: [],
  218. longTasks: [],
  219. stop: () => {
  220. running = false
  221. longTaskObserver?.disconnect()
  222. },
  223. }
  224. const sample = (time: number) => {
  225. if (!running || probe.startedAt === undefined) return
  226. const panel = document.querySelector<HTMLElement>("#review-panel")
  227. const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
  228. const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
  229. const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
  230. const header =
  231. panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
  232. const viewers = panel
  233. ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
  234. : []
  235. const diffLines = viewers.reduce(
  236. (sum, viewer) =>
  237. sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
  238. 0,
  239. )
  240. const observedAtMs = time - probe.startedAt
  241. const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
  242. const ready =
  243. logicalRows === expectedRows &&
  244. fileRows.length > 0 &&
  245. header.includes(basename) &&
  246. viewers.length === 1 &&
  247. diffLines > 0
  248. const previous = probe.samples.at(-1)
  249. const stable =
  250. ready &&
  251. previous?.ready === true &&
  252. previous.logicalRows === logicalRows &&
  253. previous.treeRows === rows.length &&
  254. previous.fileRows === fileRows.length &&
  255. previous.diffLines === diffLines &&
  256. previous.header === header
  257. probe.frameTimesMs.push(observedAtMs)
  258. probe.samples.push({
  259. observedAtMs,
  260. logicalRows,
  261. treeRows: rows.length,
  262. fileRows: fileRows.length,
  263. diffLines,
  264. header,
  265. ready,
  266. })
  267. if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
  268. if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
  269. probe.logicalTreeReadyMs = observedAtMs
  270. if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
  271. readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
  272. if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
  273. if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
  274. }
  275. longTaskObserver?.observe({ type: "longtask", buffered: true })
  276. document.addEventListener(
  277. "click",
  278. (event) => {
  279. const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
  280. if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
  281. probe.startedAt = performance.now()
  282. performance.mark("opencode.review-pane-scaling.click")
  283. requestAnimationFrame(sample)
  284. },
  285. { capture: true, once: true },
  286. )
  287. ;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
  288. },
  289. { ...input, stableFrames: readyFrames },
  290. )
  291. }