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
166 lines
9.2 KiB
JavaScript
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);
|