fix(studio): the timeline viewport gate gives the same verdict on the same code (#5361)

* fix(studio): the timeline viewport gate gives the same verdict on the same code

Steps are timed from the frame the jump is made in, so each is a whole number of frames and the
3.5-frame budget no longer cuts through one cluster of samples. When the head still misses a budget,
it is measured against its base in alternating blocks on the same runner and fails only when it
misses on more steps than the base beyond chance.

* style(studio): format the viewport gate verdict tests

* fix(studio): a head slower than its base fails the viewport gate comparison

* fix(studio): the viewport gate opens head and base fresh before comparing them

* fix(studio): each viewport gate comparison block runs alone in a fresh Chrome

* fix(studio): the viewport gate comparison also fails a head that makes late steps later

* fix(studio): the viewport gate summary names every count behind a comparison verdict

* fix(studio): label the viewport gate frame-gap counts by threshold

* fix(studio): a runner stall cannot fail identical builds in the viewport gate comparison

* fix(studio): the viewport gate serves its base the same projects as the head
This commit is contained in:
Miguel Ángel
2026-10-09 22:28:25 +00:00
committed by GitHub
parent 7b85b28406
commit 2c082b3fe5
4 changed files with 351 additions and 98 deletions
+54 -44
View File
@@ -864,6 +864,20 @@ jobs:
packages/studio/data/projects/inline-text-caret
cp -R packages/studio/tests/e2e/fixtures/undo-socket-pool \
packages/studio/data/projects/undo-socket-pool
- name: Build the base the viewport gate compares against
# The first parent of the checked-out commit, as in base-file.sh. Measured only when the head misses a budget.
run: |
set -euo pipefail
base="$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}')"
git fetch --no-tags --depth=1 origin "$base"
GIT_LFS_SKIP_SMUDGE=1 git worktree add --detach /tmp/studio-base "$base"
cd /tmp/studio-base
bash scripts/ci/install-workspace-dependencies.sh
bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
bun run --cwd packages/core build
bun run --cwd packages/core build:hyperframes-runtime
# The same projects as the head's server, so neither side serves more than the other.
cp -R "$GITHUB_WORKSPACE/packages/studio/data" packages/studio/data
- name: Read the base branch's work-count ceilings
# A ceiling may not disappear, or rise without a new reason, against the branch this PR merges into.
if: github.event_name == 'pull_request'
@@ -872,13 +886,13 @@ jobs:
run: |
set -euo pipefail
SERVER_PID=""
stop_server() {
if [[ -n "$SERVER_PID" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
fi
SERVER_PIDS=()
stop_servers() {
for pid in "${SERVER_PIDS[@]}"; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
SERVER_PIDS=()
}
wait_for_server() {
local port="$1"
@@ -889,46 +903,39 @@ jobs:
echo "FAIL: studio dev server did not start on port ${port}"
return 1
}
trap stop_server EXIT
trap stop_servers EXIT
# Run one server at a time so the measured browser never competes with
# a second Vite module graph on the shared runner. The development
# server supplies the fixture API; production React matches shipped
# rendering behavior, and the gate asserts that runtime before timing.
NODE_ENV=production \
bun run --cwd packages/studio dev -- --port 5313 --strictPort &
SERVER_PID=$!
# One arm at a time, so no other arm's Vite module graph competes with the measured browser.
# The base page loads only if the head misses a budget; production React matches shipped
# rendering, and the gate asserts that runtime before timing.
run_arm() {
local arm="$1" port="$2" virtualization="$3" count="$4" enabled="$5"
NODE_ENV=production VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED="$enabled" \
bun run --cwd /tmp/studio-base/packages/studio dev -- --port "$((port + 10))" --strictPort &
SERVER_PIDS+=($!)
NODE_ENV=production VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED="$enabled" \
bun run --cwd packages/studio dev -- --port "$port" --strictPort &
SERVER_PIDS+=($!)
local status=0
if wait_for_server "$((port + 10))" && wait_for_server "$port"; then
STUDIO_URL="http://localhost:${port}/#project/timeline-virtualization" \
BASE_STUDIO_URL="http://localhost:$((port + 10))/#project/timeline-virtualization" \
TIMELINE_ROW_VIRTUALIZATION="$virtualization" \
TIMELINE_ELEMENT_COUNT="$count" \
TIMELINE_TIER=ci \
node packages/studio/tests/e2e/timeline-virtualization.mjs \
| tee "/tmp/timeline-gate-${arm}.json" \
|| status=$?
else
status=1
fi
stop_servers
return "$status"
}
DEFAULT_STATUS=0
if wait_for_server 5313; then
STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \
TIMELINE_ROW_VIRTUALIZATION=on \
TIMELINE_ELEMENT_COUNT=50000 \
TIMELINE_TIER=ci \
node packages/studio/tests/e2e/timeline-virtualization.mjs \
| tee /tmp/timeline-gate-default.json \
|| DEFAULT_STATUS=$?
else
DEFAULT_STATUS=1
fi
stop_server
NODE_ENV=production \
VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=0 \
bun run --cwd packages/studio dev -- --port 5314 --strictPort &
SERVER_PID=$!
run_arm default 5313 on 50000 1 || DEFAULT_STATUS=$?
DISABLED_STATUS=0
if wait_for_server 5314; then
STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \
TIMELINE_ROW_VIRTUALIZATION=off \
TIMELINE_ELEMENT_COUNT=1000 \
TIMELINE_TIER=ci \
node packages/studio/tests/e2e/timeline-virtualization.mjs \
| tee /tmp/timeline-gate-disabled.json \
|| DISABLED_STATUS=$?
else
DISABLED_STATUS=1
fi
stop_server
run_arm disabled 5314 off 1000 0 || DISABLED_STATUS=$?
{
echo "### Timeline viewport gate"
@@ -940,6 +947,9 @@ jobs:
const b = e.environment.appliedBudgets;
for (const a of e.attempts)
console.log(`- ${process.argv[2]} attempt ${a.attempt}: interaction p95 ${a.interactionP95Ms.toFixed(1)}/${b.interactionP95Ms} ms, frame p95 ${a.frameIntervalP95Ms.toFixed(1)}/${b.frameIntervalP95Ms} ms, ${a.passingRuns}/${a.runs.length} runs, ${a.passed ? "pass" : "fail"}`);
const c = e.againstBase;
if (c)
console.log(`- ${process.argv[2]} against base on this runner: ${c.summary}`);
' "/tmp/timeline-gate-${arm}.json" "$arm" || echo "- ${arm}: no evidence"
done
} >> "$GITHUB_STEP_SUMMARY"
@@ -56,6 +56,73 @@ export function judgeResponsiveness(
};
}
/** Excess over-budget steps beyond this many standard deviations of the difference of two counts is not chance. */
const SLOWER_THAN_BASE_SIGMAS = 3;
const ONE_FRAME_MS = 1000 / 60;
/** A slow stretch of a shared runner makes a run of steps late together, so an excess must also be 1% of steps. */
const MIN_EXCESS_SHARE = 0.01;
function countExcess(head, base, steps) {
const allowedExcess = SLOWER_THAN_BASE_SIGMAS * Math.sqrt(head + base) + MIN_EXCESS_SHARE * steps;
return { head, base, allowedExcess, slower: head - base > allowedExcess };
}
/** Counted at the budget and a frame past it, so making already-late steps later also counts as slower. */
function overBudgetExcess(headValues, baseValues, limitMs) {
const over = (values, ms) => values.filter((value) => value > ms).length;
const steps = headValues.length;
const atBudget = countExcess(over(headValues, limitMs), over(baseValues, limitMs), steps);
const frameLater = countExcess(
over(headValues, limitMs + ONE_FRAME_MS),
over(baseValues, limitMs + ONE_FRAME_MS),
steps,
);
return { atBudget, frameLater, slower: atBudget.slower || frameLater.slower };
}
/**
* For a head that missed the budget twice: measured against its base in alternating blocks on the same machine,
* it fails if it misses the budget on more steps than the base does, beyond chance.
*/
export function judgeAgainstBase(headRuns, baseRuns, limits) {
const head = judgeResponsiveness(headRuns, limits);
const base = judgeResponsiveness(baseRuns, limits);
const values = (runs, key) => runs.flatMap((run) => run[key]);
const interactions = overBudgetExcess(
values(headRuns, "interactions"),
values(baseRuns, "interactions"),
limits.interactionLimitMs,
);
const frameIntervals = overBudgetExcess(
values(headRuns, "frameIntervals"),
values(baseRuns, "frameIntervals"),
limits.frameIntervalLimitMs,
);
return {
head,
base,
interactions,
frameIntervals,
passed: !interactions.slower && !frameIntervals.slower,
};
}
/** One line for the log and the CI step summary: head vs base, the counts behind the verdict, and the verdict. */
export function describeAgainstBase({ head, base, interactions, frameIntervals, passed }) {
const pair = (count, name) =>
`${name} ${count.head} vs ${count.base} (allowed excess ${count.allowedExcess.toFixed(1)})`;
return [
`interaction p95 ${head.interactionP95Ms.toFixed(1)} vs ${base.interactionP95Ms.toFixed(1)} ms`,
pair(interactions.atBudget, "steps over budget"),
pair(interactions.frameLater, "a frame later"),
pair(frameIntervals.atBudget, "frame gaps over budget"),
pair(frameIntervals.frameLater, "a frame past that"),
passed ? "pass" : "fail",
].join(", ");
}
/** A failed timing attempt is measured once more, so one bad stretch of a shared runner cannot fail the gate alone. */
export const TIMING_ATTEMPTS = 2;
@@ -63,7 +130,22 @@ export function attemptPassed({ responsivenessPassed, passingRuns, requiredPassi
return responsivenessPassed && passingRuns >= requiredPassingRuns;
}
export function gatePassed({ directScrollApproved, attempts, memoryReturned }) {
const timingPassed = attempts.slice(0, TIMING_ATTEMPTS).some((attempt) => attempt.passed);
return directScrollApproved && timingPassed && memoryReturned;
/** Only timing may be settled against the base: every attempt's runs must have passed their other checks. */
const runChecksHeld = (attempts) =>
attempts.length > 0 && attempts.every((attempt) => attempt.runChecksPassed);
export function shouldCompareWithBase(attempts) {
return !attempts.some((attempt) => attempt.passed) && runChecksHeld(attempts);
}
/** Timing holds when an attempt passes or, once both fail, the same-machine base comparison does. */
export function timingPassed(attempts, againstBase) {
return (
attempts.slice(0, TIMING_ATTEMPTS).some((attempt) => attempt.passed) ||
(againstBase?.passed === true && runChecksHeld(attempts))
);
}
export function gatePassed({ directScrollApproved, attempts, againstBase, memoryReturned }) {
return directScrollApproved && timingPassed(attempts, againstBase) && memoryReturned;
}
@@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest";
import { TIMELINE_VIEWPORT_BUDGETS } from "../../src/player/lib/timelineViewportBudgets";
import {
attemptPassed,
describeAgainstBase,
gatePassed,
judgeAgainstBase,
judgeResponsiveness,
percentile,
shouldCompareWithBase,
responsivenessLimits,
} from "./timeline-viewport-verdict.mjs";
@@ -118,6 +121,79 @@ describe("the CI virtualized arm's limits", () => {
});
});
describe("judgeAgainstBase", () => {
const limits = { samplesPerRun: 63, interactionLimitMs: 58.3, frameIntervalLimitMs: 25 };
// Ten runs of 63 steps, two alternating blocks of five; the first `late` steps take four frames.
const measured = (late, droppedFrames = 0) =>
Array.from({ length: 10 }, (_, run) => {
const step = (index) => run * 63 + index;
return {
interactions: Array.from({ length: 63 }, (_, i) => (step(i) < late ? 66.7 : 33.3)),
frameIntervals: Array.from({ length: 63 }, (_, i) =>
step(i) < droppedFrames ? 33.3 : 16.7,
),
};
});
// The base's 40 late steps, each made 200 ms on the head.
const lateStepsAt200 = () =>
measured(40).map((run) => ({
...run,
interactions: run.interactions.map((value) => (value > 58.3 ? 200 : value)),
}));
it("fails a head slower than its base even where this comparison puts it under the budget", () => {
const verdict = judgeAgainstBase(measured(31), measured(0), limits);
expect(verdict.head.passed).toBe(true);
expect(verdict.passed).toBe(false);
});
it("passes a head over budget when its base misses it as often on the same machine", () => {
const verdict = judgeAgainstBase(measured(40), measured(40), limits);
expect(verdict.head.passed).toBe(false);
expect(verdict.passed).toBe(true);
});
it("passes identical builds that land either side of the budget by chance", () => {
expect(judgeAgainstBase(measured(36), measured(27), limits).passed).toBe(true);
});
it("fails a head that misses the budget on more steps than its base", () => {
expect(judgeAgainstBase(measured(40), measured(2), limits)).toMatchObject({
passed: false,
interactions: { atBudget: { head: 40, base: 2 }, slower: true },
});
expect(judgeAgainstBase(measured(90), measured(40), limits).passed).toBe(false);
});
it("fails a head that makes the base's late steps a frame later, with the same count over budget", () => {
expect(judgeAgainstBase(lateStepsAt200(), measured(40), limits)).toMatchObject({
passed: false,
interactions: {
atBudget: { head: 40, base: 40, slower: false },
frameLater: { head: 40, base: 0, slower: true },
},
});
});
it("describes the counts behind a verdict, including the frame-later ones", () => {
expect(describeAgainstBase(judgeAgainstBase(lateStepsAt200(), measured(40), limits))).toBe(
"interaction p95 200.0 vs 66.7 ms, steps over budget 40 vs 40 (allowed excess 33.1), " +
"a frame later 40 vs 0 (allowed excess 25.3), frame gaps over budget 0 vs 0 (allowed excess 6.3), " +
"a frame past that 0 vs 0 (allowed excess 6.3), fail",
);
});
it("passes a stall's handful of extra late steps on identical builds", () => {
expect(judgeAgainstBase(measured(0, 10), measured(0), limits).passed).toBe(true);
expect(judgeAgainstBase(measured(0, 30), measured(0), limits).passed).toBe(false);
});
it("fails on dropped frames alone", () => {
expect(judgeAgainstBase(measured(0, 60), measured(0, 5), limits).passed).toBe(false);
});
});
describe("attemptPassed", () => {
const passing = { responsivenessPassed: true, passingRuns: 5, requiredPassingRuns: 4 };
@@ -145,4 +221,21 @@ describe("gatePassed", () => {
expect(gatePassed({ ...passing, attempts: [fail, fail, pass] })).toBe(false);
expect(gatePassed({ ...passing, attempts: [] })).toBe(false);
});
it("lets the same-machine base comparison decide timing once both attempts fail", () => {
const slow = { passed: false, runChecksPassed: true };
expect(gatePassed({ ...passing, attempts: [slow, slow], againstBase: pass })).toBe(true);
expect(gatePassed({ ...passing, attempts: [slow, slow], againstBase: fail })).toBe(false);
expect(gatePassed({ ...passing, memoryReturned: false, againstBase: pass })).toBe(false);
});
it("never lets the base comparison pass runs that failed long-task or DOM checks", () => {
const broken = { passed: false, runChecksPassed: false };
const slow = { passed: false, runChecksPassed: true };
expect(shouldCompareWithBase([slow, broken])).toBe(false);
expect(shouldCompareWithBase([slow, slow])).toBe(true);
expect(shouldCompareWithBase([])).toBe(false);
expect(gatePassed({ ...passing, attempts: [slow, broken], againstBase: pass })).toBe(false);
expect(gatePassed({ ...passing, attempts: [], againstBase: pass })).toBe(false);
});
});
@@ -20,18 +20,27 @@
* "on", the product default. The script asserts the configuration it observes
* rather than trusting the caller: the server is configured by whoever started
* it, and a mismatch would otherwise pass silently against the wrong build.
*
* BASE_STUDIO_URL serves the fixture from the base commit. If every timing attempt misses the
* budget, head and base alternate on this machine; the head fails only if slower (judgeAgainstBase).
*/
import { platform, arch } from "node:os";
import { launchStudioChrome } from "./chrome-executable.mjs";
import {
attemptPassed,
describeAgainstBase,
gatePassed,
judgeAgainstBase,
judgeResponsiveness,
responsivenessLimits,
shouldCompareWithBase,
TIMING_ATTEMPTS,
timingPassed,
} from "./timeline-viewport-verdict.mjs";
const STUDIO_URL = process.env.STUDIO_URL;
const BASE_STUDIO_URL = process.env.BASE_STUDIO_URL || null;
const BASE_COMPARISON_ROUNDS = 2;
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";
@@ -150,9 +159,11 @@ async function collectRun(page, injectedLongTaskMs = 0) {
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const ratios = [0, 0.25, 0.5, 0.75, 1, 0.5, 0];
const sampleCount = window.__studioTest.timelineViewportBudgets.scrollSamplesPerRun;
// Timed from the frame the jump is made in, so a step is a whole number of frames and
// never lands at the budget by where in that frame the callback ran.
let started = await nextFrame();
for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) {
const ratio = ratios[sampleIndex % ratios.length];
const started = performance.now();
timelineScroller.scrollLeft = Math.round(
(timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio,
);
@@ -163,6 +174,7 @@ async function collectRun(page, injectedLongTaskMs = 0) {
const secondFrame = await nextFrame();
interactions.push(secondFrame - started);
frameIntervals.push(secondFrame - firstFrame);
started = secondFrame;
}
return { interactions, frameIntervals };
}
@@ -240,30 +252,7 @@ try {
"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);
const runtimeMode = await page.evaluate(() => window.__studioTest.runtimeMode);
if (TIER === "ci" && runtimeMode !== "production") {
throw new Error(
`Timeline CI must measure the production React runtime, received ${runtimeMode}. ` +
"Start the Studio development server with NODE_ENV=production.",
);
}
const { page, client, runtimeMode } = await openStudio(browser, STUDIO_URL);
await loadFixtureAndWait(page, 1_000, PROFILE);
await client.send("HeapProfiler.collectGarbage");
@@ -280,27 +269,18 @@ try {
const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE);
const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page);
// Trust the DOM, not the caller. A virtualized build cannot mount every clip
// and an unvirtualized one cannot avoid it, so the mounted count says which
// build is really being measured.
const observedClipRoots = await page.evaluate(
() => window.__studioTest.readTimelinePerformanceDiagnostics().mountedClipRoots,
);
const observedRowVirtualization = observedClipRoots <= budgets.maxMountedClipRoots ? "on" : "off";
if (observedRowVirtualization !== ROW_VIRTUALIZATION) {
throw new Error(
`Requested TIMELINE_ROW_VIRTUALIZATION=${ROW_VIRTUALIZATION} but the server under test ` +
`behaves as ${observedRowVirtualization}: ${observedClipRoots} clip roots mounted for ` +
`${ELEMENT_COUNT} elements against a ${budgets.maxMountedClipRoots} budget. ` +
"Set VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED on the Studio dev server to match.",
);
}
const observedClipRoots = await assertRowVirtualization(page, budgets);
const { interactionLimitMs, frameIntervalLimitMs } = responsivenessLimits(
budgets,
TIER,
ROW_VIRTUALIZATION,
);
const limits = {
samplesPerRun: budgets.scrollSamplesPerRun,
interactionLimitMs,
frameIntervalLimitMs,
};
// Latency, long tasks and memory are product promises and hold for both
// builds. The DOM-size budgets describe what windowing achieves, so they only
// apply when windowing is on. They are skipped explicitly rather than relaxed,
@@ -308,11 +288,7 @@ try {
const domBudgetsApply = ROW_VIRTUALIZATION === "on";
const attempts = [];
while (attempts.length < TIMING_ATTEMPTS && !attempts.some((attempt) => attempt.passed)) {
const runs = [];
for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) {
const run = await collectRun(page);
if (index >= budgets.warmupRuns) runs.push(run);
}
const runs = await collectMeasuredRuns(page, budgets.warmupRuns, budgets.measuredRuns);
for (const run of runs) {
run.longTaskPassed = run.longestTaskMs <= longTaskLimitMs;
run.timelineMounted = run.diagnostics.timelineRoots === 1;
@@ -324,11 +300,7 @@ try {
: null;
run.passed = run.longTaskPassed && run.timelineMounted && run.domSizePassed !== false;
}
const responsiveness = judgeResponsiveness(runs, {
samplesPerRun: budgets.scrollSamplesPerRun,
interactionLimitMs,
frameIntervalLimitMs,
});
const responsiveness = judgeResponsiveness(runs, limits);
const passingRuns = runs.filter((run) => run.passed).length;
const attempt = {
attempt: attempts.length + 1,
@@ -336,6 +308,7 @@ try {
frameIntervalP95Ms: responsiveness.frameIntervalP95Ms,
responsivenessPassed: responsiveness.passed,
passingRuns,
runChecksPassed: passingRuns >= budgets.requiredPassingRuns,
passed: attemptPassed({
responsivenessPassed: responsiveness.passed,
passingRuns,
@@ -362,6 +335,13 @@ try {
const returnedHeapBytes = await collectHeapBytes(client);
const memoryReturned =
returnedHeapBytes <= baselineHeapBytes * (1 + budgets.memoryReturnToleranceRatio);
// Closed first, so the comparison's pages are the only ones the runner renders.
await page.close();
const againstBase =
BASE_STUDIO_URL && shouldCompareWithBase(attempts)
? await measureAgainstBase(budgets, limits)
: null;
const maxTimelineContentWidthPx = Math.max(
0,
...attempts.flatMap((attempt) => attempt.runs.map((run) => run.scrollWidth)),
@@ -407,8 +387,9 @@ try {
},
directScrollGate,
attempts,
againstBase,
aggregate: {
timingPassed: attempts.some((attempt) => attempt.passed),
timingPassed: timingPassed(attempts, againstBase),
baselineHeapBytes,
returnedHeapBytes,
memoryReturned,
@@ -418,6 +399,7 @@ try {
exitCode = gatePassed({
directScrollApproved: directScrollGate.decision === "approved",
attempts,
againstBase,
memoryReturned,
})
? 0
@@ -427,6 +409,92 @@ try {
}
process.exit(exitCode);
async function collectMeasuredRuns(page, warmupRuns, measuredRuns) {
const runs = [];
for (let index = 0; index < warmupRuns + measuredRuns; index += 1) {
const run = await collectRun(page);
if (index >= warmupRuns) runs.push(run);
}
return runs;
}
/** Head and base in alternating blocks on this machine, each block alone in a freshly opened Chrome. */
async function measureAgainstBase(budgets, limits) {
const urls = { head: STUDIO_URL, base: BASE_STUDIO_URL };
const runs = { head: [], base: [] };
for (let round = 0; round < BASE_COMPARISON_ROUNDS; round += 1) {
for (const side of round % 2 === 0 ? ["head", "base"] : ["base", "head"]) {
runs[side].push(...(await measureFreshStudio(urls[side], budgets)));
}
}
const verdict = judgeAgainstBase(runs.head, runs.base, limits);
const summary = describeAgainstBase(verdict);
console.error(`timeline gate ${ROW_VIRTUALIZATION} against base: ${summary}`);
return { rounds: BASE_COMPARISON_ROUNDS, ...verdict, summary, runs };
}
async function measureFreshStudio(url, budgets) {
const { browser } = await launchStudioChrome();
try {
const { page } = await openStudio(browser, url);
await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE);
await assertRowVirtualization(page, budgets);
return await collectMeasuredRuns(page, budgets.warmupRuns, budgets.measuredRuns);
} finally {
await browser.close();
}
}
async function openStudio(browser, url) {
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(url, { waitUntil: "networkidle0", timeout: 60_000 });
await page.waitForFunction(
() => typeof window.__studioTest?.loadTimelinePerformanceFixture === "function",
{ timeout: 30_000 },
);
await waitForStudioTestHookSettle(page);
return { page, client, runtimeMode: await assertProductionRuntime(page) };
}
async function assertProductionRuntime(page) {
const runtimeMode = await page.evaluate(() => window.__studioTest.runtimeMode);
if (TIER === "ci" && runtimeMode !== "production") {
throw new Error(
`Timeline CI must measure the production React runtime, received ${runtimeMode}. ` +
"Start the Studio development server with NODE_ENV=production.",
);
}
return runtimeMode;
}
async function assertRowVirtualization(page, budgets) {
// Trust the DOM, not the caller. A virtualized build cannot mount every clip
// and an unvirtualized one cannot avoid it, so the mounted count says which
// build is really being measured.
const observedClipRoots = await page.evaluate(
() => window.__studioTest.readTimelinePerformanceDiagnostics().mountedClipRoots,
);
const observedRowVirtualization = observedClipRoots <= budgets.maxMountedClipRoots ? "on" : "off";
if (observedRowVirtualization !== ROW_VIRTUALIZATION) {
throw new Error(
`Requested TIMELINE_ROW_VIRTUALIZATION=${ROW_VIRTUALIZATION} but the server under test ` +
`behaves as ${observedRowVirtualization}: ${observedClipRoots} clip roots mounted for ` +
`${ELEMENT_COUNT} elements against a ${budgets.maxMountedClipRoots} budget. ` +
"Set VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED on the Studio dev server to match.",
);
}
return observedClipRoots;
}
async function waitForFixtureRender(page, elementCount) {
const deadline = Date.now() + 60_000;
let observed = null;