Files
fspm-web/tests/01-humo.spec.ts
T
Uriel JarethandClaude Opus 5 0e0331c1fc feat: catálogo como centro del embudo y captura de baja fricción
Reestructuración pedida por el cliente tras revisar la primera versión.

Conversión:
- Formulario de 14 campos a 4: nombre, correo, teléfono y mensaje. La
  clasificación de riesgo ya no se pregunta, se deduce del contexto de la
  página y viaja oculta. Menos fricción, misma riqueza para el análisis.
- El inicio deja de bifurcar y empuja al catálogo: 36 llamados desembocan
  ahí, y las rutas por industria y escenario pasan a ser la forma de entrar
  al catálogo ya filtrado, en lugar de competir como destino.
- Catálogo rehecho como tienda: barra lateral con filtros, búsqueda que
  acepta expresiones regulares, títulos que abren la ficha, un solo botón
  primario por tarjeta y el comparador detrás de un desplegable.
- Ficha de producto orientada al cierre: bloque en lenguaje llano sobre si
  el equipo corresponde al caso, y "Cotizar este equipo" con el formulario
  precargado. Se retiraron los enlaces que sacaban del embudo a leer normas.
- Todos los llamados se unifican en "Recibir cotización".
- Widget de WhatsApp: nombre, teléfono, correo y mensaje libre. El texto que
  escribe la persona es el que viaja a la conversación.

Doble audiencia:
- 13 escenarios con la iconografía original de FSPM traducen un lugar
  reconocible a clases de fuego y productos, para quien no domina la
  terminología. La capa técnica se conserva para búsqueda.
- Héroe con fotografía en todas las landings.

Correcciones:
- El contenedor moría al arrancar porque el CLI de Prisma no podía escribir
  sus motores; el chown ahora incluye /app/prisma-cli.
- Iconos repetidos entre escenarios. El set original no tiene icono de
  cocina, así que ese escenario usa un marcador tipográfico explícito.
- FIREMIKS no aparecía en ningún escenario.
- Los ids de campo del formulario colisionaban con dos instancias por página.
- Se documentó el despliegue, que faltaba por completo.

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

105 lines
4.2 KiB
TypeScript

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.
*/
/**
* El barrido de las 45 rutas corre en un solo proyecto. Que una ruta responda
* 200 y traiga un `<h1>` lo decide el servidor, no el motor del navegador, así
* que repetirlo cuatro veces solo alargaba la suite. Lo que sí es sensible al
* motor —responsividad, interacción, foco— vive en las otras suites y ahí sí
* corre en WebKit.
*/
test.describe("rutas públicas responden", () => {
for (const ruta of TODAS_LAS_RUTAS) {
test(`200 y contenido en ${ruta}`, async ({ page }, info) => {
test.skip(
info.project.name !== "escritorio-chromium",
"El barrido completo de rutas corre en un solo proyecto; ver la nota de arriba.",
);
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();
});