import { expect, test } from "@playwright/test"; import { RUTAS_MUESTRA } from "./rutas"; /** * Accesibilidad sin dependencias externas: comprobaciones escritas a mano de * las fallas que de verdad aparecen en un sitio generado — imágenes sin texto * alternativo, campos sin etiqueta, saltos en la jerarquía de encabezados y * botones que solo tienen un icono dentro. */ for (const ruta of RUTAS_MUESTRA) { test(`accesibilidad básica en ${ruta}`, async ({ page }) => { await page.goto(ruta, { waitUntil: "networkidle" }); const problemas = await page.evaluate(() => { const fallas: string[] = []; // --- imágenes sin alternativa textual --- for (const img of Array.from(document.querySelectorAll("img"))) { const alt = img.getAttribute("alt"); if (alt === null) { fallas.push(`img sin atributo alt: ${img.getAttribute("src")?.slice(0, 70)}`); } } // --- controles sin etiqueta accesible --- for (const campo of Array.from( document.querySelectorAll( "input, select, textarea", ), )) { if (campo instanceof HTMLInputElement && ["hidden", "submit", "button"].includes(campo.type)) continue; if (campo.getAttribute("tabindex") === "-1") continue; // señuelo antispam const id = campo.getAttribute("id"); const tieneEtiqueta = Boolean( (id && document.querySelector(`label[for="${id}"]`)) || campo.closest("label") || campo.getAttribute("aria-label") || campo.getAttribute("aria-labelledby"), ); if (!tieneEtiqueta) { fallas.push(`control sin etiqueta: ${campo.tagName.toLowerCase()}[name=${campo.getAttribute("name")}]`); } } // --- botones y enlaces sin nombre accesible --- for (const el of Array.from(document.querySelectorAll("button, a"))) { const r = el.getBoundingClientRect(); if (r.width === 0 && r.height === 0) continue; const texto = (el.textContent ?? "").trim(); const nombre = texto || el.getAttribute("aria-label") || el.getAttribute("title") || ""; if (!nombre) { fallas.push(`${el.tagName.toLowerCase()} sin nombre accesible`); } } // --- jerarquía de encabezados sin saltos --- const niveles = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6")).map((h) => Number(h.tagName.slice(1)), ); const h1 = niveles.filter((n) => n === 1).length; if (h1 !== 1) fallas.push(`se esperaba un solo h1, hay ${h1}`); for (let i = 1; i < niveles.length; i++) { if (niveles[i]! - niveles[i - 1]! > 1) { fallas.push(`salto de encabezado h${niveles[i - 1]} a h${niveles[i]}`); break; } } // --- listas mal formadas --- for (const ul of Array.from(document.querySelectorAll("ul, ol"))) { for (const hijo of Array.from(ul.children)) { if (!["LI", "SCRIPT", "TEMPLATE"].includes(hijo.tagName)) { fallas.push(`<${hijo.tagName.toLowerCase()}> como hijo directo de <${ul.tagName.toLowerCase()}>`); break; } } } return fallas; }); expect(problemas, `${ruta}:\n - ${problemas.join("\n - ")}`).toEqual([]); }); } test("el salto al contenido funciona con teclado", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); await page.keyboard.press("Tab"); const enfocado = await page.evaluate(() => { const a = document.activeElement as HTMLElement | null; return { texto: (a?.textContent ?? "").trim(), href: a?.getAttribute("href") ?? "" }; }); expect(enfocado.texto, "el primer tabulador debe llegar al salto de contenido").toMatch(/contenido/i); expect(enfocado.href).toBe("#contenido"); }); test("el foco es visible al navegar con teclado", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); for (let i = 0; i < 5; i++) await page.keyboard.press("Tab"); const contorno = await page.evaluate(() => { const a = document.activeElement as HTMLElement | null; if (!a) return null; const e = getComputedStyle(a); return { outlineWidth: e.outlineWidth, outlineStyle: e.outlineStyle, boxShadow: e.boxShadow }; }); expect(contorno, "debe haber algo enfocado").not.toBeNull(); const visible = (contorno!.outlineStyle !== "none" && parseFloat(contorno!.outlineWidth) > 0) || contorno!.boxShadow !== "none"; expect(visible, "el elemento enfocado necesita indicador visible").toBe(true); }); test("el widget de WhatsApp se cierra con Escape y devuelve el foco", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded" }); const disparador = page.getByRole("button", { name: /contactar por whatsapp/i }); await disparador.click(); await expect(page.locator("#panel-whatsapp")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.locator("#panel-whatsapp")).toBeHidden(); const devuelto = await page.evaluate( () => (document.activeElement as HTMLElement | null)?.getAttribute("aria-controls") ?? "", ); expect(devuelto, "el foco debe volver al disparador").toBe("panel-whatsapp"); }); test("se respeta la preferencia de movimiento reducido", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.goto("/", { waitUntil: "networkidle" }); // con movimiento reducido, el contenido revelado debe estar visible, no atorado en opacidad 0 const ocultos = await page.evaluate(() => { let n = 0; for (const el of Array.from(document.querySelectorAll(".reveal"))) { const r = el.getBoundingClientRect(); if (r.top > window.innerHeight) continue; // fuera de pantalla, no cuenta if (parseFloat(getComputedStyle(el).opacity) < 0.5) n++; } return n; }); expect(ocultos, "con movimiento reducido nada debe quedar invisible").toBe(0); });