polyglot-alpha / ui /scripts /cross_browser_test.js
licaomeng
deploy: main@8970ffb → HF Spaces (2026-05-27T05:19Z)
88d2f2a
// Cross-browser compatibility test runner for PolyglotAlpha v2.
// Runs the same matrix of routes + viewports + a Trigger flow against
// chromium, firefox, and webkit, then writes results to outputs/.
//
// Usage: node scripts/cross_browser_test.js
// Env:
// BASE_URL default http://localhost:3001
// EVENT_ID an existing event id for /events/{id}
// SKIP_TRIGGER set to '1' to skip the (~75s) lifecycle test
// OUT_DIR absolute path to write outputs (default ../outputs)
/* eslint-disable no-console */
const fs = require("fs");
const path = require("path");
const { chromium, firefox, webkit } = require("playwright");
const BASE_URL = process.env.BASE_URL || "http://localhost:3001";
const EVENT_ID = process.env.EVENT_ID || "114";
const SKIP_TRIGGER = process.env.SKIP_TRIGGER === "1";
const OUT_DIR =
process.env.OUT_DIR ||
path.resolve(__dirname, "..", "..", "outputs");
const SHOT_DIR = path.join(OUT_DIR, "screenshots");
fs.mkdirSync(SHOT_DIR, { recursive: true });
const ROUTES = [
{ name: "home", path: "/" },
{ name: "events", path: "/events" },
{ name: `events-${EVENT_ID}`, path: `/events/${EVENT_ID}` },
{ name: "leaderboard", path: "/leaderboard" },
{ name: "about", path: "/about" },
];
const VIEWPORTS = [
{ name: "mobile", width: 375, height: 812 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 800 },
];
const ENGINES = [
{ name: "chromium", launcher: chromium },
{ name: "firefox", launcher: firefox },
{ name: "webkit", launcher: webkit },
];
/** Try to launch a browser; if it can't (binary missing), return null. */
async function tryLaunch(engine) {
try {
const browser = await engine.launcher.launch({ headless: true });
return browser;
} catch (err) {
console.error(`[${engine.name}] launch failed: ${err.message}`);
return null;
}
}
/** Read Paint timings via PerformanceObserver entries already buffered. */
async function readPaintTimings(page) {
return page.evaluate(() => {
const result = { FCP: null, LCP: null };
try {
const paints = performance.getEntriesByType("paint");
const fcp = paints.find((p) => p.name === "first-contentful-paint");
if (fcp) result.FCP = Math.round(fcp.startTime);
} catch (_) {}
// LCP is best-effort; relies on the browser exposing the entry type.
return new Promise((resolve) => {
let lcpValue = result.LCP;
try {
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
lcpValue = Math.round(entry.startTime);
}
});
po.observe({ type: "largest-contentful-paint", buffered: true });
// Resolve after a short tick so we capture any already-buffered entries.
setTimeout(() => {
try { po.disconnect(); } catch (_) {}
resolve({ FCP: result.FCP, LCP: lcpValue });
}, 600);
} catch (_) {
resolve(result);
}
});
});
}
/** Sanity heuristic: detect Flash of Unstyled Content by looking at
* the computed background color of body. The v2 UI uses a dark
* theme (background near #0a0a0a). A "white" computed bg indicates
* CSS likely hasn't loaded. */
async function checkCssApplied(page) {
return page.evaluate(() => {
const bg = getComputedStyle(document.body).backgroundColor;
const color = getComputedStyle(document.body).color;
// Parse "rgb(r, g, b)" — if rgb sum > 600 we treat as light/unstyled.
const m = bg.match(/(\d+),\s*(\d+),\s*(\d+)/);
const sum = m ? Number(m[1]) + Number(m[2]) + Number(m[3]) : 0;
return { bg, color, looksStyled: sum > 0 && sum < 600 };
});
}
async function testRoute({ browserName, browser, route, viewport }) {
const ctx = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
colorScheme: "dark",
});
const page = await ctx.newPage();
const consoleErrors = [];
const networkFailures = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
page.on("response", (resp) => {
const status = resp.status();
if (status >= 400) {
networkFailures.push({ url: resp.url(), status });
}
});
page.on("pageerror", (err) => {
consoleErrors.push(`pageerror: ${err.message}`);
});
const url = `${BASE_URL}${route.path}`;
const startedAt = Date.now();
let pageError = null;
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
// Give Next.js a moment to hydrate and run any lazy imports.
await page.waitForLoadState("networkidle", { timeout: 15000 }).catch(() => {});
} catch (err) {
pageError = err.message;
}
const loadMs = Date.now() - startedAt;
let cssCheck = null;
let timings = { FCP: null, LCP: null };
let titleText = null;
if (!pageError) {
try {
cssCheck = await checkCssApplied(page);
timings = await readPaintTimings(page);
titleText = await page.title();
} catch (err) {
pageError = pageError || `eval failure: ${err.message}`;
}
}
const shotFile = path.join(
SHOT_DIR,
`xbrowser_${browserName}_${route.name}_${viewport.name}.png`,
);
try {
await page.screenshot({ path: shotFile, fullPage: false });
} catch (err) {
// Non-fatal: continue with the rest of the matrix.
}
await ctx.close();
return {
browser: browserName,
route: route.name,
path: route.path,
viewport: viewport.name,
loadMs,
pageError,
consoleErrorCount: consoleErrors.length,
consoleErrors: consoleErrors.slice(0, 5),
networkFailureCount: networkFailures.length,
networkFailures: networkFailures.slice(0, 5),
cssApplied: cssCheck?.looksStyled ?? null,
bodyBg: cssCheck?.bg ?? null,
title: titleText,
FCP: timings.FCP,
LCP: timings.LCP,
screenshot: path.relative(OUT_DIR, shotFile),
};
}
async function testTriggerFlow({ browserName, browser }) {
const ctx = await browser.newContext({
viewport: { width: 1280, height: 800 },
colorScheme: "dark",
});
const page = await ctx.newPage();
const consoleErrors = [];
page.on("console", (m) => m.type() === "error" && consoleErrors.push(m.text()));
page.on("pageerror", (err) => consoleErrors.push(`pageerror: ${err.message}`));
const out = {
browser: browserName,
triggered: false,
urlChanged: false,
spinnerSeen: false,
finalUrl: null,
error: null,
consoleErrors: [],
};
try {
await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForLoadState("networkidle", { timeout: 10000 }).catch(() => {});
const triggerButton = page.getByRole("button", {
name: /trigger.*live demo/i,
});
await triggerButton.waitFor({ state: "visible", timeout: 10000 });
const startUrl = page.url();
await triggerButton.click();
out.triggered = true;
// Check spinner visibility within 1.5s (lucide .animate-spin)
try {
await page.waitForSelector(".animate-spin", { timeout: 1500 });
out.spinnerSeen = true;
} catch (_) {}
// Wait up to 90s for either URL change or busy state to resolve
const deadline = Date.now() + 90000;
while (Date.now() < deadline) {
const cur = page.url();
if (cur !== startUrl && /\/events\//.test(cur)) {
out.urlChanged = true;
out.finalUrl = cur;
break;
}
await page.waitForTimeout(1000);
}
// Capture final screenshot regardless
const shotFile = path.join(
SHOT_DIR,
`xbrowser_${browserName}_trigger_final.png`,
);
await page.screenshot({ path: shotFile, fullPage: false });
out.screenshot = path.relative(OUT_DIR, shotFile);
} catch (err) {
out.error = err.message;
} finally {
out.consoleErrors = consoleErrors.slice(0, 5);
await ctx.close();
}
return out;
}
(async () => {
const startedAt = new Date().toISOString();
const results = {
startedAt,
baseUrl: BASE_URL,
eventId: EVENT_ID,
skipTrigger: SKIP_TRIGGER,
engines: {},
};
for (const engine of ENGINES) {
console.log(`\n=== ${engine.name} ===`);
const browser = await tryLaunch(engine);
if (!browser) {
results.engines[engine.name] = {
available: false,
error: "launch failed (binary missing or platform unsupported)",
};
continue;
}
const engineResult = {
available: true,
routeViewport: [],
triggerFlow: null,
};
// Desktop viewport: all routes
for (const route of ROUTES) {
const r = await testRoute({
browserName: engine.name,
browser,
route,
viewport: VIEWPORTS[2], // desktop
});
console.log(
` [desktop] ${route.name.padEnd(16)} load=${r.loadMs}ms ` +
`FCP=${r.FCP} LCP=${r.LCP} err=${r.consoleErrorCount} net4xx5xx=${r.networkFailureCount} ` +
`css=${r.cssApplied}`,
);
engineResult.routeViewport.push(r);
}
// Mobile + tablet: home only (lightweight responsive check)
for (const vp of [VIEWPORTS[0], VIEWPORTS[1]]) {
const r = await testRoute({
browserName: engine.name,
browser,
route: ROUTES[0],
viewport: vp,
});
console.log(
` [${vp.name}] home load=${r.loadMs}ms FCP=${r.FCP} err=${r.consoleErrorCount}`,
);
engineResult.routeViewport.push(r);
}
if (!SKIP_TRIGGER) {
console.log(` [trigger] starting demo flow on ${engine.name}…`);
engineResult.triggerFlow = await testTriggerFlow({
browserName: engine.name,
browser,
});
console.log(
` [trigger] triggered=${engineResult.triggerFlow.triggered} ` +
`urlChanged=${engineResult.triggerFlow.urlChanged} ` +
`spinnerSeen=${engineResult.triggerFlow.spinnerSeen} ` +
`error=${engineResult.triggerFlow.error}`,
);
}
await browser.close();
results.engines[engine.name] = engineResult;
}
results.finishedAt = new Date().toISOString();
const jsonPath = path.join(OUT_DIR, "cross_browser_iter_1.json");
fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2));
console.log(`\nWrote ${jsonPath}`);
})();