Files
AgendaPro/visual-audit.mjs
T
AgendaPro Dev e8d2435cc2 AgendaPro v1.0 - multi-tenant SaaS, mobile calendar, public booking
Multi-tenant scheduling SaaS (AgendaPro-equivalent):
- Backend: Node + Express + node:sqlite, multi-tenant (admin/owner/employee),
  versioned migrations, 4 templates (estetica-spa, barberia, clinica, blank).
- Frontend: React 18 + TS + Vite + Tailwind, FullCalendar drag-and-drop,
  Recharts dashboard, mobile-first (day/list views on mobile).
- Features: calendar+services+employees+clients+tickets, dashboard with
  best employee/service, top tickets, top/frequent clients, commissions,
  cancellation policy + no-show tracking, public online booking (/b/:slug),
  cash register (cierre de caja), reminders/notifications center.
- Verification: 65/65 e2e API, 58/58 visual (0 overflow, 0 console errors),
  typecheck clean, vite build OK.
2026-07-25 13:45:53 -06:00

197 lines
8.4 KiB
JavaScript

import { chromium } from "playwright";
import fs from "node:fs";
const BASE = "http://localhost:5173";
const OUT = "screenshots";
fs.mkdirSync(OUT, { recursive: true });
const VIEWPORTS = [
{ name: "mobile", width: 375, height: 720 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1280, height: 850 },
{ name: "wide", width: 1536, height: 900 },
];
const PAGES = [
{ name: "login", path: "/", login: false },
{ name: "dashboard", path: "/dashboard", login: true, role: "owner" },
{ name: "calendar-week", path: "/calendar", login: true, role: "owner", waitFor: ".fc" },
{ name: "employees", path: "/employees", login: true, role: "owner" },
{ name: "services", path: "/services", login: true, role: "owner" },
{ name: "clients", path: "/clients", login: true, role: "owner" },
{ name: "tickets", path: "/tickets", login: true, role: "owner" },
{ name: "cash", path: "/cash", login: true, role: "owner" },
{ name: "notifications", path: "/notifications", login: true, role: "owner" },
{ name: "settings", path: "/settings", login: true, role: "owner" },
{ name: "emp-calendar", path: "/calendar", login: true, role: "employee", waitFor: ".fc" },
{ name: "admin-overview", path: "/admin", login: true, role: "admin" },
{ name: "admin-businesses", path: "/admin/businesses", login: true, role: "admin" },
{ name: "public-booking", path: "/b/lumiere-estetica-spa", login: false },
];
const results = [];
const consoleErrors = [];
const pageErrors = [];
async function login(browser, viewport, role) {
const page = await browser.newPage({ viewport });
page.on("console", (m) => {
if (m.type() === "error") consoleErrors.push({ where: "login", text: m.text() });
});
page.on("pageerror", (e) => pageErrors.push({ where: "login", text: e.message }));
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForTimeout(400);
const email =
role === "admin"
? "[email protected]"
: role === "owner"
? "[email protected]"
: "[email protected]";
// Fill the login form
await page.fill('input[type="email"]', email);
await page.fill('input[type="password"]', "demo1234");
await page.click('button[type="submit"]');
await page.waitForURL(/\/(dashboard|calendar|admin)/, { timeout: 8000 });
await page.waitForTimeout(8000); // let queries resolve
// Save storage state
const state = await page.context().storageState();
await page.close();
return state;
}
async function measureOverflow(page) {
return page.evaluate(() => {
const docWidth = document.documentElement.clientWidth;
const bodyWidth = document.body.scrollWidth;
const htmlWidth = document.documentElement.scrollWidth;
const overflow = Math.max(bodyWidth, htmlWidth) - docWidth;
// find offending elements
const offenders = [];
document.querySelectorAll("*").forEach((el) => {
const r = el.getBoundingClientRect();
if (r.right > docWidth + 2 && r.width > 50) {
offenders.push({
tag: el.tagName.toLowerCase(),
cls: (el.className || "").toString().slice(0, 60),
right: Math.round(r.right),
width: Math.round(r.width),
});
}
});
return { docWidth, scrollWidth: Math.max(bodyWidth, htmlWidth), overflow, offenders: offenders.slice(0, 5) };
});
}
async function run() {
const browser = await chromium.launch();
// Pre-login for owner, employee and admin to grab storage state
const ownerState = await login(browser, VIEWPORTS[2], "owner");
const empState = await login(browser, VIEWPORTS[2], "employee");
const adminState = await login(browser, VIEWPORTS[2], "admin");
for (const vp of VIEWPORTS) {
for (const p of PAGES) {
const tag = `${vp.name}/${p.name}`;
const state = p.role === "employee" ? empState : p.role === "admin" ? adminState : ownerState;
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: state });
const page = await context.newPage();
const localErrors = [];
page.on("console", (m) => {
if (m.type() === "error") {
localErrors.push(m.text());
consoleErrors.push({ where: tag, text: m.text() });
}
});
page.on("pageerror", (e) => {
localErrors.push("PAGEERROR: " + e.message);
pageErrors.push({ where: tag, text: e.message });
});
try {
await page.goto(`${BASE}${p.path}`, { waitUntil: "networkidle", timeout: 20000 });
if (p.waitFor) await page.waitForSelector(p.waitFor, { timeout: 15000 });
await page.waitForTimeout(2500);
const overflow = await measureOverflow(page);
const file = `${OUT}/${p.name}--${vp.width}.png`;
await page.screenshot({ path: file, fullPage: true });
// also a viewport-only shot for above-the-fold view
await page.screenshot({ path: `${OUT}/${p.name}--${vp.width}-fold.png` });
results.push({ tag, vp: vp.width, status: "ok", overflow: overflow.overflow, offenders: overflow.offenders, errors: localErrors });
console.log(`✓ ${tag} (overflow=${overflow.overflow}px${overflow.offenders.length ? ", " + overflow.offenders.length + " offenders" : ""})`);
} catch (e) {
results.push({ tag, vp: vp.width, status: "fail", error: e.message, errors: localErrors });
console.log(`✗ ${tag}${e.message.split("\n")[0]}`);
} finally {
await context.close();
}
}
}
// Interaction test: open appointment modal on mobile + desktop
for (const vp of [VIEWPORTS[0], VIEWPORTS[2]]) {
const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, storageState: ownerState });
const page = await context.newPage();
try {
await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 15000 });
await page.waitForTimeout(2000);
// open mobile menu on small screens (screenshot only, then reset)
if (vp.width < 1024) {
const menuBtn = await page.locator('button:has(svg.lucide-menu)').first();
if (await menuBtn.isVisible()) {
await menuBtn.click();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/calendar-mobile-menu--${vp.width}.png` });
// reset state by reloading
await page.reload({ waitUntil: "networkidle" });
await page.waitForSelector(".fc", { timeout: 15000 });
await page.waitForTimeout(1500);
}
}
// Click "Nueva cita"
const newBtn = page.getByRole("button", { name: /Nueva cita/ }).first();
if (await newBtn.isVisible()) {
await newBtn.click();
await page.waitForTimeout(1500);
await page.screenshot({ path: `${OUT}/appointment-modal--${vp.width}.png` });
// check modal scroll
const modalOverflow = await page.evaluate(() => {
const modal = document.querySelector('[class*="fixed inset-0"] [class*="overflow-y-auto"]');
return modal ? { scrollH: modal.scrollHeight, clientH: modal.clientHeight } : null;
});
results.push({ tag: `interaction/modal@${vp.width}`, status: "ok", modalOverflow });
}
} catch (e) {
console.log(`✗ interaction@${vp.width}${e.message.split("\n")[0]}`);
} finally {
await context.close();
}
}
await browser.close();
fs.writeFileSync("screenshots/report.json", JSON.stringify({ results, consoleErrors, pageErrors }, null, 2));
// Summary
const failed = results.filter((r) => r.status === "fail");
const overflowIssues = results.filter((r) => r.overflow > 4);
const totalErrors = consoleErrors.length + pageErrors.length;
console.log("\n========== SUMMARY ==========");
console.log(`Screens/visits: ${results.length}`);
console.log(`Failed loads: ${failed.length}`);
console.log(`Horizontal overflow issues (>4px): ${overflowIssues.length}`);
console.log(` ${overflowIssues.map((o) => `${o.tag}(+${o.overflow}px)`).join(", ") || " none"}`);
console.log(`Console/page errors: ${totalErrors}`);
if (totalErrors) {
const uniq = [...new Set([...consoleErrors.map((e) => e.text), ...pageErrors.map((e) => e.text)])];
uniq.slice(0, 10).forEach((e) => console.log(` • ${e.slice(0, 180)}`));
}
console.log(`\nReport → screenshots/report.json`);
console.log(`Screenshots → screenshots/*.png`);
}
run().catch((e) => {
console.error(e);
process.exit(1);
});