#!/usr/bin/env node /* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */ import { spawn } from "node:child_process"; import { createServer } from "node:http"; import { readFile, rm, stat, mkdtemp } from "node:fs/promises"; import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import process from "node:process"; const dashboardRoot = path.resolve(import.meta.dirname, ".."); const appRoot = path.join(dashboardRoot, "app"); const clientDistRoot = path.join(dashboardRoot, "dist", "client"); const requireBrowser = process.argv.includes("--require-browser") || process.env.FUSION_BROWSER_SMOKE_REQUIRE === "1"; function log(message) { console.log(`[dashboard-browser-smoke] ${message}`); } function fail(message) { throw new Error(message); } async function loadDashboardCss() { try { return await readEmittedClientCss(); } catch { await runCommand("pnpm", ["--filter", "@fusion/dashboard", "build:client"], dashboardRoot); return readEmittedClientCss(); } } async function readEmittedClientCss() { const indexHtml = await readFile(path.join(clientDistRoot, "index.html"), "utf8"); const hrefs = [...indexHtml.matchAll(/]*\brel=["']stylesheet["'][^>]*\bhref=["']([^"']+)["'][^>]*>/gi)] .map((match) => match[1]); if (hrefs.length === 0) { fail(`No emitted dashboard stylesheet links found in ${path.join(clientDistRoot, "index.html")}.`); } const chunks = []; for (const href of hrefs) { const file = path.join(clientDistRoot, href.replace(/^\//, "")); chunks.push(`\n/* ${path.relative(dashboardRoot, file)} */\n${await readFile(file, "utf8")}`); } return chunks.join("\n"); } function runCommand(command, commandArgs, cwd) { return new Promise((resolve, reject) => { const child = spawn(command, commandArgs, { cwd, stdio: "inherit", shell: process.platform === "win32", }); child.once("error", reject); child.once("exit", (code) => { if (code === 0) { resolve(); return; } reject(new Error(`${command} ${commandArgs.join(" ")} exited with code ${code}.`)); }); }); } function createSmokeHtml() { const columns = [ ["triage", "Triage", "1"], ["todo", "Todo", "2"], ["in-progress", "In Progress", "1"], ["in-review", "In Review", "1"], ["done", "Done", "3"], ["archived", "Archived", "0"], ]; const columnMarkup = columns .map(([column, label, count]) => `

${label} with long status heading copy

${count}

Layout smoke data for ${label}

FN-${column.length}01

Responsive task card with a deliberately long title that should wrap cleanly

