// lighthouse: what each widget does to a page's Lighthouse performance on a phone. // bun run --cwd tools/widget-lab lighthouse // Lighthouse's mobile defaults (Moto G Power emulation, simulated slow 4G, 4x CPU slowdown), performance only, one // fresh Chrome profile per run. The test page alone, then the test page + each widget. For widgets that refuse our // domain, also their own homepage as it is vs the same page with the widget's loader script blocked (Lighthouse // blockedUrlPatterns): the difference is what the widget costs there. import lighthouse from "lighthouse"; import { chrome, env, median, RUNS, save } from "./lab"; import { serve, url } from "./serve"; import { WIDGETS } from "./widgets"; const PORT = 9333; const only = process.env.ONLY?.split(","); async function once(target: string, blocked: string[] = []) { const { close } = await chrome({ args: [`--remote-debugging-port=${PORT}`] }); try { const r = await lighthouse(target, { port: PORT, output: "json", logLevel: "error", onlyCategories: ["performance"], blockedUrlPatterns: blocked }); const lhr = r!.lhr; const a = (id: string) => lhr.audits[id]?.numericValue ?? null; const third = (lhr.audits["third-party-summary"]?.details as { items?: { entity: string; transferSize: number; blockingTime: number; mainThreadTime: number }[] } | undefined)?.items ?? []; return { at: lhr.fetchTime, score: lhr.categories.performance!.score === null ? null : Math.round(lhr.categories.performance!.score * 100), fcp: a("first-contentful-paint"), lcp: a("largest-contentful-paint"), tbt: a("total-blocking-time"), cls: a("cumulative-layout-shift"), si: a("speed-index"), mainThread: a("mainthread-work-breakdown"), jsExecution: a("bootup-time"), bytes: a("total-byte-weight"), requests: (lhr.audits["network-requests"]?.details as { items?: unknown[] } | undefined)?.items?.length ?? null, thirdParties: third.map((t) => ({ entity: t.entity, kb: Math.round(t.transferSize / 102.4) / 10, blockingMs: Math.round(t.blockingTime), mainThreadMs: Math.round(t.mainThreadTime) })), warnings: lhr.runWarnings, benchmarkIndex: lhr.environment.benchmarkIndex, lighthouse: lhr.lighthouseVersion, userAgent: lhr.environment.networkUserAgent, }; } finally { await close(); } } type Run = Awaited>; const summary = (runs: Run[]) => Object.fromEntries( (["score", "fcp", "lcp", "tbt", "cls", "si", "mainThread", "jsExecution", "bytes", "requests"] as const).map((k) => [k, median(runs.map((r) => r[k] ?? NaN))]), ); async function series(name: string, target: string, blocked?: string[]) { const runs: Run[] = []; for (let i = 0; i < RUNS; i++) { try { runs.push(await once(target, blocked)); console.log(name, i + 1, "score", runs.at(-1)!.score, "tbt", Math.round(runs.at(-1)!.tbt ?? 0)); } catch (e) { console.log(name, i + 1, "failed", String(e).slice(0, 160)); } } return { url: target, blocked, summary: summary(runs), runs }; } const server = serve(); const started = new Date().toISOString(); const results: Record = {}; for (const w of [undefined, ...WIDGETS]) { const id = w?.id ?? "blank"; if (!only || only.includes(id)) results[`${id}@test-page`] = { widget: w?.name ?? null, ...(await series(id, url(w?.id))) }; } for (const w of WIDGETS.filter((w) => !w.boots && w.home)) { if (only && !only.includes(w.id)) continue; results[`${w.id}@home`] = { widget: w.name, ...(await series(`${w.id} home`, w.home!)) }; results[`${w.id}@home-without-widget`] = { widget: null, ...(await series(`${w.id} home, blocked`, w.home!, [w.loader])) }; } server.stop(true); save("lighthouse", { test: "lighthouse mobile, performance", started, finished: new Date().toISOString(), runs: RUNS, env: await env(), method: [ "Lighthouse (node module) against headed Google Chrome, a fresh profile per run, default mobile settings: Moto G Power screen, simulated throttling (150 ms RTT, 1.6 Mbps down, 4x CPU slowdown), storage and cache cleared by Lighthouse, performance category only.", "Test page: http://localhost:5190/ from tools/widget-lab/serve.ts alone (blank), then with each widget's documented snippet at the end of .", "Crisp, Intercom, LiveChat and Chatwoot refuse to boot on our domain, so on the test page they only load what comes before the refusal. For them we also ran Lighthouse on the vendor's own page as it is and with the widget's loader script blocked (blockedUrlPatterns); the difference is the widget's cost on that page.", "Values are medians over the runs. Times in ms, bytes in bytes. benchmarkIndex is Lighthouse's CPU benchmark of this machine.", ], results, });