#!/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 }, 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 }); } 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)); }