Files
canvas-frontend/qa/audit_mobile.mjs
T
shad 93de195b0a fix(mobile): stack topbar + collapse approvals at <=700px
Mobile audit at 390px viewport showed every scene with topbar
expanded to ~1333px because of fixed-width brand-lock + tabs +
topbar-actions in a grid-template-columns auto auto 1fr auto.

- Topbar collapses to single column with scrollable tab row
- Tabs become horizontally scrollable; smaller font/padding
- Hide topbar-mid (context chips) and user-email/topbar-age (the
  user knows who they are)
- Approvals split collapses at 700px instead of 900px
- Compact scene padding (16/12) on mobile
2026-06-14 16:18:02 +04:00

106 lines
3.9 KiB
JavaScript

// Mobile audit at iPhone 13 width (390x844). Visit every scene; assert
// no horizontal scroll, no element clipping past viewport edge, no
// stacked-on-top layout breakage. The user said "a regional store
// manager could use every system effectively" — most aren't on desktop.
import { chromium } from "playwright";
const args = [
"--host-resolver-rules=MAP canvas.flow-master.ai 65.21.71.186",
"--ignore-certificate-errors",
"--window-size=390,844",
];
const b = await chromium.launch({ headless: true, args });
const ctx = await b.newContext({
viewport: { width: 390, height: 844 },
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15",
isMobile: true,
hasTouch: true,
deviceScaleFactor: 3,
});
const p = await ctx.newPage();
const results = [];
function record(name, ok, detail = "") {
results.push({ name, ok, detail });
console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
}
async function noHorizontalScroll(scene) {
const { sw, vw } = await p.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: window.innerWidth }));
record(`mobile_${scene}_no_horizontal_overflow`, sw <= vw + 2, `scroll=${sw}, viewport=${vw}`);
}
async function noOverflowingElements(scene) {
const offending = await p.evaluate(() => {
const out = [];
// Walk every element; report any whose computed width is >= 600 (much
// wider than the 390 viewport) AND whose offsetParent is the document.
document.querySelectorAll("*").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.width >= 600 && r.right > window.innerWidth + 2) {
out.push({
tag: el.tagName,
cls: (el.className || "").toString().slice(0, 40),
width: Math.round(r.width),
right: Math.round(r.right),
});
}
});
return out.slice(0, 10);
});
record(
`mobile_${scene}_no_element_clipping`,
offending.length === 0,
offending.length ? offending.map((o) => `${o.tag}.${o.cls}@w${o.width}`).join(", ") : "clean"
);
}
// Pre-seed dev-login token + a CEO session into localStorage / sessionStorage
// before we ever navigate. That avoids the auth-guard navigation that resets
// the viewport on some Playwright versions.
const tokenRes = await ctx.request.post("https://canvas.flow-master.ai/api/v1/auth/dev-login", {
data: { email: "ceo-head@flow-master.ai" },
});
const tokenBody = await tokenRes.json();
const accessToken = tokenBody?.access_token;
console.log(`[seed token]: ${accessToken ? "OK" : "FAIL"}`);
await ctx.addInitScript((tok) => {
sessionStorage.setItem("fm.mc.token.v1", tok);
localStorage.setItem("fm.mc.prefs.v1", JSON.stringify({ email: "ceo-head@flow-master.ai" }));
}, accessToken);
await p.goto("https://canvas.flow-master.ai/", { waitUntil: "networkidle" });
await p.waitForTimeout(1500);
await noHorizontalScroll("landing_pre_login");
await noOverflowingElements("landing_pre_login");
for (const [chip, scene] of [
[/Approvals queue/, "approvals"],
[/Procurement Hub/, "procurement_hub"],
[/Attendance Map/, "geo"],
[/Command Assistant/, "assistant"],
[/Team Chat/, "chat"],
[/What is FlowMaster/, "explainer"],
]) {
const found = await p.locator(".hub-chip", { hasText: chip }).count();
if (found === 0) {
record(`mobile_${scene}_no_horizontal_overflow`, false, "chip not visible — auth state lost or landing not hydrated");
continue;
}
await p.locator(".hub-chip", { hasText: chip }).click();
await p.waitForTimeout(1500);
await noHorizontalScroll(scene);
await noOverflowingElements(scene);
await p.goBack().catch(() => {});
await p.waitForTimeout(600);
}
const fails = results.filter((r) => !r.ok);
console.log(`\n=== mobile audit: ${results.length - fails.length}/${results.length} passed ===`);
if (fails.length) {
console.log("FAILED:");
fails.forEach((f) => console.log(` - ${f.name}: ${f.detail}`));
}
await b.close();
process.exit(fails.length === 0 ? 0 : 1);