#!/usr/bin/env node /** * Home cold-boot timing (TASK-home-first-load-20260930 §7). Measurement only, * not part of CI. * * Opens `/` in headless Chrome over CDP, answers every `/api/*` itself * after a fixed delay with synthetic data (account, model catalog, empty * session list; everything else 404), lets static files through, and prints * one line per event: request start/end, DOMContentLoaded, load, the last * first-screen JS, web-font requests, and the reveal (`.app-loading` gone). * * The absolute times depend on this machine's network; read them for order and * number of rounds, not for what a phone sees. * * /exec-daemon/node scripts/measure-home-first-load.mjs --base http://127.0.0.1:3920 --delay 600 [--runs 3] [--port 9340] [--throttle slow] * * `--css-delay 1500` holds every stylesheet response that long (TASK-home-first-load-fix * -20261001): an inline script after the stylesheets waits for them, which a * local server hides because CSS arrives instantly. * `--ua iphone` uses an iPhone Safari user agent (headless Chrome engine, not WebKit). * * `--throttle slow` emulates 1.6 Mbps down / 150 ms latency so JS and font * downloads cost something against a local server. "First-screen JS" is the * scripts the HTML itself references (parser-initiated), not chunks loaded later. * * Needs Node 22 (built-in WebSocket) and `google-chrome` on PATH. */ import { spawn } from "node:child_process"; const args = Object.fromEntries(process.argv.slice(2).reduce((pairs, value, index, all) => { if (value.startsWith("--")) pairs.push([value.slice(2), all[index + 1]]); return pairs; }, [])); const BASE = (args.base ?? "http://127.0.0.1:3000").replace(/\/$/, ""); const DELAY = Number(args.delay ?? 600); const RUNS = Number(args.runs ?? 1); const PORT = Number(args.port ?? 9340); const CSS_DELAY = Number(args["css-delay"] ?? 0); const IPHONE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1"; const UA = args.ua === "iphone" ? IPHONE_UA : null; const THROTTLE = args.throttle === "slow" ? { offline: false, latency: 150, downloadThroughput: 1_600_000 / 8, uploadThroughput: 750_000 / 8 } : null; const ACCOUNT = JSON.stringify({ user: { id: "11111111-1111-4111-8111-111111111111", email: "fictional@example.com" }, avatar: null, credits: 100, isAdmin: false, adminUrl: null, rectificationPriceCredits: 10, activeSubscription: null, hasConfirmedBirthTime: true, hasUsableBirthTime: true, profile: { name: "虚构", birth_date: "1991-04-07", birth_time: "09:15", latitude: 30.57, longitude: 104.07, timezone_offset: 8, ayanamsa: "lahiri", birth_place_label: "虚构市", birth_time_status: "confirmed" }, }); const MODELS = JSON.stringify({ models: [{ id: "m1", label: "M", description: "d", creditCost: 1, isDefault: true }], defaultModelId: "m1" }); function reply(url) { if (/\/api\/account(\?|$)/.test(url)) return [200, ACCOUNT]; if (/\/api\/sessions(\?|$)/.test(url)) return [200, JSON.stringify({ sessions: [], nextCursor: null })]; if (/\/api\/models(\?|$)/.test(url)) return [200, MODELS]; if (/\/api\/chart-profiles(\?|$)/.test(url)) return [200, JSON.stringify({ profiles: [] })]; return [404, "{}"]; } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function measureOnce() { const chrome = spawn("google-chrome", ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", `--remote-debugging-port=${PORT}`, "--user-data-dir=" + `/tmp/measure-home-${process.pid}-${Date.now()}`, "--window-size=390,844", "about:blank"], { stdio: "ignore" }); let ws; for (let attempt = 0; attempt < 60 && !ws; attempt += 1) { try { const targets = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json(); const page = targets.find((target) => target.type === "page"); if (page) ws = new WebSocket(page.webSocketDebuggerUrl); } catch { /* Chrome is still starting. */ } if (!ws) await sleep(250); } if (!ws) throw new Error("chrome did not start"); await new Promise((resolve) => { ws.onopen = resolve; }); let id = 0; const pending = new Map(); const send = (method, params = {}) => new Promise((resolve) => { const n = ++id; pending.set(n, resolve); ws.send(JSON.stringify({ id: n, method, params })); }); let t0 = 0; const now = () => Math.round(performance.now() - t0); const events = []; const urls = new Map(); const jsDone = []; const parserJs = new Set(); const cssReleased = []; const apiStarts = []; ws.onmessage = (message) => { const m = JSON.parse(message.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id); return; } if (m.method === "Fetch.requestPaused") { const url = m.params.request.url; if (/\/api\//.test(url)) { const at = now(); const [status, body] = reply(url); apiStarts.push(at); setTimeout(() => { events.push([at, `api ${m.params.request.method} ${url.replace(/^https?:\/\/[^/]+/, "").slice(0, 60)} → ${status} (answered ${now()}ms)`]); void send("Fetch.fulfillRequest", { requestId: m.params.requestId, responseCode: status, responseHeaders: [{ name: "content-type", value: "application/json" }], body: Buffer.from(body).toString("base64") }); }, DELAY); } else if (CSS_DELAY > 0 && /\.css(\?|$)/.test(url)) { setTimeout(() => { events.push([now(), `stylesheet released ${url.split("/").pop().split("?")[0]}`]); cssReleased.push(now()); void send("Fetch.continueRequest", { requestId: m.params.requestId }); }, CSS_DELAY); } else { void send("Fetch.continueRequest", { requestId: m.params.requestId }); } } if (m.method === "Network.requestWillBeSent") { const url = m.params.request.url; urls.set(m.params.requestId, url); if (/\/_next\/static\/.*\.js/.test(url) && m.params.initiator?.type === "parser") parserJs.add(m.params.requestId); if (/\.woff2/.test(url)) events.push([now(), `font request ${url.split("/").pop().split("?")[0]}`]); } if (m.method === "Network.loadingFinished" && parserJs.has(m.params.requestId)) jsDone.push(now()); if (m.method === "Page.domContentEventFired") events.push([now(), "DOMContentLoaded"]); if (m.method === "Page.loadEventFired") events.push([now(), "load"]); }; await send("Fetch.enable", { patterns: [{ urlPattern: "*" }] }); await send("Network.enable"); if (THROTTLE) await send("Network.emulateNetworkConditions", THROTTLE); if (UA) { await send("Network.setUserAgentOverride", { userAgent: UA, platform: "iPhone" }); await send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 3, mobile: true }); } await send("Page.enable"); t0 = performance.now(); await send("Page.navigate", { url: `${BASE}/` }); let seen = false; let revealed = null; for (let tick = 0; tick < 400 && revealed === null; tick += 1) { await sleep(100); const result = await send("Runtime.evaluate", { expression: `(() => { const el = document.querySelector(".app-loading"); return !!el && el.getClientRects().length > 0; })()`, returnByValue: true }); const loading = result?.result?.value === true; if (loading) seen = true; if (seen && !loading) revealed = now(); } const firstScreenJs = jsDone; const lastJs = firstScreenJs.length ? Math.max(...firstScreenJs) : null; const firstApi = apiStarts.length ? Math.min(...apiStarts) : null; const loadAt = events.find((event) => event[1] === "load")?.[0] ?? null; events.push([lastJs ?? 0, `last first-screen JS done (${firstScreenJs.length} files)`]); events.push([revealed ?? 0, revealed === null ? "NOT REVEALED" : "REVEALED"]); events.sort((a, b) => a[0] - b[0]); const fontsBeforeLoad = events.filter((event) => event[1].startsWith("font request jyotisha-serif") && loadAt !== null && event[0] <= loadAt).length; // Serial rounds: distinct API waves from the first API request to the reveal, a wave = requests started within DELAY/2 of its first. const waves = []; for (const at of [...apiStarts].sort((a, b) => a - b)) { if (revealed !== null && at > revealed) break; if (!waves.length || at - waves[waves.length - 1] > DELAY / 2) waves.push(at); } const bootReads = await send("Runtime.evaluate", { expression: `(() => { const r = window.__jyotishaBootReads; return r === undefined ? "absent" : JSON.stringify(Object.keys(r)); })()`, returnByValue: true }); const firstCss = cssReleased.length ? Math.min(...cssReleased) : null; chrome.kill(); return { events, summary: { firstStylesheetReleased: firstCss, firstApiBeforeCss: firstCss !== null && firstApi !== null ? firstApi < firstCss : null, bootReadsAfterReveal: bootReads?.result?.value ?? null, revealed, lastJs, firstApi, firstApiVsLastJs: firstApi !== null && lastJs !== null ? firstApi - lastJs : null, apiRounds: waves.length, serifFontRequestsBeforeLoad: fontsBeforeLoad } }; } const summaries = []; for (let run = 0; run < RUNS; run += 1) { const { events, summary } = await measureOnce(); if (run === 0) for (const [at, text] of events) console.log(`${String(at).padStart(6)}ms ${text}`); console.log(`run ${run + 1}: ${JSON.stringify(summary)}`); summaries.push(summary); await sleep(500); } process.exit(0);