Skip to content

Commit e3338bf

Browse files
committed
fix(studio): the timeline scroll gate measures a failed arm once more before failing
1 parent 3e01934 commit e3338bf

4 files changed

Lines changed: 96 additions & 55 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,14 @@ jobs:
925925
echo "### Timeline viewport gate"
926926
echo "- Default arm exit: ${DEFAULT_STATUS}"
927927
echo "- Explicitly disabled arm exit: ${DISABLED_STATUS}"
928+
for arm in default disabled; do
929+
node -e '
930+
const e = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
931+
const b = e.environment.appliedBudgets;
932+
for (const a of e.attempts)
933+
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"}`);
934+
' "/tmp/timeline-gate-${arm}.json" "$arm" || echo "- ${arm}: no evidence"
935+
done
928936
} >> "$GITHUB_STEP_SUMMARY"
929937
930938
if (( DEFAULT_STATUS != 0 || DISABLED_STATUS != 0 )); then

‎packages/studio/tests/e2e/timeline-viewport-verdict.mjs‎

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,17 +56,14 @@ export function judgeResponsiveness(
5656
};
5757
}
5858

59-
export function gatePassed({
60-
directScrollApproved,
61-
responsivenessPassed,
62-
passingRuns,
63-
requiredPassingRuns,
64-
memoryReturned,
65-
}) {
66-
return (
67-
directScrollApproved &&
68-
responsivenessPassed &&
69-
passingRuns >= requiredPassingRuns &&
70-
memoryReturned
71-
);
59+
/** A failed timing attempt is measured once more, so one bad stretch of a shared runner cannot fail the gate alone. */
60+
export const TIMING_ATTEMPTS = 2;
61+
62+
export function attemptPassed({ responsivenessPassed, passingRuns, requiredPassingRuns }) {
63+
return responsivenessPassed && passingRuns >= requiredPassingRuns;
64+
}
65+
66+
export function gatePassed({ directScrollApproved, attempts, memoryReturned }) {
67+
const timingPassed = attempts.slice(0, TIMING_ATTEMPTS).some((attempt) => attempt.passed);
68+
return directScrollApproved && timingPassed && memoryReturned;
7269
}

‎packages/studio/tests/e2e/timeline-viewport-verdict.test.mjs‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import { TIMELINE_VIEWPORT_BUDGETS } from "../../src/player/lib/timelineViewportBudgets";
33
import {
4+
attemptPassed,
45
gatePassed,
56
judgeResponsiveness,
67
percentile,
@@ -117,20 +118,31 @@ describe("the CI virtualized arm's limits", () => {
117118
});
118119
});
119120

121+
describe("attemptPassed", () => {
122+
const passing = { responsivenessPassed: true, passingRuns: 5, requiredPassingRuns: 4 };
123+
124+
it("needs pooled responsiveness and enough passing runs", () => {
125+
expect(attemptPassed(passing)).toBe(true);
126+
expect(attemptPassed({ ...passing, responsivenessPassed: false })).toBe(false);
127+
expect(attemptPassed({ ...passing, passingRuns: 3 })).toBe(false);
128+
});
129+
});
130+
120131
describe("gatePassed", () => {
121-
const passing = {
122-
directScrollApproved: true,
123-
responsivenessPassed: true,
124-
passingRuns: 5,
125-
requiredPassingRuns: 4,
126-
memoryReturned: true,
127-
};
132+
const pass = { passed: true };
133+
const fail = { passed: false };
134+
const passing = { directScrollApproved: true, attempts: [pass], memoryReturned: true };
128135

129136
it("passes only when every check holds", () => {
130137
expect(gatePassed(passing)).toBe(true);
131-
expect(gatePassed({ ...passing, responsivenessPassed: false })).toBe(false);
132138
expect(gatePassed({ ...passing, directScrollApproved: false })).toBe(false);
133-
expect(gatePassed({ ...passing, passingRuns: 3 })).toBe(false);
134139
expect(gatePassed({ ...passing, memoryReturned: false })).toBe(false);
135140
});
141+
142+
it("fails timing only when the attempt and its one rerun both fail", () => {
143+
expect(gatePassed({ ...passing, attempts: [fail, pass] })).toBe(true);
144+
expect(gatePassed({ ...passing, attempts: [fail, fail] })).toBe(false);
145+
expect(gatePassed({ ...passing, attempts: [fail, fail, pass] })).toBe(false);
146+
expect(gatePassed({ ...passing, attempts: [] })).toBe(false);
147+
});
136148
});

