// Shared by every test: the browser, the throttling, the results file. // Chrome is the installed Google Chrome (channel "chrome"), headed: Crisp and others don't boot for a "HeadlessChrome" // user agent, and a real window is what a visitor has. Every run gets a fresh profile in a new temp dir: empty cache, // no cookies, no storage, Chrome's default settings. import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { chromium, type BrowserContext, type Frame, type Page } from "playwright-core"; import * as constants from "lighthouse/core/config/constants.js"; import { WIDGETS } from "./widgets"; export const RUNS = Number(process.env.RUNS ?? 7); /** Lighthouse's mobile defaults (slow 4G, 4x CPU), applied with DevTools throttling where Lighthouse itself isn't running */ export const THROTTLE = constants.throttling.mobileSlow4G; export async function chrome(opts: { viewport?: { width: number; height: number }; mobile?: boolean; dir?: string; args?: string[] } = {}) { const dir = opts.dir ?? mkdtempSync(`${tmpdir()}/widget-lab-`); const ctx = await chromium.launchPersistentContext(dir, { channel: "chrome", headless: false, args: opts.args, viewport: opts.viewport ?? { width: 1280, height: 800 }, ...(opts.mobile && { isMobile: true, hasTouch: true, deviceScaleFactor: 3, userAgent: constants.userAgents.mobile }), }); return { ctx, page: ctx.pages()[0] ?? (await ctx.newPage()), dir, close: async (keep = false) => { await ctx.close(); if (!keep) rmSync(dir, { recursive: true, force: true }); } }; } /** DevTools network + CPU throttling on a page, the values Lighthouse's mobile preset uses */ export async function throttle(ctx: BrowserContext, page: Page) { const cdp = await ctx.newCDPSession(page); await cdp.send("Network.enable"); await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: THROTTLE.requestLatencyMs, downloadThroughput: (THROTTLE.downloadThroughputKbps * 1024) / 8, uploadThroughput: (THROTTLE.uploadThroughputKbps * 1024) / 8, }); await cdp.send("Emulation.setCPUThrottlingRate", { rate: THROTTLE.cpuSlowdownMultiplier }); return cdp; } export const median = (xs: number[]) => { const s = xs.filter((x) => Number.isFinite(x)).sort((a, b) => a - b); if (!s.length) return null; const m = s.length >> 1; return s.length % 2 ? s[m]! : (s[m - 1]! + s[m]!) / 2; }; export async function env() { const { ctx, page, close } = await chrome(); const chromeVersion = (await (await ctx.newCDPSession(page)).send("Browser.getVersion")).product; await close(); return { chrome: chromeVersion, playwright: (await Bun.file(new URL("node_modules/playwright-core/package.json", import.meta.url)).json()).version, lighthouse: (await Bun.file(new URL("node_modules/lighthouse/package.json", import.meta.url)).json()).version, bun: Bun.version, os: `${process.platform} ${process.arch}`, }; } /** apps/web/public/research/-2026-09.json. With ONLY set, the new results replace just those keys in the file. */ export function save(test: string, data: { results: Record } & Record) { // in the repo: next to the site that publishes it; a copy from spookat.com/research/widget-lab/: here const dir = new URL("../../apps/web/public/research/", import.meta.url); const file = new URL(`${test}-2026-09.json`, existsSync(dir) ? dir : new URL("./", import.meta.url)); const widgets = WIDGETS.map(({ widgetUrls, widgetFrame, ...w }) => ({ ...w, widgetUrls: widgetUrls.source, widgetFrame: widgetFrame?.source })); // what was loaded, exactly const old = process.env.ONLY && existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; // LAB_NOTE: why these rows were measured again (a fix, say), kept on each of them with the date they were measured const note = process.env.LAB_NOTE; const fresh = note ? Object.fromEntries(Object.entries(data.results).map(([k, v]) => [k, { measured: data.started, note, ...(v as object) }])) : data.results; const results = old ? { ...old.results, ...fresh } : fresh; writeFileSync(file, JSON.stringify({ ...data, ...(old && { started: old.started, partialRerun: [...(old.partialRerun ?? []), { at: data.started, only: process.env.ONLY }] }), results, widgets }, null, 1) + "\n"); console.log(`wrote ${file.pathname}`); } /** really on screen: a box inside the viewport, and no ancestor (through shadow hosts) hidden or at opacity 0 */ const onScreen = (e: Element) => { const r = e.getBoundingClientRect(); if (r.width < 1 || r.height < 1 || r.bottom <= 0 || r.right <= 0 || r.top >= innerHeight || r.left >= innerWidth) return false; for (let n: Element | null = e; n; n = n.parentElement ?? ((n.getRootNode() as ShadowRoot).host ?? null)) { const s = getComputedStyle(n); if (s.display === "none" || s.visibility === "hidden" || Number(s.opacity) === 0) return false; } return true; }; /** a frame is shown when its iframe element is, all the way up (Playwright's isVisible doesn't look past the frame) */ async function frameShown(f: Frame): Promise { const parent = f.parentFrame(); if (!parent) return true; const el = await f.frameElement().catch(() => null); return !!el && (await el.evaluate(onScreen).catch(() => false)) && frameShown(parent); } /** the first match in any frame that a person could see right now (open shadow roots included), or null */ export async function find(page: Page, selector: string) { for (const f of page.frames()) { const l = f.locator(selector).filter({ visible: true }).first(); // a widget may keep hidden copies (Tawk.to: two message boxes) if ((await l.evaluate(onScreen, undefined, { timeout: 500 }).catch(() => false)) && (await frameShown(f))) return l; } return null; }