#!/usr/bin/env node // Loads app routes in a cold browser and reports how long they take and what they fetch. // // node scripts/perf.mjs --base http://127.0.0.1:4173 /@natemoo.re / /settings // node scripts/perf.mjs --base http://127.0.0.1:4173 --runs 5 --json out.json /@natemoo.re // // The gateway is whatever the served app was built or started with (VITE_NIBLING_GATEWAY). import { chromium } from "@playwright/test"; import { writeFileSync } from "node:fs"; import { parseArgs } from "node:util"; const { values, positionals } = parseArgs({ allowPositionals: true, options: { base: { type: "string", default: "http://127.0.0.1:4173" }, runs: { type: "string", default: "1" }, json: { type: "string" }, heading: { type: "string", default: "h1" }, "idle-ms": { type: "string", default: "1500" }, timeout: { type: "string", default: "30000" }, verbose: { type: "boolean", default: false }, }, }); const base = new URL(values.base); const routes = positionals.length > 0 ? positionals : ["/"]; const runs = Number(values.runs); const idleMs = Number(values["idle-ms"]); const timeout = Number(values.timeout); const browser = await chromium.launch(); const results = []; try { for (const route of routes) { const samples = []; for (let i = 0; i < runs; i++) samples.push(await measure(route)); results.push({ route, samples }); } } finally { await browser.close(); } for (const { route, samples } of results) report(route, samples); if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 2)); async function measure(route) { const context = await browser.newContext(); const page = await context.newPage(); // Watches from the first byte of the document: whether the editor's shell is ever attached, and // when the first heading (searched through open shadow roots) is laid out. await page.addInitScript((selector) => { const seen = (globalThis.__nib = { shellAt: -1, headingAt: -1, headingText: "" }); new MutationObserver(() => { if (seen.shellAt < 0 && document.querySelector("nib-shell")) seen.shellAt = performance.now(); }).observe(document, { childList: true, subtree: true }); const find = (root) => { const hit = root.querySelector(selector); if (hit && hit.getClientRects().length > 0) return hit; for (const el of root.querySelectorAll("*")) { const inner = el.shadowRoot && find(el.shadowRoot); if (inner) return inner; } return undefined; }; const poll = () => { const hit = document.documentElement && find(document); if (hit) { seen.headingAt = performance.now(); seen.headingText = hit.textContent.trim(); } else requestAnimationFrame(poll); }; requestAnimationFrame(poll); }, values.heading); const requests = new Map(); let lastActivity = 0; page.on("request", (request) => { lastActivity = Date.now(); requests.set(request, { url: request.url(), method: request.method(), type: request.resourceType(), start: Date.now() }); }); const settle = async (request, failed) => { lastActivity = Date.now(); const entry = requests.get(request); if (!entry) return; entry.end = Date.now(); entry.failed = failed; if (!failed) entry.bytes = (await request.sizes().catch(() => undefined))?.responseBodySize ?? 0; }; page.on("requestfinished", (r) => void settle(r, false)); page.on("requestfailed", (r) => void settle(r, true)); await page.goto(new URL(route, base).href, { waitUntil: "commit", timeout }); await page.waitForFunction(() => globalThis.__nib?.headingAt >= 0, undefined, { timeout, polling: 100 }).catch(() => {}); // Idle: no request started or settled for idleMs. const deadline = Date.now() + timeout; while (Date.now() < deadline) { const pending = [...requests.values()].some((r) => r.end === undefined); if (!pending && Date.now() - lastActivity >= idleMs) break; await new Promise((r) => setTimeout(r, 50)); } const seen = await page.evaluate(() => globalThis.__nib); // Request times are wall-clock; the page's clock starts at navigation. const origin = await page.evaluate(() => performance.timeOrigin); await context.close(); const at = (t) => (t === undefined ? undefined : Math.round(t - origin)); const list = [...requests.values()].map((r) => ({ ...r, start: at(r.start), end: at(r.end) })); // The device reporter's beacons are not page loading. const idleAt = Math.max(0, ...list.filter((r) => r.type !== "ping").map((r) => r.end ?? 0)); const local = list.filter((r) => new URL(r.url).origin === base.origin); const external = new Map(); for (const r of list) { const host = new URL(r.url).host; if (new URL(r.url).origin === base.origin || r.url.startsWith("data:")) continue; const e = external.get(host) ?? { count: 0, firstStart: Infinity, lastEnd: 0, slowest: 0 }; e.count++; e.firstStart = Math.min(e.firstStart, r.start); e.lastEnd = Math.max(e.lastEnd, r.end ?? 0); e.slowest = Math.max(e.slowest, (r.end ?? r.start) - r.start); external.set(host, e); } const counts = new Map(); for (const r of list) { if (r.method !== "GET") continue; const key = `${r.method} ${r.url}`; counts.set(key, (counts.get(key) ?? 0) + 1); } const duplicates = [...counts].filter(([, n]) => n > 1).map(([key, n]) => ({ key, n })); const jsBytes = list.filter((r) => r.type === "script").reduce((sum, r) => sum + (r.bytes ?? 0), 0); return { headingMs: seen.headingAt >= 0 ? Math.round(seen.headingAt) : undefined, headingText: seen.headingText, idleMs: idleAt, localRequests: local.length, requests: list.length, jsBytes, shellAttached: seen.shellAt >= 0, external: Object.fromEntries(external), duplicates, ...(values.verbose ? { list } : {}), }; } function median(xs) { const s = xs.filter((x) => x !== undefined).sort((a, b) => a - b); return s.length ? s[Math.floor((s.length - 1) / 2)] : undefined; } function report(route, samples) { const last = samples.at(-1); const ms = (x) => (x === undefined ? "none" : `${x} ms`); console.log(`\n${route} (${samples.length} run${samples.length > 1 ? "s" : ""}, median)`); console.log(` heading ${ms(median(samples.map((s) => s.headingMs)))} ${last.headingText ? JSON.stringify(last.headingText) : ""}`); console.log(` idle ${ms(median(samples.map((s) => s.idleMs)))}`); console.log(` requests ${median(samples.map((s) => s.requests))} total, ${median(samples.map((s) => s.localRequests))} local`); console.log(` js ${(median(samples.map((s) => s.jsBytes)) / 1024).toFixed(1)} KB transferred`); console.log(` nib-shell ${samples.some((s) => s.shellAttached) ? "attached" : "never attached"}`); for (const [host, e] of Object.entries(last.external)) { console.log(` ${host.padEnd(36)} ${String(e.count).padStart(3)} req ${e.firstStart}-${e.lastEnd} ms slowest ${e.slowest} ms`); } if (last.duplicates.length === 0) console.log(" duplicates none"); for (const d of last.duplicates) console.log(` duplicate ${d.n}x ${d.key}`); }