From 46b8692c5d5fed90f0226af557222c9f9b2991ea Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 19 Jul 2026 00:02:51 +0200 Subject: [PATCH] test(studio): add timeline browser performance gate --- packages/studio/package.json | 1 + .../timeline-virtualization/hyperframes.json | 8 + .../timeline-virtualization/index.html | 39 +++ .../tests/e2e/timeline-virtualization.mjs | 298 ++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json create mode 100644 packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html create mode 100644 packages/studio/tests/e2e/timeline-virtualization.mjs diff --git a/packages/studio/package.json b/packages/studio/package.json index 71faf7e722..d85ad498e5 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -40,6 +40,7 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:timeline-virtualization": "node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest" }, "dependencies": { diff --git a/packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json b/packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json new file mode 100644 index 0000000000..c4816b5eaa --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/timeline-virtualization/hyperframes.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + } +} diff --git a/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html new file mode 100644 index 0000000000..2846170bf5 --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/timeline-virtualization/index.html @@ -0,0 +1,39 @@ + + + + + + Timeline virtualization QA fixture + + + +
+ + + diff --git a/packages/studio/tests/e2e/timeline-virtualization.mjs b/packages/studio/tests/e2e/timeline-virtualization.mjs new file mode 100644 index 0000000000..de44a88a95 --- /dev/null +++ b/packages/studio/tests/e2e/timeline-virtualization.mjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node +/** + * Reproducible timeline viewport gate against a running Studio preview of the + * adjacent fixture. The script prints machine-readable evidence; it never + * substitutes synthetic timings for a browser run. + * + * STUDIO_URL=http://127.0.0.1:5190/#project/timeline-virtualization \ + * node packages/studio/tests/e2e/timeline-virtualization.mjs + */ +import { existsSync, readdirSync } from "node:fs"; +import { homedir, platform, arch } from "node:os"; +import { join } from "node:path"; +import puppeteer from "puppeteer-core"; + +const STUDIO_URL = process.env.STUDIO_URL; +const PROFILE = process.env.TIMELINE_PROFILE || "dense-short"; +const ELEMENT_COUNT = Number(process.env.TIMELINE_ELEMENT_COUNT || 50_000); +const TIER = process.env.TIMELINE_TIER || "primary"; +const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR + ? Number(process.env.TIMELINE_CHROME_MAJOR) + : null; + +if (!STUDIO_URL) { + console.error("STUDIO_URL is required and must point at the timeline-virtualization fixture"); + process.exit(2); +} +if ( + ![1_000, 50_000].includes(ELEMENT_COUNT) || + !["primary", "low-resource", "high-dpr"].includes(TIER) +) { + console.error( + "TIMELINE_ELEMENT_COUNT must be 1000 or 50000; " + + "TIMELINE_TIER must be primary, low-resource, or high-dpr", + ); + process.exit(2); +} + +function resolveChromeExecutable() { + const chromeRoot = join(homedir(), ".cache", "puppeteer", "chrome"); + const builds = existsSync(chromeRoot) ? readdirSync(chromeRoot).sort().reverse() : []; + const installedCandidates = builds.flatMap((build) => + [ + "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + "chrome-linux64/chrome", + ].map((relative) => join(chromeRoot, build, relative)), + ); + return [ + process.env.PUPPETEER_EXECUTABLE_PATH, + process.env.CHROME_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + ...installedCandidates, + ].find((candidate) => candidate && existsSync(candidate)); +} + +function percentile(values, ratio) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; +} + +async function collectHeapBytes(client) { + const usage = await client.send("Runtime.getHeapUsage"); + return usage.usedSize; +} + +async function collectRun(page) { + return page.evaluate(async () => { + const longTasks = []; + const scroller = findTimelineScroller(); + const observer = observeLongTasks(longTasks); + const { interactions, frameIntervals } = await measureScrollInteractions(scroller); + observer?.disconnect(); + return { + interactionP95Ms: percentileInPage(interactions, 0.95), + frameIntervalP95Ms: percentileInPage(frameIntervals, 0.95), + longestTaskMs: Math.max(0, ...longTasks), + scrollWidth: scroller.scrollWidth, + scrollHeight: scroller.scrollHeight, + diagnostics: window.__studioTest.readTimelinePerformanceDiagnostics(), + }; + + function percentileInPage(values, ratio) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]; + } + + function findTimelineScroller() { + const root = document.querySelector('[aria-label="Timeline"]'); + if (!(root instanceof HTMLElement)) throw new Error("Timeline root not mounted"); + const scroller = Array.from(root.querySelectorAll("div")).find( + (node) => node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight, + ); + if (!(scroller instanceof HTMLElement)) throw new Error("Timeline scroller not mounted"); + return scroller; + } + + function observeLongTasks(longTaskDurations) { + if ( + typeof PerformanceObserver !== "function" || + !PerformanceObserver.supportedEntryTypes.includes("longtask") + ) { + return null; + } + const performanceObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) longTaskDurations.push(entry.duration); + }); + performanceObserver.observe({ entryTypes: ["longtask"] }); + return performanceObserver; + } + + async function measureScrollInteractions(timelineScroller) { + const interactions = []; + const frameIntervals = []; + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + for (const ratio of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) { + const started = performance.now(); + timelineScroller.scrollLeft = Math.round( + (timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio, + ); + timelineScroller.scrollTop = Math.round( + (timelineScroller.scrollHeight - timelineScroller.clientHeight) * ratio, + ); + const firstFrame = await nextFrame(); + const secondFrame = await nextFrame(); + interactions.push(secondFrame - started); + frameIntervals.push(secondFrame - firstFrame); + } + return { interactions, frameIntervals }; + } + }); +} + +async function measureMaximumReliableScrollWidth(page) { + return page.evaluate(() => { + const viewportWidth = 320; + const container = document.createElement("div"); + const content = document.createElement("div"); + container.style.cssText = `position:fixed;left:-10000px;top:0;width:${viewportWidth}px;height:1px;overflow:auto`; + content.style.height = "1px"; + container.append(content); + document.body.append(container); + const reliable = (width) => { + content.style.width = `${width}px`; + container.scrollLeft = width; + const expected = width - viewportWidth; + return container.scrollWidth >= width - 1 && container.scrollLeft >= expected - 1; + }; + let low = 0; + let high = 64_000_000; + while (low + 1 < high) { + const middle = Math.floor((low + high) / 2); + if (reliable(middle)) low = middle; + else high = middle; + } + container.remove(); + return low; + }); +} + +const executablePath = resolveChromeExecutable(); +if (!executablePath) { + console.error("No Chrome executable found; set PUPPETEER_EXECUTABLE_PATH"); + process.exit(2); +} + +const browser = await puppeteer.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); +let exitCode = 1; +try { + const version = await browser.version(); + const chromeMajor = Number(/(?:Chrome|Chromium)\/(\d+)/.exec(version)?.[1]); + if (EXPECTED_CHROME_MAJOR !== null && chromeMajor !== EXPECTED_CHROME_MAJOR) { + throw new Error( + `Pinned Chrome ${EXPECTED_CHROME_MAJOR} required, received ${version}. ` + + "Override TIMELINE_CHROME_MAJOR only when intentionally recording a new baseline.", + ); + } + const page = await browser.newPage(); + await page.setViewport({ + width: 1440, + height: 900, + deviceScaleFactor: TIER === "high-dpr" ? 2 : 1, + }); + const client = await page.createCDPSession(); + if (TIER === "low-resource") { + await client.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + } + await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForFunction( + () => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function", + { timeout: 30_000 }, + ); + + await page.evaluate((profile) => { + window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); + }, PROFILE); + await client.send("HeapProfiler.collectGarbage"); + const baselineHeapBytes = await collectHeapBytes(client); + + const summary = await page.evaluate( + ({ elementCount, profile }) => + window.__studioTest.loadTimelinePerformanceFixture({ elementCount, profile }), + { elementCount: ELEMENT_COUNT, profile: PROFILE }, + ); + await page.waitForFunction(() => document.querySelector('[aria-label="Timeline"]')); + const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets); + const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); + + const runs = []; + const interactionLimitMs = + TIER === "primary" ? budgets.interactionP95Ms : budgets.constrainedInteractionP95Ms; + const frameIntervalLimitMs = + TIER === "primary" ? budgets.frameIntervalP95Ms : budgets.constrainedFrameIntervalP95Ms; + for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) { + const run = await collectRun(page); + if (index >= budgets.warmupRuns) runs.push(run); + } + for (const run of runs) { + run.passed = + run.interactionP95Ms <= interactionLimitMs && + run.frameIntervalP95Ms <= frameIntervalLimitMs && + run.longestTaskMs <= budgets.longTaskLimitMs && + run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots && + run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow && + run.diagnostics.mountedTimelineDescendants < budgets.maxMountedTimelineDescendants; + } + + await page.evaluate((profile) => { + window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile }); + }, PROFILE); + await client.send("HeapProfiler.collectGarbage"); + const returnedHeapBytes = await collectHeapBytes(client); + const memoryReturned = + returnedHeapBytes <= baselineHeapBytes * (1 + budgets.memoryReturnToleranceRatio); + const passingRuns = runs.filter((run) => run.passed).length; + const maxTimelineContentWidthPx = Math.max(0, ...runs.map((run) => run.scrollWidth)); + const directScrollGate = { + safetyEnvelopePx: budgets.directScrollSafetyPx, + maxTimelineContentWidthPx, + measuredMaxReliableScrollWidth, + decision: + maxTimelineContentWidthPx <= budgets.directScrollSafetyPx && + measuredMaxReliableScrollWidth >= maxTimelineContentWidthPx + ? "approved" + : "rejected", + }; + const evidence = { + environment: { + browser: version, + executablePath, + os: platform(), + architecture: arch(), + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: TIER === "high-dpr" ? 2 : 1, + cpuThrottleRate: TIER === "low-resource" ? 4 : 1, + tier: TIER, + fixture: summary, + runProtocol: { + warmups: budgets.warmupRuns, + measured: budgets.measuredRuns, + requiredPassing: budgets.requiredPassingRuns, + }, + }, + directScrollGate, + runs, + aggregate: { + interactionP95Ms: percentile( + runs.map((run) => run.interactionP95Ms), + 0.95, + ), + frameIntervalP95Ms: percentile( + runs.map((run) => run.frameIntervalP95Ms), + 0.95, + ), + passingRuns, + baselineHeapBytes, + returnedHeapBytes, + memoryReturned, + }, + }; + console.log(JSON.stringify(evidence, null, 2)); + exitCode = + directScrollGate.decision === "approved" && + passingRuns >= budgets.requiredPassingRuns && + memoryReturned + ? 0 + : 1; +} finally { + await browser.close(); +} +process.exit(exitCode);