#!/usr/bin/env node /* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */ import { spawn } from "node:child_process"; import { createServer } from "node:http"; import { superviseSpawn } from "@fusion/core"; import { readFile, readdir, 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"; import { fileURLToPath } from "node:url"; 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 = []; const cssFiles = new Set(hrefs.map((href) => path.join(clientDistRoot, href.replace(/^\//, "")))); /* FNXC:CommandCenterTesting 2026-06-19-02:19: Command Center is lazy-loaded, so its emitted CSS lives in a dynamic chunk that index.html does not link directly. The browser smoke must include emitted CSS chunks as well as root links or chart layout assertions would test an unstyled fixture instead of the production Command Center contract. */ const assetsDir = path.join(clientDistRoot, "assets"); if (existsSync(assetsDir)) { for (const entry of await readdir(assetsDir)) { if (entry.endsWith(".css")) { cssFiles.add(path.join(assetsDir, entry)); } } } for (const file of [...cssFiles].sort()) { 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}.`)); }); }); } export 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 supervised = superviseSpawn(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"], maxLifetimeMs: 60_000, }); const browser = supervised.child; 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 collectCommandCenterChartLayout(page, { clickToggle = false } = {}) { return evaluate(page, `(() => { if (${clickToggle ? "true" : "false"}) { document.querySelector('[data-smoke="show-command-center-charts"]').click(); } const section = document.querySelector('[data-smoke="command-center-charts"]'); const panel = section.querySelector('.cc-tabpanel'); const chartNodes = [...section.querySelectorAll('.cc-recharts-chart')]; const emptyNodes = [...section.querySelectorAll('.cc-recharts-empty')]; const charts = chartNodes.map((chart) => { const rect = chart.getBoundingClientRect(); const responsiveContainer = chart.querySelector('.recharts-responsive-container'); const svg = chart.querySelector('svg'); const svgRect = svg?.getBoundingClientRect(); const style = getComputedStyle(chart); return { testId: chart.closest('[data-testid]')?.getAttribute('data-testid') ?? chart.getAttribute('aria-label'), clientHeight: chart.clientHeight, responsiveHeight: responsiveContainer?.clientHeight ?? 0, svgHeight: svgRect?.height ?? 0, hasSvg: Boolean(svg), overflow: chart.scrollWidth - chart.clientWidth, overflowY: style.overflowY, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, }; }); const empties = emptyNodes.map((empty) => { const rect = empty.getBoundingClientRect(); const style = getComputedStyle(empty); return { testId: empty.closest('[data-testid]')?.getAttribute('data-testid') ?? empty.getAttribute('aria-label'), text: empty.textContent.trim(), clientHeight: empty.clientHeight, clientWidth: empty.clientWidth, overflow: empty.scrollWidth - empty.clientWidth, overflowY: style.overflowY, left: rect.left, right: rect.right, }; }); const panelStyle = getComputedStyle(panel); return { hidden: section.hidden, viewportWidth: window.innerWidth, viewportHeight: window.innerHeight, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, panelOverflow: panel.scrollWidth - panel.clientWidth, panelOverflowY: panelStyle.overflowY, charts, empties, }; })()`); } function commandCenterChartsPass(layout) { return layout.hidden === false && layout.documentOverflow <= 1 && layout.panelOverflow <= 1 && layout.panelOverflowY === "auto" && layout.charts.length >= 4 && layout.charts.every((chart) => chart.clientHeight > 0 && chart.responsiveHeight > 0 && chart.svgHeight > 0 && chart.hasSvg === true && chart.overflow <= 1 && chart.left >= 0 && chart.right <= layout.viewportWidth + 1 && chart.overflowY !== "auto" && chart.overflowY !== "scroll") && layout.empties.length >= 1 && layout.empties.every((empty) => empty.text.length > 0 && empty.clientHeight > 0 && empty.clientWidth > 0 && empty.overflow <= 1 && empty.left >= 0 && empty.right <= layout.viewportWidth + 1 && empty.overflowY !== "auto" && empty.overflowY !== "scroll"); } 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 <= 8 && 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), ); const prCreateModalLayout = await evaluate(page, `(() => { document.querySelector('[data-smoke="show-pr-create"]').click(); const modal = document.querySelector('[data-smoke="pr-create-modal"] .modal.modal-lg'); const failedRow = document.querySelector('.pr-create-modal__preflight-row.is-failed'); const textarea = document.querySelector('.pr-create-modal__body-input'); const chips = [...document.querySelectorAll('.pr-create-modal__chip')].map((chip) => chip.getBoundingClientRect().right); const preview = document.querySelector('.pr-create-modal__preview'); return { documentOverflow: document.documentElement.scrollWidth - window.innerWidth, modalWidth: Math.round(modal.getBoundingClientRect().width), failedRowBoxShadow: getComputedStyle(failedRow).boxShadow, textareaOverflow: textarea.scrollWidth - textarea.clientWidth, chipRights: chips, previewOverflow: preview.scrollWidth - preview.clientWidth, }; })()`); assertSmokeResult( "pr-create-modal layout", prCreateModalLayout.documentOverflow <= 1 && prCreateModalLayout.modalWidth === 390 && prCreateModalLayout.failedRowBoxShadow !== "none" && prCreateModalLayout.textareaOverflow <= 1 && prCreateModalLayout.chipRights.every((right) => right <= 390) && prCreateModalLayout.previewOverflow <= 1, JSON.stringify(prCreateModalLayout), ); const prPanelLayout = await evaluate(page, `(() => { document.querySelector('[data-smoke="show-pr-panel"]').click(); const panel = document.querySelector('[data-smoke="pr-panel"] .pr-panel-section'); const checkRights = [...document.querySelectorAll('[data-smoke="pr-panel"] .pr-panel-check-item')] .map((row) => row.getBoundingClientRect().right); const reviewItemOverflows = [...document.querySelectorAll('[data-smoke="pr-panel"] .pr-panel-review-item')] .map((row) => row.scrollWidth - row.clientWidth); return { panelOverflow: panel.scrollWidth - panel.clientWidth, documentOverflow: document.documentElement.scrollWidth - window.innerWidth, checkRights, successColor: getComputedStyle(document.querySelector('[data-smoke="pr-panel"] .pr-panel-check-chip--success')).color, errorColor: getComputedStyle(document.querySelector('[data-smoke="pr-panel"] .pr-panel-check-chip--error')).color, reviewItemOverflows, }; })()`); assertSmokeResult( "pr-panel layout", prPanelLayout.panelOverflow <= 1 && prPanelLayout.documentOverflow <= 1 && prPanelLayout.checkRights.every((right) => right <= 390) && prPanelLayout.successColor !== prPanelLayout.errorColor && prPanelLayout.reviewItemOverflows.every((overflow) => overflow <= 1), JSON.stringify(prPanelLayout), ); const prChecksLayout = await evaluate(page, `(() => { document.querySelector('[data-smoke="show-pr-checks"]').click(); const list = document.querySelector('[data-smoke="pr-checks"] .pr-checks__list'); const items = [...document.querySelectorAll('[data-smoke="pr-checks"] .pr-checks__item')]; const names = [...document.querySelectorAll('[data-smoke="pr-checks"] .pr-checks__name')]; const detailsLink = document.querySelector('[data-smoke="pr-checks"] .pr-checks__details-link'); return { listOverflow: list.scrollWidth - list.clientWidth, itemRights: items.map((item) => item.getBoundingClientRect().right), documentOverflow: document.documentElement.scrollWidth - window.innerWidth, nameHeights: names.map((name) => name.offsetHeight), detailsLinkVisible: detailsLink.offsetHeight > 0, detailsLinkColor: getComputedStyle(detailsLink).color, }; })()`); assertSmokeResult( "pr-checks layout", prChecksLayout.listOverflow <= 1 && prChecksLayout.itemRights.every((right) => right <= 390) && prChecksLayout.documentOverflow <= 1 && prChecksLayout.nameHeights.every((height) => height > 0) && prChecksLayout.detailsLinkVisible === true && prChecksLayout.detailsLinkColor !== "rgb(255, 255, 255)", JSON.stringify(prChecksLayout), ); const mobileCommandCenterChartsLayout = await collectCommandCenterChartLayout(page, { clickToggle: true }); assertSmokeResult( "command-center charts mobile layout", commandCenterChartsPass(mobileCommandCenterChartsLayout), JSON.stringify(mobileCommandCenterChartsLayout), ); await page.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false, }); await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true"); const desktopCommandCenterChartsLayout = await collectCommandCenterChartLayout(page); assertSmokeResult( "command-center charts desktop layout", commandCenterChartsPass(desktopCommandCenterChartsLayout), JSON.stringify(desktopCommandCenterChartsLayout), ); const chatComposerLayout = await evaluate(page, `(() => { const sandbox = document.createElement('section'); sandbox.setAttribute('data-smoke', 'chat-composer-fixture'); sandbox.style.position = 'fixed'; sandbox.style.left = '16px'; sandbox.style.bottom = '16px'; sandbox.style.width = '620px'; sandbox.style.maxWidth = 'calc(100vw - 32px)'; sandbox.style.zIndex = '5'; sandbox.style.background = 'var(--surface)'; sandbox.style.border = '1px solid var(--border)'; sandbox.innerHTML = [ '
', '
', ' ', '
', ' ', '
', ' ', '
', '
', '
', ' ', '
', ' ', '
', '
', ].join(''); document.body.appendChild(sandbox); const tallDraft = Array.from({ length: 18 }, (_, index) => 'line ' + (index + 1)).join('\\n'); for (const textarea of sandbox.querySelectorAll('.chat-input-textarea')) { textarea.value = tallDraft; textarea.style.height = '500px'; } const readLayout = (prefix) => { const textarea = sandbox.querySelector('[data-smoke="' + prefix + '-textarea"]'); const wrapper = textarea.parentElement; const textareaStyle = getComputedStyle(textarea); const wrapperStyle = getComputedStyle(wrapper); return { textareaHeight: Math.round(textarea.getBoundingClientRect().height), wrapperHeight: Math.round(wrapper.getBoundingClientRect().height), textareaClientHeight: textarea.clientHeight, wrapperClientHeight: wrapper.clientHeight, textareaStyleHeight: textarea.style.height, textareaFlexGrow: textareaStyle.flexGrow, textareaFlexShrink: textareaStyle.flexShrink, textareaFlexBasis: textareaStyle.flexBasis, wrapperDisplay: wrapperStyle.display, wrapperFlexDirection: wrapperStyle.flexDirection, }; }; const result = { direct: readLayout('chat-direct'), room: readLayout('chat-room'), }; sandbox.remove(); return result; })()`); assertSmokeResult( "chat composer autosize geometry", [chatComposerLayout.direct, chatComposerLayout.room].every((layout) => layout.textareaStyleHeight === "500px" && layout.textareaHeight >= 500 && layout.wrapperHeight >= 500 && layout.textareaClientHeight >= 498 && layout.wrapperClientHeight >= 498 && layout.textareaFlexGrow === "0" && layout.textareaFlexShrink === "0" && layout.textareaFlexBasis === "auto" && layout.wrapperDisplay === "flex" && layout.wrapperFlexDirection === "column" ), JSON.stringify(chatComposerLayout), ); } 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(); }); }); } const isMainModule = (() => { const entry = process.argv[1]; if (!entry) return false; return path.resolve(entry) === fileURLToPath(import.meta.url); })(); if (isMainModule) { main().catch((error) => { console.error(`[dashboard-browser-smoke] ${error.stack ?? error.message}`); process.exitCode = 1; }); }