mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
+54
-44
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user