Files
fusion/packages/dashboard/scripts/browser-layout-smoke.mjs
gsxdsm e5357a4afd feat(FN-5359): add push-to-origin button and hook to merge advance notice
Adds a push-to-origin workflow to the merge notice system, introducing a new `useMergeAdvanceNotice` hook, a `merge-advance-push-origin` route handler, and corresponding UI affordance in the `MergeAdvanceNotice` banner component. The engine gains TOCTOU and refusal audit assertions, and coverage exp

Fusion-Task-Id: FN-5359

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5359
2026-05-23 06:06:51 -07:00

1005 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, 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(/<link\b[^>]*\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}.`));
});
});
}
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]) => `
<section class="column" data-column="${column}">
<header class="column-header">
<span class="column-dot dot-${column}"></span>
<h2>${label} with long status heading copy</h2>
<span class="column-count">${count}</span>
</header>
<p class="column-desc">Layout smoke data for ${label}</p>
<div class="column-body">
<article class="card" data-column="${column}">
<div class="card-header">
<span class="card-id">FN-${column.length}01</span>
<h3 class="card-title">Responsive task card with a deliberately long title that should wrap cleanly</h3>
</div>
<div class="card-meta">
<span class="card-status-badge card-status-badge--${column}">${label}</span>
</div>
</article>
</div>
</section>
`)
.join("");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Fusion dashboard browser smoke</title>
<link rel="stylesheet" href="/app.css" />
</head>
<body data-theme="dark">
<div id="root">
<div class="header-wrapper">
<header class="header" data-smoke="header">
<div class="header-left">
<svg class="header-logo" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle></svg>
<div class="header-node-selector header-node-selector--mobile">
<div class="node-status-indicator node-status-indicator--local">
<span class="node-status-indicator__dot node-status-indicator__dot--online"></span>
<span class="node-status-indicator__name">Local project with very long name</span>
</div>
</div>
</div>
<div class="header-actions">
<div class="view-toggle" role="group" aria-label="Task view">
<button class="view-toggle-btn active" data-smoke="show-board" type="button" aria-label="Board view">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="4" width="6" height="6"></rect><rect x="14" y="4" width="6" height="6"></rect><rect x="4" y="14" width="6" height="6"></rect><rect x="14" y="14" width="6" height="6"></rect></svg>
</button>
<button class="view-toggle-btn" data-smoke="show-list" type="button" aria-label="List view">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg>
</button>
</div>
<button class="btn-icon mobile-search-trigger" type="button" aria-label="Search">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m16 16 4 4"></path></svg>
</button>
<button class="btn-icon" data-smoke="open-modal" type="button" aria-label="Settings">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M12 2v4M12 18v4M2 12h4M18 12h4"></path></svg>
</button>
<button class="btn-icon" data-smoke="show-pr-create" type="button" aria-label="Show PR create modal fixture">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v18M3 12h18"></path></svg>
</button>
<button class="btn-icon" data-smoke="show-pr-panel" type="button" aria-label="Show PR panel fixture">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"></path></svg>
</button>
<button class="btn-icon" data-smoke="show-pr-checks" type="button" aria-label="Show PR checks fixture">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="2"></circle><circle cx="12" cy="12" r="2"></circle><circle cx="18" cy="12" r="2"></circle></svg>
</button>
</div>
</header>
</div>
<main class="project-content project-content--with-footer project-content--with-mobile-nav">
<section class="board" data-smoke="board">${columnMarkup}</section>
<section class="list-view" data-smoke="list" hidden>
<div class="list-create-area">
<div class="quick-entry-box quick-entry-box--collapsed" data-testid="quick-entry-box">
<div class="quick-entry-main-row">
<textarea class="quick-entry-input" data-smoke="quick-entry-input" placeholder="Add a task"></textarea>
<button class="quick-entry-toggle btn btn-icon" type="button" aria-label="Quick entry options">+</button>
</div>
</div>
</div>
<div class="list-table-container">
<table class="list-table">
<thead><tr><th class="list-header-cell">Task</th><th class="list-header-cell">Status</th></tr></thead>
<tbody><tr class="list-row"><td class="list-cell list-cell-title">FN-101 Smoke task</td><td class="list-cell">Todo</td></tr></tbody>
</table>
<div class="list-cards">
<article class="card list-card"><h3 class="card-title">FN-101 Smoke task</h3></article>
</div>
</div>
</section>
</main>
<footer class="executor-status-bar">
<div class="executor-status-bar__segment">
<span class="executor-status-bar__indicator executor-status-bar__indicator--running"></span>
<span class="executor-status-bar__count">1</span>
<span class="executor-status-bar__label">running</span>
</div>
<div class="executor-status-bar__divider"></div>
<div class="executor-status-bar__segment executor-status-bar__segment--project-directory">
<button class="executor-status-bar__folder-toggle" type="button">Project</button>
<span class="executor-status-bar__project-path">/very/long/path/to/fusion/dashboard/project</span>
</div>
</footer>
<nav class="mobile-nav-bar mobile-nav-bar--with-footer" role="tablist" aria-label="Primary navigation">
<button class="mobile-nav-tab mobile-nav-tab--active" type="button"><span class="mobile-nav-tab-label">Tasks</span></button>
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Agents</span></button>
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Missions</span></button>
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Chat</span></button>
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">Mailbox</span></button>
<button class="mobile-nav-tab" type="button"><span class="mobile-nav-tab-label">More</span></button>
</nav>
<div class="modal-overlay" data-smoke="modal-overlay" role="dialog" aria-modal="true">
<div class="modal modal-md" data-smoke="modal">
<header class="modal-header">
<h3>Smoke Modal</h3>
<button class="modal-close" data-smoke="close-modal" type="button" aria-label="Close">&times;</button>
</header>
<div class="modal-body">
<label class="form-group">
<span>Modal input</span>
<input class="input" type="text" value="browser layout smoke" />
</label>
</div>
<footer class="modal-actions">
<button class="btn btn-secondary" type="button">Cancel</button>
<button class="btn btn-primary" type="button">Save</button>
</footer>
</div>
</div>
<section data-smoke="pr-create-modal" hidden>
<div class="modal-overlay open" role="dialog" aria-modal="true">
<div class="modal modal-lg">
<header class="modal-header">
<h2>Create Pull Request</h2>
<button class="modal-close" type="button" aria-label="Close">&times;</button>
</header>
<div class="pr-create-modal">
<section class="pr-create-modal__section">
<div class="pr-create-modal__preflight">
<div class="pr-create-modal__preflight-row is-ok">
<span class="status-dot status-dot--online" aria-hidden="true"></span>
<div>
<p class="pr-create-modal__preflight-label">Remote branch is available</p>
<p class="pr-create-modal__preflight-message">Ready to open a pull request.</p>
</div>
</div>
<div class="pr-create-modal__preflight-row is-failed">
<span class="status-dot status-dot--error" aria-hidden="true"></span>
<div>
<p class="pr-create-modal__preflight-label">Conflicts detected</p>
<p class="pr-create-modal__preflight-message">Resolve the branch conflicts before continuing.</p>
</div>
</div>
</div>
</section>
<section class="pr-create-modal__section">
<div class="pr-create-modal__title-row">
<label class="pr-create-modal__label">Title</label>
</div>
<input class="input" value="feat: add browser smoke fixtures for PR layout surfaces" />
</section>
<section class="pr-create-modal__section">
<div class="pr-create-modal__title-row">
<label class="pr-create-modal__label">Body</label>
</div>
<textarea class="input pr-create-modal__body-input" rows="6" placeholder="## Summary&#10;- Adds fixture coverage for PR create modal&#10;- Exercises PR panel checks and review rows&#10;- Verifies checks-list wrapping for long names"></textarea>
</section>
<section class="pr-create-modal__section">
<div class="pr-create-modal__chips">
<span class="pr-create-modal__chip">@alex</span>
<span class="pr-create-modal__chip">@sam<button type="button" class="btn btn-icon pr-create-modal__chip-remove" aria-label="Remove @sam">&times;</button></span>
</div>
</section>
<section class="pr-create-modal__section pr-create-modal__grid-two">
<div>
<label class="pr-create-modal__label">Base branch</label>
<select class="select">
<option>main</option>
<option>release/next</option>
</select>
</div>
<label class="checkbox-label pr-create-modal__draft">
<input type="checkbox" />
Create as draft
</label>
</section>
<section class="pr-create-modal__section">
<div class="pr-create-modal__option-list">
<button class="btn btn-sm pr-create-modal__option-item" type="button">@reviewer-one</button>
<button class="btn btn-sm pr-create-modal__option-item" type="button">@reviewer-two</button>
</div>
</section>
<section class="pr-create-modal__section">
<div class="pr-create-modal__preview">
<div class="pr-create-modal__commit-row"><code>5e42c70</code><span>Add PR sections to smoke fixture</span><span>fusion</span></div>
<div class="pr-create-modal__file-row"><span>packages/dashboard/scripts/browser-layout-smoke.mjs</span><span>+120 / 3</span><span class="card-status-badge card-status-badge--todo">modified</span></div>
</div>
</section>
</div>
<footer class="modal-actions">
<button class="btn btn-secondary" type="button">Cancel</button>
<button class="btn btn-primary" type="button">Create PR</button>
</footer>
</div>
</div>
</section>
<section data-smoke="pr-panel" hidden>
<div class="pr-panel-section">
<div class="pr-panel-row-label">Checks</div>
<div class="pr-panel-checks-rollup pr-panel-tone-success">3 passing, 1 warning, 0 failing</div>
<details class="pr-panel-checks-details" open>
<summary>Checks details</summary>
<div class="pr-panel-check-list">
<div class="pr-panel-check-item"><span class="pr-panel-check-dot"></span><span>lint / dashboard</span><span class="pr-panel-check-chip pr-panel-check-chip--success">success</span></div>
<div class="pr-panel-check-item"><span class="pr-panel-check-dot"></span><span>typecheck / dashboard</span><span class="pr-panel-check-chip pr-panel-check-chip--error">failed</span></div>
<div class="pr-panel-check-item"><span class="pr-panel-check-dot"></span><span>browser-smoke / dashboard</span><span class="pr-panel-check-chip pr-panel-check-chip--warning">pending</span></div>
</div>
</details>
</div>
<div class="pr-panel-review-thread">
<div class="pr-panel-review-thread-header">
<strong>@reviewer</strong>
<span class="pr-panel-review-badge pr-panel-review-badge--error">CHANGES_REQUESTED</span>
</div>
<a class="pr-panel-review-item" href="#">Please update the smoke fixture to include PR checks and review threads for mobile overflow coverage.<span class="pr-panel-comment-time">Last: just now</span></a>
<a class="pr-panel-review-item" href="#">Long review comment to ensure wrapping behavior stays contained on mobile widths and does not produce horizontal overflow in this smoke fixture section.<span class="pr-panel-comment-time">Last: 2m ago</span></a>
</div>
</section>
<section data-smoke="pr-checks" hidden>
<section class="pr-checks" aria-live="polite">
<div class="pr-checks__header">
<div class="pr-checks__summary">7 passing, 1 failing, 1 pending</div>
<div class="pr-checks__header-actions">
<button class="btn btn-icon" type="button" aria-label="Refresh checks">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg>
</button>
<span class="pr-checks__updated">updated 12s ago</span>
</div>
</div>
<div class="pr-checks__error">One required check is failing and must be addressed before merge.</div>
<div class="pr-checks__list" role="list">
<div class="pr-checks__item" role="listitem">
<span class="pr-checks__icon">●</span>
<div class="pr-checks__name-wrap">
<span class="pr-checks__name">ci / lint / dashboard / browser-smoke (ubuntu-latest)</span>
<span class="pr-checks__required">Required</span>
<span class="pr-checks__duration">04:26</span>
</div>
<a class="pr-checks__details-link" href="#">View details</a>
</div>
<div class="pr-checks__item" role="listitem">
<span class="pr-checks__icon">●</span>
<div class="pr-checks__name-wrap">
<span class="pr-checks__name">ci / test / dashboard app quality matrix with long descriptive suffix</span>
<span class="pr-checks__required">Required</span>
<span class="pr-checks__duration">09:18</span>
</div>
<a class="pr-checks__details-link" href="#">View details</a>
</div>
<div class="pr-checks__item" role="listitem">
<span class="pr-checks__icon">●</span>
<div class="pr-checks__name-wrap">
<span class="pr-checks__name">ci / typecheck / workspace / dashboard</span>
<span class="pr-checks__duration">01:54</span>
</div>
<a class="pr-checks__details-link" href="#">View details</a>
</div>
</div>
</section>
</section>
</div>
<script>
const board = document.querySelector('[data-smoke="board"]');
const list = document.querySelector('[data-smoke="list"]');
const boardButton = document.querySelector('[data-smoke="show-board"]');
const listButton = document.querySelector('[data-smoke="show-list"]');
const modalOverlay = document.querySelector('[data-smoke="modal-overlay"]');
const nav = document.querySelector('.mobile-nav-bar');
const prCreate = document.querySelector('[data-smoke="pr-create-modal"]');
const prPanel = document.querySelector('[data-smoke="pr-panel"]');
const prChecks = document.querySelector('[data-smoke="pr-checks"]');
function setView(view) {
const isList = view === 'list';
board.hidden = isList;
list.hidden = !isList;
boardButton.classList.toggle('active', !isList);
listButton.classList.toggle('active', isList);
}
function showSmokeSection(name) {
prCreate.hidden = name !== 'pr-create-modal';
prPanel.hidden = name !== 'pr-panel';
prChecks.hidden = name !== 'pr-checks';
}
boardButton.addEventListener('click', () => setView('board'));
listButton.addEventListener('click', () => setView('list'));
document.querySelector('[data-smoke="show-pr-create"]').addEventListener('click', () => showSmokeSection('pr-create-modal'));
document.querySelector('[data-smoke="show-pr-panel"]').addEventListener('click', () => showSmokeSection('pr-panel'));
document.querySelector('[data-smoke="show-pr-checks"]').addEventListener('click', () => showSmokeSection('pr-checks'));
document.querySelector('[data-smoke="open-modal"]').addEventListener('click', () => {
modalOverlay.classList.add('open');
nav.hidden = true;
});
document.querySelector('[data-smoke="close-modal"]').addEventListener('click', () => {
modalOverlay.classList.remove('open');
nav.hidden = false;
});
</script>
</body>
</html>`;
}
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 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 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 = [
'<div class="chat-input-area">',
' <div class="chat-input-row" data-smoke="chat-direct-composer">',
' <button type="button" class="btn-icon chat-attach-btn" aria-label="Attach files">+</button>',
' <div class="chat-input-wrapper">',
' <textarea class="chat-input-textarea" data-smoke="chat-direct-textarea" rows="1"></textarea>',
' </div>',
' <button type="button" class="chat-input-send" aria-label="Send">→</button>',
' </div>',
' <div class="chat-input-row" data-smoke="chat-room-composer">',
' <div class="chat-input-wrapper">',
' <textarea class="chat-input-textarea" data-smoke="chat-room-textarea" rows="1"></textarea>',
' </div>',
' <button type="button" class="chat-input-send" aria-label="Send">→</button>',
' </div>',
'</div>',
].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;
});
}