mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
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:
+57
-40
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user