feat(studio): enable timeline virtualization by default (#2926)

* feat(studio): enable timeline virtualization by default

* fix(ci): measure timeline performance in production React
This commit is contained in:
Miguel Ángel
2026-07-31 19:43:47 +02:00
committed by GitHub
parent 723d3381c4
commit c6925e471a
13 changed files with 202 additions and 68 deletions
+57 -40
View File
@@ -533,55 +533,72 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Two servers, because row virtualization is read from import.meta.env SERVER_PID=""
# at module load: one process cannot serve both builds. stop_server() {
bun run --cwd packages/studio dev -- --port 5313 --strictPort & if [[ -n "$SERVER_PID" ]]; then
DEFAULT_PID=$! kill "$SERVER_PID" 2>/dev/null || true
VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=1 \ wait "$SERVER_PID" 2>/dev/null || true
bun run --cwd packages/studio dev -- --port 5314 --strictPort & SERVER_PID=""
VIRTUALIZED_PID=$! fi
trap 'kill $DEFAULT_PID $VIRTUALIZED_PID 2>/dev/null || true' EXIT }
wait_for_server() {
local port="$1"
for i in $(seq 1 60); do
if curl -sf "http://localhost:${port}/" >/dev/null 2>&1; then return 0; fi
sleep 1
done
echo "FAIL: studio dev server did not start on port ${port}"
return 1
}
trap stop_server EXIT
for i in $(seq 1 60); do # Run one server at a time so the measured browser never competes with
if curl -sf http://localhost:5313/ >/dev/null 2>&1 \ # a second Vite module graph on the shared runner. The development
&& curl -sf http://localhost:5314/ >/dev/null 2>&1; then break; fi # server supplies the fixture API; production React matches shipped
sleep 1 # rendering behavior, and the gate asserts that runtime before timing.
done NODE_ENV=production \
if ! curl -sf http://localhost:5313/ >/dev/null 2>&1 \ bun run --cwd packages/studio dev -- --port 5313 --strictPort &
|| ! curl -sf http://localhost:5314/ >/dev/null 2>&1; then SERVER_PID=$!
echo "FAIL: studio dev servers did not start"
exit 1
fi
# The default build first. It is the one users get, and the arm that
# caught the regression this gate exists for.
# Capture both statuses so either failure still leaves two evidence files.
DEFAULT_STATUS=0 DEFAULT_STATUS=0
STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \ if wait_for_server 5313; then
TIMELINE_ROW_VIRTUALIZATION=off \ STUDIO_URL="http://localhost:5313/#project/timeline-virtualization" \
TIMELINE_ELEMENT_COUNT=1000 \ TIMELINE_ROW_VIRTUALIZATION=on \
TIMELINE_TIER=ci \ TIMELINE_ELEMENT_COUNT=50000 \
node packages/studio/tests/e2e/timeline-virtualization.mjs \ TIMELINE_TIER=ci \
| tee /tmp/timeline-gate-default.json \ node packages/studio/tests/e2e/timeline-virtualization.mjs \
|| DEFAULT_STATUS=$? | tee /tmp/timeline-gate-default.json \
|| DEFAULT_STATUS=$?
else
DEFAULT_STATUS=1
fi
stop_server
VIRTUALIZED_STATUS=0 NODE_ENV=production \
STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \ VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED=0 \
TIMELINE_ROW_VIRTUALIZATION=on \ bun run --cwd packages/studio dev -- --port 5314 --strictPort &
TIMELINE_ELEMENT_COUNT=50000 \ SERVER_PID=$!
TIMELINE_TIER=ci \ DISABLED_STATUS=0
node packages/studio/tests/e2e/timeline-virtualization.mjs \ if wait_for_server 5314; then
| tee /tmp/timeline-gate-virtualized.json \ STUDIO_URL="http://localhost:5314/#project/timeline-virtualization" \
|| VIRTUALIZED_STATUS=$? 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
{ {
echo "### Timeline viewport gate" echo "### Timeline viewport gate"
echo "- Default arm exit: ${DEFAULT_STATUS}" echo "- Default arm exit: ${DEFAULT_STATUS}"
echo "- Virtualized arm exit: ${VIRTUALIZED_STATUS}" echo "- Explicitly disabled arm exit: ${DISABLED_STATUS}"
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
if (( DEFAULT_STATUS != 0 || VIRTUALIZED_STATUS != 0 )); then if (( DEFAULT_STATUS != 0 || DISABLED_STATUS != 0 )); then
echo "FAIL: default=${DEFAULT_STATUS}, virtualized=${VIRTUALIZED_STATUS}" echo "FAIL: default=${DEFAULT_STATUS}, disabled=${DISABLED_STATUS}"
exit 1 exit 1
fi fi
- name: Upload gate evidence - name: Upload gate evidence
+1 -1
View File
@@ -52,7 +52,7 @@
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs", "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
"test:watch": "vitest", "test:watch": "vitest",
"report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts", "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts",
"test:timeline-default": "TIMELINE_ROW_VIRTUALIZATION=off TIMELINE_ELEMENT_COUNT=1000 node tests/e2e/timeline-virtualization.mjs" "test:timeline-default": "bun run test:timeline-virtualization"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1", "@codemirror/autocomplete": "^6.20.1",
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("Studio test mode", () => {
it("exposes hooks in the normal development runtime", async () => {
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
expect(STUDIO_RUNTIME_MODE).toBe("development");
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true);
});
it("keeps hooks on the development server while it uses production React", async () => {
vi.stubEnv("DEV", false);
vi.stubEnv("MODE", "development");
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
expect(STUDIO_RUNTIME_MODE).toBe("production");
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(true);
});
it("keeps test hooks out of an ordinary production build", async () => {
vi.stubEnv("DEV", false);
vi.stubEnv("MODE", "production");
const { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } = await import("./studioTestMode");
expect(STUDIO_RUNTIME_MODE).toBe("production");
expect(STUDIO_TEST_HOOKS_ENABLED).toBe(false);
});
});
@@ -0,0 +1,23 @@
export type StudioRuntimeMode = "development" | "production";
function readStudioImportMetaEnv(): ImportMetaEnv | undefined {
try {
return import.meta.env;
} catch {
return undefined;
}
}
const studioImportMetaEnv = readStudioImportMetaEnv();
/**
* Test hooks belong to Vite's development server, even when that server uses
* production React for performance measurement. A production build has neither
* DEV nor the development server mode, so the API stays out of shipped assets.
*/
export const STUDIO_RUNTIME_MODE: StudioRuntimeMode = studioImportMetaEnv?.DEV
? "development"
: "production";
export const STUDIO_TEST_HOOKS_ENABLED =
studioImportMetaEnv?.DEV === true || studioImportMetaEnv?.MODE === "development";
@@ -85,6 +85,7 @@ describe("timeline performance fixture", () => {
const api = window.__studioTest; const api = window.__studioTest;
expect(api).toBeDefined(); expect(api).toBeDefined();
if (!api) throw new Error("Expected dev Studio test API"); if (!api) throw new Error("Expected dev Studio test API");
expect(api.runtimeMode).toBe("development");
let notifications = 0; let notifications = 0;
usePlayerStore.setState({ usePlayerStore.setState({
isPlaying: true, isPlaying: true,
@@ -12,6 +12,7 @@ import {
type TimelinePerformanceFixtureSummary, type TimelinePerformanceFixtureSummary,
} from "../player/lib/timelinePerformanceFixture"; } from "../player/lib/timelinePerformanceFixture";
import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets"; import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets";
import { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } from "./studioTestMode";
interface StudioTestHookDeps { interface StudioTestHookDeps {
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>; previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -23,6 +24,7 @@ interface StudioTestHookDeps {
} }
interface StudioTestApi { interface StudioTestApi {
runtimeMode: typeof STUDIO_RUNTIME_MODE;
selectByDomId: (id: string) => Promise<boolean>; selectByDomId: (id: string) => Promise<boolean>;
loadTimelinePerformanceFixture: ( loadTimelinePerformanceFixture: (
spec: TimelinePerformanceFixtureSpec, spec: TimelinePerformanceFixtureSpec,
@@ -54,14 +56,9 @@ export function useStudioTestHooks({
}: StudioTestHookDeps): void { }: StudioTestHookDeps): void {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
useEffect(() => { useEffect(() => {
let isDev = false; if (!STUDIO_TEST_HOOKS_ENABLED || typeof window === "undefined") return;
try {
isDev = import.meta.env.DEV === true;
} catch {
isDev = false;
}
if (!isDev || typeof window === "undefined") return;
const api: StudioTestApi = { const api: StudioTestApi = {
runtimeMode: STUDIO_RUNTIME_MODE,
selectByDomId: async (id: string): Promise<boolean> => { selectByDomId: async (id: string): Promise<boolean> => {
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null; const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
if (!element) return false; if (!element) return false;
@@ -41,6 +41,10 @@ import { formatTime } from "../lib/time";
import { usePlayerStore } from "../store/playerStore"; import { usePlayerStore } from "../store/playerStore";
import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
vi.mock("./timelineRowVirtualizationFlag", () => ({
STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false,
}));
globalThis.IS_REACT_ACT_ENVIRONMENT = true; globalThis.IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => { afterEach(() => {
@@ -307,9 +307,9 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
}); });
/** /**
* The flag-off build is the one users get today. It mounts every clip, so the * The rollback build mounts every clip, so the scroll-time concessions
* scroll-time concessions windowing makes are pure cost there: this block pins * windowing makes are pure cost there. This block pins that explicit fallback
* the timeline to doing no per-frame work at all while a gesture runs. * to doing no per-frame work while a gesture runs.
*/ */
describe("Timeline without row virtualization", { timeout: 30_000 }, () => { describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
async function renderUnvirtualizedTimeline() { async function renderUnvirtualizedTimeline() {
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("timeline row virtualization flag", () => {
it("enables virtualization by default", async () => {
const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } =
await import("./timelineRowVirtualizationFlag");
expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(true);
});
it("keeps an explicit rollback path", async () => {
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "0");
const { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } =
await import("./timelineRowVirtualizationFlag");
expect(STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED).toBe(false);
});
});
@@ -1,11 +1,10 @@
/** /**
* Row virtualization opt-in. Disabled until horizontal windowing and stable * Row virtualization is the product default. Setting the environment flag to
* gesture lifetime land. * "0" keeps one explicit rollback path for comparisons and emergencies.
* *
* It lives in its own module so the scroll-viewport hook can read it without * It lives in its own module so the scroll-viewport hook can read it without
* importing the virtualization hook that already imports the viewport snapshot * importing the virtualization hook that already imports the viewport snapshot
* type back, which would close an import cycle. * type back, which would close an import cycle.
*/ */
export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED = export const STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED =
import.meta.env.DEV === true && import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED !== "0";
import.meta.env.VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED === "1";
@@ -23,6 +23,7 @@ describe("timeline viewport budgets", () => {
constrainedLongTaskLimitMs: 300, constrainedLongTaskLimitMs: 300,
posterCoverageRatio: 0.9, posterCoverageRatio: 0.9,
supportedFixtureFallbackRatio: 0.02, supportedFixtureFallbackRatio: 0.02,
scrollSamplesPerRun: 21,
warmupRuns: 3, warmupRuns: 3,
measuredRuns: 5, measuredRuns: 5,
requiredPassingRuns: 4, requiredPassingRuns: 4,
@@ -51,6 +52,7 @@ describe("timeline viewport budgets", () => {
[{ requiredPassingRuns: 0 }, "requiredPassingRuns"], [{ requiredPassingRuns: 0 }, "requiredPassingRuns"],
[{ measuredRuns: 1.5, requiredPassingRuns: 1 }, "measuredRuns"], [{ measuredRuns: 1.5, requiredPassingRuns: 1 }, "measuredRuns"],
[{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"], [{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"],
[{ scrollSamplesPerRun: 19 }, "scrollSamplesPerRun"],
[{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"], [{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"],
] as const)("rejects an invalid override %#", (overrides, message) => { ] as const)("rejects an invalid override %#", (overrides, message) => {
expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message); expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message);
@@ -40,6 +40,7 @@ export interface TimelineViewportBudgets {
richPreviewP95Ms: number; richPreviewP95Ms: number;
constrainedRichPreviewP95Ms: number; constrainedRichPreviewP95Ms: number;
supportedFixtureFallbackRatio: number; supportedFixtureFallbackRatio: number;
scrollSamplesPerRun: number;
warmupRuns: number; warmupRuns: number;
measuredRuns: number; measuredRuns: number;
requiredPassingRuns: number; requiredPassingRuns: number;
@@ -95,6 +96,7 @@ export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Obje
richPreviewP95Ms: 750, richPreviewP95Ms: 750,
constrainedRichPreviewP95Ms: 1_200, constrainedRichPreviewP95Ms: 1_200,
supportedFixtureFallbackRatio: 0.02, supportedFixtureFallbackRatio: 0.02,
scrollSamplesPerRun: 21,
warmupRuns: 3, warmupRuns: 3,
measuredRuns: 5, measuredRuns: 5,
requiredPassingRuns: 4, requiredPassingRuns: 4,
@@ -113,11 +115,21 @@ export function resolveTimelineViewportBudgets(
assertValidBudget(name as keyof TimelineViewportBudgets, value); assertValidBudget(name as keyof TimelineViewportBudgets, value);
} }
const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides }; const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides };
for (const name of ["warmupRuns", "measuredRuns", "requiredPassingRuns"] as const) { for (const name of [
"scrollSamplesPerRun",
"warmupRuns",
"measuredRuns",
"requiredPassingRuns",
] as const) {
if (!Number.isInteger(resolved[name])) { if (!Number.isInteger(resolved[name])) {
throw new RangeError(`Timeline viewport budget ${name} must be an integer`); throw new RangeError(`Timeline viewport budget ${name} must be an integer`);
} }
} }
if (resolved.scrollSamplesPerRun < 20) {
throw new RangeError(
"Timeline viewport budget scrollSamplesPerRun must be at least 20 for p95",
);
}
if (resolved.measuredRuns === 0 || resolved.requiredPassingRuns === 0) { if (resolved.measuredRuns === 0 || resolved.requiredPassingRuns === 0) {
throw new RangeError( throw new RangeError(
"Timeline viewport budget measuredRuns and requiredPassingRuns must be greater than zero", "Timeline viewport budget measuredRuns and requiredPassingRuns must be greater than zero",
@@ -13,13 +13,13 @@
* shared runner: no emulation, but the constrained budgets, because a hosted * shared runner: no emulation, but the constrained budgets, because a hosted
* runner is already slower and noisier than the machine the strict numbers were * runner is already slower and noisier than the machine the strict numbers were
* recorded on. Throttling it further would measure the throttle, not the build. * recorded on. Throttling it further would measure the throttle, not the build.
* CI also requires production React and reports the observed runtime so Vite's
* development-only checks can never contaminate the shipped-code measurement.
* *
* TIMELINE_ROW_VIRTUALIZATION selects which build is under test and defaults to * TIMELINE_ROW_VIRTUALIZATION selects which build is under test and defaults to
* "off", the product default. The gate previously only ever ran against a server * "on", the product default. The script asserts the configuration it observes
* with row virtualization enabled, so the configuration users actually get was * rather than trusting the caller: the server is configured by whoever started
* never measured. The script asserts the configuration it observes rather than * it, and a mismatch would otherwise pass silently against the wrong build.
* trusting the caller: the server is configured by whoever started it, and a
* mismatch would otherwise pass silently against the wrong build.
*/ */
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync } from "node:fs";
import { homedir, platform, arch } from "node:os"; import { homedir, platform, arch } from "node:os";
@@ -30,7 +30,7 @@ const STUDIO_URL = process.env.STUDIO_URL;
const PROFILE = process.env.TIMELINE_PROFILE || "dense-short"; const PROFILE = process.env.TIMELINE_PROFILE || "dense-short";
const ELEMENT_COUNT = Number(process.env.TIMELINE_ELEMENT_COUNT || 50_000); const ELEMENT_COUNT = Number(process.env.TIMELINE_ELEMENT_COUNT || 50_000);
const TIER = process.env.TIMELINE_TIER || "primary"; const TIER = process.env.TIMELINE_TIER || "primary";
const ROW_VIRTUALIZATION = process.env.TIMELINE_ROW_VIRTUALIZATION || "off"; const ROW_VIRTUALIZATION = process.env.TIMELINE_ROW_VIRTUALIZATION || "on";
const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR const EXPECTED_CHROME_MAJOR = process.env.TIMELINE_CHROME_MAJOR
? Number(process.env.TIMELINE_CHROME_MAJOR) ? Number(process.env.TIMELINE_CHROME_MAJOR)
: null; : null;
@@ -110,6 +110,7 @@ async function collectRun(page, injectedLongTaskMs = 0) {
return { return {
interactionP95Ms: percentileInPage(interactions, 0.95), interactionP95Ms: percentileInPage(interactions, 0.95),
frameIntervalP95Ms: percentileInPage(frameIntervals, 0.95), frameIntervalP95Ms: percentileInPage(frameIntervals, 0.95),
scrollSampleCount: interactions.length,
longestTaskMs: Math.max(0, ...longTasks), longestTaskMs: Math.max(0, ...longTasks),
scrollWidth: scroller.scrollWidth, scrollWidth: scroller.scrollWidth,
scrollHeight: scroller.scrollHeight, scrollHeight: scroller.scrollHeight,
@@ -166,7 +167,10 @@ async function collectRun(page, injectedLongTaskMs = 0) {
const interactions = []; const interactions = [];
const frameIntervals = []; const frameIntervals = [];
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
for (const ratio of [0, 0.25, 0.5, 0.75, 1, 0.5, 0]) { const ratios = [0, 0.25, 0.5, 0.75, 1, 0.5, 0];
const sampleCount = window.__studioTest.timelineViewportBudgets.scrollSamplesPerRun;
for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) {
const ratio = ratios[sampleIndex % ratios.length];
const started = performance.now(); const started = performance.now();
timelineScroller.scrollLeft = Math.round( timelineScroller.scrollLeft = Math.round(
(timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio, (timelineScroller.scrollWidth - timelineScroller.clientWidth) * ratio,
@@ -184,7 +188,7 @@ async function collectRun(page, injectedLongTaskMs = 0) {
}, injectedLongTaskMs); }, injectedLongTaskMs);
} }
async function assertLongTaskCapture(browser, longTaskLimitMs) { async function assertLongTaskCapture(browser, longTaskLimitMs, scrollSamplesPerRun) {
const page = await browser.newPage(); const page = await browser.newPage();
const injectedDurationMs = longTaskLimitMs + 25; const injectedDurationMs = longTaskLimitMs + 25;
try { try {
@@ -195,9 +199,12 @@ async function assertLongTaskCapture(browser, longTaskLimitMs) {
</div> </div>
</div> </div>
`); `);
await page.evaluate(() => { await page.evaluate((sampleCount) => {
window.__studioTest = { readTimelinePerformanceDiagnostics: () => ({}) }; window.__studioTest = {
}); timelineViewportBudgets: { scrollSamplesPerRun: sampleCount },
readTimelinePerformanceDiagnostics: () => ({}),
};
}, scrollSamplesPerRun);
const probe = await collectRun(page, injectedDurationMs); const probe = await collectRun(page, injectedDurationMs);
if (probe.longestTaskMs <= longTaskLimitMs) { if (probe.longestTaskMs <= longTaskLimitMs) {
throw new Error( throw new Error(
@@ -279,6 +286,14 @@ try {
); );
await waitForStudioTestHookSettle(page); 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.",
);
}
await loadFixtureAndWait(page, 1_000, PROFILE); await loadFixtureAndWait(page, 1_000, PROFILE);
await client.send("HeapProfiler.collectGarbage"); await client.send("HeapProfiler.collectGarbage");
const baselineHeapBytes = await collectHeapBytes(client); const baselineHeapBytes = await collectHeapBytes(client);
@@ -286,7 +301,11 @@ try {
const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets); const budgets = await page.evaluate(() => window.__studioTest.timelineViewportBudgets);
const longTaskLimitMs = const longTaskLimitMs =
TIER === "primary" ? budgets.longTaskLimitMs : budgets.constrainedLongTaskLimitMs; TIER === "primary" ? budgets.longTaskLimitMs : budgets.constrainedLongTaskLimitMs;
const longTaskObserverProbe = await assertLongTaskCapture(browser, longTaskLimitMs); const longTaskObserverProbe = await assertLongTaskCapture(
browser,
longTaskLimitMs,
budgets.scrollSamplesPerRun,
);
const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE); const summary = await loadFixtureAndWait(page, ELEMENT_COUNT, PROFILE);
const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page); const measuredMaxReliableScrollWidth = await measureMaximumReliableScrollWidth(page);
@@ -365,6 +384,7 @@ try {
deviceScaleFactor: TIER === "high-dpr" ? 2 : 1, deviceScaleFactor: TIER === "high-dpr" ? 2 : 1,
cpuThrottleRate: TIER === "low-resource" ? 4 : 1, cpuThrottleRate: TIER === "low-resource" ? 4 : 1,
tier: TIER, tier: TIER,
runtimeMode,
longTaskObserverProbe, longTaskObserverProbe,
rowVirtualization: ROW_VIRTUALIZATION, rowVirtualization: ROW_VIRTUALIZATION,
observedClipRootsAtLoad: observedClipRoots, observedClipRootsAtLoad: observedClipRoots,
@@ -376,6 +396,7 @@ try {
}, },
fixture: summary, fixture: summary,
runProtocol: { runProtocol: {
scrollSamplesPerRun: budgets.scrollSamplesPerRun,
warmups: budgets.warmupRuns, warmups: budgets.warmupRuns,
measured: budgets.measuredRuns, measured: budgets.measuredRuns,
requiredPassing: budgets.requiredPassingRuns, requiredPassing: budgets.requiredPassingRuns,