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:
co-authored by
Claude Opus 5
parent
122e06a4bb
commit
01b817d098
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user