// responsive-audit.mjs — auditoría multi-dispositivo (iPhone 16 Pro Max / iPhone 12 / iPad). // // Qué mide y por qué: // // 1. ZOOM-AL-ENFOCAR (iOS). Mobile Safari hace auto-zoom cuando enfocas un campo // con font-size < 16px, y NO revierte al desenfocar: la página queda escalada y // corrida. Ese comportamiento es exclusivo de Mobile Safari y no se reproduce en // un navegador de escritorio, así que no lo "actuamos": medimos su predictor // exacto y determinista — el font-size computado de cada campo de formulario. // < 16px en un viewport táctil = zoom garantizado en el dispositivo real. // // 2. OVERFLOW HORIZONTAL. scrollWidth > clientWidth en la raíz: la causa de que la // página "aparezca desplazada". // // 3. ALTURA DE VIEWPORT. 100vh en iOS no descuenta la barra de URL dinámica; el shell // queda más alto que el área visible. Comparamos la altura del shell contra la // altura del viewport. // // 4. SAFE AREAS. Con viewport-fit=cover el contenido se mete bajo la Dynamic Island y // el home indicator. Playwright no puede inyectar insets reales, así que se verifica // de forma estática que el CSS servido declare env(safe-area-inset-*) en los // elementos de chrome (header/sidebar). Se reporta como check de código, no de píxel. // // 5. CALENDARIO. Overflow propio, altura fija vs proporcional al viewport, y que la // vista elegida corresponda al tamaño de pantalla. // // Uso: npm run audit:responsive (requiere `npm run dev` corriendo) import fs from "node:fs"; import path from "node:path"; const BASE = process.env.RESPONSIVE_BASE_URL || "http://localhost:5173"; const OUT = process.env.RESPONSIVE_OUT || "screenshots/responsive"; const SHOOT = process.env.RESPONSIVE_SCREENSHOTS !== "0"; // CSS px reales de cada dispositivo objetivo. const DEVICES = [ { name: "iphone-12", label: "iPhone 12", width: 390, height: 844, dpr: 3, touch: true, tier: "phone" }, { name: "iphone-16-pro-max", label: "iPhone 16 Pro Max", width: 440, height: 956, dpr: 3, touch: true, tier: "phone" }, { name: "iphone-16-pro-max-land", label: "iPhone 16 Pro Max (landscape)", width: 956, height: 440, dpr: 3, touch: true, tier: "phone-land" }, { name: "ipad-portrait", label: "iPad 10.9\" (portrait)", width: 820, height: 1180, dpr: 2, touch: true, tier: "tablet" }, { name: "ipad-landscape", label: "iPad 10.9\" (landscape)", width: 1180, height: 820, dpr: 2, touch: true, tier: "tablet-land" }, { name: "ipad-mini", label: "iPad mini (portrait)", width: 744, height: 1133, dpr: 2, touch: true, tier: "tablet" }, // `tier` describe qué vista de calendario corresponde; `sidebar` marca si hay // barra lateral fija (Tailwind `lg:` = 1024px). A 1024 se cumplen las dos cosas: // tier tablet (3 días) Y barra lateral, que es lo buscado en iPad Pro portrait. { name: "ipad-pro-portrait", label: "iPad Pro 12.9\" (portrait)", width: 1024, height: 1366, dpr: 2, touch: true, tier: "tablet", sidebar: true }, { name: "ipad-pro-landscape", label: "iPad Pro 12.9\" (landscape)", width: 1366, height: 1024, dpr: 2, touch: true, tier: "desktop", sidebar: true }, { name: "desktop", label: "Desktop", width: 1440, height: 900, dpr: 1, touch: false, tier: "desktop", sidebar: true }, ]; const PAGES = [ // La landing es pública y no lleva sesión; se audita como cualquier otra página. // `/` dejó de servir el login, que ahora vive en `/login`. { name: "landing", path: "/", role: null }, { name: "login", path: "/login", role: null }, { name: "calendar", path: "/calendar", role: "owner", waitFor: ".fc" }, { name: "dashboard", path: "/dashboard", role: "owner" }, { name: "clients", path: "/clients", role: "owner" }, { name: "settings", path: "/settings", role: "owner" }, { name: "employees", path: "/employees", role: "owner" }, { name: "emp-calendar", path: "/calendar", role: "employee", waitFor: ".fc" }, { name: "booking", path: "/b/negocio", role: null }, ]; const MIN_TOUCH_FONT_PX = 16; // umbral de Mobile Safari para NO hacer auto-zoom const MIN_TAP_TARGET_PX = 40; // guía de Apple: 44pt; 40 tolera padding/borde const MIN_GUTTER_PX = 12; // margen lateral mínimo del texto en las páginas públicas const findings = []; function fail(device, page, kind, detail) { findings.push({ device, page, kind, detail }); } /** Campos de formulario cuyo font-size dispararía el auto-zoom de iOS. */ async function measureFormFields(page) { return page.evaluate((min) => { const sel = 'input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="hidden"]),select,textarea'; const bad = []; let total = 0; for (const el of document.querySelectorAll(sel)) { const cs = getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden") continue; total++; const fs = parseFloat(cs.fontSize); if (fs < min - 0.01) { bad.push({ tag: el.tagName.toLowerCase(), type: el.getAttribute("type") || "", cls: (el.className || "").toString().slice(0, 70), fontSize: Math.round(fs * 100) / 100, }); } } return { total, bad }; }, MIN_TOUCH_FONT_PX); } async function measureOverflow(page) { return page.evaluate(() => { const de = document.documentElement; const docWidth = de.clientWidth; const scrollWidth = Math.max(de.scrollWidth, document.body.scrollWidth); const offenders = []; for (const el of document.querySelectorAll("*")) { const r = el.getBoundingClientRect(); if (r.width < 40 || r.height < 8) continue; if (r.right > docWidth + 2) { const cs = getComputedStyle(el); if (cs.position === "fixed" && r.width <= docWidth + 2) continue; offenders.push({ tag: el.tagName.toLowerCase(), cls: (el.className || "").toString().slice(0, 70), right: Math.round(r.right), width: Math.round(r.width), }); } } return { docWidth, scrollWidth, overflow: scrollWidth - docWidth, offenders: offenders.slice(0, 6) }; }); } /** El shell no debe ser más alto que el viewport (síntoma de 100vh en iOS). */ async function measureShellHeight(page) { return page.evaluate(() => { const vh = window.innerHeight; // El defecto que se busca es un shell de ALTURA FIJA más alto que el área // visible: el clásico 100vh que en iOS no descuenta la barra de URL. Ese shell // se marca con [data-app-shell] (AppShell y AdminShell). // // Una página que scrollea a propósito —la landing, el login, el booking // público— es legítimamente más alta que el viewport, así que comparar su alto // contra vh no mide un defecto: mide que existe scroll. Antes se colaba por el // fallback a body.firstElementChild, que en esas páginas apunta a #root. const fixed = document.querySelector("[data-app-shell]"); const shell = fixed || document.body.firstElementChild; if (!shell) return null; const h = Math.round(shell.getBoundingClientRect().height); return { viewportHeight: vh, shellHeight: h, delta: h - vh, hasFixedShell: !!fixed }; }); } /** * Canaleta lateral del texto en las páginas públicas. * * Existe por un fallo concreto: `.safe-x` está fuera de `@layer` en index.css, así * que gana a las utilidades `px-*` de Tailwind y deja el padding lateral en 0 cuando * el inset del sistema es 0 — o sea, en todo lo que no sea un iPhone en landscape. El * texto acaba pegado al borde y ni el check de overflow ni el de zoom lo detectan, * porque no hay desborde: hay cero margen. */ async function measureGutter(page) { return page.evaluate(() => { const vw = document.documentElement.clientWidth; let min = Infinity; let culpable = null; for (const el of document.querySelectorAll("h1,h2,h3,p,label,button,a,li")) { // Solo nodos con texto propio y visibles: los contenedores a sangre (fondos, // lavados de color) sí pueden y deben tocar el borde. const propio = [...el.childNodes].some( (n) => n.nodeType === 3 && n.textContent.trim().length > 1 ); if (!propio) continue; const cs = getComputedStyle(el); if (cs.visibility === "hidden" || cs.display === "none" || Number(cs.opacity) < 0.05) continue; const r = el.getBoundingClientRect(); if (r.width < 4 || r.height < 4) continue; const holgura = Math.min(r.left, vw - r.right); if (holgura < min) { min = holgura; culpable = `${el.tagName.toLowerCase()} "${(el.textContent || "").trim().slice(0, 30)}"`; } } return { min: Number.isFinite(min) ? Math.round(min) : null, culpable }; }); } async function measureCalendar(page) { return page.evaluate(() => { const fc = document.querySelector(".fc"); if (!fc) return null; const r = fc.getBoundingClientRect(); const scroller = fc.querySelector(".fc-scroller-harness") || fc; // La clase de vista real es del tipo `fc-timeGridDay-view`; hay que excluir // `fc-media-screen` y compañía, que también viven en el nodo raíz. let viewName = ""; for (const el of fc.querySelectorAll("*")) { const m = /\bfc-([A-Za-z]+)-view\b/.exec(el.className || ""); if (m) { viewName = m[1]; break; } } // ¿el calendario desborda su tarjeta contenedora? const card = fc.closest(".card"); const cardR = card ? card.getBoundingClientRect() : null; return { width: Math.round(r.width), height: Math.round(r.height), viewportHeight: window.innerHeight, heightRatio: Math.round((r.height / window.innerHeight) * 100) / 100, view: viewName, innerScrollWidth: scroller.scrollWidth, innerClientWidth: scroller.clientWidth, cardOverflowX: cardR ? Math.round(r.width - cardR.width) : 0, }; }); } /** * El menú lateral debe ser una única columna vertical. Se comprueba de verdad y * no por inspección de clases: una regla CSS que cambie el `display` de los * enlaces a inline-flex los reparte en dos por fila, que es exactamente el fallo * que se vio en iPad Pro. */ async function measureSidebarNav(page) { return page.evaluate(() => { // La barra visible en lg+ (la del panel móvil está fuera de flujo). const aside = [...document.querySelectorAll("aside")].find((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(a).position !== "absolute"; }); if (!aside) return null; const nav = aside.querySelector("nav"); if (!nav) return null; const links = [...nav.querySelectorAll("a,button")].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); if (links.length < 2) return null; const rects = links.map((el) => el.getBoundingClientRect()); // Dos entradas comparten fila si sus rangos verticales se solapan. let sameRowPairs = 0; for (let i = 0; i < rects.length; i++) { for (let j = i + 1; j < rects.length; j++) { const a = rects[i], b = rects[j]; const vOverlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); if (vOverlap > 4) sameRowPairs++; } } const lefts = new Set(rects.map((r) => Math.round(r.left))); return { count: links.length, sameRowPairs, distinctLefts: lefts.size, asideWidth: Math.round(aside.getBoundingClientRect().width), displays: [...new Set(links.map((el) => getComputedStyle(el).display))], }; }); } /** * Los botones de solo icono se agrandan a 40px en táctil; el icono debe quedar * centrado en la caja nueva y no pegado a un borde. */ async function measureIconCentering(page) { return page.evaluate(() => { const bad = []; for (const btn of document.querySelectorAll("button")) { if (btn.querySelectorAll(":scope > *").length !== 1) continue; const svg = btn.querySelector(":scope > svg"); if (!svg) continue; // Sin texto visible: en un botón con etiqueta (" Nueva cita") el icono // va a la izquierda por diseño. Contar hijos elemento no basta para // distinguirlos, porque el label es un nodo de texto y no cuenta. if ((btn.textContent || "").trim().length > 0) continue; const br = btn.getBoundingClientRect(); const sr = svg.getBoundingClientRect(); if (br.width === 0 || sr.width === 0) continue; const dx = sr.left + sr.width / 2 - (br.left + br.width / 2); const dy = sr.top + sr.height / 2 - (br.top + br.height / 2); if (Math.abs(dx) > 2.5 || Math.abs(dy) > 2.5) { bad.push({ label: (btn.getAttribute("aria-label") || btn.getAttribute("title") || "?").slice(0, 24), dx: Math.round(dx), dy: Math.round(dy), }); } } return bad.slice(0, 6); }); } /** Objetivos táctiles demasiado pequeños en pantallas touch. */ async function measureTapTargets(page) { return page.evaluate((min) => { const small = []; for (const el of document.querySelectorAll('button,a[href],[role="button"],select')) { const cs = getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden" || cs.pointerEvents === "none") continue; const r = el.getBoundingClientRect(); // Descarta lo oculto visualmente (sr-only mide 1px y no se toca). if (r.width < 8 || r.height < 8) continue; if (r.height < min) { small.push({ tag: el.tagName.toLowerCase(), text: (el.textContent || "").trim().slice(0, 28), cls: (el.className || "").toString().slice(0, 50), h: Math.round(r.height), }); } } return small.slice(0, 8); }, MIN_TAP_TARGET_PX); } async function login(browser, role) { const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const page = await context.newPage(); const email = role === "admin" ? "admin@agendamax.demo" : role === "owner" ? "owner@agendamax.demo" : "valentina.cruz@lumiere.mx"; await page.goto(`${BASE}/login`, { waitUntil: "networkidle" }); 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: 15000 }); await page.waitForTimeout(1500); const state = await context.storageState(); await context.close(); return state; } /** Check estático: el CSS servido debe declarar safe-area en el chrome de la app. */ async function auditSafeAreaCss() { const css = fs.readFileSync("src/index.css", "utf8"); const shells = ["src/components/AppShell.tsx", "src/components/AdminShell.tsx"]; const out = { insetsInCss: /env\(\s*safe-area-inset-/.test(css), shellUsage: {} }; for (const f of shells) { const src = fs.readFileSync(f, "utf8"); out.shellUsage[path.basename(f)] = { top: /\bsafe-top\b/.test(src), bottom: /\bsafe-area-bottom\b/.test(src), // La altura dinámica llega vía la clase .h-screen-safe, que resuelve // 100dvh con fallback a 100vh en index.css. dvh: /\bh-screen-safe\b/.test(src) && /100dvh/.test(css), noRawScreen: !/\bh-screen\b(?!-safe)/.test(src), }; } const html = fs.readFileSync("index.html", "utf8"); const vp = html.match(/ n.endsWith(".tsx")) .map((n) => `src/components/landing/${n}`), ]; out.rawViewportUnits = []; for (const f of publicFiles) { const src = fs.readFileSync(f, "utf8"); // `h-screen`/`min-h-screen` sin el sufijo `-safe`, o un `100vh` literal. if (/\b(?:min-|max-)?h-screen\b(?!-safe)/.test(src) || /(^|[^d])100vh/.test(src)) { out.rawViewportUnits.push(path.basename(f)); } } return out; } async function pickBrowser() { const pw = await import("playwright"); for (const name of ["webkit", "chromium"]) { try { const b = await pw[name].launch(); return { browser: b, engine: name }; } catch { /* try next */ } } throw new Error("No se pudo lanzar ningún navegador de Playwright."); } async function run() { if (SHOOT) fs.mkdirSync(OUT, { recursive: true }); const { browser, engine } = await pickBrowser(); console.log(`Motor: ${engine}${engine === "chromium" ? " (webkit no instalado; layout válido, sin gestos de Safari)" : " (Safari real)"}`); console.log(`Base: ${BASE}\n`); const staticAudit = await auditSafeAreaCss(); for (const f of staticAudit.rawViewportUnits) { fail("estático", "landing", "raw-viewport-unit", `${f} usa h-screen/100vh crudo (usa las utilidades -safe con dvh)`); } const states = { owner: await login(browser, "owner"), employee: await login(browser, "employee"), }; const rows = []; for (const dev of DEVICES) { for (const p of PAGES) { const tag = `${dev.name}/${p.name}`; const context = await browser.newContext({ viewport: { width: dev.width, height: dev.height }, deviceScaleFactor: dev.dpr, hasTouch: dev.touch, isMobile: engine === "chromium" ? dev.touch : undefined, storageState: p.role ? states[p.role] : undefined, }); const page = await context.newPage(); const errors = []; page.on("console", (m) => m.type() === "error" && errors.push(m.text().slice(0, 160))); page.on("pageerror", (e) => errors.push("PAGEERROR: " + e.message.slice(0, 160))); try { await page.goto(`${BASE}${p.path}`, { waitUntil: "networkidle", timeout: 25000 }); if (p.waitFor) await page.waitForSelector(p.waitFor, { timeout: 20000 }); await page.waitForTimeout(1200); const fields = await measureFormFields(page); const overflow = await measureOverflow(page); const shell = await measureShellHeight(page); const cal = await measureCalendar(page); const taps = dev.touch ? await measureTapTargets(page) : []; // Solo en las páginas públicas de marketing: el panel usa contenedores a // sangre a propósito y ahí la medida daría falsos positivos. const gutter = p.name === "landing" || p.name === "login" ? await measureGutter(page) : null; if (gutter && gutter.min !== null && gutter.min < MIN_GUTTER_PX) { fail(dev.name, p.name, "gutter", `texto a ${gutter.min}px del borde (${gutter.culpable})`); } const nav = p.role ? await measureSidebarNav(page) : null; const icons = dev.touch ? await measureIconCentering(page) : []; if (icons.length) { fail(dev.name, p.name, "icon-center", `${icons.length} iconos descentrados (ej. "${icons[0].label}" dx=${icons[0].dx} dy=${icons[0].dy})`); } if (nav && nav.sameRowPairs > 0) { fail(dev.name, p.name, "nav-columns", `${nav.sameRowPairs} pares de entradas comparten fila (menú en ${nav.distinctLefts} columnas, display=${nav.displays.join("/")})`); } if (dev.touch && fields.bad.length) { fail(dev.name, p.name, "ios-zoom", `${fields.bad.length}/${fields.total} campos < ${MIN_TOUCH_FONT_PX}px (min ${Math.min(...fields.bad.map((b) => b.fontSize))}px)`); } if (overflow.overflow > 2) { const first = overflow.offenders[0]; fail(dev.name, p.name, "overflow-x", `+${overflow.overflow}px; primer culpable: ${first ? `${first.tag}.${first.cls.split(" ")[0]}` : "?"}`); } if (shell && shell.hasFixedShell && shell.delta > 2) { fail(dev.name, p.name, "shell-height", `shell ${shell.shellHeight}px > viewport ${shell.viewportHeight}px (+${shell.delta})`); } if (cal && cal.innerScrollWidth - cal.innerClientWidth > 2) { fail(dev.name, p.name, "calendar-scroll-x", `scroll interno +${cal.innerScrollWidth - cal.innerClientWidth}px`); } if (cal && dev.tier !== "desktop" && cal.heightRatio > 1.15) { fail(dev.name, p.name, "calendar-height", `alto ${cal.height}px = ${cal.heightRatio}× viewport`); } // El otro extremo: una grilla aplastada es tan inservible como una que desborda. if (cal && cal.height < 320) { fail(dev.name, p.name, "calendar-too-short", `alto ${cal.height}px (< 320px mínimo usable)`); } // La vista por defecto debe corresponder al tamaño de pantalla. if (cal && p.name === "calendar") { // En landscape un teléfono tiene ancho de tablet (956px en el 16 Pro Max), // así que la vista Semana sí corresponde: son 7 columnas de ~120px, no las // columnas aplastadas que se querían evitar en portrait. // El tier se decide por ancho, que es lo que condiciona el layout: un // teléfono en landscape (956px) recibe la misma vista que una tablet. const expected = dev.tier === "phone" ? ["timeGridDay", "listWeek"] : dev.tier === "tablet" || dev.tier === "phone-land" ? ["timeGridThreeDay", "timeGridWeek", "timeGridDay", "listWeek"] : ["timeGridWeek", "timeGridDay", "listWeek", "dayGridMonth"]; if (cal.view && !expected.includes(cal.view)) { fail(dev.name, p.name, "calendar-view", `vista "${cal.view}" inesperada para ${dev.tier}`); } } if (taps.length) { fail(dev.name, p.name, "tap-target", `${taps.length} objetivos < ${MIN_TAP_TARGET_PX}px alto (ej. "${taps[0].text || taps[0].tag}" ${taps[0].h}px)`); } if (errors.length) fail(dev.name, p.name, "console", errors[0]); rows.push({ tag, device: dev.label, page: p.name, fields, overflow, shell, cal, nav, taps: taps.length, errors }); const flags = [ dev.touch && fields.bad.length ? `zoom:${fields.bad.length}` : "", overflow.overflow > 2 ? `overflow:+${overflow.overflow}` : "", shell && shell.hasFixedShell && shell.delta > 2 ? `alto:+${shell.delta}` : "", cal ? `cal:${cal.view}@${cal.width}x${cal.height}` : "", ].filter(Boolean).join(" "); console.log(`${flags.includes("zoom") || flags.includes("overflow") || flags.includes("alto") ? "✗" : "✓"} ${tag.padEnd(38)} ${flags}`); if (SHOOT) await page.screenshot({ path: `${OUT}/${p.name}--${dev.name}.png` }); } catch (e) { fail(dev.name, p.name, "load", e.message.split("\n")[0]); console.log(`✗ ${tag.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 70)}`); } finally { await context.close(); } } } // ---- Modal de cita ---- // Es el formulario con más campos (donde el auto-zoom de iOS pegaría más // fuerte) y un bottom-sheet que debe caber en el área visible sin quedar bajo // el home indicator. No se cubre en el barrido de páginas porque hay que // abrirlo interactuando. for (const dev of DEVICES.filter((d) => d.touch && d.tier !== "phone-land")) { const context = await browser.newContext({ viewport: { width: dev.width, height: dev.height }, deviceScaleFactor: dev.dpr, hasTouch: true, isMobile: engine === "chromium" ? true : undefined, storageState: states.owner, }); const page = await context.newPage(); try { await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle", timeout: 25000 }); await page.waitForSelector(".fc", { timeout: 20000 }); await page.waitForTimeout(1200); await page.getByRole("button", { name: /Nueva cita/ }).first().click(); await page.waitForSelector('[role="dialog"]', { timeout: 10000 }); await page.waitForTimeout(1000); const fields = await measureFormFields(page); const overflow = await measureOverflow(page); const sheet = await page.evaluate(() => { const d = document.querySelector('[role="dialog"]'); const r = d.getBoundingClientRect(); return { h: Math.round(r.height), bottom: Math.round(r.bottom), vh: window.innerHeight }; }); if (fields.bad.length) fail(dev.name, "modal", "ios-zoom", `${fields.bad.length} campos < ${MIN_TOUCH_FONT_PX}px`); if (overflow.overflow > 2) fail(dev.name, "modal", "overflow-x", `+${overflow.overflow}px`); if (sheet.h > sheet.vh || sheet.bottom > sheet.vh + 1) { fail(dev.name, "modal", "modal-fit", `sheet ${sheet.h}px / viewport ${sheet.vh}px (bottom ${sheet.bottom})`); } console.log(`${fields.bad.length || overflow.overflow > 2 || sheet.h > sheet.vh ? "✗" : "✓"} ${`${dev.name}/modal`.padEnd(38)} campos=${fields.total} sheet=${sheet.h}/${sheet.vh}`); if (SHOOT) await page.screenshot({ path: `${OUT}/modal--${dev.name}.png` }); } catch (e) { fail(dev.name, "modal", "load", e.message.split("\n")[0]); console.log(`✗ ${`${dev.name}/modal`.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 60)}`); } finally { await context.close(); } } // ---- Barra lateral colapsable (lg+) ---- // Se comprueba el comportamiento completo: que colapse, que el contenido gane // el ancho liberado, que el menú siga en una columna en modo mini, que las // entradas conserven etiqueta accesible al perder el texto, y que la // preferencia sobreviva a una recarga. for (const dev of DEVICES.filter((d) => d.sidebar)) { const context = await browser.newContext({ viewport: { width: dev.width, height: dev.height }, deviceScaleFactor: dev.dpr, hasTouch: dev.touch, storageState: states.owner, }); const page = await context.newPage(); try { await page.goto(`${BASE}/calendar`, { waitUntil: "networkidle", timeout: 25000 }); await page.waitForSelector(".fc", { timeout: 20000 }); await page.waitForTimeout(1000); const metrics = () => page.evaluate(() => { const aside = [...document.querySelectorAll("aside")].find( (a) => a.getBoundingClientRect().width > 0 && getComputedStyle(a).position !== "absolute" ); const main = document.querySelector("main"); return { aside: aside ? Math.round(aside.getBoundingClientRect().width) : 0, main: main ? Math.round(main.getBoundingClientRect().width) : 0, }; }); const before = await metrics(); const btn = page.getByRole("button", { name: /Colapsar menú/ }); if (!(await btn.count())) throw new Error("no se encontró el botón de colapsar"); await btn.first().click(); await page.waitForTimeout(500); const after = await metrics(); const navMini = await measureSidebarNav(page); // Al quedarse sin texto, cada entrada debe seguir teniendo nombre accesible. const labelled = await page.evaluate(() => { const aside = [...document.querySelectorAll("aside")].find( (a) => a.getBoundingClientRect().width > 0 && getComputedStyle(a).position !== "absolute" ); const links = [...(aside?.querySelectorAll("nav a") ?? [])]; return { total: links.length, named: links.filter((a) => (a.getAttribute("title") || a.getAttribute("aria-label") || a.textContent || "").trim().length > 0).length, }; }); if (SHOOT) await page.screenshot({ path: `${OUT}/sidebar-collapsed--${dev.name}.png` }); // La preferencia debe persistir tras recargar. await page.reload({ waitUntil: "networkidle" }); await page.waitForSelector(".fc", { timeout: 20000 }); await page.waitForTimeout(800); const afterReload = await metrics(); const shrank = after.aside < before.aside - 40; const mainGrew = after.main > before.main + 40; const oneColumn = !navMini || navMini.sameRowPairs === 0; const persisted = Math.abs(afterReload.aside - after.aside) <= 2; const allNamed = labelled.total > 0 && labelled.named === labelled.total; if (!shrank) fail(dev.name, "sidebar", "collapse", `no se estrechó: ${before.aside}px → ${after.aside}px`); if (!mainGrew) fail(dev.name, "sidebar", "collapse", `el contenido no ganó ancho: ${before.main}px → ${after.main}px`); if (!oneColumn) fail(dev.name, "sidebar", "nav-columns", `menú mini en ${navMini.distinctLefts} columnas`); if (!persisted) fail(dev.name, "sidebar", "collapse", `no persistió tras recargar: ${afterReload.aside}px`); if (!allNamed) fail(dev.name, "sidebar", "collapse", `${labelled.total - labelled.named} entradas sin nombre accesible en modo mini`); const ok = shrank && mainGrew && oneColumn && persisted && allNamed; console.log(`${ok ? "✓" : "✗"} ${`${dev.name}/sidebar`.padEnd(38)} aside ${before.aside}→${after.aside} main ${before.main}→${after.main} (+${after.main - before.main}) 1col=${oneColumn ? "sí" : "NO"} persiste=${persisted ? "sí" : "NO"}`); // Se restaura expandida para no contaminar el estado guardado del perfil. const openBtn = page.getByRole("button", { name: /Expandir menú/ }); if (await openBtn.count()) await openBtn.first().click(); await page.waitForTimeout(200); } catch (e) { fail(dev.name, "sidebar", "load", e.message.split("\n")[0]); console.log(`✗ ${`${dev.name}/sidebar`.padEnd(38)} ERROR ${e.message.split("\n")[0].slice(0, 60)}`); } finally { await context.close(); } } await browser.close(); fs.mkdirSync("screenshots", { recursive: true }); fs.writeFileSync("screenshots/responsive-report.json", JSON.stringify({ engine, staticAudit, rows, findings }, null, 2)); console.log("\n=============== RESUMEN ==============="); console.log(`viewport meta: ${staticAudit.viewport}`); console.log(`env(safe-area-inset-*) en index.css: ${staticAudit.insetsInCss ? "sí" : "NO"}`); for (const [f, u] of Object.entries(staticAudit.shellUsage)) { console.log(` ${f}: safe-top=${u.top ? "sí" : "NO"} safe-bottom=${u.bottom ? "sí" : "NO"} dvh=${u.dvh ? "sí" : "NO"} sin-100vh-crudo=${u.noRawScreen ? "sí" : "NO"}`); } const byKind = {}; for (const f of findings) byKind[f.kind] = (byKind[f.kind] || 0) + 1; console.log(`\nHallazgos: ${findings.length}`); for (const [k, n] of Object.entries(byKind).sort((a, b) => b[1] - a[1])) console.log(` ${k}: ${n}`); if (findings.length) { console.log("\nDetalle (primeros 20):"); for (const f of findings.slice(0, 20)) console.log(` • [${f.kind}] ${f.device}/${f.page} — ${f.detail}`); } console.log(`\nReporte → screenshots/responsive-report.json`); const blocking = findings.filter((f) => ["ios-zoom", "overflow-x", "shell-height", "raw-viewport-unit", "gutter", "load", "calendar-scroll-x", "calendar-height", "calendar-too-short", "calendar-view", "modal-fit", "nav-columns", "collapse", "icon-center"].includes(f.kind) ); console.log(blocking.length ? `\n${blocking.length} hallazgos bloqueantes.` : "\nSin hallazgos bloqueantes."); process.exitCode = blocking.length ? 1 : 0; } run().catch((e) => { console.error(e); process.exit(1); });