Files
fspm-web/tests/03-captura.spec.ts
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

186 lines
8.1 KiB
TypeScript

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: /recibir cotizaci/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: /recibir cotizaci/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: /recibir cotizaci/i }).click();
const aviso = page.getByText(/OPP-\d{4}-\d{4}/);
// WebKit en móvil tarda más en pintar el acuse; el margen es de espera, no
// de tolerancia: si no aparece, la prueba sigue fallando
await expect(aviso).toBeVisible({ timeout: 25_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('textarea[name="mensaje"]').fill("Tengo una verificación la próxima semana");
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: /enviar mensaje/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!,
);
expect(
mensaje,
"el mensaje que escribió la persona debe viajar a la conversación, no un motivo enlatado",
).toContain("verificación la próxima semana");
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: /recibir cotizaci/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");
});