Files
hyperframes/packages/studio/tests/e2e/studio-runtime-cost.mjs
Miguel Angel Simon Sierra 62f04ce240 fix(studio): count logical media fetches, not ranged reads
A media element pulls one file as a series of ranged reads. Counting raw
HTTP requests reported a file fetched once in eight chunks as "8x
amplification", and the number got WORSE when a fix deferred a source so
the browser later fetched it in pieces: 19 requests became 37 for byte
identical content, and the ratio climbed from 2.38x to 4.63x while the
actual cost was unchanged.

Ranged reads of one URL now collapse into a single logical fetch. Raw and
ranged counts stay in the output as diagnostics. Same tree, same fixture:
4.63x becomes 1.13x, which is what the transferred bytes said all along.
2026-08-03 21:51:44 -07:00

840 lines
30 KiB
JavaScript

#!/usr/bin/env node
/**
* What a project costs while it is merely open.
*
* Open time is a single number at a single instant; it cannot see idle CPU,
* per-scrub-frame work, request amplification, or memory retained across
* project switches. This gate measures those four directly, against a fixture
* carrying real media that many clips share.
*
* STUDIO_URL=http://localhost:5190 \
* bun packages/studio/tests/e2e/studio-runtime-cost.mjs
*
* Run under bun, not node: the budgets are imported from their TypeScript home
* so the gate and the app read one owner rather than two copies of the numbers.
*
* STUDIO_URL is the Studio origin, not a project URL. The gate owns its
* fixtures: it writes two generated projects into `data/projects/` (gitignored)
* and drives them itself, so a run is one command and the switch window has two
* projects to alternate.
*
* STUDIO_RUNTIME_WINDOWS selects which windows run, for iterating on one of
* them without paying for the others. STUDIO_RUNTIME_TIER=ci relaxes only the
* idle CPU ceiling and the readiness timeout — a shared runner is slower and
* noisier than the machine the strict numbers were recorded on. Counting
* assertions are deterministic and are never relaxed.
*
* Everything the gate reports is measured in a browser. A window that cannot
* run fails; it never falls back to a partial or synthesized number.
*/
import { cpus, platform, arch, totalmem } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import puppeteer from "puppeteer-core";
import { resolveChromeExecutable } from "./puppeteerEnv.mjs";
import { writeStudioLoadFixture } from "./generateStudioLoadFixture.mjs";
import {
STUDIO_RUNTIME_BUDGETS,
findIdleQuiescentWindow,
} from "../../src/lib/studioRuntimeBudgets.ts";
const ALL_WINDOWS = ["idle", "scrub", "open", "switch"];
const TIERS = ["primary", "ci"];
const STUDIO_URL = process.env.STUDIO_URL;
const TIER = process.env.STUDIO_RUNTIME_TIER || "primary";
const WINDOWS = (process.env.STUDIO_RUNTIME_WINDOWS || ALL_WINDOWS.join(","))
.split(",")
.map((name) => name.trim())
.filter(Boolean);
if (!STUDIO_URL) {
console.error(
"STUDIO_URL is required and must be the origin of a running Studio, e.g. " +
"http://localhost:5190",
);
process.exit(2);
}
if (!TIERS.includes(TIER)) {
console.error(`STUDIO_RUNTIME_TIER must be one of: ${TIERS.join(", ")}`);
process.exit(2);
}
const unknownWindows = WINDOWS.filter((name) => !ALL_WINDOWS.includes(name));
if (WINDOWS.length === 0 || unknownWindows.length > 0) {
console.error(
`STUDIO_RUNTIME_WINDOWS must be a comma-separated subset of: ${ALL_WINDOWS.join(", ")}` +
(unknownWindows.length > 0 ? ` (received: ${unknownWindows.join(", ")})` : ""),
);
process.exit(2);
}
const budgets = STUDIO_RUNTIME_BUDGETS;
const idleCpuBudget =
TIER === "ci" ? budgets.constrainedIdleCpuFractionOfCore : budgets.idleCpuFractionOfCore;
const readyTimeoutMs = TIER === "ci" ? budgets.constrainedReadyTimeoutMs : budgets.readyTimeoutMs;
/**
* The two fixture projects. They differ in clip count so a switch is observable
* from the DOM rather than assumed from the URL, and both carry media reused
* seven times per source — the shape that produces request amplification.
*/
const GENERATED_FIXTURES = [
{
id: "studio-runtime-cost-a",
clipCount: 133,
trackCount: 12,
mediaSourceCount: 20,
mediaClipCount: 140,
},
{
id: "studio-runtime-cost-b",
clipCount: 90,
trackCount: 12,
mediaSourceCount: 15,
mediaClipCount: 105,
},
];
const MEDIA_EXTENSIONS = /\.(mp4|m4a|mov|webm|mp3|wav|ogg|aac|flac)(\?|$)/i;
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i;
/**
* STUDIO_RUNTIME_PROJECTS points the gate at projects already on disk instead of
* the generated fixtures. The gate runs on the fixture; this exists so the same
* four windows can be read against a real project and the two compared, because
* a synthesized project's tiny files can change fetch and caching behaviour.
*/
const PROJECT_OVERRIDE = (process.env.STUDIO_RUNTIME_PROJECTS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (PROJECT_OVERRIDE.length > 2) {
console.error(
"STUDIO_RUNTIME_PROJECTS accepts at most two project ids: one to measure, one to alternate with",
);
process.exit(2);
}
if (PROJECT_OVERRIDE.length === 1 && WINDOWS.includes("switch")) {
console.error(
"STUDIO_RUNTIME_PROJECTS needs two project ids when the switch window runs; " +
"a switch measurement alternates two projects",
);
process.exit(2);
}
const FIXTURES =
PROJECT_OVERRIDE.length > 0
? PROJECT_OVERRIDE.map((id) => ({ id, generated: false }))
: GENERATED_FIXTURES;
/** The messages of every exceeded budget, from `[exceeded, message]` pairs. */
function failuresFrom(checks) {
return checks.filter(([exceeded]) => exceeded).map(([, message]) => message);
}
/**
* Counts requestAnimationFrame scheduling and full-document media queries, by
* call site, in every frame.
*
* Installed through `evaluateOnNewDocument`, so it runs before any application
* script in the top document AND in the preview iframe — the frame the media
* queries actually happen in. Attribution by call site is the point: a bare
* count says a regression exists, a call site says where.
*/
function installStudioRuntimeCostProbe() {
const state = {
enabled: false,
animationFrameCallsBySite: Object.create(null),
fullDocumentMediaQueries: 0,
fullDocumentMediaQueriesBySite: Object.create(null),
};
window.__studioRuntimeCostProbe = state;
function studioRuntimeCostCallSite() {
// A short stack is the difference between attribution costing nothing and
// the probe showing up in the CPU number it is measuring. Four frames reach
// the caller; the limit is restored immediately so nothing else sees it.
const previousLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 4;
const stack = String(new Error().stack || "");
Error.stackTraceLimit = previousLimit;
const line = stack
.split("\n")
.map((raw) => raw.trim())
.find((raw) => raw.startsWith("at ") && !raw.includes("studioRuntimeCost"));
return line === undefined
? "unattributed"
: line
.replace(/^at\s+/, "")
.replace(/\?[^\s):]*/g, "")
.replace(/https?:\/\/[^/]+\//g, "")
.slice(0, 160);
}
function studioRuntimeCostRecord(bucket, site) {
bucket[site] = (bucket[site] || 0) + 1;
}
// A steady-state animation loop reschedules the same function object every
// frame, so its call site is captured once and read from the map thereafter.
// Two sites sharing one function object are attributed to the first — a
// trade the idle CPU number is worth, since a stack per frame would put the
// probe itself into the measurement.
const siteByCallback = new WeakMap();
function studioRuntimeCostSiteFor(callback) {
if (typeof callback !== "function") return studioRuntimeCostCallSite();
const cached = siteByCallback.get(callback);
if (cached !== undefined) return cached;
const site = studioRuntimeCostCallSite();
siteByCallback.set(callback, site);
return site;
}
const nativeRequestAnimationFrame = window.requestAnimationFrame;
window.requestAnimationFrame = function studioRuntimeCostRaf(callback) {
if (state.enabled) {
studioRuntimeCostRecord(state.animationFrameCallsBySite, studioRuntimeCostSiteFor(callback));
}
return nativeRequestAnimationFrame.call(window, callback);
};
// A full-document media query is one issued on a Document (not an element
// subtree) whose selector list contains a `video` or `audio` type selector —
// `video, audio` and `audio[data-start]` both qualify. That is the scan the
// runtime performs per seek, and the one this gate exists to drive to zero.
const nativeQuerySelectorAll = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function studioRuntimeCostQsa(selectors) {
if (state.enabled) {
const isMediaSelector = String(selectors)
.split(",")
.some((part) => /^(video|audio)\b/i.test(part.trim()));
if (isMediaSelector) {
state.fullDocumentMediaQueries += 1;
studioRuntimeCostRecord(state.fullDocumentMediaQueriesBySite, studioRuntimeCostCallSite());
}
}
return nativeQuerySelectorAll.call(this, selectors);
};
}
async function forEachFrame(page, fn) {
const results = [];
for (const frame of page.frames()) {
if (frame.isDetached()) continue;
try {
results.push({ url: frame.url(), value: await frame.evaluate(fn) });
} catch {
// A frame torn down mid-read contributes nothing rather than failing the
// run; the frames that matter here outlive every window.
}
}
return results;
}
async function setProbeEnabled(page, enabled) {
await Promise.all(
page.frames().map(async (frame) => {
if (frame.isDetached()) return;
try {
await frame.evaluate((on) => {
if (window.__studioRuntimeCostProbe) window.__studioRuntimeCostProbe.enabled = on;
}, enabled);
} catch {
/* detached */
}
}),
);
}
async function resetProbe(page) {
await Promise.all(
page.frames().map(async (frame) => {
if (frame.isDetached()) return;
try {
await frame.evaluate(() => {
const probe = window.__studioRuntimeCostProbe;
if (!probe) return;
probe.animationFrameCallsBySite = Object.create(null);
probe.fullDocumentMediaQueries = 0;
probe.fullDocumentMediaQueriesBySite = Object.create(null);
});
} catch {
/* detached */
}
}),
);
}
async function readProbe(page) {
const frames = await forEachFrame(page, () => {
const probe = window.__studioRuntimeCostProbe;
if (!probe) return null;
return {
animationFrameCallsBySite: { ...probe.animationFrameCallsBySite },
fullDocumentMediaQueries: probe.fullDocumentMediaQueries,
fullDocumentMediaQueriesBySite: { ...probe.fullDocumentMediaQueriesBySite },
};
});
const present = frames.filter((frame) => frame.value !== null);
if (present.length === 0) {
throw new Error("The runtime-cost probe is installed in no frame; nothing was measured");
}
const byFrame = present.map(({ url, value }) => ({ frame: describeFrame(url), ...value }));
return {
byFrame,
animationFrameCalls: byFrame.reduce(
(total, frame) => total + sumCounts(frame.animationFrameCallsBySite),
0,
),
fullDocumentMediaQueries: byFrame.reduce(
(total, frame) => total + frame.fullDocumentMediaQueries,
0,
),
};
}
function describeFrame(url) {
return url.includes("/preview") ? "preview-iframe" : "studio-document";
}
function sumCounts(bucket) {
return Object.values(bucket).reduce((total, count) => total + count, 0);
}
function topSites(bucket, limit = 8) {
return Object.fromEntries(
Object.entries(bucket)
.sort((a, b) => b[1] - a[1])
.slice(0, limit),
);
}
async function readPerformanceMetrics(client) {
const { metrics } = await client.send("Performance.getMetrics");
return Object.fromEntries(metrics.map(({ name, value }) => [name, value]));
}
function projectUrl(id) {
return `${STUDIO_URL.replace(/\/$/, "")}/#project/${id}`;
}
/**
* Interactive means the timeline has mounted the project's elements AND the
* preview iframe has a composition in it. A project that never reaches both
* fails here on timeout rather than contributing a partial number downstream.
*/
async function waitForProjectInteractive(page, expectedClipsAtLeast) {
const deadline = Date.now() + readyTimeoutMs;
let lastObserved = null;
while (Date.now() < deadline) {
lastObserved = await readInteractiveState(page);
if (lastObserved.timelineElements >= expectedClipsAtLeast && lastObserved.previewElements > 0) {
return lastObserved;
}
await sleep(250);
}
throw new Error(
`Studio never became interactive within ${readyTimeoutMs}ms: ${JSON.stringify(lastObserved)}`,
);
}
async function readInteractiveState(page) {
const top = await page
.evaluate(() => {
const timeline = document.querySelector('[aria-label="Timeline"]');
return {
timelineElements: Number(timeline?.getAttribute("data-timeline-element-count") ?? -1),
mountedClips: document.querySelectorAll('[data-clip="true"]').length,
};
})
.catch(() => ({ timelineElements: -1, mountedClips: 0 }));
let previewElements = 0;
for (const frame of page.frames()) {
if (frame === page.mainFrame() || frame.isDetached()) continue;
previewElements = Math.max(
previewElements,
// A composition root, not a clip count: a project may legitimately render
// its clips into sub-compositions, and a readiness signal that misses
// those would time out on a project that is in fact open.
await frame
.evaluate(() => document.querySelectorAll("[data-composition-id], .clip").length)
.catch(() => 0),
);
}
return { ...top, previewElements };
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ── Windows ──────────────────────────────────────────────────────────────────
/**
* Idle, past quiescence.
*
* Consecutive windows are sampled until the style-recalc count stabilises; the
* first windows on a media project drain the thumbnail-extraction backlog and
* read several times the steady state. Reporting the first window would gate on
* a transient, so the quiescence condition — not an elapsed time — decides which
* window is the answer.
*/
async function sampleIdleWindow(page, client, index, before) {
const windowSeconds = budgets.idleWindowMs / 1000;
await resetProbe(page);
await setProbeEnabled(page, true);
await sleep(budgets.idleWindowMs);
const probe = await readProbe(page);
await setProbeEnabled(page, false);
const after = await readPerformanceMetrics(client);
return {
after,
sample: {
windowIndex: index,
styleRecalcs: after.RecalcStyleCount - before.RecalcStyleCount,
layouts: after.LayoutCount - before.LayoutCount,
cpuFractionOfCore: (after.TaskDuration - before.TaskDuration) / windowSeconds,
animationFrameCalls: probe.animationFrameCalls,
animationFrameCallsByFrame: probe.byFrame.map((frame) => ({
frame: frame.frame,
calls: sumCounts(frame.animationFrameCallsBySite),
topSites: topSites(frame.animationFrameCallsBySite),
})),
nodes: after.Nodes,
jsEventListeners: after.JSEventListeners,
jsHeapUsedBytes: after.JSHeapUsedSize,
},
};
}
function quiescentIndexOf(samples) {
return findIdleQuiescentWindow(
samples.map((sample) => sample.styleRecalcs),
budgets,
);
}
function summariseIdleWindow(settled, quiescentIndex) {
const windowSeconds = budgets.idleWindowMs / 1000;
return {
quiescentWindowIndex: quiescentIndex,
cpuFractionOfCore: round(settled.cpuFractionOfCore, 4),
styleRecalcsPerSecond: round(settled.styleRecalcs / windowSeconds, 2),
layoutsPerSecond: round(settled.layouts / windowSeconds, 2),
animationFrameCallsPerSecond: round(settled.animationFrameCalls / windowSeconds, 2),
animationFrameCallSites: settled.animationFrameCallsByFrame,
};
}
function idleFailures(measured) {
return failuresFrom([
[
measured.cpuFractionOfCore > idleCpuBudget,
`idle CPU ${(measured.cpuFractionOfCore * 100).toFixed(1)}% of a core exceeds ` +
`${(idleCpuBudget * 100).toFixed(1)}%`,
],
[
measured.styleRecalcsPerSecond > budgets.idleStyleRecalcsPerSecond,
`idle style recalcs ${measured.styleRecalcsPerSecond}/s exceeds ` +
`${budgets.idleStyleRecalcsPerSecond}/s`,
],
[
measured.layoutsPerSecond > budgets.idleLayoutsPerSecond,
`idle layouts ${measured.layoutsPerSecond}/s exceeds ${budgets.idleLayoutsPerSecond}/s`,
],
[
measured.animationFrameCallsPerSecond > budgets.idleAnimationFrameCallsPerSecond,
`idle animation-frame calls ${measured.animationFrameCallsPerSecond}/s exceeds ` +
`${budgets.idleAnimationFrameCallsPerSecond}/s`,
],
]);
}
async function measureIdle(page, client) {
const samples = [];
let before = await readPerformanceMetrics(client);
for (let index = 0; index < budgets.idleMaxWindows; index += 1) {
const { after, sample } = await sampleIdleWindow(page, client, index, before);
samples.push(sample);
before = after;
if (quiescentIndexOf(samples) !== null) break;
}
const quiescentIndex = quiescentIndexOf(samples);
if (quiescentIndex === null) {
return {
windows: samples,
quiescent: false,
failures: [
`idle never reached quiescence across ${samples.length} windows of ` +
`${budgets.idleWindowMs}ms: recalcs ${samples.map((s) => s.styleRecalcs).join(", ")}`,
],
};
}
const measured = summariseIdleWindow(samples[quiescentIndex], quiescentIndex);
return { windows: samples, quiescent: true, measured, failures: idleFailures(measured) };
}
/**
* Scrub: drag the playhead a fixed distance across the timeline ruler and count
* the full-document media queries the drag causes — in every frame, because the
* scan happens inside the preview iframe, not the top document.
*/
async function readScrubSurface(page) {
const ruler = await page.evaluate(() => {
const viewport = document.querySelector("[data-timeline-scroll-viewport]");
if (!viewport) return null;
const rect = viewport.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
if (!ruler || ruler.width < budgets.scrubDistancePx + 40) {
throw new Error(
`The timeline scroll viewport is missing or too narrow to drag ${budgets.scrubDistancePx}px: ` +
JSON.stringify(ruler),
);
}
// The ruler strip at the top of the viewport is the scrub surface; a press
// lower down draws a marquee instead and would measure nothing.
return { startX: ruler.x + 20, y: ruler.y + 6 };
}
async function dragPlayhead(page, { startX, y }) {
await page.mouse.move(startX, y);
await page.mouse.down();
for (let step = 1; step <= budgets.scrubSteps; step += 1) {
await page.mouse.move(startX + (budgets.scrubDistancePx * step) / budgets.scrubSteps, y);
await sleep(16);
}
await page.mouse.up();
await sleep(200);
}
async function measureScrub(page) {
const surface = await readScrubSurface(page);
await resetProbe(page);
await setProbeEnabled(page, true);
await dragPlayhead(page, surface);
const probe = await readProbe(page);
await setProbeEnabled(page, false);
const measured = {
fullDocumentMediaQueries: probe.fullDocumentMediaQueries,
perStep: round(probe.fullDocumentMediaQueries / budgets.scrubSteps, 2),
byFrame: probe.byFrame.map((frame) => ({
frame: frame.frame,
fullDocumentMediaQueries: frame.fullDocumentMediaQueries,
topSites: topSites(frame.fullDocumentMediaQueriesBySite),
})),
};
return {
measured,
failures: failuresFrom([
[
measured.fullDocumentMediaQueries > budgets.scrubFullDocumentMediaQueries,
`scrub issued ${measured.fullDocumentMediaQueries} full-document media queries, ` +
`budget ${budgets.scrubFullDocumentMediaQueries}`,
],
]),
};
}
/**
* Open: every request the browser makes from a cold navigation until the
* network goes quiet, and how many times each distinct media file is fetched.
*/
/** Every completed request on this page, with the bytes that crossed the wire. */
async function recordRequests(client) {
const pending = new Map();
const completed = [];
const activity = { at: Date.now() };
const finish = (requestId, bytes) => {
const request = pending.get(requestId);
if (!request) return;
pending.delete(requestId);
completed.push({ ...request, bytes: bytes ?? 0 });
activity.at = Date.now();
};
await client.send("Network.enable");
client.on("Network.requestWillBeSent", ({ requestId, request, type }) => {
const headers = request.headers ?? {};
const ranged = Boolean(headers.Range ?? headers.range);
pending.set(requestId, { url: request.url, type, ranged });
activity.at = Date.now();
});
client.on("Network.loadingFinished", ({ requestId, encodedDataLength }) =>
finish(requestId, encodedDataLength),
);
client.on("Network.loadingFailed", ({ requestId }) => finish(requestId, 0));
return { completed, activity };
}
async function waitForNetworkQuiet(activity) {
const deadline = Date.now() + readyTimeoutMs;
while (Date.now() - activity.at < budgets.openQuietPeriodMs) {
if (Date.now() > deadline) {
throw new Error(
`Network never went quiet for ${budgets.openQuietPeriodMs}ms within ${readyTimeoutMs}ms`,
);
}
await sleep(250);
}
}
/**
* A media element fetches one file as a series of ranged reads — that is normal
* playback behaviour, not duplication. Counting raw HTTP requests therefore
* reports a file pulled once in eight chunks as "8x amplification", and gets
* WORSE when a fix defers a source so it is later fetched in pieces.
*
* What this gate is for is wasted fetching: the same source pulled from scratch
* by independent subsystems that do not share a cache. So ranged reads of one
* URL collapse into a single logical fetch, and the ratio is built from those.
* Raw counts stay in the output as diagnostics.
*/
function countLogicalFetches(requests) {
const rangedUrls = new Set();
let fetches = 0;
for (const request of requests) {
const url = request.url.split("?")[0];
if (!request.ranged) {
fetches += 1;
continue;
}
if (rangedUrls.has(url)) continue;
rangedUrls.add(url);
fetches += 1;
}
return fetches;
}
function summariseRequests(completed, wallClockMs) {
const media = completed.filter((request) => MEDIA_EXTENSIONS.test(request.url));
const images = completed.filter((request) => IMAGE_EXTENSIONS.test(request.url));
const distinct = (requests) => new Set(requests.map((request) => request.url.split("?")[0])).size;
const distinctMediaSources = distinct(media);
const mediaFetches = countLogicalFetches(media);
if (distinctMediaSources === 0) {
throw new Error("No media requests observed; the project under test carries no media");
}
const sumBytes = (requests) => requests.reduce((total, request) => total + request.bytes, 0);
return {
wallClockMs,
requests: completed.length,
bytes: sumBytes(completed),
mediaRequests: media.length,
mediaRangedRequests: media.filter((request) => request.ranged).length,
mediaFetches,
mediaBytes: sumBytes(media),
distinctMediaSources,
mediaRequestsPerDistinctSource: round(mediaFetches / distinctMediaSources, 2),
imageRequests: images.length,
distinctImageSources: distinct(images),
};
}
async function measureOpen(browser) {
const context = await browser.createBrowserContext();
try {
const page = await context.newPage();
await page.setViewport({ width: 1600, height: 1000 });
// The cache is deliberately left ON. A fresh browser context already starts
// cold, and disabling the cache would turn every same-source refetch into a
// network request — inflating the amplification ratio into a number no user
// ever pays. What is measured here is a cold open with the cache a real
// session has.
const client = await page.createCDPSession();
const { completed, activity } = await recordRequests(client);
const startedAt = Date.now();
await page.goto(projectUrl(FIXTURES[0].id), {
waitUntil: "domcontentloaded",
timeout: readyTimeoutMs,
});
await waitForProjectInteractive(page, 1);
await waitForNetworkQuiet(activity);
const measured = summariseRequests(completed, Date.now() - startedAt);
return {
measured,
failures: failuresFrom([
[
measured.mediaRequestsPerDistinctSource > budgets.openMediaRequestsPerDistinctSource,
`open fetched each media source ${measured.mediaRequestsPerDistinctSource}x, ` +
`budget ${budgets.openMediaRequestsPerDistinctSource}x`,
],
]),
};
} finally {
await context.close();
}
}
/**
* Switch: alternate two projects with a forced collection between samples and
* report the heap retained per cycle. DOM nodes, listeners and documents are
* reported alongside so a heap climb with flat node counts reads as what it is.
*/
async function sampleSwitchCycle(page, client, cycle) {
const fixture = FIXTURES[cycle % FIXTURES.length];
await page.evaluate((url) => {
window.location.hash = url;
}, `#project/${fixture.id}`);
await waitForProjectInteractive(page, 1);
await sleep(1_000);
await client.send("HeapProfiler.collectGarbage");
const { usedSize } = await client.send("Runtime.getHeapUsage");
const metrics = await readPerformanceMetrics(client);
return {
cycle,
project: fixture.id,
jsHeapUsedBytes: usedSize,
nodes: metrics.Nodes,
jsEventListeners: metrics.JSEventListeners,
documents: metrics.Documents,
};
}
async function measureSwitch(page, client) {
const cycles = [];
for (let cycle = 0; cycle < budgets.switchCycles; cycle += 1) {
cycles.push(await sampleSwitchCycle(page, client, cycle));
}
// The first switch mounts what the session needs; retention is the slope
// after it, not the step onto it.
const measurable = cycles.slice(1);
const heapDeltaBytes =
measurable[measurable.length - 1].jsHeapUsedBytes - measurable[0].jsHeapUsedBytes;
// DOM counts are compared across cycles showing the SAME project. The two
// fixtures differ in clip count, so an a-to-b comparison would report their
// shape difference as retention.
const sameProject = cycles.filter((cycle) => cycle.project === cycles[cycles.length - 1].project);
const drift = (key) => sameProject[sameProject.length - 1][key] - sameProject[0][key];
const measured = {
cycles,
heapDeltaBytes,
heapBytesPerCycle: round(heapDeltaBytes / (measurable.length - 1), 0),
nodeDriftSameProject: drift("nodes"),
listenerDriftSameProject: drift("jsEventListeners"),
documentDriftSameProject: drift("documents"),
};
return {
measured,
failures: failuresFrom([
[
measured.heapBytesPerCycle > budgets.switchHeapBytesPerCycle,
`switch retained ${formatBytes(measured.heapBytesPerCycle)} per cycle, ` +
`budget ${formatBytes(budgets.switchHeapBytesPerCycle)}`,
],
]),
};
}
function round(value, digits) {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function formatBytes(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ── Run ──────────────────────────────────────────────────────────────────────
const executablePath = resolveChromeExecutable();
if (!executablePath) {
console.error("No Chrome executable found. Set PUPPETEER_EXECUTABLE_PATH or CHROME_PATH.");
process.exit(2);
}
// `data/projects/` is gitignored; generated fixtures never enter the tree.
const projectsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "data", "projects");
for (const { id, generated, ...options } of FIXTURES) {
if (generated === false) continue;
writeStudioLoadFixture(join(projectsDir, id), options);
}
const browser = await puppeteer.launch({
executablePath,
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--window-size=1600,1000"],
defaultViewport: { width: 1600, height: 1000 },
});
let exitCode = 1;
const results = {};
const failures = [];
let version = "unknown";
try {
version = await browser.version();
if (WINDOWS.includes("open")) {
const open = await measureOpen(browser);
results.open = open.measured;
failures.push(...open.failures);
}
if (WINDOWS.some((name) => ["idle", "scrub", "switch"].includes(name))) {
const page = await browser.newPage();
const pageErrors = [];
page.on("pageerror", (error) => pageErrors.push(String(error).slice(0, 200)));
await page.evaluateOnNewDocument(installStudioRuntimeCostProbe);
const client = await page.createCDPSession();
await client.send("Performance.enable");
await page.goto(projectUrl(FIXTURES[0].id), {
waitUntil: "domcontentloaded",
timeout: readyTimeoutMs,
});
results.interactive = await waitForProjectInteractive(page, 1);
if (WINDOWS.includes("idle")) {
const idle = await measureIdle(page, client);
results.idle = idle;
failures.push(...idle.failures);
}
if (WINDOWS.includes("scrub")) {
const scrub = await measureScrub(page);
results.scrub = scrub.measured;
failures.push(...scrub.failures);
}
if (WINDOWS.includes("switch")) {
const switched = await measureSwitch(page, client);
results.switch = switched.measured;
failures.push(...switched.failures);
}
results.pageErrors = pageErrors;
await page.close();
}
} catch (error) {
// A window that cannot run says why and fails. It contributes no measurement:
// a number read off a page that never became interactive would gate on
// nothing while looking like evidence.
failures.push(error instanceof Error ? error.message : String(error));
} finally {
const evidence = {
environment: {
browser: version,
executablePath,
os: platform(),
architecture: arch(),
cores: cpus().length,
totalMemoryBytes: totalmem(),
tier: TIER,
windows: WINDOWS,
studioUrl: STUDIO_URL,
fixtures: FIXTURES,
appliedBudgets: {
idleCpuFractionOfCore: idleCpuBudget,
idleStyleRecalcsPerSecond: budgets.idleStyleRecalcsPerSecond,
idleLayoutsPerSecond: budgets.idleLayoutsPerSecond,
idleAnimationFrameCallsPerSecond: budgets.idleAnimationFrameCallsPerSecond,
scrubFullDocumentMediaQueries: budgets.scrubFullDocumentMediaQueries,
openMediaRequestsPerDistinctSource: budgets.openMediaRequestsPerDistinctSource,
switchHeapBytesPerCycle: budgets.switchHeapBytesPerCycle,
readyTimeoutMs,
},
},
results,
failures,
};
console.log(JSON.stringify(evidence, null, 2));
exitCode = failures.length === 0 ? 0 : 1;
if (failures.length > 0) console.error(`\nFAIL: ${failures.join("; ")}`);
else console.error("\nPASS");
await browser.close();
}
process.exit(exitCode);