1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
|
#!/usr/bin/env node
// Browser render benchmark for notevi web pages: drives headless Chrome over CDP
// (no npm deps; node >= 22 for native WebSocket). Serves the site dir, loads
// each page N times, reports parse/paint/load timings, node count, and the
// cost of jumping to the bottom of the file.
//
// node bench-browser.mjs SITE_DIR PAGE [PAGE...] [--shot DIR]
import { spawn } from "node:child_process";
import { createReadStream, existsSync, mkdirSync, writeFileSync } from "node:fs";
import http from "node:http";
import path from "node:path";
import { tmpdir } from "node:os";
const CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const RUNS = 3;
const args = process.argv.slice(2);
const shotIdx = args.indexOf("--shot");
let shotDir = null;
if (shotIdx >= 0) {
shotDir = args[shotIdx + 1];
args.splice(shotIdx, 2);
mkdirSync(shotDir, { recursive: true });
}
const [siteDir, ...pages] = args;
if (!siteDir || pages.length === 0) {
console.error("usage: node bench-browser.mjs SITE_DIR PAGE [PAGE...] [--shot DIR]");
process.exit(2);
}
// ── tiny static server ────────────────────────────────────────────────────
const types = { html: "text/html", css: "text/css", js: "text/javascript" };
const server = http.createServer((req, res) => {
const file = path.join(siteDir, decodeURIComponent(new URL(req.url, "http://x").pathname));
if (!existsSync(file)) { res.writeHead(404).end(); return; }
res.writeHead(200, { "content-type": types[path.extname(file).slice(1)] ?? "text/plain" });
createReadStream(file).pipe(res);
});
await new Promise(r => server.listen(0, r));
const port = server.address().port;
// ── chrome + CDP ──────────────────────────────────────────────────────────
const profile = path.join(tmpdir(), "notevi-bench-profile");
const chrome = spawn(CHROME, [
"--headless=new", "--remote-debugging-port=0", `--user-data-dir=${profile}`,
"--no-first-run", "--disable-extensions", "--window-size=1600,1000", "about:blank",
], { stdio: ["ignore", "ignore", "pipe"] });
const wsUrl = await new Promise((resolve, reject) => {
let buf = "";
chrome.stderr.on("data", d => {
buf += d;
const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
if (m) resolve(m[1]);
});
chrome.on("exit", () => reject(new Error("chrome exited")));
});
const debugPort = new URL(wsUrl).port;
const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json();
const pageTarget = targets.find(t => t.type === "page");
const ws = new WebSocket(pageTarget.webSocketDebuggerUrl);
await new Promise(r => (ws.onopen = r));
let msgId = 0;
const pending = new Map();
const eventWaiters = new Map();
ws.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
if (msg.method && eventWaiters.has(msg.method)) {
eventWaiters.get(msg.method)(msg.params);
eventWaiters.delete(msg.method);
}
};
const cdp = (method, params = {}) => new Promise(resolve => {
const id = ++msgId;
pending.set(id, m => resolve(m.result));
ws.send(JSON.stringify({ id, method, params }));
});
const waitEvent = method => new Promise(r => eventWaiters.set(method, r));
const evaluate = async expr => {
const r = await cdp("Runtime.evaluate", { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.value;
};
await cdp("Page.enable");
await cdp("Runtime.enable");
const median = a => [...a].sort((x, y) => x - y)[a.length >> 1];
console.log("page kB nodes parse FCP DCL load toBottom");
for (const p of pages) {
const url = `http://127.0.0.1:${port}/${p}`;
const runs = [];
for (let i = 0; i < RUNS; i++) {
const loaded = waitEvent("Page.loadEventFired");
await cdp("Page.navigate", { url });
await loaded;
const m = await evaluate(`(async () => {
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const nav = performance.getEntriesByType("navigation")[0];
const fcp = performance.getEntriesByType("paint").find(e => e.name === "first-contentful-paint");
const t0 = performance.now();
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
const toBottom = performance.now() - t0;
return JSON.stringify({
kb: Math.round(nav.transferSize / 1024),
nodes: document.getElementsByTagName("*").length,
parse: Math.round(nav.domInteractive),
fcp: Math.round(fcp?.startTime ?? -1),
dcl: Math.round(nav.domContentLoadedEventEnd),
load: Math.round(nav.loadEventEnd),
toBottom: Math.round(toBottom),
});
})()`);
runs.push(JSON.parse(m));
}
const med = k => median(runs.map(r => r[k]));
console.log(
p.padEnd(38) + String(med("kb")).padStart(6) + String(med("nodes")).padStart(8) +
String(med("parse")).padStart(9) + String(med("fcp")).padStart(8) +
String(med("dcl")).padStart(8) + String(med("load")).padStart(8) +
String(med("toBottom")).padStart(10));
if (shotDir) {
await evaluate(`window.scrollTo(0, 0)`);
await evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`);
const shot = await cdp("Page.captureScreenshot", { format: "png" });
writeFileSync(path.join(shotDir, p.replaceAll("/", "_") + ".png"), Buffer.from(shot.data, "base64"));
}
}
chrome.kill();
server.close();
process.exit(0);
|