/** * Diagnostic, NOT part of `npm run test:e2e`, macOS only: what an idle * Freezmo screen costs the machine on its real display. Headed WebKit and * Chromium (Playwright's builds) open each screen and sit still while the * probe reads the browser's own CPU time and the GPU time of its processes * and of WindowServer (macOS composites every window there, and Core * Animation runs WebKit's CSS animations there), against an about:blank * baseline and with the scene parked. * * npx playwright-core install webkit chromium # once * node tests/heat-probe.mjs # both engines * node tests/heat-probe.mjs webkit # one engine * * Unlike tests/bubble-perf.mjs (frame times in a throttled software renderer) * this measures the cost itself, at the display's own refresh rate: on a * 144Hz screen every running animation is composited 144 times a second. * Keep the window in front while it runs (other apps add noise to the * WindowServer column). Always exits 0. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { chromium, webkit } from "playwright-core"; import { BASE, CHROME, DECK, seed } from "./lib.mjs"; if (process.platform !== "darwin") { console.log("heat-probe: macOS only (it reads ps and the IOKit GPU counters)."); process.exit(0); } const run = promisify(execFile); const SETTLE_MS = 2500; const SAMPLE_MS = 8000; const ENGINES = { webkit: { launch: () => webkit.launch({ headless: false }), match: "/ms-playwright/webkit-" }, chromium: { launch: () => chromium.launch({ headless: false, executablePath: CHROME }), match: CHROME.split("/Contents/")[0] }, }; const SCREENS = [ ["library", "/"], ["study", `/deck/${DECK.id}`], ["chat", "/chat"], ]; function seconds(text) { const [days, clock] = text.includes("-") ? text.split("-") : ["0", text]; return clock.split(":").reduce((total, part) => total * 60 + Number(part), 0) + Number(days) * 86400; } async function processes() { const { stdout } = await run("ps", ["-axo", "pid=,time=,command="], { maxBuffer: 64 << 20 }); const map = new Map(); for (const line of stdout.split("\n")) { const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/); if (match) map.set(Number(match[1]), { cpu: seconds(match[2]), command: match[3] }); } return map; } /** Accumulated GPU time per process (ns) — the number Activity Monitor shows. */ async function gpuTimes() { const { stdout } = await run("ioreg", ["-r", "-c", "AGXDeviceUserClient", "-l", "-w", "0"], { maxBuffer: 256 << 20, encoding: "latin1", }); const map = new Map(); for (const block of stdout.split("+-o AGXDeviceUserClient")) { const creator = block.match(/"IOUserClientCreator" = "pid (\d+),/); if (!creator) continue; let total = 0; for (const match of block.matchAll(/"accumulatedGPUTime"=(\d+)/g)) total += Number(match[1]); map.set(Number(creator[1]), (map.get(Number(creator[1])) ?? 0) + total); } return map; } async function measure(match) { const [cpu0, gpu0] = [await processes(), await gpuTimes()]; const start = Date.now(); await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS)); const [cpu1, gpu1] = [await processes(), await gpuTimes()]; const elapsed = (Date.now() - start) / 1000; const owner = (pid) => { const command = cpu1.get(pid)?.command ?? ""; return command.includes(match) ? "browser" : command.includes("WindowServer") ? "windowServer" : null; }; const total = { browserCpu: 0, browserGpu: 0, windowServerGpu: 0 }; for (const [pid, after] of cpu1) { const before = cpu0.get(pid); if (before && owner(pid) === "browser") total.browserCpu += after.cpu - before.cpu; } for (const [pid, after] of gpu1) { const before = gpu0.get(pid); if (before === undefined) continue; if (owner(pid) === "browser") total.browserGpu += (after - before) / 1e6; if (owner(pid) === "windowServer") total.windowServerGpu += (after - before) / 1e6; } return { cpu: (total.browserCpu / elapsed) * 100, gpu: total.browserGpu / elapsed, windowServer: total.windowServerGpu / elapsed, }; } const wanted = process.argv.slice(2); for (const [name, engine] of Object.entries(ENGINES)) { if (wanted.length && !wanted.includes(name)) continue; const browser = await engine.launch(); const runs = [["baseline", null, null, false]]; for (const theme of ["aero", "dark"]) { for (const [screen, path] of SCREENS) runs.push([`${screen} ${theme}`, path, theme, false]); runs.push([`library ${theme}, parked`, "/", theme, true]); } console.log(`\n${name} — browser CPU %, browser GPU ms/s, WindowServer GPU ms/s (${SAMPLE_MS / 1000}s each)`); for (const [label, path, theme, parked] of runs) { const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } }); const page = await context.newPage(); if (path) { await seed(page, { decks: [DECK], theme }); await page.goto(BASE + path, { waitUntil: "load" }); } else { await page.goto("about:blank"); } await page.bringToFront(); if (parked) { await page.evaluate(() => { document.hasFocus = () => false; window.dispatchEvent(new Event("blur")); }); } await page.waitForTimeout(SETTLE_MS); const cost = await measure(engine.match); console.log( ` ${label.padEnd(24)} CPU ${cost.cpu.toFixed(1).padStart(5)}% GPU ${cost.gpu.toFixed(0).padStart(4)} WindowServer ${cost.windowServer.toFixed(0).padStart(4)}`, ); await context.close(); } await browser.close(); }