Spaces:
Running
Running
| // 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}`); | |
| })(); | |