Files
Jyotisha/frontend/scripts/measure-home-first-load.mjs
T
Jesse_ChenandClaude Opus 5.5 c2265dabb2 fix(home): early boot reads load as an async data: script so stylesheets no longer hold them back (BUG-1127)
An inline classic script after the stylesheets waited for them to download;
on staging the reads left at 2.4-3.1 s. Measurement script gains
--css-delay and --ua iphone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-01 01:36:35 +08:00

166 lines
9.2 KiB
JavaScript

#!/usr/bin/env node
/**
* Home cold-boot timing (TASK-home-first-load-20260930 §7). Measurement only,
* not part of CI.
*
* Opens `<base>/` 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);