mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
334 lines
12 KiB
JavaScript
334 lines
12 KiB
JavaScript
#!/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 = root.querySelector("[data-timeline-scroll-viewport]");
|
|
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: "networkidle0", timeout: 60_000 });
|
|
await page.waitForFunction(
|
|
() => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function",
|
|
{ timeout: 30_000 },
|
|
);
|
|
await waitForStudioTestHookSettle(page);
|
|
|
|
await loadFixtureAndWait(page, 1_000, PROFILE);
|
|
await client.send("HeapProfiler.collectGarbage");
|
|
const baselineHeapBytes = await collectHeapBytes(client);
|
|
|
|
const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE);
|
|
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 loadFixtureAndWait(page, 1_000, 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);
|
|
|
|
async function waitForFixtureRender(page, elementCount) {
|
|
const deadline = Date.now() + 60_000;
|
|
let observed = null;
|
|
while (Date.now() < deadline) {
|
|
observed = await page.evaluate(() => ({
|
|
modelCount: window.__playerStore?.getState().elements.length ?? null,
|
|
renderedCount:
|
|
document
|
|
.querySelector('[aria-label="Timeline"]')
|
|
?.getAttribute("data-timeline-element-count") ?? null,
|
|
}));
|
|
if (observed.renderedCount === String(elementCount)) {
|
|
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve())));
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
throw new Error(`Timeline fixture ${elementCount} did not render: ${JSON.stringify(observed)}`);
|
|
}
|
|
|
|
async function loadFixtureAndWait(page, elementCount, profile) {
|
|
const summary = await page.evaluate(
|
|
({ count, fixtureProfile }) =>
|
|
window.__studioTest.loadTimelinePerformanceFixture({
|
|
elementCount: count,
|
|
profile: fixtureProfile,
|
|
}),
|
|
{ count: elementCount, fixtureProfile: profile },
|
|
);
|
|
await waitForFixtureRender(page, elementCount);
|
|
return summary;
|
|
}
|
|
|
|
async function waitForStudioTestHookSettle(page) {
|
|
await page.evaluate(async () => {
|
|
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
|
|
for (;;) {
|
|
const candidate = window.__studioTest;
|
|
await nextFrame();
|
|
await nextFrame();
|
|
if (candidate === window.__studioTest) return;
|
|
}
|
|
});
|
|
}
|