‎packages/studio/tests/e2e/timeline-virtualization.mjs‎

Lines changed: 57 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* TIMELINE_TIER selects the budget set and the emulation applied. "primary" is
1111
* a developer machine and holds the strict budgets. "low-resource" and
1212
* "high-dpr" add CPU throttling and a 2x scale factor respectively. "ci" is a
13-
* shared runner: no emulation, but the constrained budgets, because a hosted
14-
* runner is already slower and noisier than the machine the strict numbers were
15-
* recorded on. Throttling it further would measure the throttle, not the build.
13+
* shared runner with no emulation: its virtualized arm holds its own limits
14+
* (responsivenessLimits), its unvirtualized arm the constrained ones. Throttling
15+
* it further would measure the throttle, not the build.
1616
* CI also requires production React and reports the observed runtime so Vite's
1717
* development-only checks can never contaminate the shipped-code measurement.
1818
*
@@ -24,9 +24,11 @@
2424
import { platform, arch } from "node:os";
2525
import { launchStudioChrome } from "./chrome-executable.mjs";
2626
import {
27+
attemptPassed,
2728
gatePassed,
2829
judgeResponsiveness,
2930
responsivenessLimits,
31+
TIMING_ATTEMPTS,
3032
} from "./timeline-viewport-verdict.mjs";
3133

3234
const STUDIO_URL = process.env.STUDIO_URL;
@@ -294,37 +296,61 @@ try {
294296
);
295297
}
296298

