Files
fspm-web/tests/05-accesibilidad.spec.ts
T
Uriel JarethandClaude Opus 5 31ee899987 fix: defectos encontrados por la suite de extremo a extremo
La primera corrida completa dio 57 fallas. Solo una parte eran del
producto; el resto era instrumentación mal escrita. Queda en 261 en verde.

Defectos del producto:

- La atribución de campaña se perdía. La captura vivía en un efecto de
  React, así que si la persona llegaba con parámetros de campaña y
  navegaba antes de hidratar, el primer toque nunca se guardaba y
  utmCampaign llegaba vacío al CRM. Ahora se captura en un script en
  línea que corre al analizar el documento.
- Siete páginas sin og:image: una página que declara su propio openGraph
  no hereda las imágenes del layout, las sobreescribe. Solo se nota al
  compartir el enlace. Se resuelve con una función compartida en seo.ts.
- El panel no se podía usar desde Safari por HTTP: la cookie Secure no se
  guarda y el acceso falla en silencio. Queda explícito y documentado.
- El panel se corría de lado en móvil: la cabecera no cabía a 375px.
- Las tarjetas de perfil del acceso se cortaban en móvil. Un elemento de
  rejilla nace con min-width auto y no puede encogerse bajo su contenido.
  Era además el origen de un desplazamiento que se arrastraba al tablero.
- Las tablas accesibles de las gráficas volvían desplazable la página:
  sr-only fija width 1px y el algoritmo de tablas lo ignora.
- El campo de búsqueda del catálogo no tenía nombre accesible.
- Límite de tasa de 5 envíos por IP: bloqueaba una demostración en vivo
  con varias personas en la misma red. Configurable, ahora 20.
- El salto de contenido pasa de left:-9999px a recorte de 1px.

Instrumentación corregida, con la razón escrita en cada caso:

- El desborde horizontal se mide intentando desplazar la página, no con
  documentElement.scrollWidth, que sobreinforma con contenedores propios.
- La comprobación de accesibilidad respeta el árbol de accesibilidad.
- El aislamiento entre roles se prueba pidiendo por URL una oportunidad
  ajena, no comparando folios entre listados paginados.
- Fuera las esperas por networkidle, que agotaban el tiempo sin fallar.
- Que el panel no esté enlazado se comprueba leyendo el HTML servido.
- Safari no enfoca enlaces con Tab: declarado como del navegador.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 02:37:42 -06:00

173 lines
7.3 KiB
TypeScript

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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(
"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<HTMLElement>("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;
}
}
// --- 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 }, info) => {
// Safari no lleva el foco a los enlaces con Tab salvo que la persona active
// "Presionar Tab para resaltar cada elemento" en sus preferencias. Es
// comportamiento del navegador, no del sitio, así que la comprobación no
// aplica en WebKit: ahí el salto sigue existiendo y sigue siendo alcanzable
// con las teclas de accesibilidad del sistema.
test.skip(
info.project.name.includes("webkit"),
"En Safari el tabulador no enfoca enlaces por omisión; es del navegador, no del sitio.",
);
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 }, info) => {
test.skip(
info.project.name.includes("webkit"),
"Misma razón: en Safari el tabulador no recorre los enlaces por omisión.",
);
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: "domcontentloaded" });
// 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<HTMLElement>(".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);
});