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: "domcontentloaded" }); // las comprobaciones miden cajas, así que hace falta que el diseño ya haya // asentado; se espera una condición concreta en lugar de la red en reposo await page.locator("h1").first().waitFor({ state: "visible" }); await page.waitForTimeout(400); 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; // Un elemento marcado como oculto al árbol de accesibilidad no aparece // para un lector de pantalla, así que no necesita nombre. Es el caso // legítimo de la imagen de una tarjeta que duplica el enlace del // título: se marca aria-hidden con tabindex -1 justo para no anunciar // dos veces el mismo destino. Exigirle nombre era un falso positivo. if (el.closest('[aria-hidden="true"]')) 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; } } // --- roles que contradicen la semántica del elemento --- // // Una auditoría marcó `