302 lines
16 KiB
JavaScript
Executable File
302 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/** Repeatable anonymous WebGL performance gate over a built Tera bundle. */
|
|
|
|
import { chromium } from "playwright";
|
|
import { createServer } from "node:http";
|
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
import { extname, join, normalize, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
const DIST = join(ROOT, "dist");
|
|
const DEFAULT_BUDGETS = join(ROOT, "scripts", "performance-budgets.json");
|
|
const PRIVATE_PATH = /^\/api\/v1\/(?:media|realtime|presence|offices)(?:\/|$)|^\/assets\/(?:remoteMedia|presenceIndicator|scenePeers)-/;
|
|
const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", ".webmanifest": "application/manifest+json" };
|
|
const VIEWPORTS = {
|
|
desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
};
|
|
const SCENES = {
|
|
california: { host: "tera.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 0 },
|
|
"california-drive": {
|
|
host: "tera.lumbridgecorp.com",
|
|
ready: () => document.getElementById("boot")?.hidden === true && document.querySelectorAll("#chapters .chapter").length > 1,
|
|
activate: () => document.querySelectorAll("#chapters .chapter")[1]?.click(),
|
|
active: () => document.querySelector("[data-control-mode='drive']")?.getAttribute("aria-pressed") === "true" && document.getElementById("play-hud")?.hidden === false,
|
|
},
|
|
office: { host: "office.lumbridgecorp.com", ready: () => document.getElementById("boot")?.hidden === true && document.getElementById("enter")?.textContent?.includes("Back to the city") === true },
|
|
};
|
|
|
|
const args = process.argv.slice(2);
|
|
function option(name, fallback) {
|
|
const at = args.indexOf(`--${name}`);
|
|
return at < 0 ? fallback : args[at + 1];
|
|
}
|
|
function positive(name, fallback) {
|
|
const value = Number(option(name, fallback));
|
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`);
|
|
return value;
|
|
}
|
|
function sceneBudget(value, label) {
|
|
if (!value || typeof value !== "object") throw new Error(`missing budget for ${label}`);
|
|
for (const key of ["p95FrameIntervalMs", "maxDrawCalls", "maxTriangles"]) {
|
|
if (typeof value[key] !== "number" || !Number.isFinite(value[key]) || value[key] <= 0) {
|
|
throw new Error(`${label}.${key} must be a positive number`);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
const outputPath = resolve(option("output", "/tmp/tera-performance-budget.json"));
|
|
const budgetPath = resolve(option("budgets", DEFAULT_BUDGETS));
|
|
const warmupMs = positive("warmup-ms", 3_000);
|
|
const sampleMs = positive("sample-ms", 8_000);
|
|
const readyTimeoutMs = positive("ready-timeout-ms", 180_000);
|
|
const softwareOnly = args.includes("--software");
|
|
// Chrome exposes rAF timestamps at 0.1 ms precision, so an ideal 60 Hz cadence
|
|
// can quantize to 16.8 ms. This tolerance is measurement resolution, not budget
|
|
// headroom; reported values and declared budgets remain unchanged.
|
|
const FRAME_COMPARISON_EPSILON_MS = 0.1;
|
|
|
|
function percentile(values, fraction) {
|
|
if (values.length === 0) return null;
|
|
const ordered = [...values].sort((a, b) => a - b);
|
|
return ordered[Math.max(0, Math.ceil(ordered.length * fraction) - 1)];
|
|
}
|
|
const rounded = (value) => value === null ? null : Math.round(value * 1_000) / 1_000;
|
|
|
|
async function serve() {
|
|
const requests = [];
|
|
const server = createServer(async (req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://local.invalid");
|
|
requests.push({ method: req.method ?? "GET", path: url.pathname });
|
|
if (url.pathname === "/api/v1/health") {
|
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ auth: { mode: "jwt", entryUrl: "/login.html" }, sources: { weather: "none", flights: "none", satellites: "none", markers: "none" } }));
|
|
return;
|
|
}
|
|
if (url.pathname === "/api/v1/session") {
|
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ authenticated: false, subject: null, passwordLogin: false, admin: false }));
|
|
return;
|
|
}
|
|
if (url.pathname.startsWith("/api/v1/")) {
|
|
res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ error: "not_found" }));
|
|
return;
|
|
}
|
|
const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
|
|
for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
|
|
const target = resolve(DIST, `.${relative}`);
|
|
if (!target.startsWith(`${resolve(DIST)}/`)) continue;
|
|
try {
|
|
const body = await readFile(target);
|
|
res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
|
|
res.end(body);
|
|
return;
|
|
} catch { /* SPA fallback */ }
|
|
}
|
|
res.writeHead(404).end("not found");
|
|
});
|
|
await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", ok); });
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("performance server did not expose a TCP port");
|
|
return { server, requests, port: address.port };
|
|
}
|
|
|
|
const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"];
|
|
async function rendererOf(browser) {
|
|
const page = await browser.newPage();
|
|
try {
|
|
await page.goto("about:blank");
|
|
return await page.evaluate(() => {
|
|
const gl = document.createElement("canvas").getContext("webgl2");
|
|
const extension = gl?.getExtension("WEBGL_debug_renderer_info");
|
|
return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null;
|
|
});
|
|
} finally { await page.close(); }
|
|
}
|
|
async function launchBrowser(port) {
|
|
const resolver = `--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1, MAP office.lumbridgecorp.com 127.0.0.1`;
|
|
const attempts = softwareOnly
|
|
? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]]
|
|
: [["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]], ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader"]]];
|
|
let last;
|
|
for (const [backend, flags] of attempts) {
|
|
try {
|
|
const browser = await chromium.launch({ channel: "chrome", args: [...COMMON_ARGS, resolver, ...flags] });
|
|
const renderer = await rendererOf(browser);
|
|
const softwareRenderer = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer);
|
|
if (renderer && (backend === "swiftshader" || !softwareRenderer)) return { browser, backend, renderer, port };
|
|
await browser.close();
|
|
} catch (error) { last = error; }
|
|
}
|
|
for (const executablePath of ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]) {
|
|
try {
|
|
await access(executablePath);
|
|
const browser = await chromium.launch({ executablePath, args: [...COMMON_ARGS, resolver, "--use-gl=angle", "--use-angle=swiftshader"] });
|
|
return { browser, backend: "system-chrome-swiftshader", renderer: await rendererOf(browser), port };
|
|
} catch (error) { last = error; }
|
|
}
|
|
throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
|
|
}
|
|
|
|
function instrumentation() {
|
|
// Custom production hostnames mapped to loopback are not secure contexts over
|
|
// HTTP, so Chrome withholds randomUUID even though getRandomValues remains.
|
|
// Production is HTTPS; this shim only restores that API in the local harness.
|
|
if (typeof crypto.randomUUID !== "function") {
|
|
crypto.randomUUID = () => {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
};
|
|
}
|
|
const state = { frames: [], longTasks: [], drawCalls: [], triangles: [], currentCalls: 0, currentTriangles: 0, last: 0, longTaskSupported: false };
|
|
Object.defineProperty(globalThis, "__teraPerformanceBudget", { value: state });
|
|
const triangleCount = (mode, count) => mode === 4 ? count / 3 : (mode === 5 || mode === 6) ? Math.max(0, count - 2) : 0;
|
|
const patch = (prototype, method, countAt, instancesAt = null) => {
|
|
if (!prototype || typeof prototype[method] !== "function") return;
|
|
const original = prototype[method];
|
|
if (original.__teraPerformancePatched) return;
|
|
const wrapped = function (...values) {
|
|
state.currentCalls += 1;
|
|
const instances = instancesAt === null ? 1 : Number(values[instancesAt]) || 0;
|
|
state.currentTriangles += triangleCount(Number(values[0]), Number(values[countAt]) || 0) * instances;
|
|
return original.apply(this, values);
|
|
};
|
|
Object.defineProperty(wrapped, "__teraPerformancePatched", { value: true });
|
|
prototype[method] = wrapped;
|
|
};
|
|
for (const prototype of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) {
|
|
patch(prototype, "drawArrays", 2);
|
|
patch(prototype, "drawElements", 1);
|
|
patch(prototype, "drawArraysInstanced", 2, 3);
|
|
patch(prototype, "drawElementsInstanced", 1, 4);
|
|
}
|
|
try {
|
|
new PerformanceObserver((list) => state.longTasks.push(...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })))).observe({ type: "longtask", buffered: true });
|
|
state.longTaskSupported = true;
|
|
} catch { /* Long Tasks API is optional. */ }
|
|
requestAnimationFrame(function sample(now) {
|
|
if (state.last > 0) {
|
|
state.frames.push(now - state.last);
|
|
state.drawCalls.push(state.currentCalls);
|
|
state.triangles.push(state.currentTriangles);
|
|
}
|
|
state.last = now;
|
|
state.currentCalls = 0;
|
|
state.currentTriangles = 0;
|
|
requestAnimationFrame(sample);
|
|
});
|
|
}
|
|
|
|
async function measure(browser, port, sceneName, viewportName, budget, requestLog) {
|
|
const viewport = VIEWPORTS[viewportName];
|
|
const context = await browser.newContext({ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: viewport.deviceScaleFactor, isMobile: viewport.isMobile, hasTouch: viewport.hasTouch });
|
|
const page = await context.newPage();
|
|
const consoleErrors = [];
|
|
page.on("pageerror", (error) => consoleErrors.push(String(error)));
|
|
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
|
const before = requestLog.length;
|
|
await page.addInitScript(instrumentation);
|
|
const scene = SCENES[sceneName];
|
|
const url = `http://${scene.host}:${port}/`;
|
|
try {
|
|
await page.goto(url, { waitUntil: "networkidle", timeout: readyTimeoutMs });
|
|
try {
|
|
await page.waitForFunction(scene.ready, null, { timeout: readyTimeoutMs });
|
|
} catch (error) {
|
|
const state = await page.evaluate(() => ({
|
|
title: document.title,
|
|
bootHidden: document.getElementById("boot")?.hidden ?? null,
|
|
bootText: document.getElementById("boot")?.textContent?.trim().slice(0, 240) ?? null,
|
|
chapters: document.querySelectorAll("#chapters .chapter").length,
|
|
enter: document.getElementById("enter")?.textContent?.trim() ?? null,
|
|
})).catch(() => null);
|
|
throw new Error(`scene readiness timed out: ${JSON.stringify({ state, consoleErrors, requests: requestLog.slice(before) })}`, { cause: error });
|
|
}
|
|
if (scene.activate) {
|
|
await page.evaluate(scene.activate);
|
|
await page.waitForFunction(scene.active, null, { timeout: readyTimeoutMs });
|
|
}
|
|
await page.waitForTimeout(warmupMs);
|
|
await page.evaluate(() => {
|
|
const state = globalThis.__teraPerformanceBudget;
|
|
state.frames.length = state.drawCalls.length = state.triangles.length = state.longTasks.length = 0;
|
|
state.last = performance.now(); state.currentCalls = 0; state.currentTriangles = 0;
|
|
});
|
|
await page.waitForTimeout(sampleMs);
|
|
const raw = await page.evaluate(() => {
|
|
const state = globalThis.__teraPerformanceBudget;
|
|
const canvas = document.querySelector("canvas");
|
|
const gl = canvas?.getContext("webgl2") ?? canvas?.getContext("webgl");
|
|
const extension = gl?.getExtension("WEBGL_debug_renderer_info");
|
|
return {
|
|
frames: [...state.frames], drawCalls: [...state.drawCalls], triangles: [...state.triangles],
|
|
longTasks: [...state.longTasks], longTaskSupported: state.longTaskSupported,
|
|
renderer: extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null,
|
|
};
|
|
});
|
|
const relevantRequests = requestLog.slice(before);
|
|
const privateRequests = relevantRequests.filter((request) => PRIVATE_PATH.test(request.path));
|
|
const metrics = {
|
|
frameSamples: raw.frames.length,
|
|
p50FrameIntervalMs: rounded(percentile(raw.frames, 0.5)),
|
|
p95FrameIntervalMs: rounded(percentile(raw.frames, 0.95)),
|
|
p99FrameIntervalMs: rounded(percentile(raw.frames, 0.99)),
|
|
maxFrameIntervalMs: rounded(raw.frames.length ? Math.max(...raw.frames) : null),
|
|
p95DrawCalls: rounded(percentile(raw.drawCalls, 0.95)),
|
|
maxDrawCalls: raw.drawCalls.length ? Math.max(...raw.drawCalls) : null,
|
|
p95Triangles: rounded(percentile(raw.triangles, 0.95)),
|
|
maxTriangles: raw.triangles.length ? Math.max(...raw.triangles) : null,
|
|
longTasksSupported: raw.longTaskSupported,
|
|
longTaskCount: raw.longTasks.length,
|
|
longTaskTotalMs: raw.longTasks.reduce((sum, task) => sum + task.duration, 0),
|
|
};
|
|
const checks = {
|
|
p95FrameIntervalMs: metrics.p95FrameIntervalMs !== null &&
|
|
Math.round(metrics.p95FrameIntervalMs * 10) <=
|
|
Math.round((budget.p95FrameIntervalMs + FRAME_COMPARISON_EPSILON_MS) * 10),
|
|
maxDrawCalls: metrics.maxDrawCalls !== null && metrics.maxDrawCalls <= budget.maxDrawCalls,
|
|
maxTriangles: metrics.maxTriangles !== null && metrics.maxTriangles <= budget.maxTriangles,
|
|
anonymousPrivateRequests: privateRequests.length === 0,
|
|
consoleErrors: consoleErrors.length === 0,
|
|
enoughFrameSamples: metrics.frameSamples >= Math.max(30, Math.floor(sampleMs / 100)),
|
|
};
|
|
return { scene: sceneName, viewport: viewportName, url, viewportPixels: viewport, renderer: raw.renderer, budget, metrics, checks, passed: Object.values(checks).every(Boolean), privateRequests, consoleErrors };
|
|
} finally { await context.close(); }
|
|
}
|
|
|
|
let server;
|
|
let browser;
|
|
try {
|
|
await access(join(DIST, "index.html"));
|
|
const budgets = JSON.parse(await readFile(budgetPath, "utf8"));
|
|
const hosted = await serve(); server = hosted.server;
|
|
const launched = await launchBrowser(hosted.port); browser = launched.browser;
|
|
const results = [];
|
|
for (const scene of Object.keys(SCENES)) for (const viewport of Object.keys(VIEWPORTS)) {
|
|
process.stderr.write(`performance-budget: ${scene}/${viewport}\n`);
|
|
const budget = sceneBudget(budgets?.scenes?.[scene]?.[viewport], `${scene}.${viewport}`);
|
|
results.push(await measure(browser, hosted.port, scene, viewport, budget, hosted.requests));
|
|
}
|
|
const report = {
|
|
schemaVersion: 1, generatedAt: new Date().toISOString(), browserPlugin: "not available; Playwright system Chrome fallback used",
|
|
browser: { backend: launched.backend, renderer: launched.renderer }, warmupMs, sampleMs,
|
|
frameComparisonEpsilonMs: FRAME_COMPARISON_EPSILON_MS,
|
|
budgets: budgetPath, passed: results.every((result) => result.passed), results,
|
|
};
|
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
process.stderr.write(`performance-budget: ${report.passed ? "PASS" : "FAIL"} — ${outputPath}\n`);
|
|
if (!report.passed) process.exitCode = 1;
|
|
} catch (error) {
|
|
console.error(`performance-budget: ERROR — ${error instanceof Error ? error.stack : String(error)}`);
|
|
process.exitCode = 2;
|
|
} finally {
|
|
await browser?.close().catch(() => undefined);
|
|
if (server) await new Promise((resolveClose) => server.close(resolveClose));
|
|
}
|