297-
const runs = [];
298299
const { interactionLimitMs, frameIntervalLimitMs } = responsivenessLimits(
299300
budgets,
300301
TIER,
301302
ROW_VIRTUALIZATION,
302303
);
303-
for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) {
304-
const run = await collectRun(page);
305-
if (index >= budgets.warmupRuns) runs.push(run);
306-
}
307304
// Latency, long tasks and memory are product promises and hold for both
308305
// builds. The DOM-size budgets describe what windowing achieves, so they only
309306
// apply when windowing is on. They are skipped explicitly rather than relaxed,
310307
// so a skipped budget never reads as a passed one.
311308
const domBudgetsApply = ROW_VIRTUALIZATION === "on";
312-
for (const run of runs) {
313-
run.longTaskPassed = run.longestTaskMs <= longTaskLimitMs;
314-
run.timelineMounted = run.diagnostics.timelineRoots === 1;
315-
run.domSizePassed = domBudgetsApply
316-
? run.diagnostics.mountedRows <= budgets.maxMountedRows &&
317-
run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots &&
318-
run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow &&
319-
run.diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants
320-
: null;
321-
run.passed = run.longTaskPassed && run.timelineMounted && run.domSizePassed !== false;
309+
const attempts = [];
310+
while (attempts.length < TIMING_ATTEMPTS && !attempts.some((attempt) => attempt.passed)) {
311+
const runs = [];
312+
for (let index = 0; index < budgets.warmupRuns + budgets.measuredRuns; index += 1) {
313+
const run = await collectRun(page);
314+
if (index >= budgets.warmupRuns) runs.push(run);
315+
}
316+
for (const run of runs) {
317+
run.longTaskPassed = run.longestTaskMs <= longTaskLimitMs;
318+
run.timelineMounted = run.diagnostics.timelineRoots === 1;
319+
run.domSizePassed = domBudgetsApply
320+
? run.diagnostics.mountedRows <= budgets.maxMountedRows &&
321+
run.diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots &&
322+
run.diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow &&
323+
run.diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants
324+
: null;
325+
run.passed = run.longTaskPassed && run.timelineMounted && run.domSizePassed !== false;
326+
}
327+
const responsiveness = judgeResponsiveness(runs, {
328+
samplesPerRun: budgets.scrollSamplesPerRun,
329+
interactionLimitMs,
330+
frameIntervalLimitMs,
331+
});
332+
const passingRuns = runs.filter((run) => run.passed).length;
333+
const attempt = {
334+
attempt: attempts.length + 1,
335+
interactionP95Ms: responsiveness.interactionP95Ms,
336+
frameIntervalP95Ms: responsiveness.frameIntervalP95Ms,
337+
responsivenessPassed: responsiveness.passed,
338+
passingRuns,
339+
passed: attemptPassed({
340+
responsivenessPassed: responsiveness.passed,
341+
passingRuns,
342+
requiredPassingRuns: budgets.requiredPassingRuns,
343+
}),
344+
runs,
345+
};
346+
attempts.push(attempt);
347+
console.error(
348+
`timeline gate ${ROW_VIRTUALIZATION} attempt ${attempt.attempt}: ` +
349+
`interaction p95 ${attempt.interactionP95Ms.toFixed(1)}/${interactionLimitMs} ms, ` +
350+
`frame p95 ${attempt.frameIntervalP95Ms.toFixed(1)}/${frameIntervalLimitMs} ms, ` +
351+
`${passingRuns}/${runs.length} runs passed, ${attempt.passed ? "PASS" : "FAIL"}`,
352+
);
322353
}
323-
const responsiveness = judgeResponsiveness(runs, {
324-
samplesPerRun: budgets.scrollSamplesPerRun,
325-
interactionLimitMs,
326-
frameIntervalLimitMs,
327-
});
328354

329355
await page.evaluate(() => window.__studioTest.resetTimelinePerformanceFixture());
330356
await page.waitForFunction(
@@ -336,8 +362,10 @@ try {
336362
const returnedHeapBytes = await collectHeapBytes(client);
337363
const memoryReturned =
338364
returnedHeapBytes <= baselineHeapBytes * (1 + budgets.memoryReturnToleranceRatio);
339-
const passingRuns = runs.filter((run) => run.passed).length;
340-
const maxTimelineContentWidthPx = Math.max(0, ...runs.map((run) => run.scrollWidth));
365+
const maxTimelineContentWidthPx = Math.max(
366+
0,
367+
...attempts.flatMap((attempt) => attempt.runs.map((run) => run.scrollWidth)),
368+
);
341369
const directScrollGate = {
342370
safetyEnvelopePx: budgets.directScrollSafetyPx,
343371
maxTimelineContentWidthPx,
@@ -374,15 +402,13 @@ try {
374402
warmups: budgets.warmupRuns,
375403
measured: budgets.measuredRuns,
376404
requiredPassing: budgets.requiredPassingRuns,
405+
timingAttempts: TIMING_ATTEMPTS,
377406
},
378407
},
379408
directScrollGate,
380-
runs,
409+
attempts,
381410
aggregate: {
382-
interactionP95Ms: responsiveness.interactionP95Ms,
383-
frameIntervalP95Ms: responsiveness.frameIntervalP95Ms,
384-
responsivenessPassed: responsiveness.passed,
385-
passingRuns,
411+
timingPassed: attempts.some((attempt) => attempt.passed),
386412
baselineHeapBytes,
387413
returnedHeapBytes,
388414
memoryReturned,
@@ -391,9 +417,7 @@ try {
391417
console.log(JSON.stringify(evidence, null, 2));
392418
exitCode = gatePassed({
393419
directScrollApproved: directScrollGate.decision === "approved",
394-
responsivenessPassed: responsiveness.passed,
395-
passingRuns,
396-
requiredPassingRuns: budgets.requiredPassingRuns,
420+
attempts,
397421
memoryReturned,
398422
})
399423
? 0

0 commit comments

Comments
 (0)