Skip to content

Commit bad5a54

Browse files
committed
test(studio): gate timeline viewport performance in Chromium
1 parent fa190d8 commit bad5a54

4 files changed

Lines changed: 346 additions & 0 deletions

File tree

packages/studio/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"build": "vite build && tsup",
5050
"typecheck": "tsc --noEmit",
5151
"test": "vitest run",
52+
"test:timeline-virtualization": "node tests/e2e/timeline-virtualization.mjs",
5253
"test:watch": "vitest",
5354
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts"
5455
},
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
3+
"paths": {
4+
"blocks": "compositions",
5+
"components": "compositions/components",
6+
"assets": "assets"
7+
}
8+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Timeline virtualization QA fixture</title>
7+
<style>
8+
html,
9+
body {
10+
width: 1920px;
11+
height: 1080px;
12+
margin: 0;
13+
overflow: hidden;
14+
background: #0a0a0b;
15+
}
16+
</style>
17+
</head>
18+
<body>
19+
<div
20+
id="timeline-virtualization-anchor"
21+
class="clip"
22+
data-composition-id="timeline-virtualization"
23+
data-width="1920"
24+
data-height="1080"
25+
data-start="0"
26+
data-duration="60"
27+
data-fps="30"
28+
data-track-index="0"
29+
style="position: absolute; inset: 0"
30+
></div>
31+
<script>
32+
window.__timelines = window.__timelines || {};
33+
window.__timelines["timeline-virtualization"] = {
34+
pause() {},
35+
seek() {},
36+
};
37+
</script>
38+
</body>
39+
</html>
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Reproducible timeline viewport gate against a running Studio preview of the
4+
* adjacent fixture. The script prints machine-readable evidence; it never
5+
* substitutes synthetic timings for a browser run.
6+
*
7+
* STUDIO_URL=http://127.0.0.1:5190/#project/timeline-virtualization \
8+
* node packages/studio/tests/e2e/timeline-virtualization.mjs
9+
*/
10+
import { existsSync, readdirSync } from "node:fs";
11+
import { homedir, platform, arch } from "node:os";
12+
import { join } from "node:path";
13+
import puppeteer from "puppeteer-core";
14+
15+
const STUDIO_URL = process.env.STUDIO_URL;
16+
const PROFILE = process.env.TIMELINE_PROFILE || "dense-short";
17+
const ELEMENT_COUNT = Number(process.env.TIMELINE_ELEMENT_COUNT || 50_000);
18+
const TIER = process.env.TIMELINE_TIER || "primary";
19+
const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR
20+
? Number(process.env.TIMELINE_CHROME_MAJOR)
21+
: null;
22+
23+
if (!STUDIO_URL) {
24+
console.error("STUDIO_URL is required and must point at the timeline-virtualization fixture");
25+
process.exit(2);
26+
}
27+
if (
28+
![1_000, 50_000].includes(ELEMENT_COUNT) ||
29+
!["primary", "low-resource", "high-dpr"].includes(TIER)
30+
) {
31+
console.error(
32+
"TIMELINE_ELEMENT_COUNT must be 1000 or 50000; " +
33+
"TIMELINE_TIER must be primary, low-resource, or high-dpr",
34+
);
35+
process.exit(2);
36+
}
37+
38+
function resolveChromeExecutable() {
39+
const chromeRoot = join(homedir(), ".cache", "puppeteer", "chrome");
40+
const builds = existsSync(chromeRoot) ? readdirSync(chromeRoot).sort().reverse() : [];
41+
const installedCandidates = builds.flatMap((build) =>
42+
[
43+
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
44+
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
45+
"chrome-linux64/chrome",
46+
].map((relative) => join(chromeRoot, build, relative)),
47+
);
48+
return [
49+
process.env.PUPPETEER_EXECUTABLE_PATH,
50+
process.env.CHROME_PATH,
51+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
52+
"/usr/bin/google-chrome",
53+
"/usr/bin/chromium",
54+
...installedCandidates,
55+
].find((candidate) => candidate && existsSync(candidate));
56+
}
57+
58+
function percentile(values, ratio) {
59+
if (values.length === 0) return 0;
60+
const sorted = [...values].sort((a, b) => a - b);
61+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
62+
}
63+
64+
async function collectHeapBytes(client) {
65+
const usage = await client.send("Runtime.getHeapUsage");
66+
return usage.usedSize;
67+
}
68+
69+
async function collectRun(page) {
70+
return page.evaluate(async () => {
71+
const longTasks = [];
72+
const scroller = findTimelineScroller();
73+
const observer = observeLongTasks(longTasks);
74+
const { interactions, frameIntervals } = await measureScrollInteractions(scroller);
75+
observer?.disconnect();
76+
return {
77+
interactionP95Ms: percentileInPage(interactions, 0.95),
78+
frameIntervalP95Ms: percentileInPage(frameIntervals, 0.95),
79+
longestTaskMs: Math.max(0, ...longTasks),
80+
scrollWidth: scroller.scrollWidth,
81+
scrollHeight: scroller.scrollHeight,
82+
diagnostics: window.__studioTest.readTimelinePerformanceDiagnostics(),
83+
};
84+
85+
function percentileInPage(values, ratio) {
86+
if (values.length === 0) return 0;
87+
const sorted = [...values].sort((a, b) => a - b);
88+
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
89+
}
90+
91+
function findTimelineScroller() {
92+
const root = document.querySelector('[aria-label="Timeline"]');
93+
if (!(root instanceof HTMLElement)) throw new Error("Timeline root not mounted");
94+
const scroller = Array.from(root.querySelectorAll("div")).find(
95+
(node) => node.scrollWidth > node.clientWidth || node.scrollHeight > node.clientHeight,
96+
);
97+
if (!(scroller instanceof HTMLElement)) throw new Error("Timeline scroller not mounted");
98+
return scroller;
99+
}
100+
101+
function observeLongTasks(longTaskDurations) {
102+
if (
103+
typeof PerformanceObserver !== "function" ||
104+
!PerformanceObserver.supportedEntryTypes.includes("longtask")
105+
) {
106+
return null;
107+
}
108+
const performanceObserver = new PerformanceObserver((list) => {
109+
for (const entry of list.getEntries()) longTaskDurations.push(entry.duration);
110+
});
111+
performanceObserver.observe({ entryTypes: ["longtask"] });
112+
return performanceObserver;
113+
}
114+
115+
async function measureScrollInteractions(timelineScroller) {
116+
const interactions = [];
117+
const frameIntervals = [];
118+
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
119+
for (const ratio of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) {
120+
const started = performance.now();
121+
timelineScroller.scrollLeft = Math.round(
122+
(timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio,
123+
);
124+
timelineScroller.scrollTop = Math.round(
125+
(timelineScroller.scrollHeight - timelineScroller.clientHeight) * ratio,
126+
);
127+
const firstFrame = await nextFrame();
128+
const secondFrame = await nextFrame();
129+
interactions.push(secondFrame - started);
130+
frameIntervals.push(secondFrame - firstFrame);
131+
}
132+
return { interactions, frameIntervals };
133+
}
134+
});
135+
}
136+
137+
async function measureMaximumReliableScrollWidth(page) {
138+
return page.evaluate(() => {
139+
const viewportWidth = 320;
140+
const container = document.createElement("div");
141+
const content = document.createElement("div");
142+
container.style.cssText = `position:fixed;left:-10000px;top:0;width:${viewportWidth}px;height:1px;overflow:auto`;
143+
content.style.height = "1px";
144+
container.append(content);
145+
document.body.append(container);
146+
const reliable = (width) => {
147+
content.style.width = `${width}px`;
148+
container.scrollLeft = width;
149+
const expected = width - viewportWidth;
150+
return container.scrollWidth >= width - 1 && container.scrollLeft >= expected - 1;
151+
};
152+
let low = 0;
153+
let high = 64_000_000;
154+
while (low + 1 < high) {
155+
const middle = Math.floor((low + high) / 2);
156+
if (reliable(middle)) low = middle;
157+
else high = middle;
158+
}
159+
container.remove();
160+
return low;
161+
});
162+
}
163+
164+
const executablePath = resolveChromeExecutable();
165+
if (!executablePath) {
166+
console.error("No Chrome executable found; set PUPPETEER_EXECUTABLE_PATH");
167+
process.exit(2);
168+
}
169+
170+
const browser = await puppeteer.launch({
171+
executablePath,
172+
headless: true,
173+
args: ["--no-sandbox", "--disable-dev-shm-usage"],
174+
});
175+
let exitCode = 1;
176+
try {
177+
const version = await browser.version();
178+
const chromeMajor = Number(/(?:Chrome|Chromium)\/(\d+)/.exec(version)?.[1]);
179+
if (EXPECTED_CHROME_MAJOR !== null && chromeMajor !== EXPECTED_CHROME_MAJOR) {
180+
throw new Error(
181+
`Pinned Chrome ${EXPECTED_CHROME_MAJOR} required, received ${version}. ` +
182+
"Override TIMELINE_CHROME_MAJOR only when intentionally recording a new baseline.",
183+
);
184+
}
185+
const page = await browser.newPage();
186+
await page.setViewport({
187+
width: 1440,
188+
height: 900,
189+
deviceScaleFactor: TIER === "high-dpr" ? 2 : 1,
190+
});
191+
const client = await page.createCDPSession();
192+
if (TIER === "low-resource") {
193+
await client.send("Emulation.setCPUThrottlingRate", { rate: 4 });
194+
}
195+
await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
196+
await page.waitForFunction(
197+
() => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function",
198+
{ timeout: 30_000 },
199+
);
200+
201+
await page.evaluate((profile) => {
202+
window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile });
203+
}, PROFILE);
204+
await client.send("HeapProfiler.collectGarbage");
205+
const baselineHeapBytes = await collectHeapBytes(client);
206+
207+
const summary = await page.evaluate(
208+
({ elementCount, profile }) =>
209+
window.__studioTest.loadTimelinePerformanceFixture({ elementCount, profile }),
210+
{ elementCount: ELEMENT_COUNT, profile: PROFILE },
211+
);
212+
await page.waitForFunction(() => document.querySelector('[aria-label="Timeline"]'));
213+
const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets);
214+
const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page);
215+
216+
const runs = [];
217+
const interactionLimitMs =
218+
TIER === "primary" ? budgets.interactionP95Ms : budgets.constrainedInteractionP95Ms;
219+
const frameIntervalLimitMs =
220+
TIER === "primary" ? budgets.frameIntervalP95Ms : budgets.constrainedFrameIntervalP95Ms;
221+
for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) {
222+
const run = await collectRun(page);
223+
if (index >= budgets.warmupRuns) runs.push(run);
224+
}
225+
for (const run of runs) {
226+
run.passed =
227+
run.interactionP95Ms <= interactionLimitMs &&
228+
run.frameIntervalP95Ms <= frameIntervalLimitMs &&
229+
run.longestTaskMs <= budgets.longTaskLimitMs &&
230+
run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots &&
231+
run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow &&
232+
run.diagnostics.mountedTimelineDescendants < budgets.maxMountedTimelineDescendants;
233+
}
234+
235+
await page.evaluate((profile) => {
236+
window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile });
237+
}, PROFILE);
238+
await client.send("HeapProfiler.collectGarbage");
239+
const returnedHeapBytes = await collectHeapBytes(client);
240+
const memoryReturned =
241+
returnedHeapBytes <= baselineHeapBytes * (1 + budgets.memoryReturnToleranceRatio);
242+
const passingRuns = runs.filter((run) => run.passed).length;
243+
const maxTimelineContentWidthPx = Math.max(0, ...runs.map((run) => run.scrollWidth));
244+
const directScrollGate = {
245+
safetyEnvelopePx: budgets.directScrollSafetyPx,
246+
maxTimelineContentWidthPx,
247+
measuredMaxReliableScrollWidth,
248+
decision:
249+
maxTimelineContentWidthPx <= budgets.directScrollSafetyPx &&
250+
measuredMaxReliableScrollWidth >= maxTimelineContentWidthPx
251+
? "approved"
252+
: "rejected",
253+
};
254+
const evidence = {
255+
environment: {
256+
browser: version,
257+
executablePath,
258+
os: platform(),
259+
architecture: arch(),
260+
viewport: { width: 1440, height: 900 },
261+
deviceScaleFactor: TIER === "high-dpr" ? 2 : 1,
262+
cpuThrottleRate: TIER === "low-resource" ? 4 : 1,
263+
tier: TIER,
264+
fixture: summary,
265+
runProtocol: {
266+
warmups: budgets.warmupRuns,
267+
measured: budgets.measuredRuns,
268+
requiredPassing: budgets.requiredPassingRuns,
269+
},
270+
},
271+
directScrollGate,
272+
runs,
273+
aggregate: {
274+
interactionP95Ms: percentile(
275+
runs.map((run) => run.interactionP95Ms),
276+
0.95,
277+
),
278+
frameIntervalP95Ms: percentile(
279+
runs.map((run) => run.frameIntervalP95Ms),
280+
0.95,
281+
),
282+
passingRuns,
283+
baselineHeapBytes,
284+
returnedHeapBytes,
285+
memoryReturned,
286+
},
287+
};
288+
console.log(JSON.stringify(evidence, null, 2));
289+
exitCode =
290+
directScrollGate.decision === "approved" &&
291+
passingRuns >= budgets.requiredPassingRuns &&
292+
memoryReturned
293+
? 0
294+
: 1;
295+
} finally {
296+
await browser.close();
297+
}
298+
process.exit(exitCode);

0 commit comments

Comments
 (0)