#!/usr/bin/env node
/* global WebSocket, URL, fetch, console, setTimeout, clearTimeout */
import { Buffer } from "node:buffer";
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { superviseSpawn } from "@fusion/core";
import { readFile, readdir, rm, stat, mkdtemp, writeFile } 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";
const screenshotPath = process.env.FUSION_BROWSER_SMOKE_SCREENSHOT;
const agentHeartbeatMobileScreenshotPath = process.env.FUSION_AGENT_HEARTBEAT_MOBILE_SCREENSHOT;
const agentHeartbeatDesktopScreenshotPath = process.env.FUSION_AGENT_HEARTBEAT_DESKTOP_SCREENSHOT;
function log(message) {
console.log(`[dashboard-browser-smoke] ${message}`);
}
function fail(message) {
throw new Error(message);
}
/*
FNXC:GitHubImport 2026-07-23-13:05:
The mobile GitHub action-bar regression measures emitted CSS, so every smoke invocation must
rebuild the client before loading it. Reusing a stale dist/client artifact could make geometry
assertions pass against styles predating the one-row compact-layout requirement.
*/
async function loadDashboardCss() {
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}
`)
.join("");
/*
FNXC:QuickAddActionRow 2026-07-17-12:00:
FN-8299 protects the localized Quick Add Save label with a production-CSS browser fixture.
The Board column's 300px effective minimum content width is the supported boundary: below it,
a fixed-height single-line action cannot promise an unbreakable label without changing the UX.
Render every supported translation here so the smoke measures the widest emitted-font label
instead of assuming French is widest from character count.
FNXC:QuickAddActionRow 2026-07-18-11:22:
The fixture must include all five production icon controls before Save, including the session
advisor toggle. Omitting it understates the primary group's minimum width and could conceal a
300px overflow or wrap regression on either Board or List.
*/
const localizedSaveLabels = [
["en", "Save"],
["es", "Guardar"],
["fr", "Enregistrer"],
["ko", "저장"],
["zh-CN", "保存"],
["zh-TW", "儲存"],
];
const quickAddComposerFixtures = [
["board", "", "minimum", "300px", "disabled"],
["board", "", "wide", "600px", "disabled"],
["list", "quick-entry--single-line", "minimum", "300px", "enabled"],
["list", "quick-entry--single-line", "wide", "600px", "enabled"],
].flatMap(([surface, modifier, width, maxWidth, state]) => localizedSaveLabels.map(([locale, label]) => `
`)).join("");
/*
FNXC:TaskDetailModalResponsive 2026-07-19-12:00:
FN-8396 mirrors Task Detail's direct and wrapped SVG structures so Blink can
prove the scoped row rule normalizes ProviderIcon alongside the CSS-only
Oversight Eye/EyeOff contract at every responsive breakpoint.
*/
const taskDetailInlineRowFixtures = [
["full", true, true],
["without-github", false, true],
["without-oversight", true, false],
["without-optionals", false, false],
].map(([variant, includeGithub, includeOversight]) => `
`).join("");
/*
FNXC:MailboxMobile 2026-07-19-17:00:
FN-8407 requires a real-browser 320px regression surface because jsdom cannot
measure the shared ViewHeader flex geometry. Exercise the unread Inbox's tightest
badge + Compose + Mark all read + Refresh row and both Compose + Refresh-only states.
*/
/*
FNXC:GitHubImport 2026-07-23-12:18:
FN-8548 mirrors the selected GitHub issue's four-action footer with the emitted dashboard CSS.
Blink must measure the production responsive contract at each supported phone width because jsdom
cannot detect wrapping, flex-track shrinkage, overflow, or touch-target geometry.
*/
const githubImportMobileActionFixture = `
Close issue
Plan
Chat
Import as task
`;
const mailboxMobileHeaderFixtures = [
["unread-inbox", '9 Compose Mark all read '],
["read-inbox", 'Compose '],
["non-inbox", 'Compose '],
].map(([state, actions]) => `
`).join("");
return `
Fusion dashboard browser smoke
${quickAddComposerFixtures}
${taskDetailInlineRowFixtures}
${mailboxMobileHeaderFixtures}
${githubImportMobileActionFixture}
Remote branch is available
Ready to open a pull request.
Conflicts detected
Resolve the branch conflicts before continuing.
@reviewer-one
@reviewer-two
5e42c70Add PR sections to smoke fixture fusion
packages/dashboard/scripts/browser-layout-smoke.mjs +120 / −3 modified
Checks
3 passing, 1 warning, 0 failing
Checks details
lint / dashboard success
typecheck / dashboard failed
browser-smoke / dashboard pending
One required check is failing and must be addressed before merge.
●
ci / lint / dashboard / browser-smoke (ubuntu-latest)
Required
04:26
View details
●
ci / test / dashboard app quality matrix with long descriptive suffix
Required
09:18
View details
Enabled durable agent
Disable heartbeat
Disabled durable agent
Enable heartbeat
Task worker (excluded)
Charts
Triage · Todo · In progress
`;
}
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 collectAgentHeartbeatControlLayout = () => evaluate(page, `(() => {
const fixture = document.querySelector('[data-smoke="agent-heartbeat-controls"]');
const viewportWidth = window.innerWidth;
const controls = [...fixture.querySelectorAll('[data-smoke="agent-heartbeat-toggle"], [data-smoke="enable-all-heartbeats"], [data-smoke="disable-all-heartbeats"]')].map((control) => {
const rect = control.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width, height: rect.height };
});
const worker = fixture.querySelector('[data-smoke="ephemeral-agent-card"]');
return { viewportWidth, controls, workerToggleCount: worker.querySelectorAll('[data-smoke="agent-heartbeat-toggle"]').length, fixtureOverflow: fixture.scrollWidth - fixture.clientWidth, documentOverflow: document.documentElement.scrollWidth - viewportWidth };
})()`);
const mobileAgentHeartbeatLayout = await collectAgentHeartbeatControlLayout();
assertSmokeResult(
"agent heartbeat controls stay visible on mobile and omit ephemeral shells",
mobileAgentHeartbeatLayout.controls.length === 4 && mobileAgentHeartbeatLayout.controls.every((control) => control.width > 0 && control.height > 0 && control.left >= 0 && control.right <= mobileAgentHeartbeatLayout.viewportWidth + 1) && mobileAgentHeartbeatLayout.workerToggleCount === 0 && mobileAgentHeartbeatLayout.fixtureOverflow <= 1 && mobileAgentHeartbeatLayout.documentOverflow <= 1,
JSON.stringify(mobileAgentHeartbeatLayout),
);
if (agentHeartbeatMobileScreenshotPath) {
/* FNXC:AgentHeartbeatControls 2026-07-23-14:30: Optional proof captures document the durable-agent controls and omitted task-worker shell at the tested mobile viewport. */
await evaluate(page, "document.querySelector('[data-smoke=\"agent-heartbeat-controls\"]').scrollIntoView({ block: 'start' })");
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(agentHeartbeatMobileScreenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved agent heartbeat mobile screenshot to ${agentHeartbeatMobileScreenshotPath}`);
}
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),
);
const collectQuickAddSaveLayout = () => evaluate(page, `(() => {
const fixtures = [...document.querySelectorAll('[data-smoke^="quick-add-save-"][data-smoke*="-minimum-"], [data-smoke^="quick-add-save-"][data-smoke*="-wide-"]')];
return fixtures.map((fixture) => {
const save = fixture.querySelector('[data-smoke="quick-add-save-button"]');
const row = fixture.querySelector('[data-smoke="quick-add-save-row"]');
const composer = fixture.querySelector('[data-smoke$="-composer"]');
const rect = save.getBoundingClientRect();
return {
fixture: fixture.dataset.smoke,
locale: save.dataset.locale,
label: save.textContent.trim(),
saveWidth: rect.width,
saveOverflow: save.scrollWidth - save.clientWidth,
rowOverflow: row.scrollWidth - row.clientWidth,
composerOverflow: composer.scrollWidth - composer.clientWidth,
saveRight: rect.right,
composerRight: composer.getBoundingClientRect().right,
};
});
})()`);
const collectTaskDetailInlineIconSizes = () => evaluate(page, `(() => {
return [...document.querySelectorAll('section[data-smoke^="task-detail-inline-row-"]:not([data-smoke="task-detail-inline-row-fixtures"])')].map((fixture) => {
const row = fixture.querySelector('.detail-meta-inline-controls');
const icons = [...row.querySelectorAll('svg')].map((svg) => {
const style = getComputedStyle(svg);
return { width: style.width, height: style.height };
});
return {
fixture: fixture.dataset.smoke,
rowOverflow: row.scrollWidth - row.clientWidth,
icons,
};
});
})()`);
/*
FNXC:TaskDetailModalResponsive 2026-07-19-12:00:
Visible SVG dimensions are a browser-only invariant: every optional-control
variant must measure the compact token at mobile, tablet, and desktop, rather
than relying on CSS-source parsing or a tablet-only regression check.
*/
for (const [name, width, height, deviceScaleFactor, mobile] of [
["mobile", 390, 844, 2, true],
["tablet", 900, 900, 1, false],
["desktop", 1440, 900, 1, false],
]) {
await page.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor, mobile });
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const taskDetailIconSizes = await collectTaskDetailInlineIconSizes();
assertSmokeResult(
`Task Detail inline action icons are uniformly 14px at ${name}`,
taskDetailIconSizes.length === 4
&& taskDetailIconSizes.every((fixture) => fixture.rowOverflow <= 1
&& fixture.icons.length >= 3
&& fixture.icons.every((icon) => icon.width === "14px" && icon.height === "14px")
&& new Set(fixture.icons.map((icon) => `${icon.width}×${icon.height}`)).size === 1),
JSON.stringify(taskDetailIconSizes),
);
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 320,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const mailboxMobileHeaderLayout = await evaluate(page, `(() => {
return [...document.querySelectorAll('[data-smoke^="mailbox-mobile-header-"]:not([data-smoke="mailbox-mobile-header-fixtures"])')].map((fixture) => {
const header = fixture.querySelector('.view-header').getBoundingClientRect();
const title = fixture.querySelector('.view-header__title').getBoundingClientRect();
const titleLabel = fixture.querySelector('.view-header__title span').getBoundingClientRect();
const actions = fixture.querySelector('.view-header__actions').getBoundingClientRect();
return {
state: fixture.dataset.smoke,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
headerLeft: header.left,
headerRight: header.right,
headerOverflow: fixture.scrollWidth - fixture.clientWidth,
titleLeft: title.left,
titleTop: title.top,
titleBottom: title.bottom,
titleLabelWidth: titleLabel.width,
actionsLeft: actions.left,
actionsRight: actions.right,
actionsTop: actions.top,
actionsBottom: actions.bottom,
actionsHeight: actions.height,
};
});
})()`);
assertSmokeResult(
"Mailbox mobile headers keep title and actions inline at 320px",
mailboxMobileHeaderLayout.length === 3
&& mailboxMobileHeaderLayout.every((layout) => layout.documentOverflow <= 1
&& layout.headerOverflow <= 1
&& layout.actionsLeft > layout.titleLeft
&& layout.actionsRight <= layout.headerRight + 1
&& Math.abs(layout.titleTop - layout.actionsTop) <= layout.actionsHeight
&& layout.actionsTop < layout.titleBottom)
&& mailboxMobileHeaderLayout.find((layout) => layout.state === "mailbox-mobile-header-unread-inbox")?.titleLabelWidth > 0,
JSON.stringify(mailboxMobileHeaderLayout),
);
/*
FNXC:GitHubImport 2026-07-23-12:18:
Four GitHub issue actions must remain one contained, non-overlapping row at 320px, 390px, and
412px. This verifies the full labels and usable 44px minimum targets in a real browser instead
of relying on jsdom's zero-layout model or a CSS-source-only assertion.
*/
for (const width of [320, 390, 412]) {
await page.send("Emulation.setDeviceMetricsOverride", {
width,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const githubImportActionsLayout = await evaluate(page, `(() => {
const bar = document.querySelector('[data-smoke="github-import-mobile-actions"] .github-import-detail-actions');
const actionRow = bar.querySelector('.github-import-detail-action-row');
const barRect = bar.getBoundingClientRect();
const actionRowRect = actionRow.getBoundingClientRect();
const composer = bar.querySelector('.github-import-issue-comment-composer');
const composerRect = composer.getBoundingClientRect();
const buttons = [...actionRow.querySelectorAll('button')].map((button) => {
const rect = button.getBoundingClientRect();
const style = getComputedStyle(button);
return {
label: button.innerText.trim(),
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
overflowX: button.scrollWidth - button.clientWidth,
overflowY: button.scrollHeight - button.clientHeight,
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
textOverflow: style.textOverflow,
};
});
return {
composer: {
bottom: composerRect.bottom,
top: composerRect.top,
},
actionRow: {
left: actionRowRect.left,
right: actionRowRect.right,
top: actionRowRect.top,
bottom: actionRowRect.bottom,
overflowX: actionRow.scrollWidth - actionRow.clientWidth,
overflowY: actionRow.scrollHeight - actionRow.clientHeight,
},
bar: {
left: barRect.left,
right: barRect.right,
top: barRect.top,
bottom: barRect.bottom,
overflowX: bar.scrollWidth - bar.clientWidth,
overflowY: bar.scrollHeight - bar.clientHeight,
},
buttons,
};
})()`);
const expectedLabels = ["Close issue", "Plan", "Chat", "Import as task"];
assertSmokeResult(
`GitHub issue actions remain a visible one-row touch-safe layout at ${width}px`,
githubImportActionsLayout.buttons.length === expectedLabels.length
&& githubImportActionsLayout.buttons.every((button, index, buttons) =>
button.label === expectedLabels[index]
&& button.top === buttons[0].top
&& button.bottom === buttons[0].bottom
&& button.left >= githubImportActionsLayout.actionRow.left - 1
&& button.right <= githubImportActionsLayout.actionRow.right + 1
&& button.width >= 44
&& button.height >= 44
&& button.overflowX <= 1
&& button.overflowY <= 1
&& button.display !== "none"
&& button.visibility !== "hidden"
&& button.opacity !== "0"
&& button.textOverflow !== "ellipsis")
&& githubImportActionsLayout.buttons.every((button, index, buttons) =>
index === 0 || buttons[index - 1].right <= button.left + 1)
&& githubImportActionsLayout.actionRow.overflowX <= 1
&& githubImportActionsLayout.actionRow.overflowY <= 1
&& githubImportActionsLayout.composer.bottom <= githubImportActionsLayout.actionRow.top + 1
&& githubImportActionsLayout.bar.overflowX <= 1
&& githubImportActionsLayout.bar.overflowY <= 1,
JSON.stringify(githubImportActionsLayout),
);
if (screenshotPath && width === 390) {
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved GitHub import mobile screenshot to ${screenshotPath}`);
}
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 412,
height: 915,
deviceScaleFactor: 2,
mobile: true,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const mobileQuickAddSaveLayout = await collectQuickAddSaveLayout();
const frenchMobileWidth = mobileQuickAddSaveLayout.find((layout) => layout.fixture === "quick-add-save-board-minimum-fr")?.saveWidth;
const widestMobileWidth = Math.max(...mobileQuickAddSaveLayout
.filter((layout) => layout.fixture.includes("-minimum-"))
.map((layout) => layout.saveWidth));
assertSmokeResult(
"Quick Add localized Save labels fit at the 300px supported minimum on mobile",
frenchMobileWidth === widestMobileWidth
&& mobileQuickAddSaveLayout.length === 24
&& mobileQuickAddSaveLayout.every((layout) => layout.saveOverflow <= 1
&& layout.rowOverflow <= 1
&& layout.composerOverflow <= 1
&& layout.saveRight <= layout.composerRight + 1),
JSON.stringify(mobileQuickAddSaveLayout),
);
await page.send("Emulation.setDeviceMetricsOverride", {
width: 1400,
height: 900,
deviceScaleFactor: 1,
mobile: false,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const desktopAgentHeartbeatLayout = await collectAgentHeartbeatControlLayout();
assertSmokeResult(
"agent heartbeat controls stay visible on desktop and omit ephemeral shells",
desktopAgentHeartbeatLayout.controls.length === 4 && desktopAgentHeartbeatLayout.controls.every((control) => control.width > 0 && control.height > 0 && control.left >= 0 && control.right <= desktopAgentHeartbeatLayout.viewportWidth + 1) && desktopAgentHeartbeatLayout.workerToggleCount === 0 && desktopAgentHeartbeatLayout.fixtureOverflow <= 1 && desktopAgentHeartbeatLayout.documentOverflow <= 1,
JSON.stringify(desktopAgentHeartbeatLayout),
);
if (agentHeartbeatDesktopScreenshotPath) {
await evaluate(page, "document.querySelector('[data-smoke=\"agent-heartbeat-controls\"]').scrollIntoView({ block: 'start' })");
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(agentHeartbeatDesktopScreenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved agent heartbeat desktop screenshot to ${agentHeartbeatDesktopScreenshotPath}`);
}
const desktopQuickAddSaveLayout = await collectQuickAddSaveLayout();
const frenchDesktopWidth = desktopQuickAddSaveLayout.find((layout) => layout.fixture === "quick-add-save-board-minimum-fr")?.saveWidth;
const widestDesktopWidth = Math.max(...desktopQuickAddSaveLayout
.filter((layout) => layout.fixture.includes("-minimum-"))
.map((layout) => layout.saveWidth));
assertSmokeResult(
"Quick Add localized Save labels fit at the 300px supported minimum on desktop",
frenchDesktopWidth === widestDesktopWidth
&& desktopQuickAddSaveLayout.length === 24
&& desktopQuickAddSaveLayout.every((layout) => layout.saveOverflow <= 1
&& layout.rowOverflow <= 1
&& layout.composerOverflow <= 1
&& layout.saveRight <= layout.composerRight + 1),
JSON.stringify(desktopQuickAddSaveLayout),
);
log(`Quick Add Save intrinsic widths at the 300px minimum: mobile French=${frenchMobileWidth}px, desktop French=${frenchDesktopWidth}px.`);
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;
});
}