${label}
`) .join(""); return ` Fusion dashboard browser smoke
Local project with very long name
${columnMarkup}
1 running
/very/long/path/to/fusion/dashboard/project
`; } async function startFixtureServer() { const css = await loadDashboardCss(); const html = createSmokeHtml(); const server = createServer((req, res) => { if (req.url === "/app.css") { res.writeHead(200, { "content-type": "text/css; charset=utf-8" }); res.end(css); return; } res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(html); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); return { server, url: `http://127.0.0.1:${server.address().port}/`, }; } async function findBrowserExecutable() { const envCandidates = [ process.env.FUSION_BROWSER_SMOKE_BROWSER, process.env.CHROME_BIN, process.env.CHROMIUM_BIN, process.env.BROWSER, ].filter(Boolean); const platformCandidates = process.platform === "darwin" ? [ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", ] : process.platform === "win32" ? [ path.join(process.env.PROGRAMFILES ?? "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"), path.join(process.env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"), ] : [ "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/microsoft-edge", ]; for (const candidate of [...envCandidates, ...platformCandidates]) { if (!candidate) continue; try { const info = await stat(candidate); if (info.isFile()) return candidate; } catch { // Try the next known browser path. } } return null; } async function launchBrowser(executable) { const userDataDir = await mkdtemp(path.join(os.tmpdir(), "fusion-dashboard-browser-smoke-")); const browser = spawn(executable, [ "--headless=new", "--disable-gpu", "--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check", "--remote-debugging-port=0", `--user-data-dir=${userDataDir}`, "about:blank", ], { stdio: ["ignore", "pipe", "pipe"], }); try { const wsUrl = await new Promise((resolve, reject) => { let settled = false; const timeout = setTimeout(() => { rejectReady(new Error("Timed out waiting for the browser DevTools endpoint.")); }, 15_000); const cleanupListeners = () => { clearTimeout(timeout); browser.stdout.off("data", onData); browser.stderr.off("data", onData); browser.off("error", rejectReady); browser.off("exit", onExit); }; const resolveReady = (url) => { if (settled) return; settled = true; cleanupListeners(); resolve(url); }; function rejectReady(error) { if (settled) return; settled = true; cleanupListeners(); reject(error); } function onData(data) { const text = data.toString(); const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (match) { resolveReady(match[1]); } } function onExit(code) { rejectReady(new Error(`Browser exited before DevTools was ready (code ${code}).`)); } browser.stdout.on("data", onData); browser.stderr.on("data", onData); browser.once("error", rejectReady); browser.once("exit", onExit); }); return { browser, userDataDir, wsUrl }; } catch (error) { await stopBrowser(browser); await rm(userDataDir, { recursive: true, force: true }); throw error; } } async function stopBrowser(browser) { if (browser.exitCode !== null || browser.signalCode !== null) { return; } const exited = new Promise((resolve) => { browser.once("exit", resolve); }); if (!browser.killed) { browser.kill(); } const exitedCleanly = await Promise.race([ exited.then(() => true), new Promise((resolve) => setTimeout(() => resolve(false), 5_000)), ]); if (!exitedCleanly && browser.exitCode === null && browser.signalCode === null) { browser.kill("SIGKILL"); await exited; } } function cdpConnect(wsUrl) { return new Promise((resolve, reject) => { const socket = new WebSocket(wsUrl); const pending = new Map(); const listeners = new Map(); let nextId = 1; socket.addEventListener("open", () => { resolve({ send(method, params = {}) { const id = nextId++; socket.send(JSON.stringify({ id, method, params })); return new Promise((resolveCommand, rejectCommand) => { pending.set(id, { resolve: resolveCommand, reject: rejectCommand }); }); }, once(method) { return new Promise((resolveEvent) => { const list = listeners.get(method) ?? []; list.push(resolveEvent); listeners.set(method, list); }); }, close() { socket.close(); }, }); }); socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.id && pending.has(message.id)) { const command = pending.get(message.id); pending.delete(message.id); if (message.error) { command.reject(new Error(`${message.error.message}: ${message.error.data ?? ""}`)); } else { command.resolve(message.result); } return; } if (message.method && listeners.has(message.method)) { const list = listeners.get(message.method); const listener = list.shift(); if (list.length === 0) listeners.delete(message.method); listener?.(message.params); } }); socket.addEventListener("error", reject); }); } async function createPage(browserWsUrl) { const browserEndpoint = new URL(browserWsUrl); const targetUrl = new URL(`/json/new?${encodeURIComponent("about:blank")}`, `http://127.0.0.1:${browserEndpoint.port}`); let response = await fetch(targetUrl, { method: "PUT" }); if (!response.ok) { response = await fetch(targetUrl); } if (!response.ok) { fail(`Unable to create browser target: HTTP ${response.status}`); } const target = await response.json(); return cdpConnect(target.webSocketDebuggerUrl); } async function evaluate(page, expression) { const result = await page.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, }); if (result.exceptionDetails) { fail(result.exceptionDetails.text ?? "Browser evaluation failed"); } return result.result.value; } function assertSmokeResult(name, passed, details) { if (!passed) { fail(`${name} failed: ${details}`); } log(`ok: ${name}`); } async function runSmokeChecks(page, pageUrl) { await page.send("Page.enable"); await page.send("Runtime.enable"); await page.send("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 2, mobile: true, }); const loaded = page.once("Page.loadEventFired"); await page.send("Page.navigate", { url: pageUrl }); await loaded; await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true"); const initialLayout = await evaluate(page, `(() => { const viewportWidth = window.innerWidth; const nav = document.querySelector('.mobile-nav-bar').getBoundingClientRect(); const footer = document.querySelector('.executor-status-bar').getBoundingClientRect(); const header = document.querySelector('[data-smoke="header"]').getBoundingClientRect(); const content = document.querySelector('.project-content'); const contentStyle = getComputedStyle(content); const tabs = [...document.querySelectorAll('.mobile-nav-tab')].map((tab) => tab.getBoundingClientRect()); const board = document.querySelector('[data-smoke="board"]'); const columns = [...document.querySelectorAll('.board > .column')].map((column) => column.getBoundingClientRect()); return { viewportWidth, documentOverflow: document.documentElement.scrollWidth - viewportWidth, headerLeft: header.left, headerRight: header.right, navDisplay: getComputedStyle(document.querySelector('.mobile-nav-bar')).display, navLeft: nav.left, navRight: nav.right, navBottomGap: Math.abs(window.innerHeight - nav.bottom), footerBottomGap: Math.abs(nav.top - footer.bottom), contentPaddingBottom: parseFloat(contentStyle.paddingBottom), navHeight: nav.height, footerHeight: footer.height, tabMinWidth: Math.min(...tabs.map((tab) => tab.width)), boardOverflow: board.scrollWidth - board.clientWidth, boardOverflowX: getComputedStyle(board).overflowX, columnWidths: columns.map((column) => Math.round(column.width)), }; })()`); assertSmokeResult( "mobile nav/header/footer fit viewport", initialLayout.navDisplay === "flex" && initialLayout.documentOverflow <= 1 && initialLayout.headerLeft >= 0 && initialLayout.headerRight <= initialLayout.viewportWidth + 1 && initialLayout.navLeft >= 0 && initialLayout.navRight <= initialLayout.viewportWidth + 1 && initialLayout.navBottomGap <= 1 && initialLayout.footerBottomGap <= 1 && initialLayout.contentPaddingBottom >= initialLayout.navHeight + initialLayout.footerHeight - 1 && initialLayout.tabMinWidth >= 36, JSON.stringify(initialLayout), ); assertSmokeResult( "mobile board uses contained horizontal scrolling", initialLayout.boardOverflow > 300 && initialLayout.boardOverflowX === "auto" && initialLayout.columnWidths.every((width) => width === 300), JSON.stringify(initialLayout), ); const listLayout = await evaluate(page, `(() => { document.querySelector('[data-smoke="show-list"]').click(); const board = document.querySelector('[data-smoke="board"]'); const list = document.querySelector('[data-smoke="list"]'); const table = document.querySelector('.list-table'); const cards = document.querySelector('.list-cards'); const input = document.querySelector('[data-smoke="quick-entry-input"]'); return { boardHidden: board.hidden, listHidden: list.hidden, listActive: document.querySelector('[data-smoke="show-list"]').classList.contains('active'), tableDisplay: getComputedStyle(table).display, cardsDisplay: getComputedStyle(cards).display, inputFontSize: getComputedStyle(input).fontSize, inputHeight: input.getBoundingClientRect().height, inputRight: input.getBoundingClientRect().right, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, }; })()`); assertSmokeResult( "board/list switch exposes mobile list cards and contained input", listLayout.boardHidden === true && listLayout.listHidden === false && listLayout.listActive === true && listLayout.tableDisplay === "none" && listLayout.cardsDisplay === "flex" && listLayout.inputHeight >= 30 && listLayout.inputRight <= 391 && listLayout.documentOverflow <= 1, JSON.stringify(listLayout), ); const modalLayout = await evaluate(page, `(() => { document.querySelector('[data-smoke="open-modal"]').click(); const overlay = document.querySelector('[data-smoke="modal-overlay"]'); const modal = document.querySelector('[data-smoke="modal"]'); const close = document.querySelector('[data-smoke="close-modal"]'); const nav = document.querySelector('.mobile-nav-bar'); const modalRect = modal.getBoundingClientRect(); const closeRect = close.getBoundingClientRect(); return { overlayDisplay: getComputedStyle(overlay).display, modalWidth: Math.round(modalRect.width), modalHeight: Math.round(modalRect.height), modalRadius: getComputedStyle(modal).borderRadius, closeTop: closeRect.top, closeRight: closeRect.right, navHidden: nav.hidden, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, }; })()`); assertSmokeResult( "mobile modal fills viewport without horizontal overflow", modalLayout.overlayDisplay === "flex" && modalLayout.modalWidth === 390 && modalLayout.modalHeight === 844 && modalLayout.modalRadius === "0px" && modalLayout.closeTop >= 0 && modalLayout.closeRight <= 390 && modalLayout.navHidden === true && modalLayout.documentOverflow <= 1, JSON.stringify(modalLayout), ); } async function main() { if (!existsSync(appRoot)) { fail(`Dashboard app directory not found: ${appRoot}`); } if (typeof WebSocket === "undefined") { fail("This smoke script requires Node's global WebSocket support."); } const executable = await findBrowserExecutable(); if (!executable) { const message = "No local Chrome/Chromium/Edge executable found. Set FUSION_BROWSER_SMOKE_BROWSER=/path/to/browser to run the real-browser smoke. This lane is local-only and fixture-based; it verifies layout overflow with real dashboard CSS, not full API routing."; if (requireBrowser) fail(message); log(`skip: ${message}`); return; } log("using local browser; this fixture smoke checks real CSS layout but does not replace full dashboard E2E coverage."); const launched = await launchBrowser(executable); let fixture; let page; try { fixture = await startFixtureServer(); page = await createPage(launched.wsUrl); await runSmokeChecks(page, fixture.url); } finally { page?.close(); if (fixture) { await closeServer(fixture.server); } await stopBrowser(launched.browser); await rm(launched.userDataDir, { recursive: true, force: true }); } } function closeServer(server) { return new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve(); }); }); } main().catch((error) => { console.error(`[dashboard-browser-smoke] ${error.stack ?? error.message}`); process.exitCode = 1; });