#!/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, SUPPORTED_LOCALES } from "@fusion/core";
import { readFile, readdir, rm, stat, mkdtemp, writeFile } from "node:fs/promises";
import { existsSync, readFileSync } 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 i18nLocalesRoot = path.resolve(dashboardRoot, "..", "i18n", "locales");
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;
const gitManagerBeforeMobileScreenshotPath = process.env.FUSION_GIT_MANAGER_BEFORE_MOBILE_SCREENSHOT;
const gitManagerAfterMobileScreenshotPath = process.env.FUSION_GIT_MANAGER_AFTER_MOBILE_SCREENSHOT;
const gitHubImportBeforeMobileScreenshotPath = process.env.FUSION_GITHUB_IMPORT_BEFORE_MOBILE_SCREENSHOT;
const gitHubImportAfterMobileScreenshotPath = process.env.FUSION_GITHUB_IMPORT_AFTER_MOBILE_SCREENSHOT;
const gitHubImportAfterShortScreenshotPath = process.env.FUSION_GITHUB_IMPORT_AFTER_SHORT_SCREENSHOT;
const resolvedGithubDesktopScreenshotPath = process.env.FUSION_RESOLVED_GITHUB_DESKTOP_SCREENSHOT;
const resolvedGithubMobileScreenshotPath = process.env.FUSION_RESOLVED_GITHUB_MOBILE_SCREENSHOT;
const planApprovalDesktopScreenshotPath = process.env.FUSION_PLAN_APPROVAL_DESKTOP_SCREENSHOT;
const planApprovalMobileScreenshotPath = process.env.FUSION_PLAN_APPROVAL_MOBILE_SCREENSHOT;
const smokeTheme = process.env.FUSION_BROWSER_SMOKE_THEME === "light" ? "light" : "dark";
function log(message) {
console.log(`[dashboard-browser-smoke] ${message}`);
}
function fail(message) {
throw new Error(message);
}
function escapeHtml(value) {
return value.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function validateQuickAddSaveLabels(labels) {
for (const [locale, label] of labels) {
if (typeof locale !== "string" || locale.length === 0 || typeof label !== "string" || label.trim().length === 0) {
fail(`Quick Add Save fixture requires a non-empty tasks.save translation for locale ${String(locale)}.`);
}
}
return labels;
}
function loadShippedQuickAddSaveLabels() {
return validateQuickAddSaveLabels(SUPPORTED_LOCALES
.map((locale) => {
const catalog = JSON.parse(readFileSync(path.join(i18nLocalesRoot, locale, "app.json"), "utf8"));
return [locale, catalog?.tasks?.save];
})
// Preserve the emitted fixture section's existing deterministic order while deriving its members.
.sort(([left], [right]) => left.localeCompare(right)));
}
const QUICK_ADD_COMPOSER_VARIANTS = [
["board", "", "minimum", "300px", "disabled"],
["board", "", "wide", "600px", "disabled"],
["list", "quick-entry--single-line", "minimum", "300px", "enabled"],
["list", "quick-entry--single-line", "wide", "600px", "enabled"],
];
const shippedQuickAddSaveLabels = loadShippedQuickAddSaveLabels();
export const QUICK_ADD_SAVE_FIXTURE_COUNT = QUICK_ADD_COMPOSER_VARIANTS.length * shippedQuickAddSaveLabels.length;
export function buildQuickAddSaveFixtures(labels = shippedQuickAddSaveLabels) {
return QUICK_ADD_COMPOSER_VARIANTS.flatMap(([surface, modifier, width, maxWidth, state]) => validateQuickAddSaveLabels(labels).map(([locale, label]) => `
`)).join("");
}
/*
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(options = {}) {
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("");
const agentsOverviewCards = Array.from({ length: 13 }, (_, index) => `
Waiting for workflow output and heartbeat activity.
`).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.
FNXC:QuickAddActionRow 2026-08-07-23:56:
pt-BR joined the supported translations (the scaffold was previously empty and now carries
machine-drafted translations) — add it here so the smoke keeps measuring the widest
emitted-font label across every shipped locale.
FNXC:QuickAddActionRow 2026-08-10-19:11:
FN-8952 replaces the stale 24-fixture expectation desynchronized by pt-BR with fixtures and
counts derived from SUPPORTED_LOCALES plus each shipped tasks.save catalog value. Catalog text
is HTML-escaped before interpolation because QuickEntryBox renders a React text child: arbitrary
metacharacters must remain literal measured glyphs, never become fixture markup.
*/
const quickAddComposerFixtures = buildQuickAddSaveFixtures(options.quickAddSaveLabels);
/*
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.
*/
/*
FNXC:CommandCenterGithub 2026-08-03-04:08:
FN-8750 needs a real-browser, production-CSS fixture because jsdom cannot measure fixed-table tracks,
long-word wrapping, or page overflow. The fixture mirrors URL/no-URL, exact/approximate, and title-fallback rows
so the desktop and mobile proof captures show the same resilient resolved-issue contract operators use.
*/
const resolvedGithubTableFixture = `
Resolved issues
Issue Resolving task Resolved at
acme/a-deliberately-long-repository-reference#123
Resolve a deliberately long imported GitHub issue title without forcing the Command Center table beyond its responsive container FN-100
6/10/2026, 12:34 PM
(unknown)
FN-101
6/09/2026, 8:00 AM approx
`;
/*
FNXC:PlanReviewReplan 2026-08-04-06:35 FN-8768:
Mirror both exhausted-review operator surfaces so Blink can prove the card badge and detail banner
remain visible and contained at mobile and desktop widths, not merely that fixture HTML loaded.
*/
const planApprovalFixture = `
Plan Review needs approval
Plan Review needs approval
Automatic revisions reached their limit. Review the latest plan, then approve it or send it back for replanning.
`;
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("");
/*
FNXC:GitManagerMobileSpacing 2026-08-01-19:10:
FN-8702 measures the emitted standalone FloatingWindow chain and the embedded container separately.
The phone-only gutter reset must align every standalone shell edge below 768px without turning the
embedded pane or the 768px-and-up movable window into a viewport sheet.
*/
const gitManagerFixtures = `
Loading repository
Fixture error state stays contained
${"Long populated Git Manager row ".repeat(30)}
Loading repository
Embedded fixture error state stays contained
${"Embedded populated row ".repeat(30)}
`;
/*
FNXC:GitHubImport 2026-08-02-02:45:
FN-8722 mirrors the standalone FloatingWindow chain rather than a generic overlay so Chromium
measures the inherited resize-handle gutter on the real sheet host. The fixture includes the
header/close control, controls, populated list, pagination, and footer plus embedded and detail
controls; all states must remain horizontally contained without changing their own geometry.
*/
const gitHubImportFixtures = `
GitHub
Issues Pull requests
#8722 A deliberately long populated GitHub issue title that must stay inside the import sheet Already imported issue
`;
return `
Fusion dashboard browser smoke
${gitManagerFixtures}
${gitHubImportFixtures}
${quickAddComposerFixtures}
${taskDetailInlineRowFixtures}
${mailboxMobileHeaderFixtures}
${githubImportMobileActionFixture}
${resolvedGithubTableFixture}
${planApprovalFixture}
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}/`,
};
}
/*
FNXC:DashboardBrowserSmoke 2026-08-04-12:24:
Prepare emitted client CSS and bind the fixture server before starting Chrome's supervised 60-second lifetime. A cold client build can take several minutes on supported development hosts; that build time must not consume the browser's geometry-check budget or kill Chrome before the first named assertion.
*/
export async function prepareBrowserSmoke(executable, {
startFixture = startFixtureServer,
launch = launchBrowser,
closeFixture = (fixture) => closeServer(fixture.server),
} = {}) {
const fixture = await startFixture();
try {
const launched = await launch(executable);
return { fixture, launched };
} catch (error) {
try {
await closeFixture(fixture);
} catch (cleanupError) {
console.warn(
"[dashboard-browser-smoke] fixture cleanup after browser launch failure also failed:",
cleanupError,
);
}
throw error;
}
}
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;
}
/*
FNXC:CommandCenterGithub 2026-08-03-04:32:
FN-8750 proof captures must preserve the production fixture layout so overflow checks at later viewports
measure the table rather than a temporary overlay. Render a disposable clone in an isolated host,
which keeps unrelated dashboard UI out of the artifact without mutating the measured fixture.
*/
async function captureFixtureScreenshot(page, selector, outputPath) {
await evaluate(page, `(async () => {
const fixture = document.querySelector(${JSON.stringify(selector)});
const host = document.createElement("div");
host.dataset.smokeCaptureHost = "true";
host.style.cssText = "position: fixed; inset: 0; z-index: 9999; display: flex; overflow: auto; background: var(--bg);";
const clone = fixture.cloneNode(true);
host.append(clone);
document.body.append(host);
await new Promise(requestAnimationFrame);
})()`);
try {
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(outputPath, Buffer.from(screenshot.data, "base64"));
} finally {
await evaluate(page, "document.querySelector('[data-smoke-capture-host]').remove()");
}
}
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 collectResolvedGithubTableLayout = () => evaluate(page, `(() => {
const fixture = document.querySelector('[data-smoke="github-resolved-table"]');
const table = fixture.querySelector('.cc-github-resolved-table');
const title = fixture.querySelector('.cc-github-resolved-task-title');
const link = fixture.querySelector('.cc-github-resolved-issue-link');
const fixtureRect = fixture.getBoundingClientRect();
const titleRect = title.getBoundingClientRect();
const linkStyle = getComputedStyle(link);
return {
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
fixtureOverflow: fixture.scrollWidth - fixture.clientWidth,
tableOverflow: table.scrollWidth - table.clientWidth,
titleHeight: titleRect.height,
titleWidth: titleRect.width,
fixtureWidth: fixtureRect.width,
linkColor: linkStyle.color,
linkTextDecoration: linkStyle.textDecorationLine,
};
})()`);
const collectPlanApprovalLayout = () => evaluate(page, `(() => {
const fixture = document.querySelector('[data-smoke="plan-review-replan-cap-approval"]');
const card = fixture.querySelector('.card');
const badge = fixture.querySelector('.awaiting-approval--plan-review-replan-cap');
const banner = fixture.querySelector('.detail-plan-approval-banner--replan-cap');
const rect = (node) => {
const box = node.getBoundingClientRect();
return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width, height: box.height };
};
return {
viewportWidth: window.innerWidth,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
fixtureOverflow: fixture.scrollWidth - fixture.clientWidth,
fixture: rect(fixture),
card: rect(card),
badge: rect(badge),
banner: rect(banner),
badgeReason: badge.getAttribute('data-awaiting-approval-reason'),
bannerReason: banner.getAttribute('data-awaiting-approval-reason'),
};
})()`);
const planApprovalLayoutPasses = (layout) => layout.documentOverflow <= 1
&& layout.fixtureOverflow <= 1
&& layout.fixture.width > 0
&& layout.card.height > 0
&& layout.banner.height > 0
&& layout.badge.width > 0
&& layout.card.left >= layout.fixture.left - 1
&& layout.card.right <= layout.fixture.right + 1
&& layout.banner.left >= layout.fixture.left - 1
&& layout.banner.right <= layout.fixture.right + 1
&& layout.badge.left >= layout.card.left - 1
&& layout.badge.right <= layout.card.right + 1
&& layout.banner.top >= layout.card.bottom - 1
&& layout.badgeReason === "plan-review-replan-cap"
&& layout.bannerReason === "plan-review-replan-cap";
const collectAgentsOverviewScrollLayout = () => evaluate(page, `(() => {
document.querySelector('[data-smoke="show-agents-overview-scroll"]').click();
const section = document.querySelector('[data-smoke="agents-overview-scroll"]');
const fixture = section.querySelector('.agents-view');
const owner = section.querySelector('[data-smoke="agents-overview-scroll-owner"]');
const lastCard = section.querySelector('[data-smoke="agents-overview-last-card"]');
const sibling = fixture.querySelector('.agents-view-content');
const empty = section.querySelector('[data-smoke="agents-overview-scroll-empty"]');
const emptyOwner = section.querySelector('[data-smoke="agents-overview-empty-scroll-owner"]');
owner.scrollTop = owner.scrollHeight - owner.clientHeight;
const ownerRect = owner.getBoundingClientRect();
const lastRect = lastCard.getBoundingClientRect();
const toggleRect = empty.querySelector('.agents-overview-bar__toggle').getBoundingClientRect();
const emptyRect = empty.getBoundingClientRect();
return {
overflowY: getComputedStyle(owner).overflowY,
overflow: owner.scrollHeight - owner.clientHeight,
lastCard: { top: lastRect.top, bottom: lastRect.bottom },
owner: { top: ownerRect.top, bottom: ownerRect.bottom },
siblingHeight: sibling.clientHeight,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
emptyOverflow: emptyOwner.scrollHeight - emptyOwner.clientHeight,
emptyToggleVisible: toggleRect.top >= emptyRect.top - 1 && toggleRect.bottom <= emptyRect.bottom + 1,
};
})()`);
for (const { width, height, deviceScaleFactor, mobile } of [
{ width: 390, height: 844, deviceScaleFactor: 2, mobile: true },
{ width: 1280, height: 700, deviceScaleFactor: 1, mobile: false },
]) {
await page.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor, mobile });
const agentsOverviewLayout = await collectAgentsOverviewScrollLayout();
const viewport = `${width}×${height}`;
assertSmokeResult(
`Agents Overview scroll owner reaches every active card at ${viewport}`,
(agentsOverviewLayout.overflowY === "auto" || agentsOverviewLayout.overflowY === "scroll")
&& agentsOverviewLayout.overflow > 0
&& agentsOverviewLayout.lastCard.bottom <= agentsOverviewLayout.owner.bottom + 1
&& agentsOverviewLayout.lastCard.top >= agentsOverviewLayout.owner.top - 1
&& agentsOverviewLayout.siblingHeight > 0
&& agentsOverviewLayout.documentOverflow <= 1,
JSON.stringify(agentsOverviewLayout),
);
assertSmokeResult(
`Agents Overview empty scroll owner stays unclipped at ${viewport}`,
agentsOverviewLayout.emptyOverflow <= 1 && agentsOverviewLayout.emptyToggleVisible,
JSON.stringify(agentsOverviewLayout),
);
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
const mobileResolvedGithubTableLayout = await collectResolvedGithubTableLayout();
assertSmokeResult(
"resolved GitHub table wraps long content without mobile page overflow",
mobileResolvedGithubTableLayout.documentOverflow <= 1
&& mobileResolvedGithubTableLayout.fixtureOverflow <= 1
&& mobileResolvedGithubTableLayout.tableOverflow <= 1
&& mobileResolvedGithubTableLayout.titleHeight > 0
&& mobileResolvedGithubTableLayout.titleWidth <= mobileResolvedGithubTableLayout.fixtureWidth
&& mobileResolvedGithubTableLayout.linkColor !== ""
&& mobileResolvedGithubTableLayout.linkTextDecoration.includes("underline"),
JSON.stringify(mobileResolvedGithubTableLayout),
);
if (resolvedGithubMobileScreenshotPath) {
await captureFixtureScreenshot(page, '[data-smoke="github-resolved-table"]', resolvedGithubMobileScreenshotPath);
log(`saved resolved GitHub mobile screenshot to ${resolvedGithubMobileScreenshotPath}`);
}
if (planApprovalMobileScreenshotPath) {
await captureFixtureScreenshot(page, '[data-smoke="plan-review-replan-cap-approval"]', planApprovalMobileScreenshotPath);
log(`saved Plan Review approval mobile screenshot to ${planApprovalMobileScreenshotPath}`);
}
const mobilePlanApprovalLayout = await collectPlanApprovalLayout();
assertSmokeResult(
"Plan Review approval card and detail banner stay visible and contained on mobile",
planApprovalLayoutPasses(mobilePlanApprovalLayout),
JSON.stringify(mobilePlanApprovalLayout),
);
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),
);
/*
FNXC:GitManagerMobileSpacing 2026-08-01-19:10:
Real Chromium must prove the original 390px body-gutter failure is gone across the header,
close control, layout, and populated/error content. The strict 767.98px boundary intentionally
does not claim a false 768px containment failure: production flex shrinking contains that child,
while its desktop/tablet gutter and movable geometry must remain present.
*/
for (const width of [390, 767, 768, 1024]) {
await page.send("Emulation.setDeviceMetricsOverride", {
width,
height: 844,
deviceScaleFactor: width < 768 ? 2 : 1,
mobile: width < 768,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const gitManagerLayout = await evaluate(page, `(() => {
const viewportWidth = window.innerWidth;
const readRect = (selector) => {
const rect = document.querySelector(selector).getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width, top: rect.top, bottom: rect.bottom };
};
const standalone = document.querySelector('[data-smoke="git-manager-standalone"]');
const standaloneBody = document.querySelector('[data-smoke="git-manager-standalone-body"]');
const embeddedHost = document.querySelector('[data-smoke="git-manager-embedded-host"]');
return {
viewportWidth,
documentOverflow: document.documentElement.scrollWidth - viewportWidth,
standalone: {
host: readRect('[data-smoke="git-manager-standalone"]'),
body: readRect('[data-smoke="git-manager-standalone-body"]'),
modal: readRect('[data-smoke="git-manager-standalone-modal"]'),
header: readRect('[data-smoke="git-manager-standalone-header"]'),
close: readRect('[data-smoke="git-manager-standalone-close"]'),
layout: readRect('[data-smoke="git-manager-standalone-layout"]'),
content: readRect('[data-smoke="git-manager-standalone-content"]'),
bodyMarginInlineEnd: getComputedStyle(standaloneBody).marginInlineEnd,
overflow: standalone.scrollWidth - standalone.clientWidth,
resizeHandleDisplay: getComputedStyle(standalone.querySelector('.floating-window__resize-handle')).display,
},
embedded: {
host: readRect('[data-smoke="git-manager-embedded-host"]'),
modal: readRect('[data-smoke="git-manager-embedded-modal"]'),
header: readRect('[data-smoke="git-manager-embedded-header"]'),
close: readRect('[data-smoke="git-manager-embedded-close"]'),
layout: readRect('[data-smoke="git-manager-embedded-layout"]'),
content: readRect('[data-smoke="git-manager-embedded-content"]'),
overflow: embeddedHost.scrollWidth - embeddedHost.clientWidth,
},
};
})()`);
if (width === 390 && gitManagerBeforeMobileScreenshotPath) {
/*
FNXC:GitManagerMobileSpacing 2026-08-01-19:31:
FN-8702 preserves an executable reproduction by temporarily restoring the inherited desktop
resize-handle gutter. Chromium must observe that resulting right-edge asymmetry before the
production phone-sheet assertion proves the reset removes it.
*/
const preFixLayout = await evaluate(page, `(() => {
const body = document.querySelector('[data-smoke="git-manager-standalone-body"]');
body.style.marginInlineEnd = 'var(--space-lg)';
const rect = body.getBoundingClientRect();
return { right: rect.right, marginInlineEnd: getComputedStyle(body).marginInlineEnd, viewportWidth: window.innerWidth };
})()`);
assertSmokeResult(
"Git Manager 390px desktop-gutter reproduction",
parseFloat(preFixLayout.marginInlineEnd) > 0 && preFixLayout.right < preFixLayout.viewportWidth - 1,
JSON.stringify(preFixLayout),
);
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(gitManagerBeforeMobileScreenshotPath, Buffer.from(screenshot.data, "base64"));
await evaluate(page, "document.querySelector('[data-smoke=\"git-manager-standalone-body\"]').style.removeProperty('margin-inline-end')");
log(`saved Git Manager before mobile screenshot to ${gitManagerBeforeMobileScreenshotPath}`);
}
if (width === 390 && gitManagerAfterMobileScreenshotPath) {
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(gitManagerAfterMobileScreenshotPath, Buffer.from(screenshot.data, "base64"));
log(`saved Git Manager after mobile screenshot to ${gitManagerAfterMobileScreenshotPath}`);
}
const standaloneRects = Object.values(gitManagerLayout.standalone)
.filter((value) => value && typeof value === "object" && "left" in value);
const embeddedRects = Object.values(gitManagerLayout.embedded)
.filter((value) => value && typeof value === "object" && "left" in value);
const embeddedContained = embeddedRects.every((rect) => rect.left >= gitManagerLayout.embedded.host.left - 1
&& rect.right <= gitManagerLayout.embedded.host.right + 1);
const standaloneContained = standaloneRects.every((rect) => rect.left >= -1 && rect.right <= width + 1);
const common = gitManagerLayout.documentOverflow <= 1
&& gitManagerLayout.standalone.overflow <= 1
&& gitManagerLayout.embedded.overflow <= 1
&& standaloneContained
&& embeddedContained
&& gitManagerLayout.standalone.close.right <= gitManagerLayout.standalone.header.right + 1
&& gitManagerLayout.embedded.close.right <= gitManagerLayout.embedded.header.right + 1;
const passed = width < 768
? common
&& Math.abs(gitManagerLayout.standalone.host.left) <= 1
&& Math.abs(gitManagerLayout.standalone.host.right - width) <= 1
&& Math.abs(gitManagerLayout.standalone.body.right - width) <= 1
&& Math.abs(gitManagerLayout.standalone.modal.right - width) <= 1
&& gitManagerLayout.standalone.bodyMarginInlineEnd === "0px"
&& gitManagerLayout.embedded.modal.width < width
: common
&& gitManagerLayout.standalone.host.width < width - 1
&& parseFloat(gitManagerLayout.standalone.bodyMarginInlineEnd) > 0
&& gitManagerLayout.standalone.resizeHandleDisplay !== "none"
&& gitManagerLayout.embedded.modal.width < width;
assertSmokeResult(`Git Manager standalone and embedded geometry at ${width}px`, passed, JSON.stringify(gitManagerLayout));
}
/*
FNXC:GitHubImport 2026-08-02-02:45:
FN-8722 requires both branches of FloatingWindow's width-or-height sheet predicate. Positive
assertions cover the real standalone regions at phone, wide-short, and desktop-short viewports,
while non-short 768px/desktop retain the desktop gutter and visible resize handle. Embedded and
detail fixtures are controls: they remain contained but receive no standalone-only reset.
*/
for (const [width, height] of [[390, 844], [768, 480], [1024, 480], [768, 844], [1024, 844]]) {
const isSheet = width < 768 || height <= 480;
await page.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: width < 768 ? 2 : 1, mobile: width < 768 });
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
const layout = await evaluate(page, `(() => {
const viewportWidth = window.innerWidth;
const readRect = (selector) => { const rect = document.querySelector(selector).getBoundingClientRect(); return { left: rect.left, right: rect.right, width: rect.width, top: rect.top, bottom: rect.bottom }; };
const standalone = document.querySelector('[data-smoke="github-import-standalone"]');
const standaloneBody = document.querySelector('[data-smoke="github-import-standalone-body"]');
const embeddedHost = document.querySelector('[data-smoke="github-import-embedded-host"]');
const detail = document.querySelector('[data-smoke="github-import-detail"]');
return { viewportWidth, documentOverflow: document.documentElement.scrollWidth - viewportWidth, standalone: { host: readRect('[data-smoke="github-import-standalone"]'), body: readRect('[data-smoke="github-import-standalone-body"]'), modal: readRect('[data-smoke="github-import-standalone-modal"]'), header: readRect('[data-smoke="github-import-standalone-header"]'), close: readRect('[data-smoke="github-import-standalone-close"]'), controls: readRect('[data-smoke="github-import-standalone-controls"]'), list: readRect('[data-smoke="github-import-standalone-list"]'), pagination: readRect('[data-smoke="github-import-standalone-pagination"]'), footer: readRect('[data-smoke="github-import-standalone-footer"]'), bodyMarginInlineEnd: getComputedStyle(standaloneBody).marginInlineEnd, overflow: standalone.scrollWidth - standalone.clientWidth, resizeHandleDisplay: getComputedStyle(standalone.querySelector('.floating-window__resize-handle')).display }, embedded: { host: readRect('[data-smoke="github-import-embedded-host"]'), modal: readRect('[data-smoke="github-import-embedded-modal"]'), header: readRect('[data-smoke="github-import-embedded-header"]'), content: readRect('[data-smoke="github-import-embedded-content"]'), overflow: embeddedHost.scrollWidth - embeddedHost.clientWidth }, detail: { host: readRect('[data-smoke="github-import-detail"]'), body: readRect('[data-smoke="github-import-detail-body"]'), panel: readRect('[data-smoke="github-import-detail-panel"]'), close: readRect('[data-smoke="github-import-detail-close"]'), overflow: detail.scrollWidth - detail.clientWidth } };
})()`);
if (width === 390 && gitHubImportBeforeMobileScreenshotPath) {
const preFixLayout = await evaluate(page, `(() => { const body = document.querySelector('[data-smoke="github-import-standalone-body"]'); body.style.marginInlineEnd = 'var(--space-lg)'; const rect = body.getBoundingClientRect(); return { right: rect.right, marginInlineEnd: getComputedStyle(body).marginInlineEnd, viewportWidth: window.innerWidth }; })()`);
assertSmokeResult("GitHub Import 390px desktop-gutter reproduction", parseFloat(preFixLayout.marginInlineEnd) > 0 && preFixLayout.right < preFixLayout.viewportWidth - 1, JSON.stringify(preFixLayout));
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
await writeFile(gitHubImportBeforeMobileScreenshotPath, Buffer.from(screenshot.data, "base64"));
await evaluate(page, "document.querySelector('[data-smoke=\"github-import-standalone-body\"]').style.removeProperty('margin-inline-end')");
log(`saved GitHub Import before mobile screenshot to ${gitHubImportBeforeMobileScreenshotPath}`);
}
if (isSheet && ((width === 390 && gitHubImportAfterMobileScreenshotPath) || (width === 768 && height === 480 && gitHubImportAfterShortScreenshotPath))) {
const screenshot = await page.send("Page.captureScreenshot", { format: "png" });
const target = width === 390 ? gitHubImportAfterMobileScreenshotPath : gitHubImportAfterShortScreenshotPath;
await writeFile(target, Buffer.from(screenshot.data, "base64"));
log(`saved GitHub Import sheet screenshot to ${target}`);
}
const standaloneRects = [layout.standalone.body, layout.standalone.modal, layout.standalone.header, layout.standalone.close, layout.standalone.controls, layout.standalone.list, layout.standalone.pagination, layout.standalone.footer];
const embeddedRects = [layout.embedded.modal, layout.embedded.header, layout.embedded.content];
const detailRects = [layout.detail.body, layout.detail.panel, layout.detail.close];
const standaloneContained = standaloneRects.every((rect) => rect.left >= -1 && rect.right <= width + 1);
const embeddedContained = embeddedRects.every((rect) => rect.left >= layout.embedded.host.left - 1 && rect.right <= layout.embedded.host.right + 1);
const detailContained = detailRects.every((rect) => rect.left >= -1 && rect.right <= width + 1);
const common = layout.documentOverflow <= 1 && layout.standalone.overflow <= 1 && layout.embedded.overflow <= 1 && layout.detail.overflow <= 1 && standaloneContained && embeddedContained && detailContained && layout.standalone.close.right <= layout.standalone.header.right + 1 && layout.detail.close.right <= layout.detail.panel.right + 1;
const passed = isSheet
? common && Math.abs(layout.standalone.host.left) <= 1 && Math.abs(layout.standalone.host.right - width) <= 1 && Math.abs(layout.standalone.body.right - width) <= 1 && Math.abs(layout.standalone.modal.right - width) <= 1 && layout.standalone.bodyMarginInlineEnd === "0px" && layout.standalone.resizeHandleDisplay === "none"
: common && layout.standalone.host.width < width - 1 && parseFloat(layout.standalone.bodyMarginInlineEnd) > 0 && layout.standalone.resizeHandleDisplay !== "none";
assertSmokeResult(`GitHub Import standalone, embedded, and detail geometry at ${width}x${height}`, passed, JSON.stringify(layout));
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
await evaluate(page, "document.fonts ? document.fonts.ready.then(() => true) : true");
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",
Number.isFinite(frenchMobileWidth)
&& frenchMobileWidth === widestMobileWidth
&& mobileQuickAddSaveLayout.length === QUICK_ADD_SAVE_FIXTURE_COUNT
&& 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 desktopResolvedGithubTableLayout = await collectResolvedGithubTableLayout();
assertSmokeResult(
"resolved GitHub table keeps readable desktop columns without overflow",
desktopResolvedGithubTableLayout.documentOverflow <= 1
&& desktopResolvedGithubTableLayout.fixtureOverflow <= 1
&& desktopResolvedGithubTableLayout.tableOverflow <= 1
&& desktopResolvedGithubTableLayout.titleHeight > 0
&& desktopResolvedGithubTableLayout.titleWidth <= desktopResolvedGithubTableLayout.fixtureWidth
&& desktopResolvedGithubTableLayout.linkColor !== ""
&& desktopResolvedGithubTableLayout.linkTextDecoration.includes("underline"),
JSON.stringify(desktopResolvedGithubTableLayout),
);
if (resolvedGithubDesktopScreenshotPath) {
await captureFixtureScreenshot(page, '[data-smoke="github-resolved-table"]', resolvedGithubDesktopScreenshotPath);
log(`saved resolved GitHub desktop screenshot to ${resolvedGithubDesktopScreenshotPath}`);
}
if (planApprovalDesktopScreenshotPath) {
await captureFixtureScreenshot(page, '[data-smoke="plan-review-replan-cap-approval"]', planApprovalDesktopScreenshotPath);
log(`saved Plan Review approval desktop screenshot to ${planApprovalDesktopScreenshotPath}`);
}
const desktopPlanApprovalLayout = await collectPlanApprovalLayout();
assertSmokeResult(
"Plan Review approval card and detail banner stay visible and contained on desktop",
planApprovalLayoutPasses(desktopPlanApprovalLayout),
JSON.stringify(desktopPlanApprovalLayout),
);
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",
Number.isFinite(frenchDesktopWidth)
&& frenchDesktopWidth === widestDesktopWidth
&& desktopQuickAddSaveLayout.length === QUICK_ADD_SAVE_FIXTURE_COUNT
&& 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.");
let fixture;
let launched;
let page;
try {
({ fixture, launched } = await prepareBrowserSmoke(executable));
page = await createPage(launched.wsUrl);
await runSmokeChecks(page, fixture.url);
} finally {
page?.close();
if (fixture) {
await closeServer(fixture.server);
}
if (launched) {
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;
});
}