#!/usr/bin/env node /** * What a board switch actually costs, in the four numbers that decide whether * retaining a board was worth it. * * node scripts/switch-cost.mjs # all six directions, once each * node scripts/switch-cost.mjs --repeat 3 # median of three per direction * node scripts/switch-cost.mjs --only california->sf * node scripts/switch-cost.mjs --json /tmp/switch-cost.json * node scripts/switch-cost.mjs --dist /some/other/dist * * ## Why this exists, and why the frame timer could not answer it * * `performance-budget.mjs` measures a *settled* board: it waits `warmup-ms`, * throws away everything before that, and samples a stationary camera. That is * the right instrument for "is this board affordable" and it is structurally * incapable of seeing a board *change*, because the change happens entirely * inside the window it discards. So the most-complained-about moment in the * product — the pause between one board and the next — was the one moment * nothing measured. Every claim about it was a feeling. * * It is also the moment where this box's frame timer lies worst. The GPU here * never leaves 500 MHz of a possible 2,725, so p95 frame interval flips between * 16.8 and 33.3 ms on fragment changes with geometry identical to the digit. * None of the four numbers below is a frame time. They are wall-clock cover, * main-thread block, the single worst task, and how many frames the page * managed to paint — and every one of them is a count or a duration that a * throttled GPU does not move. * * ## The numbers * * **coveredMs** — from the gesture to the end of the switch, where the end is * whichever comes last: the destination board's chapters appearing, or the boot * card coming off. Today the card is last, because `building()` in `main.ts` * adds `.done` and hides the card 300 ms later; `opaqueMs` is reported beside it * as click-to-`.done`, the part that is fully opaque. * * **arrivalMs and card** — when the destination was on screen, and whether a * boot card was raised at all. These two are the *shape* of the switch rather * than its length, and they are the pair that tells you which world you are in: * `card=yes` with `arrival ≈ covered - 300` is the disposal model, and `card=no` * with `blank` near zero is retention. Arrival is detected by watching * `#chapters` for the destination's signature chapter — not by the card, which * retention removes, and not by the board tab, which the continuity work * removes. * * **blockedMs** — the sum of Long Tasks inside that window. This is the part * that is genuinely frozen: no input, no animation, no clock. Everything else * in `coveredMs` is a live, animating page that simply has nothing to draw. * The gap between the two is the prize: it is the fraction of the pause that * exists only because the outgoing board was disposed before the incoming one * was built, and retention deletes it without a single new triangle. * * **longestTaskMs** — the worst single task. Sums are consoling and a 512 ms * task is what a hitch feels like, so the maximum is reported separately and is * the number to watch after retention lands: today it hides inside a full-screen * card with a percentage on it, which reads as *busy*; behind a live picture the * same half second reads as a *hang*. * * **frames / blankFrames** — frames the page painted during the switch, and how * many of them issued zero WebGL draw calls. A blank frame is the compositor * doing its job over a scene that no longer exists. Two thirds blank is the * disposal model — 46 of 69 on the baseline below — and that ratio collapsing * toward zero is retention working, measured rather than felt. * * ## The baseline, measured * * `bcac6aa`, before any retention work, desktop 1440x900, Chrome/ANGLE/Vulkan on * a Radeon RX 6700 XT, `--repeat 3` and the median of each column: * * | direction | covered | opaque | blocked | longest | frames | blank | live | * |---|---|---|---|---|---|---|---| * | california->sf | 1,715 | 1,415 | 608 | 526 | 69 | 46 | 65% | * | socal->sf | 1,627 | 1,326 | 523 | 459 | 69 | 46 | 68% | * | california->socal | 1,109 | 809 | 400 | 312 | 45 | 22 | 64% | * | sf->socal | 1,027 | 726 | 328 | 245 | 44 | 21 | 68% | * | sf->california | 1,120 | 820 | 340 | 196 | 49 | 26 | 70% | * | socal->california | 1,061 | 761 | 319 | 205 | 47 | 24 | 70% | * * Two things to read off it before changing anything. * * **Cost is a property of the destination, not of the pair.** The two arrivals * at the Bay Area differ by 5% and the two at California by 6%, while Bay Area * against California is 1.6x. Nothing is being reused between boards today, and * this table is what will show that changing. * * **Roughly two thirds of every pause is not work.** 64-70% of the cover is a * live, animating page with an empty world in it, because the outgoing board was * disposed before the incoming one was built. That is the fraction retention * gets back without a single new triangle — and the residual, the 526 ms task on * an arrival at the Bay Area, is a separate and nameable defect (main-thread * instance placement for 83,137 buildings) that retention does not touch and * will make *more* visible, because today it hides behind a full-screen card * with a percentage on it and afterwards it will freeze a live picture. * * Reproduce it before trusting a change, on the same box, with nothing else * competing for the GPU. This is a wall-clock instrument on a shared machine: * treat 5% as noise and 2x as a finding. * * ## GPU flags * * Same ladder as `performance-budget.mjs` and `ui-smoke.mjs`, for the same * reason: this box has an AMD card and no monitor, `--use-angle=vulkan` is what * makes Chrome render headlessly on it at all, and the SwiftShader rung behind * it keeps the script runnable on a CI box with no card. A software fall-back is * reported in the output, because switch cost measured on SwiftShader is a * different quantity and must not be compared to the table above. */ import { chromium } from "playwright"; import { createServer } from "node:http"; import { readFile, writeFile } from "node:fs/promises"; import { extname, join, normalize, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = fileURLToPath(new URL("..", import.meta.url)); const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".webmanifest": "application/manifest+json", }; const args = process.argv.slice(2); const option = (name, fallback) => { const at = args.indexOf(`--${name}`); return at < 0 ? fallback : args[at + 1]; }; const has = (name) => args.includes(`--${name}`); // A six-direction sweep is ten minutes of browser. `--help` must not start one: // this file was asked for its usage once and answered by launching Chrome. if (has("help") || args.includes("-h")) { console.log( [ "node scripts/switch-cost.mjs [options]", "", " --dist build to measure (default ./dist)", " --repeat takes per direction, median reported (default 1)", " --only b> one direction, e.g. --only california->sf", " --viewport desktop | mobile (default desktop)", " --settle-ms wait on the first board before switching (default 2500)", " --timeout-ms per-wait timeout (default 90000)", " --json write the full result, samples included", " --software SwiftShader only; NOT comparable to the GPU baseline", " --headed watch it happen", "", "Reports, per direction: how long the switch covered the screen, when the", "destination arrived, whether a boot card was raised at all, blocked", "main-thread milliseconds, the longest single task, and how many painted", "frames issued zero WebGL draw calls. See the header for the baseline.", ].join("\n"), ); process.exit(0); } const DIST = resolve(option("dist", join(ROOT, "dist"))); const REPEAT = Math.max(1, Number(option("repeat", "1")) || 1); const SETTLE_MS = Number(option("settle-ms", "2500")) || 2500; const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000; const ONLY = option("only", null); const JSON_OUT = option("json", null); const softwareOnly = has("software"); const headed = has("headed"); /** * The three boards, and every ordered pair of them. * * Six directions rather than three, because the cost is not symmetric and the * asymmetry is the interesting part: leaving the Bay Area is cheap and arriving * at it is not, so a table that averaged the two would hide the only direction * anybody complains about. `id` is the `?city=` value, which is also what the * board tab and the places rung both carry. */ const BOARDS = [ /* * `signature` is one chapter `data-view` that exists on this board and on no * other, and it is how arrival is detected. * * Not the pressed board tab, which is what this file checked first and which * stopped existing within the hour when the continuity work removed the tab * strip. Not the boot card either: retention's whole point is that a switch * stops raising one, so an instrument that timed "card up to card down" would * report nothing at all on the build it exists to measure. A chapter id is * pack data, it is what `scripts/fixtures/chapter-identity.json` pins, and the * moment it appears in `#chapters` is the moment the destination board is on * screen. `all` is shared by both metro boards, which is why neither is * identified by its first chapter. */ { id: "california", label: "California", signature: "california-overview" }, { id: "sf", label: "the Bay Area", signature: "hayes-valley" }, { id: "socal", label: "SoCal", signature: "dtla" }, ]; const DIRECTIONS = BOARDS.flatMap((from) => BOARDS.filter((to) => to.id !== from.id).map((to) => ({ from, to })), ); const VIEWPORTS = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1 }, mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, }; const VIEWPORT_NAME = option("viewport", "desktop"); const VIEWPORT = VIEWPORTS[VIEWPORT_NAME]; if (!VIEWPORT) throw new Error(`--viewport must be one of ${Object.keys(VIEWPORTS).join(", ")}`); // ---- The deployment: the built bundle and a server that says nothing ------- // // Every optional source off, exactly as `ui-smoke.mjs` serves it. A switch that // waits on a weather fetch is measuring somebody's uplink, and CONTRACT §0's // visitor has no key anyway. async function serve() { const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://local.invalid"); const json = (status, body) => { res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(body)); }; if (url.pathname === "/api/v1/health") { return json(200, { auth: { mode: "jwt", entryUrl: "/login.html" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" }, regions: [], }); } if (url.pathname === "/api/v1/session") { return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false }); } if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" }); const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, ""); for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) { const target = resolve(DIST, `.${relative}`); if (!target.startsWith(`${resolve(DIST)}/`)) continue; try { const body = await readFile(target); res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" }); res.end(body); return; } catch { /* fall through to the SPA entry */ } } res.writeHead(404).end("not found"); }); await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", ok); }); const address = server.address(); if (!address || typeof address === "string") throw new Error("no TCP port"); return { server, port: address.port }; } // ---- Chrome --------------------------------------------------------------- const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"]; async function rendererOf(browser) { const page = await browser.newPage(); try { await page.goto("about:blank"); return await page.evaluate(() => { const gl = document.createElement("canvas").getContext("webgl2"); const extension = gl?.getExtension("WEBGL_debug_renderer_info"); return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null; }); } finally { await page.close(); } } async function launch() { const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1"; const ladder = softwareOnly ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]]] : [ ["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]], ]; let last; for (const [backend, flags] of ladder) { try { const browser = await chromium.launch({ channel: "chrome", headless: !headed, args: [...COMMON_ARGS, resolver, ...flags], }); const renderer = await rendererOf(browser); const software = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer); if (renderer !== null && (backend === "swiftshader" || !software)) { return { browser, backend, renderer, software }; } await browser.close(); } catch (error) { last = error; } } throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`); } /** * Everything measured is measured in the page, before any of the page's own * script runs. * * Four instruments, and none of them is a frame time: * * - **Draw calls per painted frame.** The same four `drawArrays`/`drawElements` * entry points `performance-budget.mjs` patches, counted per animation frame. * A frame with zero of them is a frame with no world in it. * - **Long Tasks.** `buffered: true`, so a task that started before the observer * attached is still seen. * - **The boot card.** A `MutationObserver` on `#boot`'s `hidden` and `class`, * which is how `building()` covers and uncovers the screen. Observed rather * than polled, because a poll inside a 512 ms task returns after it and would * report the card as having gone away early. * - **A mark for the click**, written in the same task as the click itself, so * the window starts at the gesture rather than at Playwright's round trip. * * The observer has to wait for `#boot` to exist: this runs at document-start and * the element is in `index.html`, so a `readystatechange` hook is enough and * costs nothing on a page that is already parsed. */ function instrumentation() { const state = { frames: [], longTasks: [], boot: [], chapters: [], clickAt: null, calls: 0, longTaskSupported: false, }; Object.defineProperty(globalThis, "__teraSwitchCost", { value: state }); const patch = (prototype, method) => { if (!prototype || typeof prototype[method] !== "function") return; const original = prototype[method]; if (original.__teraSwitchCostPatched) return; const wrapped = function (...values) { state.calls += 1; return original.apply(this, values); }; Object.defineProperty(wrapped, "__teraSwitchCostPatched", { value: true }); prototype[method] = wrapped; }; for (const prototype of [ globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype, ]) { for (const method of ["drawArrays", "drawElements", "drawArraysInstanced", "drawElementsInstanced"]) { patch(prototype, method); } } try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) { state.longTasks.push({ startTime: entry.startTime, duration: entry.duration }); } }).observe({ type: "longtask", buffered: true }); state.longTaskSupported = true; } catch { /* Long Tasks API is optional. */ } requestAnimationFrame(function sample(now) { state.frames.push({ at: now, calls: state.calls }); state.calls = 0; requestAnimationFrame(sample); }); const watchBoot = () => { const card = document.getElementById("boot"); if (card === null) return false; const snapshot = () => state.boot.push({ at: performance.now(), hidden: card.hidden === true, done: card.classList.contains("done"), }); snapshot(); new MutationObserver(snapshot).observe(card, { attributes: true, attributeFilter: ["hidden", "class"], }); return true; }; if (!watchBoot()) document.addEventListener("readystatechange", watchBoot, { once: false }); /* * When the destination board arrived, timestamped in the page. * * `#chapters` is replaced wholesale when a board mounts (`mount.ts` rebuilds * it from a signature over the view list), so a `childList` observer on it * fires once, at the moment the new board's chapters exist. That instant is * "the new board is on screen" — independent of the boot card, which retention * removes, and independent of the board tab strip, which the continuity work * removes. Observed rather than polled: a poll inside a 512 ms task returns * after the task and would credit the switch with time it did not take. */ const watchChapters = () => { const nav = document.getElementById("chapters"); if (nav === null) return false; const snapshot = () => state.chapters.push({ at: performance.now(), views: [...nav.querySelectorAll(".chapter")].map((node) => node.getAttribute("data-view")), }); snapshot(); new MutationObserver(snapshot).observe(nav, { childList: true, subtree: true }); return true; }; if (!watchChapters()) document.addEventListener("readystatechange", watchChapters, { once: false }); } const percentile = (values, p) => { if (values.length === 0) return null; const sorted = [...values].sort((a, b) => a - b); return sorted[Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)))]; }; const median = (values) => percentile(values, 0.5); const round = (value) => (value === null || value === undefined ? null : Math.round(value)); /** * One switch, from a settled board to another board. * * A fresh page per sample, deliberately. A second visit to a board reuses * nothing today — `three` refcounts shader programs per material and deletes * them at zero, so disposing a board deletes its programs and coming back * relinks them — but that is a fact about today's engine, not a rule, and the * whole point of this file is to be able to see it change. A shared page would * bake the current answer into the instrument. */ async function measureSwitch(browser, port, from, to) { const context = await browser.newContext({ viewport: { width: VIEWPORT.width, height: VIEWPORT.height }, deviceScaleFactor: VIEWPORT.deviceScaleFactor, isMobile: VIEWPORT.isMobile, hasTouch: VIEWPORT.hasTouch, }); const page = await context.newPage(); const consoleErrors = []; page.on("pageerror", (error) => consoleErrors.push(String(error))); page.on("console", (message) => { if (message.type() !== "error") return; const at = message.location()?.url ?? ""; if (/\/api\/v1\//.test(at)) return; consoleErrors.push(message.text()); }); await page.addInitScript(instrumentation); try { await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${from.id}`, { waitUntil: "networkidle", timeout: READY_TIMEOUT_MS, }); const ready = () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0; await page.waitForFunction(ready, null, { timeout: READY_TIMEOUT_MS }); await page.waitForTimeout(SETTLE_MS); // The onboarding coach covers the board it is teaching you about, and a // click that lands on its scrim is not a board switch. await page .getByText(/^Skip$/) .first() .click({ timeout: 1500 }) .then(() => page.waitForTimeout(600)) .catch(() => undefined); /* * The gesture, by whatever affordance this build offers for it. * * `[data-board]` is the three-tab board strip. `[data-place-board]` is a rung * of the places list, which carries the board it belongs to. The continuity * work replaces the first with the second — the tab strip is the loudest * "you are somewhere else now" signal on the screen and it is being removed — * so an instrument that only knew about tabs would stop working on exactly * the change it exists to measure. Which one was used is reported, because * they are not the same gesture: a tab selects a board and a rung selects a * board *and a pose on it*. */ const clicked = await page.evaluate((boardId) => { const state = globalThis.__teraSwitchCost; state.frames.length = 0; state.longTasks.length = 0; state.boot.length = 0; state.chapters.length = 0; state.calls = 0; const tab = document.querySelector(`[data-board="${boardId}"]`); const rung = document.querySelector(`[data-place-board="${boardId}"]`); const button = tab instanceof HTMLElement ? tab : rung instanceof HTMLElement ? rung : null; if (button === null) return null; state.clickAt = performance.now(); button.click(); return { at: state.clickAt, via: tab instanceof HTMLElement ? "board-tab" : "place-rung" }; }, to.id); if (clicked === null) { throw new Error( `nothing on screen selects "${to.id}" — no [data-board="${to.id}"] and no ` + `[data-place-board="${to.id}"]. If the affordance moved again, teach this file about it.`, ); } /* * Wait for the destination, not for the cover. * * The signature chapter on screen and the boot card down. Both conditions, * because today the card outlives the arrival by 300 ms and after retention * there may be no card at all — and a wait written against the card alone * would hang forever on the build this file exists to measure. Everything * timed is timed by the in-page observers; this wait only decides when it is * safe to read them. */ await page.waitForFunction( (signature) => document.querySelector(`#chapters .chapter[data-view="${signature}"]`) !== null && document.getElementById("boot")?.hidden === true, to.signature, { timeout: READY_TIMEOUT_MS }, ); const raw = await page.evaluate((signature) => { const state = globalThis.__teraSwitchCost; return { clickAt: state.clickAt, frames: state.frames.map((frame) => ({ at: frame.at, calls: frame.calls })), longTasks: state.longTasks.map((task) => ({ ...task })), boot: state.boot.map((entry) => ({ ...entry })), chapterEvents: state.chapters.map((entry) => ({ at: entry.at, arrived: entry.views.includes(signature), })), longTaskSupported: state.longTaskSupported, views: [...document.querySelectorAll("#chapters .chapter")].map((node) => node.getAttribute("data-view"), ), }; }, to.signature); const start = raw.clickAt; const raised = raw.boot.find((entry) => entry.at >= start && !entry.hidden) ?? null; const done = raw.boot.find((entry) => entry.at >= start && entry.done) ?? null; const cleared = raised === null ? null : (raw.boot.find((entry) => entry.at > raised.at && entry.hidden) ?? null); const arrived = raw.chapterEvents.find((entry) => entry.at >= start && entry.arrived) ?? null; /* * The window runs from the gesture to whichever happened last: the * destination's chapters appearing, or the cover coming off. * * Today the cover is last, because `building()` fades the card 300 ms after * the board is up. Under retention there may be no card at all, and then * arrival is the whole story. Taking the later of the two means the same * command measures both worlds without an argument about which one it is in. */ const end = Math.max(arrived?.at ?? start, cleared?.at ?? start, start); const inWindow = (at) => at >= start && at <= end; const frames = raw.frames.filter((frame) => inWindow(frame.at)); const tasks = raw.longTasks.filter( (task) => task.startTime + task.duration >= start && task.startTime <= end, ); // Clipped to the window: a task that straddles the click contributes only // the part of itself that froze the switch. const blockedMs = tasks.reduce( (sum, task) => sum + Math.max(0, Math.min(end, task.startTime + task.duration) - Math.max(start, task.startTime)), 0, ); return { // Landing is asserted on the destination's signature chapter, not on a // board tab: the tab strip is chrome and the chapter id is pack data. ok: raw.views.includes(to.signature), landedOn: raw.views.includes(to.signature) ? to.id : (raw.views[0] ?? null), chapters: raw.views.length, via: clicked.via, bootCardRaised: raised !== null, coveredMs: end - start, arrivalMs: arrived === null ? null : arrived.at - start, opaqueMs: done === null ? null : done.at - start, raiseLatencyMs: raised === null ? null : raised.at - start, blockedMs, longestTaskMs: tasks.length === 0 ? 0 : Math.max(...tasks.map((task) => task.duration)), longTaskCount: tasks.length, longTaskSupported: raw.longTaskSupported, frames: frames.length, blankFrames: frames.filter((frame) => frame.calls === 0).length, consoleErrors, }; } finally { await context.close(); } } // ---- Run ------------------------------------------------------------------ const { server, port } = await serve(); const { browser, backend, renderer, software } = await launch(); const started = new Date().toISOString(); const rows = []; let failed = false; try { for (const direction of DIRECTIONS) { const name = `${direction.from.id}->${direction.to.id}`; if (ONLY !== null && ONLY !== name) continue; const samples = []; for (let take = 0; take < REPEAT; take += 1) { const sample = await measureSwitch(browser, port, direction.from, direction.to); samples.push(sample); if (!sample.ok) failed = true; if (sample.consoleErrors.length > 0) failed = true; } const pick = (key) => median(samples.map((sample) => sample[key]).filter((v) => v !== null)); rows.push({ direction: name, into: direction.to.label, takes: samples.length, via: [...new Set(samples.map((sample) => sample.via))].join("+"), bootCard: samples.every((sample) => sample.bootCardRaised) ? "yes" : samples.some((sample) => sample.bootCardRaised) ? "mixed" : "no", coveredMs: round(pick("coveredMs")), arrivalMs: round(pick("arrivalMs")), opaqueMs: round(pick("opaqueMs")), blockedMs: round(pick("blockedMs")), longestTaskMs: round(pick("longestTaskMs")), longTaskCount: round(pick("longTaskCount")), frames: round(pick("frames")), blankFrames: round(pick("blankFrames")), samples: samples.map((sample) => ({ via: sample.via, bootCardRaised: sample.bootCardRaised, coveredMs: round(sample.coveredMs), arrivalMs: round(sample.arrivalMs), opaqueMs: round(sample.opaqueMs), blockedMs: round(sample.blockedMs), longestTaskMs: round(sample.longestTaskMs), frames: sample.frames, blankFrames: sample.blankFrames, ok: sample.ok, landedOn: sample.landedOn, consoleErrors: sample.consoleErrors, })), }); } } finally { await browser.close(); server.close(); } const pad = (value, width) => String(value ?? "—").padStart(width); const header = [ pad("direction", 22), pad("via", 11), pad("card", 6), pad("covered", 9), pad("arrival", 9), pad("opaque", 8), pad("blocked", 9), pad("longest", 9), pad("tasks", 7), pad("frames", 8), pad("blank", 7), pad("live%", 7), ].join(" "); console.log(`switch-cost — ${started}`); console.log(` dist ${DIST}`); console.log(` viewport ${VIEWPORT_NAME} ${VIEWPORT.width}x${VIEWPORT.height}`); console.log(` backend ${backend}${software ? " (SOFTWARE — not comparable to the GPU baseline)" : ""}`); console.log(` renderer ${renderer ?? "unknown"}`); console.log(` takes ${REPEAT} per direction (median reported)`); console.log(""); console.log(header); console.log("-".repeat(header.length)); for (const row of rows) { const live = row.coveredMs ? Math.round(((row.coveredMs - row.blockedMs) / row.coveredMs) * 100) : null; console.log( [ pad(row.direction, 22), pad(row.via, 11), pad(row.bootCard, 6), pad(row.coveredMs, 9), pad(row.arrivalMs, 9), pad(row.opaqueMs, 8), pad(row.blockedMs, 9), pad(row.longestTaskMs, 9), pad(row.longTaskCount, 7), pad(row.frames, 8), pad(row.blankFrames, 7), pad(live === null ? "—" : `${live}%`, 7), ].join(" "), ); } console.log(""); console.log( "live% is the share of the cover that is NOT blocked — a live, animating page with\n" + "nothing to draw because the outgoing board was disposed. That is the part retention\n" + "gets back for free. blank frames are frames that issued zero WebGL draw calls.", ); if (rows.every((row) => row.longTaskCount === 0)) { console.log("NOTE: no Long Tasks were observed at all — check longTaskSupported before believing it."); } for (const row of rows) { for (const sample of row.samples) { if (!sample.ok) console.log(`FAIL ${row.direction}: landed on ${sample.landedOn ?? "nothing"}`); if (sample.consoleErrors.length > 0) { console.log(`FAIL ${row.direction}: console errors ${JSON.stringify(sample.consoleErrors)}`); } } } if (JSON_OUT !== null) { await writeFile( JSON_OUT, `${JSON.stringify({ started, dist: DIST, viewport: VIEWPORT_NAME, backend, renderer, software, repeat: REPEAT, rows }, null, 2)}\n`, ); console.log(`wrote ${JSON_OUT}`); } process.exit(failed ? 1 : 0);