Skip to content

Commit 287a998

Browse files
committed
test(studio): gate timeline viewport performance in Chromium
1 parent 09b469a commit 287a998

4 files changed

Lines changed: 350 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: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
throw new Error(
107+
"The Long Tasks API is required to enforce the timeline responsiveness gate",
108+
);
109+
}
110+
const performanceObserver = new PerformanceObserver((list) => {
111+
for (const entry of list.getEntries()) longTaskDurations.push(entry.duration);
112+
});
113+
performanceObserver.observe({ entryTypes: ["longtask"] });
114+
return performanceObserver;
115+
}
116+
117+
async function measureScrollInteractions(timelineScroller) {
118+
const interactions = [];
119+
const frameIntervals = [];
120+
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
121+
for (const ratio of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) {
122+
const started = performance.now();
123+
timelineScroller.scrollLeft = Math.round(
124+
(timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio,
125+
);
126+
timelineScroller.scrollTop = Math.round(
127+
(timelineScroller.scrollHeight - timelineScroller.clientHeight) * ratio,
128+
);
129+
const firstFrame = await nextFrame();
130+
const secondFrame = await nextFrame();
131+
interactions.push(secondFrame - started);
132+
frameIntervals.push(secondFrame - firstFrame);
133+
}
134+
return { interactions, frameIntervals };
135+
}
136+
});
137+
}
138+
139+
async function measureMaximumReliableScrollWidth(page) {
140+
return page.evaluate(() => {
141+
const viewportWidth = 320;
142+
const container = document.createElement("div");
143+
const content = document.createElement("div");
144+
container.style.cssText = `position:fixed;left:-10000px;top:0;width:${viewportWidth}px;height:1px;overflow:auto`;
145+
content.style.height = "1px";
146+
container.append(content);
147+
document.body.append(container);
148+
const reliable = (width) => {
149+
content.style.width = `${width}px`;
150+
container.scrollLeft = width;
151+
const expected = width - viewportWidth;
152+
return container.scrollWidth >= width - 1 && container.scrollLeft >= expected - 1;
153+
};
154+
let low = 0;
155+
let high = 64_000_000;
156+
while (low + 1 < high) {
157+
const middle = Math.floor((low + high) / 2);
158+
if (reliable(middle)) low = middle;
159+
else high = middle;
160+
}
161+
container.remove();
162+
return low;
163+
});
164+
}
165+
166+
const executablePath = resolveChromeExecutable();
167+
if (!executablePath) {
168+
console.error("No Chrome executable found; set PUPPETEER_EXECUTABLE_PATH");
169+
process.exit(2);
170+
}
171+
172+
const browser = await puppeteer.launch({
173+
executablePath,
174+
headless: true,
175+
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
176+
});
177+
let exitCode = 1;
178+
try {
179+
const version = await browser.version();
180+
const chromeMajor = Number(/(?:Chrome|Chromium)\/(\d+)/.exec(version)?.[1]);
181+
if (EXPECTED_CHROME_MAJOR !== null && chromeMajor !== EXPECTED_CHROME_MAJOR) {
182+
throw new Error(
183+
`Pinned Chrome ${EXPECTED_CHROME_MAJOR} required, received ${version}. ` +
184+
"Override TIMELINE_CHROME_MAJOR only when intentionally recording a new baseline.",
185+
);
186+
}
187+
const page = await browser.newPage();
188+
await page.setViewport({
189+
width: 1440,
190+
height: 900,
191+
deviceScaleFactor: TIER === "high-dpr" ? 2 : 1,
192+
});
193+
const client = await page.createCDPSession();
194+
if (TIER === "low-resource") {
195+
await client.send("Emulation.setCPUThrottlingRate", { rate: 4 });
196+
}
197+
await page.goto(STUDIO_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
198+
await page.waitForFunction(
199+
() => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function",
200+
{ timeout: 30_000 },
201+
);
202+
203+
await page.evaluate((profile) => {
204+
window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile });
205+
}, PROFILE);
206+
await client.send("HeapProfiler.collectGarbage");
207+
const baselineHeapBytes = await collectHeapBytes(client);
208+
209+
const summary = await page.evaluate(
210+
({ elementCount, profile }) =>
211+
window.__studioTest.loadTimelinePerformanceFixture({ elementCount, profile }),
212+
{ elementCount: ELEMENT_COUNT, profile: PROFILE },
213+
);
214+
await page.waitForFunction(() => document.querySelector('[aria-label="Timeline"]'));
215+
const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets);
216+
const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page);
217+
218+
const runs = [];
219+
const interactionLimitMs =
220+
TIER === "primary" ? budgets.interactionP95Ms : budgets.constrainedInteractionP95Ms;
221+
const frameIntervalLimitMs =
222+
TIER === "primary" ? budgets.frameIntervalP95Ms : budgets.constrainedFrameIntervalP95Ms;
223+
for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) {
224+
const run = await collectRun(page);
225+
if (index >= budgets.warmupRuns) runs.push(run);
226+
}
227+
for (const run of runs) {
228+
run.passed =
229+
run.interactionP95Ms <= interactionLimitMs &&
230+
run.frameIntervalP95Ms <= frameIntervalLimitMs &&
231+
run.longestTaskMs <= budgets.longTaskLimitMs &&
232+
run.diagnostics.timelineRoots === 1 &&
233+
run.diagnostics.mountedRows <= budgets.maxMountedRows &&
234+
run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots &&
235+
run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow &&
236+
run.diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants;
237+
}
238+
239+
await page.evaluate((profile) => {
240+
window.__studioTest.loadTimelinePerformanceFixture({ elementCount: 1_000, profile });
241+
}, PROFILE);
242+
await client.send("HeapProfiler.collectGarbage");
243+
const returnedHeapBytes = await collectHeapBytes(client);
244+
const memoryReturned =
245+
returnedHeapBytes <= baselineHeapBytes * (1 + budgets.memoryReturnToleranceRatio);
246+
const passingRuns = runs.filter((run) => run.passed).length;
247+
const maxTimelineContentWidthPx = Math.max(0, ...runs.map((run) => run.scrollWidth));
248+
const directScrollGate = {
249+
safetyEnvelopePx: budgets.directScrollSafetyPx,
250+
maxTimelineContentWidthPx,
251+
measuredMaxReliableScrollWidth,
252+
decision:
253+
maxTimelineContentWidthPx <= budgets.directScrollSafetyPx &&
254+
measuredMaxReliableScrollWidth >= maxTimelineContentWidthPx
255+
? "approved"
256+
: "rejected",
257+
};
258+
const evidence = {
259+
environment: {
260+
browser: version,
261+
executablePath,
262+
os: platform(),
263+
architecture: arch(),
264+
viewport: { width: 1440, height: 900 },
265+
deviceScaleFactor: TIER === "high-dpr" ? 2 : 1,
266+
cpuThrottleRate: TIER === "low-resource" ? 4 : 1,
267+
tier: TIER,
268+
fixture: summary,
269+
runProtocol: {
270+
warmups: budgets.warmupRuns,
271+
measured: budgets.measuredRuns,
272+
requiredPassing: budgets.requiredPassingRuns,
273+
},
274+
},
275+
directScrollGate,
276+
runs,
277+
aggregate: {
278+
interactionP95Ms: percentile(
279+
runs.map((run) => run.interactionP95Ms),
280+
0.95,
281+
),
282+
frameIntervalP95Ms: percentile(
283+
runs.map((run) => run.frameIntervalP95Ms),
284+
0.95,
285+
),
286+
passingRuns,
287+
baselineHeapBytes,
288+
returnedHeapBytes,
289+
memoryReturned,
290+
},
291+
};
292+
console.log(JSON.stringify(evidence, null, 2));
293+
exitCode =
294+
directScrollGate.decision === "approved" &&
295+
passingRuns >= budgets.requiredPassingRuns &&
296+
memoryReturned
297+
? 0
298+
: 1;
299+
} finally {
300+
await browser.close();
301+
}
302+
process.exit(exitCode);

0 commit comments

Comments
 (0)