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" ? "admin@agendapro.demo" : role === "owner" ? "owner@agendapro.demo" : "valentina.cruz@lumiere.mx"; // 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); });