/** * Diagnostic, NOT part of `npm run test:e2e`: measures the ice-scene frame * cost on a throttled phone profile with a CDP performance trace (frame * timing via rAF, long tasks, main-thread busy time, style recalcs, layouts). * * node tests/bubble-perf.mjs # numbers only * node tests/bubble-perf.mjs before # label the run (e.g. before/after) * * It always exits 0 — the numbers are for tuning the bubble count/speed, not * a pass/fail gate (CI machines vary wildly). */ import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs"; const label = process.argv[2] ?? "run"; const browser = await launchBrowser(); async function measure(name, { width, height, throttle, reducedMotion, extraCss }) { const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 2, reducedMotion }); const cdp = await context.newCDPSession(page); await cdp.send("Emulation.setCPUThrottlingRate", { rate: throttle }); await cdp.send("Performance.enable"); await seed(page, { decks: [DECK] }); await page.goto(BASE, { waitUntil: "networkidle" }); await page.waitForTimeout(1500); if (extraCss) { await page.addStyleTag({ content: extraCss }); await page.waitForTimeout(400); } const scene = await page.evaluate(() => { const orb = document.querySelector(".ice-orb-1"); const blob = document.querySelector(".ice-blob-a"); const read = (element) => { const style = getComputedStyle(element); return { animation: style.animationName, play: style.animationPlayState, willChange: style.willChange }; }; return { orb: read(orb), blob: read(blob), orbs: document.querySelectorAll(".ice-orb:not([style*='display: none'])").length }; }); const before = await cdp.send("Performance.getMetrics"); const frames = await page.evaluate( () => new Promise((resolve) => { const times = []; const longTasks = []; try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) longTasks.push(entry.duration); }).observe({ entryTypes: ["longtask"] }); } catch { /* not supported */ } let last = performance.now(); const start = last; const tick = (now) => { times.push(now - last); last = now; if (now - start < 8000) requestAnimationFrame(tick); else resolve({ times, longTasks }); }; requestAnimationFrame(tick); }), ); const after = await cdp.send("Performance.getMetrics"); const metric = (snapshot, key) => snapshot.metrics.find((entry) => entry.name === key)?.value ?? 0; const busyMs = (metric(after, "TaskDuration") - metric(before, "TaskDuration")) * 1000; const recalcs = metric(after, "RecalcStyleCount") - metric(before, "RecalcStyleCount"); const layouts = metric(after, "LayoutCount") - metric(before, "LayoutCount"); const sorted = frames.times.slice(1).sort((a, b) => a - b); const pct = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? 0; const over34 = sorted.filter((time) => time > 34).length; console.log(`\n[${label}] ${name} (${throttle}x CPU)`); console.log(` scene: orb anim=${scene.orb.animation} play=${scene.orb.play} will-change=${scene.orb.willChange} | blob anim=${scene.blob.animation} play=${scene.blob.play}`); console.log(` frames: ${sorted.length} over 8s | p50 ${pct(0.5).toFixed(1)}ms | p95 ${pct(0.95).toFixed(1)}ms | max ${(sorted.at(-1) ?? 0).toFixed(1)}ms | over 34ms: ${over34}`); console.log(` long tasks: ${frames.longTasks.length} (max ${Math.max(0, ...frames.longTasks).toFixed(0)}ms)`); console.log(` main-thread busy: ${busyMs.toFixed(0)}ms | style recalc: ${recalcs} | layout: ${layouts}`); await context.close(); return { p95: pct(0.95), over34, busyMs, recalcs, layouts }; } const phone = { width: 390, height: 844 }; const animated = await measure("phone 390 animated", { ...phone, throttle: 4 }); const still = await measure("phone 390 reduced-motion", { ...phone, throttle: 4, reducedMotion: "reduce" }); const desktop = await measure("desktop 1280 animated", { width: 1280, height: 900, throttle: 2 }); /* The glass cost: how much of the frame time is backdrop-filter re-blurring the (always animating) scene. Static surfaces no longer blur at all — see the "Frosted ice glass" block in app/globals.css — so this number should stay close to the animated run; a jump means a new blurred surface landed on top of the scene. */ const noGlass = await measure("desktop 1280 no backdrop-filter", { width: 1280, height: 900, throttle: 2, extraCss: "*{backdrop-filter:none !important;-webkit-backdrop-filter:none !important;}", }); console.log( `\n[${label}] deltas — animated vs reduced-motion: p95 ${(animated.p95 - still.p95).toFixed(1)}ms, busy ${(animated.busyMs - still.busyMs).toFixed(0)}ms; desktop p95 ${desktop.p95.toFixed(1)}ms; glass cost on desktop: p95 ${(desktop.p95 - noGlass.p95).toFixed(1)}ms, over-34ms frames ${desktop.over34 - noGlass.over34}`, ); await browser.close();