feat: sitio comercial FSPM con micro CRM interno

Sitio público orientado a venta consultiva e implementación de las
recomendaciones de la auditoría de marca y mercado:

- Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador
  de riesgo en el héroe, contra el hallazgo de que el visitante tenía que
  inferir qué solución le correspondía.
- Cinco páginas por segmento prioritario y catálogo comparable por clase
  de fuego, capacidad y agente.
- Los claims sin evidencia por modelo quedan aislados y se presentan como
  compromiso de documentación, nunca como característica del producto.
- SEO técnico completo: metadatos por página, sitemap derivado de los
  datos, JSON-LD, iconos y tarjeta social generadas.

Micro CRM:

- Captura por formulario embebido y por widget de WhatsApp que registra
  antes de abrir la conversación.
- Atribución de primer toque que sobrevive a la navegación.
- Deduplicación de contacto; un mismo contacto acumula oportunidades.
- Motor de asignación por reglas con reparto por turno de respaldo.
- Panel con dos roles de alcance distinto, tablero analítico, bitácora,
  pedidos y reglas. No enlazado desde el sitio público y con noindex.

Empaquetado en Docker multietapa con la base de demostración horneada en
la imagen y el volumen de datos separado, para que los leads capturados
sobrevivan a los redespliegues.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-08-28 00:07:20 -06:00
co-authored by Claude Opus 5
parent 122e06a4bb
commit 01b817d098
649 changed files with 26690 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
import { RUTAS_MUESTRA, TODAS_LAS_RUTAS } from "./rutas";
/**
* Humo: cada ruta pública responde, tiene una sola cabecera principal, título
* propio y los metadatos que la auditoría de marca señaló como ausentes en el
* sitio original.
*/
test.describe("rutas públicas responden", () => {
for (const ruta of TODAS_LAS_RUTAS) {
test(`200 y contenido en ${ruta}`, async ({ page }) => {
const res = await page.goto(ruta, { waitUntil: "domcontentloaded" });
expect(res?.status(), `${ruta} debe responder 200`).toBe(200);
// exactamente un h1: si hay dos, la jerarquía del documento está mal
const h1 = page.locator("h1");
await expect(h1, `${ruta} debe tener un solo h1`).toHaveCount(1);
await expect(h1).not.toBeEmpty();
const titulo = await page.title();
expect(titulo.length, `${ruta} necesita un <title> con sustancia`).toBeGreaterThan(12);
});
}
});
test.describe("metadatos que faltaban en el sitio original", () => {
for (const ruta of RUTAS_MUESTRA) {
test(`description y open graph en ${ruta}`, async ({ page }) => {
await page.goto(ruta, { waitUntil: "domcontentloaded" });
const desc = await page.locator('meta[name="description"]').getAttribute("content");
expect(desc, `${ruta} sin meta description`).toBeTruthy();
expect(desc!.length, `${ruta}: description demasiado corta`).toBeGreaterThan(50);
expect(desc!.length, `${ruta}: description demasiado larga para el snippet`).toBeLessThan(300);
const ogTitulo = await page.locator('meta[property="og:title"]').getAttribute("content");
expect(ogTitulo, `${ruta} sin og:title`).toBeTruthy();
const ogImagen = await page.locator('meta[property="og:image"]').first().getAttribute("content");
expect(ogImagen, `${ruta} sin og:image`).toBeTruthy();
const canonica = await page.locator('link[rel="canonical"]').getAttribute("href");
expect(canonica, `${ruta} sin canónica`).toBeTruthy();
});
}
});
test("idioma declarado en español de México", async ({ page }) => {
await page.goto("/");
await expect(page.locator("html")).toHaveAttribute("lang", /^es/);
});
test("sitemap lista rutas reales y excluye el panel", async ({ page }) => {
const res = await page.goto("/sitemap.xml");
expect(res?.status()).toBe(200);
const xml = await page.content();
expect(xml).toContain("/catalogo");
expect(xml).toContain("/soluciones/");
expect(xml, "el panel no debe aparecer en el sitemap").not.toContain("/admin");
});
test("robots prohíbe el panel y la API", async ({ request }) => {
const res = await request.get("/robots.txt");
expect(res.status()).toBe(200);
const txt = await res.text();
expect(txt).toMatch(/Disallow:\s*\/admin/i);
expect(txt).toMatch(/Sitemap:/i);
});
test("iconos y tarjeta social se generan", async ({ request }) => {
for (const ruta of ["/icon", "/opengraph-image"]) {
const res = await request.get(ruta);
expect(res.status(), `${ruta} debe responder`).toBeLessThan(400);
expect(res.headers()["content-type"], `${ruta} debe ser imagen`).toContain("image");
}
});
test("sonda de salud responde", async ({ request }) => {
const res = await request.get("/api/salud");
expect(res.status()).toBe(200);
const json = (await res.json()) as { ok: boolean };
expect(json.ok).toBe(true);
});
test("una ruta inexistente devuelve 404 con salidas útiles", async ({ page }) => {
const res = await page.goto("/producto/esto-no-existe-jamas");
expect(res?.status()).toBe(404);
// un 404 sin camino de regreso es un callejón
await expect(page.getByRole("link", { name: /cat[áa]logo|soluciones|inicio|contacto/i }).first()).toBeVisible();
});
+117
View File
@@ -0,0 +1,117 @@
import { expect, test, type Page } from "@playwright/test";
import { ANCHOS, RUTAS_MUESTRA } from "./rutas";
/**
* Responsividad. Corre en los cuatro proyectos de la configuración, incluidos
* los dos de WebKit: Safari e iOS son donde de verdad se rompe el diseño, y el
* cliente lo pidió por nombre.
*
* La falla que perseguimos es el desbordamiento horizontal del documento. Una
* tabla que se desplaza dentro de su contenedor está bien; el cuerpo de la
* página desplazándose en horizontal no lo está nunca.
*/
async function desbordaEnHorizontal(page: Page): Promise<{ desborda: boolean; ancho: number; visible: number }> {
return page.evaluate(() => {
const doc = document.documentElement;
return {
// 2px de tolerancia por redondeo de subpíxel
desborda: doc.scrollWidth > doc.clientWidth + 2,
ancho: doc.scrollWidth,
visible: doc.clientWidth,
};
});
}
/** Devuelve los elementos que sobresalen del ancho de la ventana, para poder señalarlos. */
async function culpablesDeDesborde(page: Page): Promise<string[]> {
return page.evaluate(() => {
const limite = document.documentElement.clientWidth;
const malos: string[] = [];
for (const el of Array.from(document.querySelectorAll<HTMLElement>("body *"))) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
if (r.right > limite + 2 || r.left < -2) {
// ignoramos lo que desborda a propósito dentro de su propio contenedor con scroll
let padre: HTMLElement | null = el.parentElement;
let contenido = false;
while (padre && padre !== document.body) {
const estilo = getComputedStyle(padre);
if (estilo.overflowX === "auto" || estilo.overflowX === "scroll" || estilo.overflowX === "hidden") {
contenido = true;
break;
}
padre = padre.parentElement;
}
if (contenido) continue;
const clases = typeof el.className === "string" ? el.className.slice(0, 60) : "";
malos.push(`${el.tagName.toLowerCase()}${clases ? "." + clases.trim().split(/\s+/).join(".") : ""}`);
if (malos.length >= 6) break;
}
}
return malos;
});
}
for (const ruta of RUTAS_MUESTRA) {
test(`sin desborde horizontal en ${ruta}`, async ({ page }) => {
await page.goto(ruta, { waitUntil: "networkidle" });
for (const ancho of ANCHOS) {
await page.setViewportSize({ width: ancho, height: 900 });
// dar chance al reflow y a las imágenes
await page.waitForTimeout(220);
const r = await desbordaEnHorizontal(page);
if (r.desborda) {
const culpables = await culpablesDeDesborde(page);
expect(
r.desborda,
`${ruta} desborda a ${ancho}px (documento ${r.ancho}px sobre ventana ${r.visible}px). Sospechosos: ${culpables.join(", ") || "no identificados"}`,
).toBe(false);
}
}
});
}
test("el menú móvil abre, es navegable y cierra", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 780 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const disparador = page.getByRole("button", { name: /abrir men[úu]/i });
await expect(disparador).toBeVisible();
await disparador.click();
const menu = page.locator("#menu-movil");
await expect(menu).toBeVisible();
await expect(menu.getByRole("link").first()).toBeVisible();
await page.getByRole("button", { name: /cerrar men[úu]/i }).click();
await expect(menu).toBeHidden();
});
test("el widget de WhatsApp no tapa contenido ni desborda en móvil", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /contactar por whatsapp/i }).click();
const panel = page.locator("#panel-whatsapp");
await expect(panel).toBeVisible();
const caja = await panel.boundingBox();
expect(caja, "el panel debe tener caja").not.toBeNull();
expect(caja!.x, "el panel se sale por la izquierda").toBeGreaterThanOrEqual(-1);
expect(caja!.x + caja!.width, "el panel se sale por la derecha").toBeLessThanOrEqual(321);
const r = await desbordaEnHorizontal(page);
expect(r.desborda, "el widget abierto no debe provocar desborde del documento").toBe(false);
});
test("las tablas anchas se desplazan dentro de su contenedor", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 780 });
await page.goto("/catalogo", { waitUntil: "networkidle" });
const r = await desbordaEnHorizontal(page);
expect(r.desborda, "el comparador no debe desplazar el cuerpo de la página").toBe(false);
});
+175
View File
@@ -0,0 +1,175 @@
import { expect, test } from "@playwright/test";
import { PRODUCTOS } from "../src/data/catalogo";
/**
* El circuito comercial completo, que es la razón de ser del proyecto:
*
* llega por una campaña → navega → envía el formulario en una ficha
* → queda registrado con la campaña del primer toque y el producto exacto
*
* Si la atribución se pierde entre la página de aterrizaje y la de conversión,
* la promesa de "medir efectividad de campañas" no se sostiene. Eso es lo que
* esta prueba vigila.
*/
const PRODUCTO = PRODUCTOS[0]!;
function sufijo(): string {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
test("formulario del inicio registra el lead y devuelve folio", async ({ page }) => {
const s = sufijo();
await page.goto("/", { waitUntil: "domcontentloaded" });
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
await forma.scrollIntoViewIfNeeded();
await forma.locator('input[name="nombre"]').fill(`Prueba Inicio ${s}`);
await forma.locator('input[name="email"]').fill(`inicio.${s}@pruebafspm.mx`);
await forma.locator('input[name="telefono"]').fill("5581000001");
await forma.locator('input[name="privacidad"]').check();
await forma.getByRole("button", { name: /solicitar/i }).click();
await expect(page.getByText(/solicitud registrada/i)).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/OPP-\d{4}-\d{4}/)).toBeVisible();
});
test("la atribución de primer toque sobrevive a la navegación", async ({ page }) => {
const s = sufijo();
const campana = `e2e-primer-toque-${s}`;
// 1. entra por una campaña, no directo a la ficha
await page.goto(`/?utm_source=google&utm_medium=cpc&utm_campaign=${campana}&gclid=gclid-${s}`, {
waitUntil: "domcontentloaded",
});
// 2. navega a una ficha de producto, ya sin parámetros en la URL
await page.goto(`/producto/${PRODUCTO.slug}`, { waitUntil: "domcontentloaded" });
expect(page.url()).not.toContain("utm_campaign");
// 3. convierte ahí
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
await forma.scrollIntoViewIfNeeded();
await forma.locator('input[name="nombre"]').fill(`Prueba Atribución ${s}`);
await forma.locator('input[name="email"]').fill(`atribucion.${s}@pruebafspm.mx`);
await forma.locator('input[name="telefono"]').fill("5581000002");
await forma.locator('input[name="privacidad"]').check();
// interceptamos el envío para leer lo que el navegador manda de verdad
const peticion = page.waitForRequest(
(r) => r.url().includes("/api/leads") && r.method() === "POST",
);
await forma.getByRole("button", { name: /solicitar/i }).click();
const req = await peticion;
const cuerpo = JSON.parse(req.postData() ?? "{}") as Record<string, unknown>;
expect(cuerpo.utmCampaign, "la campaña del primer toque debe viajar").toBe(campana);
expect(cuerpo.utmSource).toBe("google");
expect(cuerpo.utmMedium).toBe("cpc");
expect(cuerpo.gclid).toBe(`gclid-${s}`);
expect(cuerpo.productoSlug, "la ficha debe declarar su producto").toBe(PRODUCTO.slug);
expect(String(cuerpo.productoUrl), "debe guardarse la URL que vio el usuario").toContain(
`/producto/${PRODUCTO.slug}`,
);
expect(cuerpo.canal).toBe("producto");
expect(String(cuerpo.landingPath), "la ruta de aterrizaje debe ser la de la campaña").toContain("/");
await expect(page.getByText(/solicitud registrada/i)).toBeVisible({ timeout: 15_000 });
});
test("un mismo contacto puede enviar varias veces y se acumula", async ({ page }) => {
const s = sufijo();
const correo = `recurrente.${s}@pruebafspm.mx`;
const folios: string[] = [];
for (const [i, ruta] of ["/", "/contacto"].entries()) {
await page.goto(ruta, { waitUntil: "domcontentloaded" });
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
await forma.scrollIntoViewIfNeeded();
await forma.locator('input[name="nombre"]').fill(`Contacto Recurrente ${s}`);
await forma.locator('input[name="email"]').fill(correo);
await forma.locator('input[name="telefono"]').fill("5581000003");
await forma.locator('input[name="privacidad"]').check();
await forma.getByRole("button", { name: /solicitar/i }).click();
const aviso = page.getByText(/OPP-\d{4}-\d{4}/);
await expect(aviso).toBeVisible({ timeout: 15_000 });
const texto = (await aviso.first().textContent()) ?? "";
const folio = texto.match(/OPP-\d{4}-\d{4}/)?.[0];
expect(folio, `envío ${i + 1} debe devolver folio`).toBeTruthy();
folios.push(folio!);
}
expect(folios[0]).not.toBe(folios[1]);
// el mismo contacto, dos oportunidades: es el requisito explícito del cliente
});
test("el widget de WhatsApp registra antes de abrir la conversación", async ({ page, context }) => {
const s = sufijo();
await context.grantPermissions([]);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /contactar por whatsapp/i }).click();
const panel = page.locator("#panel-whatsapp");
await expect(panel).toBeVisible();
await panel.locator('input[name="nombre"]').fill(`Prueba WhatsApp ${s}`);
await panel.locator('input[name="telefono"]').fill("5581000004");
await panel.locator('input[name="email"]').fill(`whats.${s}@pruebafspm.mx`);
await panel.locator('select[name="motivo"]').selectOption("urgencia");
await panel.locator('input[name="privacidad"]').check();
const respuesta = page.waitForResponse((r) => r.url().includes("/api/leads") && r.request().method() === "POST");
await panel.getByRole("button", { name: /abrir whatsapp/i }).click();
const res = await respuesta;
const json = (await res.json()) as { ok: boolean; folio?: string; whatsappUrl?: string };
expect(json.ok, "el widget debe registrar el contacto en el CRM").toBe(true);
expect(json.folio).toMatch(/^OPP-\d{4}-\d{4}$/);
expect(json.whatsappUrl, "debe devolver el enlace a la conversación").toBeTruthy();
const url = new URL(json.whatsappUrl!);
expect(url.hostname).toContain("whatsapp.com");
const mensaje = url.searchParams.get("text") ?? "";
expect(mensaje, "el mensaje debe llevar el prefijo de demostración").toContain("[DEMO]");
expect(mensaje, "el mensaje debe llevar el folio para rastrear la conversación").toContain(json.folio!);
await expect(panel.getByText(/contacto registrado/i)).toBeVisible();
});
test("el formulario rechaza datos incompletos sin perder lo escrito", async ({ page }) => {
await page.goto("/contacto", { waitUntil: "domcontentloaded" });
const forma = page.locator("form").filter({ has: page.locator('input[name="nombre"]') }).first();
await forma.locator('input[name="nombre"]').fill("Ana");
await forma.locator('input[name="email"]').fill("esto-no-es-un-correo");
await forma.locator('input[name="telefono"]').fill("123");
await forma.locator('input[name="privacidad"]').check();
await forma.getByRole("button", { name: /solicitar/i }).click();
// no debe declarar éxito
await expect(page.getByText(/solicitud registrada/i)).toHaveCount(0);
// y lo que el usuario escribió sigue ahí
await expect(forma.locator('input[name="nombre"]')).toHaveValue("Ana");
});
test("la trampa antispam descarta el envío sin escribir nada", async ({ request }) => {
const s = sufijo();
const res = await request.post("/api/leads", {
data: {
nombre: "Bot Automático",
email: `bot.${s}@pruebafspm.mx`,
telefono: "5581000009",
origen: "FORMULARIO",
canal: "home",
privacidad: true,
_sitio: "http://sitio-de-spam.example",
},
});
expect(res.status()).toBe(200);
const json = (await res.json()) as { ok: boolean; folio: string };
// responde en falso positivo para no enseñarle al bot que fue detectado
expect(json.ok).toBe(true);
expect(json.folio).toBe("OPP-0000-0000");
});
+187
View File
@@ -0,0 +1,187 @@
import { expect, test, type Page } from "@playwright/test";
/**
* El panel comercial. Lo que más importa verificar aquí no es que las páginas
* carguen, sino las dos reglas de negocio que el cliente pidió:
*
* 1. El panel no está expuesto en el sitio público.
* 2. Un vendedor ve solo sus propios leads; el administrador ve todo.
*
* Una fuga en la regla 2 es una falla de seguridad, no un detalle de interfaz.
*/
/** Entra con el acceso rápido de demostración, eligiendo por rol. */
async function accesoRapido(page: Page, rol: "admin" | "vendedor"): Promise<string> {
await page.goto("/admin/login", { waitUntil: "domcontentloaded" });
const botones = page.locator("form button, button").filter({ hasText: /.+/ });
await expect(botones.first()).toBeVisible();
// el bloque de demostración lista los perfiles con su rol como insignia
const patron = rol === "admin" ? /administrador/i : /vendedor/i;
const perfil = page.locator("button", { has: page.locator(`text=${rol === "admin" ? "Administrador" : "Vendedor"}`) }).first();
const objetivo = (await perfil.count()) > 0 ? perfil : page.getByRole("button").filter({ hasText: patron }).first();
const nombre = ((await objetivo.textContent()) ?? "").trim();
await objetivo.click();
await page.waitForURL(/\/admin(?!\/login)/, { timeout: 20_000 });
return nombre;
}
test("el panel no se enlaza desde ninguna página pública", async ({ page }) => {
for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones"]) {
await page.goto(ruta, { waitUntil: "domcontentloaded" });
const enlaces = page.locator('a[href^="/admin"]');
await expect(enlaces, `${ruta} no debe enlazar al panel`).toHaveCount(0);
}
});
test("el panel exige sesión y devuelve a donde se quería ir", async ({ page }) => {
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/admin\/login/);
expect(page.url(), "debe recordar el destino").toContain("destino");
});
test("las páginas del panel no se indexan", async ({ page }) => {
await page.goto("/admin/login", { waitUntil: "domcontentloaded" });
const robots = await page.locator('meta[name="robots"]').first().getAttribute("content");
expect(robots ?? "", "el panel debe declarar noindex").toMatch(/noindex/i);
});
test("el administrador entra de un clic y ve el tablero con datos", async ({ page }) => {
await accesoRapido(page, "admin");
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
// el tablero tiene que traer cifras reales del seed, no ceros
const cuerpo = (await page.locator("body").textContent()) ?? "";
const numeros = cuerpo.match(/\d+/g) ?? [];
expect(numeros.length, "el tablero debe mostrar cifras").toBeGreaterThan(8);
// navegación completa disponible para el administrador
for (const seccion of [/oportunidades/i, /contactos/i, /pedidos/i, /anal[íi]tica/i, /equipo/i, /reglas/i]) {
await expect(page.getByRole("link", { name: seccion }).first(), `falta ${seccion}`).toBeVisible();
}
});
test("el vendedor no ve las secciones de administración", async ({ page }) => {
await accesoRapido(page, "vendedor");
await expect(page.getByRole("link", { name: /equipo/i })).toHaveCount(0);
await expect(page.getByRole("link", { name: /reglas/i })).toHaveCount(0);
// y si escribe la URL a mano, tampoco entra
await page.goto("/admin/equipo", { waitUntil: "domcontentloaded" });
const url = page.url();
const cuerpo = (await page.locator("body").textContent()) ?? "";
const bloqueado =
!url.includes("/admin/equipo") || /no tienes|sin permiso|no autorizado|404/i.test(cuerpo);
expect(bloqueado, "un vendedor no debe poder abrir /admin/equipo").toBe(true);
});
test("el vendedor solo ve oportunidades propias", async ({ page }) => {
const nombreVendedor = await accesoRapido(page, "vendedor");
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const filas = page.locator("table tbody tr");
const total = await filas.count();
if (total === 0) {
// aceptable solo si el panel lo dice con un estado vacío, no con una tabla muda
await expect(page.getByText(/sin oportunidades|todav[íi]a no|no hay/i).first()).toBeVisible();
return;
}
// el nombre del vendedor debe salir de su propio perfil, no del listado
const propio = nombreVendedor.split("\n")[0]?.trim().split(" ")[0] ?? "";
expect(propio.length, "no se pudo leer el nombre del vendedor").toBeGreaterThan(1);
// ninguna fila debe pertenecer a otra persona: comparamos contra el total del admin
expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200);
});
test("el administrador ve más oportunidades que un vendedor", async ({ browser }) => {
const ctxAdmin = await browser.newContext();
const ctxVendedor = await browser.newContext();
const pAdmin = await ctxAdmin.newPage();
const pVendedor = await ctxVendedor.newPage();
try {
await accesoRapido(pAdmin, "admin");
await pAdmin.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const textoAdmin = (await pAdmin.locator("body").textContent()) ?? "";
await accesoRapido(pVendedor, "vendedor");
await pVendedor.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const textoVendedor = (await pVendedor.locator("body").textContent()) ?? "";
const foliosAdmin = new Set(textoAdmin.match(/OPP-\d{4}-\d{4}/g) ?? []);
const foliosVendedor = new Set(textoVendedor.match(/OPP-\d{4}-\d{4}/g) ?? []);
expect(foliosAdmin.size, "el administrador debe ver folios").toBeGreaterThan(0);
expect(
foliosAdmin.size,
"el alcance del administrador debe ser mayor o igual al del vendedor",
).toBeGreaterThanOrEqual(foliosVendedor.size);
// y ningún folio del vendedor debe estar fuera del universo del administrador
for (const f of foliosVendedor) {
expect(foliosAdmin.has(f), `el folio ${f} del vendedor no aparece para el administrador`).toBe(true);
}
} finally {
await ctxAdmin.close();
await ctxVendedor.close();
}
});
test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => {
await accesoRapido(page, "admin");
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
const primera = page.locator('a[href^="/admin/oportunidades/"]').first();
await expect(primera).toBeVisible();
await primera.click();
await page.waitForLoadState("networkidle");
const cuerpo = (await page.locator("body").textContent()) ?? "";
// los campos que justifican todo el sistema de medición de campañas
for (const campo of [/source/i, /medium/i, /campaign/i]) {
expect(cuerpo, `el detalle debe mostrar ${campo}`).toMatch(campo);
}
expect(cuerpo).toMatch(/OPP-\d{4}-\d{4}/);
});
test("el historial del contacto acumula sus envíos", async ({ page }) => {
await accesoRapido(page, "admin");
await page.goto("/admin/contactos", { waitUntil: "networkidle" });
const primera = page.locator('a[href^="/admin/contactos/"]').first();
await expect(primera).toBeVisible();
await primera.click();
await page.waitForLoadState("networkidle");
const cuerpo = (await page.locator("body").textContent()) ?? "";
expect(cuerpo, "la ficha del contacto debe listar sus oportunidades").toMatch(/OPP-\d{4}-\d{4}/);
});
test("el panel es usable en móvil", async ({ page }) => {
await page.setViewportSize({ width: 375, height: 780 });
await accesoRapido(page, "admin");
const r = await page.evaluate(() => {
const d = document.documentElement;
return { desborda: d.scrollWidth > d.clientWidth + 2, ancho: d.scrollWidth, visible: d.clientWidth };
});
expect(r.desborda, `el tablero desborda en móvil (${r.ancho} sobre ${r.visible})`).toBe(false);
});
test("cerrar sesión devuelve al acceso", async ({ page }) => {
await accesoRapido(page, "admin");
await page.getByRole("button", { name: /salir|cerrar sesi[óo]n/i }).first().click();
await page.waitForURL(/\/admin\/login/, { timeout: 15_000 });
// y la sesión ya no sirve
await page.goto("/admin", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/admin\/login/);
});
+147
View File
@@ -0,0 +1,147 @@
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<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;
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<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);
});
+23
View File
@@ -0,0 +1,23 @@
import { chromium, webkit } from "@playwright/test";
const BASE = "http://127.0.0.1:3100";
const OBJETIVOS = [
{ ruta: "/", nombre: "inicio", ancho: 1440, alto: 1000, completa: false },
{ ruta: "/", nombre: "inicio-movil", ancho: 375, alto: 900, completa: false, motor: "webkit" },
{ ruta: "/catalogo", nombre: "catalogo", ancho: 1440, alto: 1000, completa: false },
{ ruta: "/producto/enforcer-10", nombre: "producto", ancho: 1440, alto: 1400, completa: false },
{ ruta: "/soluciones/industria-energia", nombre: "solucion", ancho: 1440, alto: 1000, completa: false },
{ ruta: "/admin/login", nombre: "login", ancho: 1440, alto: 1000, completa: false },
];
for (const o of OBJETIVOS) {
const motor = o.motor === "webkit" ? webkit : chromium;
const nav = await motor.launch();
const ctx = await nav.newContext({ viewport: { width: o.ancho, height: o.alto }, locale: "es-MX" });
const pag = await ctx.newPage();
await pag.goto(BASE + o.ruta, { waitUntil: "networkidle" });
await pag.waitForTimeout(1200);
await pag.screenshot({ path: `tests/reporte/vista/${o.nombre}.png`, fullPage: o.completa });
console.log(`ok ${o.nombre} ${o.ancho}px ${o.motor ?? "chromium"}`);
await nav.close();
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Inventario de rutas públicas bajo prueba. Se deriva de los mismos datos que
* construyen el sitio, así que si alguien agrega un producto la prueba lo cubre
* sin que nadie actualice una lista a mano.
*/
import { CATEGORIAS, PRODUCTOS } from "../src/data/catalogo";
import { INDUSTRIAS } from "../src/data/industrias";
export const RUTAS_ESTATICAS = [
"/",
"/soluciones",
"/catalogo",
"/cumplimiento-nom-002",
"/evidencia",
"/casos",
"/preguntas-frecuentes",
"/nosotros",
"/contacto",
"/aviso-de-privacidad",
] as const;
export const RUTAS_SOLUCIONES = INDUSTRIAS.map((i) => `/soluciones/${i.slug}`);
export const RUTAS_CATEGORIAS = CATEGORIAS.map((c) => `/catalogo/${c.slug}`);
export const RUTAS_PRODUCTOS = PRODUCTOS.map((p) => `/producto/${p.slug}`);
export const TODAS_LAS_RUTAS = [
...RUTAS_ESTATICAS,
...RUTAS_SOLUCIONES,
...RUTAS_CATEGORIAS,
...RUTAS_PRODUCTOS,
];
/** Muestra representativa, para las pruebas costosas que no valen 45 rutas. */
export const RUTAS_MUESTRA = [
"/",
"/soluciones",
`/soluciones/${INDUSTRIAS[0]!.slug}`,
"/catalogo",
`/catalogo/${CATEGORIAS[0]!.slug}`,
`/producto/${PRODUCTOS[0]!.slug}`,
"/cumplimiento-nom-002",
"/evidencia",
"/preguntas-frecuentes",
"/contacto",
];
/** Anchos donde el diseño tiene que aguantar. 320 es el piso acordado. */
export const ANCHOS = [320, 375, 768, 1024, 1440] as const;