The scene stops heating the MacBook
"Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook Air on a 144Hz screen. Measured on that machine with headed engines (process CPU plus the per-process GPU time macOS keeps): an idle Aero library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%, Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of WindowServer GPU. Priced one by one: - the gradient mask on .ice-floats re-rendered the animated layer offscreen on every frame (~75 points of Gecko CPU). The floats now rise through a plain rectangular band clear of the header, the bottom chrome and the rail, and fade in and out inside it, so the clip never cuts a visible one; - the overlay grain forced a backdrop readback per frame for a texture that measured a mean <=0.11/255 difference: removed; - the invisible blob drift made every frame a full-screen redraw: the blobs are painted once; - static glass (answer tiles, feedback panel, chips, inputs, dropzones, lists, segmented controls, Aero hint chips) re-blurred the moving scene every frame: only floating glass blurs now, and Aero's baked chat chrome drops a live blur that never showed under the bake; - the scene also parks while the window has no focus and under any dialog or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS animation instead of a framer-motion main-thread loop. After, focused and animating: LibreWolf library 109% -> 33% CPU, study 134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark. Unfocused or under a sheet: nothing. bubbles-test sweeps every float through 41 phases against the band and pins the parking and the cost contract; tests/heat-probe.mjs (macOS) repeats the measurement; the suites find Playwright's Chromium on macOS. All 15 suites pass, lint clean, the contrast scan lost a finding. Version 0.6.7, SW v89. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b5c4f237cd
commit
bfc0f806b9
27 changed files with 613 additions and 249 deletions
|
|
@ -8,7 +8,8 @@ reboot cannot wipe the harness.
|
|||
|
||||
- The app is running on `http://127.0.0.1:3207` (`systemctl --user start freezmo`).
|
||||
- A Chromium build. The suites auto-detect the newest Playwright Chromium in
|
||||
`~/.cache/ms-playwright/`; override with `CHROME=/path/to/chrome`.
|
||||
`~/.cache/ms-playwright/` (Linux) or `~/Library/Caches/ms-playwright/` (macOS);
|
||||
override with `CHROME=/path/to/chrome`.
|
||||
- `npm install` (dev deps: `playwright-core`, `axe-core`).
|
||||
|
||||
## Running
|
||||
|
|
@ -27,7 +28,7 @@ prints a per-suite summary at the end.
|
|||
| Suite | Covers |
|
||||
| --- | --- |
|
||||
| `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. |
|
||||
| `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, tab-hidden parking. |
|
||||
| `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, the open band (a plain clip, no mask) and a 41-phase sweep that no visible orb or bubble ever touches its edges, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, parking (hidden tab, unfocused window, dialog scrim), and the cost contract (static blobs, no blend-mode grain, no blur on static glass, no hidden blur under Aero's bake). |
|
||||
| `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. |
|
||||
| `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. |
|
||||
| `native-test.mjs` | Manifest/PWA head, offline banner + disabled generate, share target GET + POST, Back-gesture layers, undo toast, tab re-tap, haptics, draft restore. |
|
||||
|
|
@ -53,3 +54,10 @@ leak between suites. Scratch data for the VAPID test lives in `tests/.tmp/`
|
|||
CDP performance trace (rAF frame timing, long tasks, main-thread busy time,
|
||||
style recalcs) on a 4× throttled phone profile and a 2× desktop profile. It
|
||||
always exits 0; the numbers are for tuning the bubble count/speed.
|
||||
|
||||
`node tests/heat-probe.mjs [webkit|chromium]` (macOS only) measures what an
|
||||
idle screen actually costs the machine: headed Playwright WebKit and Chromium
|
||||
on the real display, the browser's CPU, the GPU time of its processes and of
|
||||
WindowServer, per screen and theme, against an `about:blank` baseline and with
|
||||
the scene parked. Install the engines once with
|
||||
`npx playwright-core install webkit chromium` and keep the window in front.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
/**
|
||||
* Ice-scene bubbles: the glossy orbs must rise from below the bottom edge to
|
||||
* above the top edge with sway + slight scale, fade at the edges, animate only
|
||||
* transform/opacity on their own composited layers, stay behind everything,
|
||||
* park under reduced motion and while the tab is hidden, and keep the
|
||||
* per-breakpoint count (5 desktop / 3 tablet / 2 phones).
|
||||
* Ice-scene bubbles: the glossy orbs (and Aero's bubbles) must rise through
|
||||
* the open band — clear of the header, the bottom chrome and the desktop
|
||||
* rail — with sway + slight scale, fade in and out inside it so no visible
|
||||
* float ever touches the band's clip, animate only transform/opacity on their
|
||||
* own composited layers, stay behind everything, park under reduced motion,
|
||||
* while the tab is hidden, while the window has no focus and under a dialog
|
||||
* scrim, and keep the per-breakpoint count (5 desktop / 3 tablet / 2 phones).
|
||||
* The cost contract rides along: no mask on the animated layer, no blend-mode
|
||||
* grain, static blobs, and no backdrop blur on glass that sits still.
|
||||
*/
|
||||
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
|
||||
|
||||
|
|
@ -24,16 +28,56 @@ async function orbAt(page, phase) {
|
|||
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const rect = orb.getBoundingClientRect();
|
||||
const band = document.querySelector(".ice-floats").getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
opacity: Number(getComputedStyle(orb).opacity),
|
||||
viewport: window.innerHeight,
|
||||
band: { top: band.top, bottom: band.bottom, left: band.left },
|
||||
};
|
||||
}, phase);
|
||||
}
|
||||
|
||||
/** Parks every displayed float matching `selector` at 41 phases of its pass
|
||||
and lists the frames where a visible one (opacity > 0.05) pokes out of the
|
||||
band — the band's top, its bottom and, where it starts right of the
|
||||
viewport edge, its left side (the rail). Those are the frames where the
|
||||
clip would slice a bubble. */
|
||||
async function sweep(page, selector) {
|
||||
return page.evaluate(async (selector) => {
|
||||
const band = document.querySelector(".ice-floats").getBoundingClientRect();
|
||||
const floats = [...document.querySelectorAll(selector)].filter((el) => getComputedStyle(el).display !== "none");
|
||||
const animations = floats.map((el) => {
|
||||
const animation = el.getAnimations()[0];
|
||||
animation.pause();
|
||||
return animation;
|
||||
});
|
||||
const misses = [];
|
||||
let samples = 0;
|
||||
for (let step = 0; step <= 40; step += 1) {
|
||||
const phase = step / 40;
|
||||
for (const animation of animations) {
|
||||
const timing = animation.effect.getTiming();
|
||||
const wanted = phase * timing.duration + (timing.delay ?? 0);
|
||||
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
|
||||
}
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
floats.forEach((el) => {
|
||||
const opacity = Number(getComputedStyle(el).opacity);
|
||||
if (opacity <= 0.05) return;
|
||||
samples += 1;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const out =
|
||||
rect.top < band.top - 2 || rect.bottom > band.bottom + 2 || (band.left > 0 && rect.left < band.left - 2);
|
||||
if (out) misses.push(`${el.className} @${phase}: ${Math.round(rect.top)}-${Math.round(rect.bottom)} x${Math.round(rect.left)} (${opacity.toFixed(2)})`);
|
||||
});
|
||||
}
|
||||
return { floats: floats.length, samples, misses };
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/* 1. scene contract (desktop) ---------------------------------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
||||
|
|
@ -75,25 +119,37 @@ async function orbAt(page, phase) {
|
|||
check("orbs: own duration per pass (30-70s, 5 distinct)", orbs.every((orb) => orb.duration >= 30 && orb.duration <= 70) && new Set(orbs.map((orb) => orb.duration)).size === 5, orbs.map((orb) => orb.duration).join(","));
|
||||
check("orbs: reduced opacity token applied", orbs.every((orb) => orb.opacity > 0.3 && orb.opacity < 0.45), String(orbs[0]?.opacity));
|
||||
|
||||
/* The floats layer: orbs + bubbles live in one masked wrapper that fades
|
||||
them out before the chrome bands, so none of them is sliced by a panel
|
||||
edge (see .ice-floats in globals.css). */
|
||||
/* The floats layer: orbs + bubbles live in one wrapper that is the open
|
||||
band between the chrome — a plain rectangular clip, never a mask (a mask
|
||||
re-renders the whole animated layer offscreen on every frame). */
|
||||
const floats = await page.evaluate(() => {
|
||||
const layer = document.querySelector(".ice-floats");
|
||||
const style = layer ? getComputedStyle(layer) : null;
|
||||
const rect = layer?.getBoundingClientRect();
|
||||
return {
|
||||
children: layer ? layer.children.length : 0,
|
||||
orbs: document.querySelectorAll(".ice-floats .ice-orb").length,
|
||||
bubbles: document.querySelectorAll(".ice-floats .ice-bubble").length,
|
||||
mask: style ? style.maskImage || style.webkitMaskImage : "",
|
||||
mask: style ? style.maskImage || style.webkitMaskImage || "none" : "",
|
||||
overflow: style?.overflow,
|
||||
top: rect?.top,
|
||||
bottom: rect?.bottom,
|
||||
left: rect?.left,
|
||||
right: rect?.right,
|
||||
viewport: { width: document.documentElement.clientWidth, height: window.innerHeight },
|
||||
};
|
||||
});
|
||||
check(
|
||||
"floats: orbs and bubbles ride one masked layer",
|
||||
"floats: orbs and bubbles ride one layer",
|
||||
floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18,
|
||||
JSON.stringify(floats),
|
||||
);
|
||||
check("floats: the chrome bands fade the layer out", floats.mask.includes("gradient"), floats.mask.slice(0, 60));
|
||||
check("floats: a plain clip, no mask on the animated layer", floats.mask === "none" && floats.overflow === "hidden", `${floats.mask} / ${floats.overflow}`);
|
||||
check(
|
||||
"floats: the band clears the header, the bottom chrome and the rail",
|
||||
floats.top === 68 && floats.viewport.height - floats.bottom === 151 && floats.left === 268 && floats.right === floats.viewport.width,
|
||||
JSON.stringify(floats),
|
||||
);
|
||||
|
||||
const keyframes = await page.evaluate(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
|
|
@ -119,12 +175,43 @@ async function orbAt(page, phase) {
|
|||
const mid = await orbAt(page, 0.5);
|
||||
const upper = await orbAt(page, 0.8);
|
||||
const end = await orbAt(page, 0.99);
|
||||
check("rise: starts at the bottom edge, ends above the top edge", Math.abs(start.top - start.viewport) < 6 && end.top < -120, `start ${Math.round(start.top)}/${start.viewport}, end ${Math.round(end.top)}`);
|
||||
check(
|
||||
"rise: starts at the band's bottom, ends at its top",
|
||||
Math.abs(start.bottom - start.band.bottom) < 8 && Math.abs(end.top - end.band.top) < 12,
|
||||
`start ${Math.round(start.bottom)}/${start.band.bottom}, end ${Math.round(end.top)}/${end.band.top}`,
|
||||
);
|
||||
check("rise: moves upward the whole way", lower.top < start.top && mid.top < lower.top && upper.top < mid.top && end.top < upper.top);
|
||||
check("sway: swings across the path", Math.abs(mid.left - lower.left) > 30 && Math.abs(upper.left - mid.left) > 15, `${Math.round(lower.left)} → ${Math.round(mid.left)} → ${Math.round(upper.left)}`);
|
||||
check("scale: drifts slightly across the pass", end.width > start.width + 8, `${start.width.toFixed(1)} → ${end.width.toFixed(1)}`);
|
||||
check("fade: in at the bottom, out at the top, full between", start.opacity < 0.1 && mid.opacity > 0.3 && end.opacity < 0.1, `${start.opacity} / ${mid.opacity} / ${end.opacity}`);
|
||||
|
||||
const swept = await sweep(page, ".ice-orb");
|
||||
check(
|
||||
"band: a visible orb never touches its edges (nothing to slice)",
|
||||
swept.floats === 5 && swept.samples > 100 && swept.misses.length === 0,
|
||||
swept.misses.slice(0, 3).join(" | ") || `${swept.samples} visible frames`,
|
||||
);
|
||||
|
||||
/* The cost contract of the static parts: the blobs are painted once (their
|
||||
drift was invisible but made every frame a full-screen redraw) and there
|
||||
is no blend-mode grain over the animated layer. */
|
||||
const still = await page.evaluate(() => {
|
||||
const blobs = [...document.querySelectorAll(".ice-blob")].map((blob) => {
|
||||
const style = getComputedStyle(blob);
|
||||
return { animation: style.animationName, willChange: style.willChange };
|
||||
});
|
||||
const blended = [...document.querySelectorAll(".ice-scene, .ice-scene *")].filter(
|
||||
(el) => getComputedStyle(el).mixBlendMode !== "normal",
|
||||
).length;
|
||||
return { blobs, blended, grain: document.querySelector(".ice-grain") !== null };
|
||||
});
|
||||
check(
|
||||
"blobs: static, painted once (no drift, no own layer)",
|
||||
still.blobs.length === 4 && still.blobs.every((blob) => blob.animation === "none" && blob.willChange === "auto"),
|
||||
JSON.stringify(still.blobs),
|
||||
);
|
||||
check("scene: no blend-mode grain over the animated layer", !still.grain && still.blended === 0, JSON.stringify(still));
|
||||
|
||||
const layoutBefore = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
|
||||
await page.waitForTimeout(1400);
|
||||
const layoutAfter = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
|
||||
|
|
@ -138,6 +225,30 @@ async function orbAt(page, phase) {
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 1b. Aero's bubbles ride the same band ------------------------------- */
|
||||
for (const [width, height] of [[1280, 900], [390, 844]]) {
|
||||
const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 1 });
|
||||
await seed(page, { decks: [DECK], theme: "aero" });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1000);
|
||||
const band = await page.evaluate(() => {
|
||||
const rect = document.querySelector(".ice-floats").getBoundingClientRect();
|
||||
return { top: rect.top, bottom: window.innerHeight - rect.bottom, left: rect.left };
|
||||
});
|
||||
check(
|
||||
`aero ${width}px: the band clears the chrome`,
|
||||
band.top === 68 && band.bottom === (width >= 640 ? 151 : 172) && band.left === (width >= 1024 ? 268 : 0),
|
||||
JSON.stringify(band),
|
||||
);
|
||||
const swept = await sweep(page, ".ice-bubble, .ice-orb");
|
||||
check(
|
||||
`aero ${width}px: a visible bubble never touches the band's edges`,
|
||||
swept.floats >= 20 && swept.samples > 300 && swept.misses.length === 0,
|
||||
swept.misses.slice(0, 3).join(" | ") || `${swept.floats} floats, ${swept.samples} visible frames`,
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 2. per-breakpoint counts + phone pacing ----------------------------- */
|
||||
{
|
||||
const counts = {};
|
||||
|
|
@ -173,17 +284,21 @@ async function orbAt(page, phase) {
|
|||
const orb = document.querySelector(".ice-orb-1");
|
||||
const style = getComputedStyle(orb);
|
||||
const rect = orb.getBoundingClientRect();
|
||||
const floats = getComputedStyle(document.querySelector(".ice-floats"));
|
||||
const floats = document.querySelector(".ice-floats").getBoundingClientRect();
|
||||
return {
|
||||
animation: style.animationName,
|
||||
willChange: style.willChange,
|
||||
opacity: Number(style.opacity),
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
mask: floats.maskImage || floats.webkitMaskImage,
|
||||
floats: { top: floats.top, bottom: floats.bottom, viewport: window.innerHeight },
|
||||
};
|
||||
});
|
||||
check("reduced motion: the floats mask is off (parked orbs keep their spots)", state.mask === "none", state.mask);
|
||||
check(
|
||||
"reduced motion: the floats cover the whole scene again (parked orbs keep their spots)",
|
||||
state.floats.top === 0 && state.floats.bottom === state.floats.viewport,
|
||||
JSON.stringify(state.floats),
|
||||
);
|
||||
check("reduced motion: rise removed, layer released", state.animation === "none" && state.willChange === "auto", JSON.stringify(state));
|
||||
check("reduced motion: orb visible and faded in at a static spot", state.opacity > 0.08 && state.top > 0 && state.top < 900, JSON.stringify(state));
|
||||
await page.waitForTimeout(1100);
|
||||
|
|
@ -195,7 +310,7 @@ async function orbAt(page, phase) {
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 4. tab hidden: everything parks ------------------------------------- */
|
||||
/* 4. tab hidden, window blurred, dialog open: everything parks -------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { decks: [DECK] });
|
||||
|
|
@ -209,12 +324,10 @@ async function orbAt(page, phase) {
|
|||
const paused = await page.evaluate(() => ({
|
||||
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
|
||||
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
|
||||
blob: getComputedStyle(document.querySelector(".ice-blob-a")).animationPlayState,
|
||||
transform: getComputedStyle(document.querySelector(".ice-orb-1")).transform,
|
||||
top: document.querySelector(".ice-orb-1").getBoundingClientRect().top,
|
||||
}));
|
||||
check("tab hidden: scene class + orb parked", paused.scene && paused.orb === "paused", JSON.stringify(paused));
|
||||
check("tab hidden: blob drift parked too", paused.blob === "paused", paused.blob);
|
||||
check("tab hidden: parked orb keeps its frame (no jump)", paused.transform !== "none", paused.transform);
|
||||
await page.waitForTimeout(500);
|
||||
const held = await page.evaluate(() => document.querySelector(".ice-orb-1").getBoundingClientRect().top);
|
||||
|
|
@ -229,8 +342,91 @@ async function orbAt(page, phase) {
|
|||
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
|
||||
}));
|
||||
check("tab visible again: rise resumes", !running.scene && running.orb === "running", JSON.stringify(running));
|
||||
|
||||
/* A visible window without focus (another app in front, the browser on a
|
||||
second screen) parks too, and focus brings it back. */
|
||||
const focus = await page.evaluate(async () => {
|
||||
const frame = () => new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const read = () => ({
|
||||
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
|
||||
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
|
||||
});
|
||||
document.hasFocus = () => false;
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
await frame();
|
||||
const blurred = read();
|
||||
document.hasFocus = () => true;
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await frame();
|
||||
return { blurred, focused: read() };
|
||||
});
|
||||
check("window blurred: the scene parks", focus.blurred.scene && focus.blurred.orb === "paused", JSON.stringify(focus.blurred));
|
||||
check("window focused again: the rise resumes", !focus.focused.scene && focus.focused.orb === "running", JSON.stringify(focus.focused));
|
||||
|
||||
/* A dialog or sheet: its scrim (and the sheet's glass) blur whatever is
|
||||
behind them, so the scene holds still while one is open. */
|
||||
const scrim = await page.evaluate(async () => {
|
||||
const frame = () => new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "scene-pause";
|
||||
document.body.append(overlay);
|
||||
await frame();
|
||||
const open = getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState;
|
||||
overlay.remove();
|
||||
await frame();
|
||||
return { open, closed: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState };
|
||||
});
|
||||
check("dialog scrim: the scene parks while it is open, resumes after", scrim.open === "paused" && scrim.closed === "running", JSON.stringify(scrim));
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 5. glass that sits still never blurs -------------------------------- */
|
||||
{
|
||||
/* The study feed: only its floating chrome (header + pager pills, which
|
||||
the slides scroll under) keeps a backdrop blur. */
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
||||
await seed(page, { decks: [DECK] });
|
||||
await page.goto(`${BASE}/deck/${DECK.id}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1200);
|
||||
const blurred = await page.evaluate(() =>
|
||||
[...document.querySelectorAll("body *")]
|
||||
.filter((el) => {
|
||||
const style = getComputedStyle(el);
|
||||
const filter = style.backdropFilter || style.webkitBackdropFilter;
|
||||
return filter && filter !== "none" && style.display !== "none" && el.getClientRects().length > 0;
|
||||
})
|
||||
.map((el) => String(el.className).split(" ")[0]),
|
||||
);
|
||||
check(
|
||||
"study feed: only the floating chrome blurs (no blur on tiles, chips or hints)",
|
||||
blurred.length > 0 && blurred.every((name) => name === "study-chrome"),
|
||||
blurred.join(", "),
|
||||
);
|
||||
await context.close();
|
||||
|
||||
/* Aero desktop: under the opaque bake a live blur never shows, so the chat
|
||||
chrome carries none; touch drops the bake and keeps the blur. */
|
||||
for (const touch of [false, true]) {
|
||||
const aero = await makePage(browser, touch ? { width: 390, height: 844, isMobile: true, hasTouch: true } : { width: 1280, height: 900, deviceScaleFactor: 1 });
|
||||
await seed(aero.page, { decks: [DECK], theme: "aero" });
|
||||
await aero.page.goto(`${BASE}/chat`, { waitUntil: "networkidle" });
|
||||
await aero.page.waitForTimeout(1000);
|
||||
const chrome = await aero.page.evaluate(() =>
|
||||
[".chat-header", ".chat-composer-band"].map((selector) => {
|
||||
const style = getComputedStyle(document.querySelector(selector));
|
||||
return { filter: style.backdropFilter || style.webkitBackdropFilter, bake: style.backgroundImage.includes("background-blur") };
|
||||
}),
|
||||
);
|
||||
check(
|
||||
touch ? "aero touch: the chat chrome keeps its live blur (no bake)" : "aero desktop: the baked chat chrome drops the hidden live blur",
|
||||
touch
|
||||
? chrome.every((panel) => !panel.bake && panel.filter.includes("blur"))
|
||||
: chrome.every((panel) => panel.bake && panel.filter === "none"),
|
||||
JSON.stringify(chrome),
|
||||
);
|
||||
await aero.context.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
finish();
|
||||
|
|
|
|||
138
tests/heat-probe.mjs
Normal file
138
tests/heat-probe.mjs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
@ -30,16 +30,26 @@ export const SPAWN_CWD = process.env.FREEZMO_SPAWN_CWD || REPO;
|
|||
|
||||
export function resolveChrome() {
|
||||
if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
|
||||
const root = path.join(os.homedir(), ".cache", "ms-playwright");
|
||||
const dirs = existsSync(root)
|
||||
? readdirSync(root)
|
||||
.filter((entry) => /^chromium-\d+$/.test(entry))
|
||||
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]))
|
||||
: [];
|
||||
for (const dir of dirs) {
|
||||
for (const sub of ["chrome-linux64", "chrome-linux"]) {
|
||||
const candidate = path.join(root, dir, sub, "chrome");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const roots = [
|
||||
path.join(os.homedir(), ".cache", "ms-playwright"),
|
||||
path.join(os.homedir(), "Library", "Caches", "ms-playwright"),
|
||||
];
|
||||
const binaries = [
|
||||
"chrome-linux64/chrome",
|
||||
"chrome-linux/chrome",
|
||||
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
"chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
"chrome-mac/Chromium.app/Contents/MacOS/Chromium",
|
||||
];
|
||||
for (const root of roots.filter((dir) => existsSync(dir))) {
|
||||
const dirs = readdirSync(root)
|
||||
.filter((entry) => /^chromium-\d+$/.test(entry))
|
||||
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
|
||||
for (const dir of dirs) {
|
||||
for (const sub of binaries) {
|
||||
const candidate = path.join(root, dir, sub);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue