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