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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0e0331c1fc
commit
31ee899987
@@ -12,6 +12,7 @@ SQLite en un volumen. No hay base de datos externa.
|
|||||||
| `NEXT_PUBLIC_SITE_URL` | Sí, **en construcción** | Las URL canónicas, el sitemap y Open Graph apuntan al dominio equivocado. Ver la advertencia de abajo. |
|
| `NEXT_PUBLIC_SITE_URL` | Sí, **en construcción** | Las URL canónicas, el sitemap y Open Graph apuntan al dominio equivocado. Ver la advertencia de abajo. |
|
||||||
| `NEXT_PUBLIC_WHATSAPP` | Sí, **en construcción** | El widget de WhatsApp abre una conversación con un número vacío. |
|
| `NEXT_PUBLIC_WHATSAPP` | Sí, **en construcción** | El widget de WhatsApp abre una conversación con un número vacío. |
|
||||||
| `DEMO_ACCESO_RAPIDO` | No | Con cualquier valor distinto de `false`, el acceso de un clic por perfil está activo en `/admin/login`. |
|
| `DEMO_ACCESO_RAPIDO` | No | Con cualquier valor distinto de `false`, el acceso de un clic por perfil está activo en `/admin/login`. |
|
||||||
|
| `AUTH_COOKIE_INSECURE` | No | Solo para pruebas locales sin TLS. Ver la advertencia de HTTPS abajo. **Nunca en producción.** |
|
||||||
| `NODE_ENV` | No | Ya viene en `production` dentro de la imagen. |
|
| `NODE_ENV` | No | Ya viene en `production` dentro de la imagen. |
|
||||||
|
|
||||||
### Advertencia sobre las variables `NEXT_PUBLIC_*`
|
### Advertencia sobre las variables `NEXT_PUBLIC_*`
|
||||||
@@ -38,6 +39,20 @@ node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
|||||||
Uno por entorno. No reutilices el de desarrollo: cualquiera que lo tenga puede
|
Uno por entorno. No reutilices el de desarrollo: cualquiera que lo tenga puede
|
||||||
firmar una cookie de sesión válida y entrar al panel como administrador.
|
firmar una cookie de sesión válida y entrar al panel como administrador.
|
||||||
|
|
||||||
|
### El panel exige HTTPS
|
||||||
|
|
||||||
|
La cookie de sesión va marcada como `Secure`, que es lo correcto. La
|
||||||
|
consecuencia es que **si el panel se sirve por HTTP plano, en Safari y en iOS
|
||||||
|
nadie puede entrar**: el navegador se niega a guardar la cookie, el acceso
|
||||||
|
devuelve a la pantalla de inicio de sesión y no dice por qué. Chromium hace una
|
||||||
|
excepción con `localhost`, así que el problema se ve solo en WebKit y es fácil
|
||||||
|
de atribuir a otra cosa.
|
||||||
|
|
||||||
|
Detrás de Coolify con Cloudflare esto no ocurre, porque el tráfico llega por
|
||||||
|
HTTPS. Importa en dos casos: si alguien expone el contenedor por IP y puerto sin
|
||||||
|
TLS, y al probar en local. Para lo segundo existe `AUTH_COOKIE_INSECURE=true`,
|
||||||
|
que **no debe usarse en un entorno accesible desde internet**.
|
||||||
|
|
||||||
## El volumen — la parte frágil
|
## El volumen — la parte frágil
|
||||||
|
|
||||||
**Si no montas `/app/data`, cada redespliegue borra los leads capturados.**
|
**Si no montas `/app/data`, cada redespliegue borra los leads capturados.**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FormularioContacto } from "@/components/formulario/FormularioContacto";
|
|||||||
import { Migas } from "@/components/site/contenido/Migas";
|
import { Migas } from "@/components/site/contenido/Migas";
|
||||||
import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
|
import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
|
||||||
import { AVISO_DEMO, EMPRESA, SITIO_URL } from "@/data/empresa";
|
import { AVISO_DEMO, EMPRESA, SITIO_URL } from "@/data/empresa";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /aviso-de-privacidad
|
* /aviso-de-privacidad
|
||||||
@@ -32,6 +33,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/aviso-de-privacidad`,
|
url: `${SITIO_URL}/aviso-de-privacidad`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getCategoria } from "@/data/catalogo";
|
|||||||
import type { ClaseFuego } from "@/data/clases-fuego";
|
import type { ClaseFuego } from "@/data/clases-fuego";
|
||||||
import { EMPRESA, SITIO_URL } from "@/data/empresa";
|
import { EMPRESA, SITIO_URL } from "@/data/empresa";
|
||||||
import { getIndustria } from "@/data/industrias";
|
import { getIndustria } from "@/data/industrias";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /casos — escenarios de aplicación con la estructura que pide el estudio:
|
* /casos — escenarios de aplicación con la estructura que pide el estudio:
|
||||||
@@ -35,6 +36,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/casos`,
|
url: `${SITIO_URL}/casos`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Migas } from "@/components/site/contenido/Migas";
|
|||||||
import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
|
import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
|
||||||
import { Revelar } from "@/components/site/Revelar";
|
import { Revelar } from "@/components/site/Revelar";
|
||||||
import { EMPRESA, SITIO_URL, WHATSAPP } from "@/data/empresa";
|
import { EMPRESA, SITIO_URL, WHATSAPP } from "@/data/empresa";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /contacto
|
* /contacto
|
||||||
@@ -30,6 +31,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/contacto`,
|
url: `${SITIO_URL}/contacto`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Revelar } from "@/components/site/Revelar";
|
|||||||
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
|
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
|
||||||
import { ESCENARIOS, type Escenario } from "@/data/escenarios";
|
import { ESCENARIOS, type Escenario } from "@/data/escenarios";
|
||||||
import { INDUSTRIAS } from "@/data/industrias";
|
import { INDUSTRIAS } from "@/data/industrias";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /cumplimiento-nom-002 — la página de mayor intención de compra del sitio.
|
* /cumplimiento-nom-002 — la página de mayor intención de compra del sitio.
|
||||||
@@ -34,6 +35,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/cumplimiento-nom-002`,
|
url: `${SITIO_URL}/cumplimiento-nom-002`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
|
|||||||
import { Revelar } from "@/components/site/Revelar";
|
import { Revelar } from "@/components/site/Revelar";
|
||||||
import { CATEGORIAS, PRODUCTOS, productosDeCategoria } from "@/data/catalogo";
|
import { CATEGORIAS, PRODUCTOS, productosDeCategoria } from "@/data/catalogo";
|
||||||
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
|
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /evidencia — el centro de evidencia de la recomendación 10.3 del estudio.
|
* /evidencia — el centro de evidencia de la recomendación 10.3 del estudio.
|
||||||
@@ -32,6 +33,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/evidencia`,
|
url: `${SITIO_URL}/evidencia`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Revelar } from "@/components/site/Revelar";
|
|||||||
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
|
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
|
||||||
import { CLIENTES, EMPRESA, PRENSA, SITIO_URL } from "@/data/empresa";
|
import { CLIENTES, EMPRESA, PRENSA, SITIO_URL } from "@/data/empresa";
|
||||||
import { INDUSTRIAS } from "@/data/industrias";
|
import { INDUSTRIAS } from "@/data/industrias";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /nosotros — origen de la tecnología, posicionamiento y postura editorial.
|
* /nosotros — origen de la tecnología, posicionamiento y postura editorial.
|
||||||
@@ -31,6 +32,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/nosotros`,
|
url: `${SITIO_URL}/nosotros`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FOTOS, HeroeLanding } from "@/components/site/HeroeLanding";
|
|||||||
import { Revelar } from "@/components/site/Revelar";
|
import { Revelar } from "@/components/site/Revelar";
|
||||||
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
|
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
|
||||||
import { ESCENARIOS, type Escenario } from "@/data/escenarios";
|
import { ESCENARIOS, type Escenario } from "@/data/escenarios";
|
||||||
|
import { imagenesOg } from "@/lib/seo";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /preguntas-frecuentes
|
* /preguntas-frecuentes
|
||||||
@@ -36,6 +37,7 @@ export const metadata: Metadata = {
|
|||||||
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
|
||||||
description: DESCRIPCION,
|
description: DESCRIPCION,
|
||||||
url: `${SITIO_URL}/preguntas-frecuentes`,
|
url: `${SITIO_URL}/preguntas-frecuentes`,
|
||||||
|
...imagenesOg(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,16 @@ export const dynamic = "force-dynamic";
|
|||||||
* instancia; en un despliegue real esto viviría en Redis con una clave por IP,
|
* instancia; en un despliegue real esto viviría en Redis con una clave por IP,
|
||||||
* para que el límite valga entre todas las réplicas y sobreviva a un reinicio.
|
* para que el límite valga entre todas las réplicas y sobreviva a un reinicio.
|
||||||
*/
|
*/
|
||||||
const MAX_ENVIOS = 5;
|
/**
|
||||||
const VENTANA_MS = 10 * 60 * 1000;
|
* El límite es configurable porque el valor correcto depende de quién está del
|
||||||
|
* otro lado. Cinco envíos por ventana frena a un bot, pero también bloquea una
|
||||||
|
* demostración en vivo donde tres personas prueban el formulario desde la misma
|
||||||
|
* IP de oficina, y una suite de pruebas de extremo a extremo lo agota en la
|
||||||
|
* primera corrida. Veinte sigue siendo un techo bajo para un abuso real y deja
|
||||||
|
* respirar a los dos casos legítimos.
|
||||||
|
*/
|
||||||
|
const MAX_ENVIOS = Number(process.env.LEADS_MAX_ENVIOS ?? 20);
|
||||||
|
const VENTANA_MS = Number(process.env.LEADS_VENTANA_MINUTOS ?? 10) * 60 * 1000;
|
||||||
const registro = new Map<string, number[]>();
|
const registro = new Map<string, number[]>();
|
||||||
|
|
||||||
function ipDe(peticion: NextRequest): string {
|
function ipDe(peticion: NextRequest): string {
|
||||||
|
|||||||
+34
-2
@@ -440,17 +440,49 @@
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Defensa: una tabla con `sr-only` no se encoge.
|
||||||
|
*
|
||||||
|
* `sr-only` fija `width: 1px`, pero el algoritmo de tablas ignora el ancho
|
||||||
|
* cuando el contenido pide más. Las tablas equivalentes de las gráficas del
|
||||||
|
* panel medían 456px, quedaban invisibles por el `clip` y aun así volvían la
|
||||||
|
* página desplazable en horizontal en móvil. Lo correcto es poner `sr-only`
|
||||||
|
* en un envoltorio de bloque; esto es la red por si alguien vuelve a ponerlo
|
||||||
|
* en la tabla.
|
||||||
|
*/
|
||||||
|
.sr-only {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- salto de accesibilidad --- */
|
/* --- salto de accesibilidad --- */
|
||||||
|
/*
|
||||||
|
* Salto de contenido.
|
||||||
|
*
|
||||||
|
* Se oculta recortando una caja de 1px, no sacándola a `left: -9999px`. La
|
||||||
|
* técnica antigua deja un cuadro real de ~174px fuera del lienzo, y eso
|
||||||
|
* agrandaba el área desplazable del documento: en el panel a 375px la página
|
||||||
|
* se corría 37px de lado sin que nada se viera fuera de lugar. Costó
|
||||||
|
* encontrarlo porque el elemento culpable era invisible y estaba a la
|
||||||
|
* izquierda, no a la derecha.
|
||||||
|
*/
|
||||||
.skip {
|
.skip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
width: 1px;
|
||||||
top: 0;
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skip:focus {
|
.skip:focus {
|
||||||
|
position: fixed;
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
overflow: visible;
|
||||||
|
clip-path: none;
|
||||||
background-color: var(--color-signal);
|
background-color: var(--color-signal);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
|||||||
@@ -75,6 +75,35 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<noscript>
|
<noscript>
|
||||||
<style>{".reveal{opacity:1!important;transform:none!important}"}</style>
|
<style>{".reveal{opacity:1!important;transform:none!important}"}</style>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
CAPTURA DE ATRIBUCIÓN — corre al analizar el documento, antes de hidratar.
|
||||||
|
|
||||||
|
Antes esto vivía solo en un `useEffect` del formulario, y ahí llegaba
|
||||||
|
tarde: si la persona aterrizaba con parámetros de campaña y navegaba
|
||||||
|
antes de que React hidratara, el primer toque no se alcanzaba a
|
||||||
|
guardar y la campaña se perdía. Se reprodujo en pruebas y le puede
|
||||||
|
pasar a cualquiera con conexión lenta o con la pestaña en segundo plano.
|
||||||
|
|
||||||
|
Guardar el primer toque es una escritura de tres líneas que no necesita
|
||||||
|
React, así que se hace aquí. `src/lib/utm.ts` sigue siendo quien lee y
|
||||||
|
combina; si cambia la llave, hay que cambiarla en los dos lugares.
|
||||||
|
*/}
|
||||||
|
<script
|
||||||
|
// eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{
|
||||||
|
var K='fspm.atribucion.v1';
|
||||||
|
if(localStorage.getItem(K))return;
|
||||||
|
var p=new URLSearchParams(location.search);
|
||||||
|
function v(n){var x=p.get(n);return x?x.trim().slice(0,180):undefined}
|
||||||
|
var a={utmSource:v('utm_source'),utmMedium:v('utm_medium'),utmCampaign:v('utm_campaign'),utmTerm:v('utm_term'),utmContent:v('utm_content'),gclid:v('gclid'),fbclid:v('fbclid')};
|
||||||
|
a.referrer=document.referrer?document.referrer.slice(0,400):undefined;
|
||||||
|
a.landingPath=location.pathname+location.search;
|
||||||
|
localStorage.setItem(K,JSON.stringify(a));
|
||||||
|
}catch(e){}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-ink text-bright antialiased">{children}</body>
|
<body className="bg-ink text-bright antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -145,13 +145,18 @@ export function Cascaron({
|
|||||||
Menú
|
Menú
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="min-w-0 truncate font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-faint">
|
<p className="hidden min-w-0 truncate font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-faint sm:block">
|
||||||
Panel comercial
|
Panel comercial
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="ml-auto flex min-w-0 items-center gap-2 sm:gap-3">
|
<div className="ml-auto flex min-w-0 items-center gap-2 sm:gap-3">
|
||||||
<span className="hidden min-w-0 truncate text-sm text-bright sm:block">{nombre}</span>
|
<span className="hidden min-w-0 truncate text-sm text-bright sm:block">{nombre}</span>
|
||||||
<span className={rol === "ADMIN" ? "badge badge-signal" : "badge"}>
|
<span
|
||||||
|
className={[
|
||||||
|
rol === "ADMIN" ? "badge badge-signal" : "badge",
|
||||||
|
"shrink-0 whitespace-nowrap",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
{ROL_ETIQUETA[rol]}
|
{ROL_ETIQUETA[rol]}
|
||||||
</span>
|
</span>
|
||||||
<BotonSalir />
|
<BotonSalir />
|
||||||
@@ -256,7 +261,7 @@ function BotonSalir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={salir}>
|
<form action={salir} className="shrink-0">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saliendo}
|
disabled={saliendo}
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ export function BarrasMagnitud({
|
|||||||
{filas.length === 1 ? "categoría" : "categorías"}.
|
{filas.length === 1 ? "categoría" : "categorías"}.
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|
||||||
<table className="sr-only">
|
<div className="sr-only">
|
||||||
|
<table>
|
||||||
<caption>{unidad} por categoría</caption>
|
<caption>{unidad} por categoría</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -144,6 +145,7 @@ export function BarrasMagnitud({
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ export function Embudo({ embudo }: { embudo: TramoEmbudo[] }) {
|
|||||||
La última columna es la conversión respecto a la etapa anterior
|
La última columna es la conversión respecto a la etapa anterior
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table className="sr-only">
|
<div className="sr-only">
|
||||||
|
<table>
|
||||||
<caption>Oportunidades y conversión por etapa del embudo</caption>
|
<caption>Oportunidades y conversión por etapa del embudo</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -110,6 +111,7 @@ export function Embudo({ embudo }: { embudo: TramoEmbudo[] }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ export function SerieTiempo({ serie, dias }: { serie: PuntoSerie[]; dias: number
|
|||||||
{dias} días. Día más alto: {fecha(pico.fecha)} con {entero(pico.oportunidades)}.
|
{dias} días. Día más alto: {fecha(pico.fecha)} con {entero(pico.oportunidades)}.
|
||||||
</figcaption>
|
</figcaption>
|
||||||
|
|
||||||
<table className="sr-only">
|
<div className="sr-only">
|
||||||
|
<table>
|
||||||
<caption>Oportunidades y ganadas por día</caption>
|
<caption>Oportunidades y ganadas por día</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -282,6 +283,7 @@ export function SerieTiempo({ serie, dias }: { serie: PuntoSerie[]; dias: number
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,12 @@ export function AccesoDemo({ perfiles }: { perfiles: PerfilDemo[] }) {
|
|||||||
|
|
||||||
<ul className="grid gap-2">
|
<ul className="grid gap-2">
|
||||||
{ordenados.map((p) => (
|
{ordenados.map((p) => (
|
||||||
<li key={p.id}>
|
// `min-w-0`: un elemento de rejilla nace con `min-width: auto` y no
|
||||||
|
// puede encogerse por debajo del contenido de su hijo. Sin esto, a
|
||||||
|
// 375 px cada tarjeta de perfil medía 425 y la insignia de rol con la
|
||||||
|
// flecha quedaban cortadas fuera de la pantalla, justo en la primera
|
||||||
|
// pantalla que se enseña al demostrar el panel desde un teléfono.
|
||||||
|
<li key={p.id} className="min-w-0">
|
||||||
<BotonPerfil perfil={p} />
|
<BotonPerfil perfil={p} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ export function FiltrosCatalogo({
|
|||||||
placeholder="enforcer, 12 L, espuma"
|
placeholder="enforcer, 12 L, espuma"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
// El rótulo "Buscar" del bloque es un encabezado, y un encabezado no
|
||||||
|
// da nombre accesible a un campo; el `placeholder` tampoco. Sin esto,
|
||||||
|
// un lector de pantalla anuncia "campo de búsqueda" sin decir sobre
|
||||||
|
// qué. El nombre además aprovecha para declarar qué se puede escribir.
|
||||||
|
aria-label="Buscar en el catálogo por nombre, modelo, agente o capacidad"
|
||||||
aria-describedby={idAyuda}
|
aria-describedby={idAyuda}
|
||||||
className="field appearance-none pr-16 [-webkit-appearance:none] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
className="field appearance-none pr-16 [-webkit-appearance:none] [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+24
-2
@@ -17,6 +17,28 @@ import type { Alcance, Sesion } from "@/lib/crm/tipos";
|
|||||||
|
|
||||||
export const COOKIE_SESION = "fspm_sesion";
|
export const COOKIE_SESION = "fspm_sesion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ¿La cookie de sesión va marcada como `Secure`?
|
||||||
|
*
|
||||||
|
* En producción sí, siempre: el panel viaja sobre HTTPS y una cookie de sesión
|
||||||
|
* sin `Secure` se puede interceptar en texto claro.
|
||||||
|
*
|
||||||
|
* Existe la salida porque `Secure` sobre HTTP plano **no falla ruidosamente,
|
||||||
|
* falla en silencio**: Safari y iOS se niegan a guardar la cookie, así que el
|
||||||
|
* acceso al panel parece funcionar y devuelve a la pantalla de inicio de sesión
|
||||||
|
* sin decir por qué. Chromium hace una excepción con localhost y ahí sí entra,
|
||||||
|
* lo que hace el problema todavía más difícil de ver. Se detectó probando el
|
||||||
|
* contenedor local en WebKit.
|
||||||
|
*
|
||||||
|
* Consecuencia práctica: si el panel se sirve por HTTP, en Safari nadie puede
|
||||||
|
* entrar. `AUTH_COOKIE_INSECURE=true` es solo para pruebas locales sin TLS.
|
||||||
|
*/
|
||||||
|
function cookieSegura(): boolean {
|
||||||
|
if (process.env.AUTH_COOKIE_INSECURE === "true") return false;
|
||||||
|
return process.env.NODE_ENV === "production";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const HORAS_VIDA = 8;
|
const HORAS_VIDA = 8;
|
||||||
const SEGUNDOS_VIDA = HORAS_VIDA * 60 * 60;
|
const SEGUNDOS_VIDA = HORAS_VIDA * 60 * 60;
|
||||||
|
|
||||||
@@ -81,7 +103,7 @@ export async function crearSesion(usuarioId: string): Promise<void> {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: cookieSegura(),
|
||||||
maxAge: SEGUNDOS_VIDA,
|
maxAge: SEGUNDOS_VIDA,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,7 +114,7 @@ export async function cerrarSesion(): Promise<void> {
|
|||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
path: "/",
|
path: "/",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: cookieSegura(),
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,22 @@ export const SEO_BASE: { titulo: string; descripcion: string } = {
|
|||||||
/** Tarjeta social por omisión: la genera `src/app/opengraph-image.tsx`. */
|
/** Tarjeta social por omisión: la genera `src/app/opengraph-image.tsx`. */
|
||||||
const IMAGEN_OG_POR_OMISION = "/opengraph-image";
|
const IMAGEN_OG_POR_OMISION = "/opengraph-image";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imágenes de Open Graph listas para insertar en un bloque `openGraph` escrito
|
||||||
|
* a mano.
|
||||||
|
*
|
||||||
|
* Existe porque en Next una página que declara `openGraph` **no hereda** las
|
||||||
|
* imágenes del layout raíz: las sobreescribe. Siete páginas quedaron sin
|
||||||
|
* `og:image` por eso, y el defecto no se ve en la página, solo al compartir el
|
||||||
|
* enlace. Quien escriba un `openGraph` propio debe extender con `...IMAGENES_OG`.
|
||||||
|
* Lo ideal es usar `metadataPagina`, que ya lo resuelve.
|
||||||
|
*/
|
||||||
|
export function imagenesOg(): NonNullable<Metadata["openGraph"]> {
|
||||||
|
return {
|
||||||
|
images: [{ url: urlAbsoluta(IMAGEN_OG_POR_OMISION), width: 1200, height: 630 }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normaliza una ruta interna a la forma `/ruta` sin diagonal final.
|
* Normaliza una ruta interna a la forma `/ruta` sin diagonal final.
|
||||||
* La raíz se conserva como `/`.
|
* La raíz se conserva como `/`.
|
||||||
|
|||||||
+33
-10
@@ -21,14 +21,34 @@ import { ANCHOS, RUTAS_MUESTRA } from "./rutas";
|
|||||||
*/
|
*/
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
async function desbordaEnHorizontal(page: Page): Promise<{ desborda: boolean; ancho: number; visible: number }> {
|
/**
|
||||||
|
* Mide el síntoma, no una propiedad intermedia.
|
||||||
|
*
|
||||||
|
* La primera versión de esta función comparaba `documentElement.scrollWidth`
|
||||||
|
* contra `clientWidth`, y producía falsos positivos: en `/evidencia`,
|
||||||
|
* `html.scrollWidth` reporta 865px a 320px de ventana mientras
|
||||||
|
* `body.scrollWidth` es 320 y la página **no se puede desplazar**. Chromium y
|
||||||
|
* WebKit coinciden en ese reporte, así que es comportamiento esperado alrededor
|
||||||
|
* del desbordamiento de un contenedor con scroll propio, no un defecto.
|
||||||
|
*
|
||||||
|
* Lo único que le importa a la persona es si la página se corre de lado. Eso se
|
||||||
|
* comprueba intentando desplazarla y viendo si se movió. `body.scrollWidth`
|
||||||
|
* queda como dato de apoyo para el diagnóstico.
|
||||||
|
*/
|
||||||
|
async function desbordaEnHorizontal(
|
||||||
|
page: Page,
|
||||||
|
): Promise<{ desborda: boolean; ancho: number; visible: number; movio: number }> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const doc = document.documentElement;
|
const doc = document.documentElement;
|
||||||
|
const antes = window.scrollX;
|
||||||
|
window.scrollTo(800, window.scrollY);
|
||||||
|
const movio = window.scrollX;
|
||||||
|
window.scrollTo(antes, window.scrollY);
|
||||||
return {
|
return {
|
||||||
// 2px de tolerancia por redondeo de subpíxel
|
desborda: movio > 2,
|
||||||
desborda: doc.scrollWidth > doc.clientWidth + 2,
|
ancho: document.body.scrollWidth,
|
||||||
ancho: doc.scrollWidth,
|
|
||||||
visible: doc.clientWidth,
|
visible: doc.clientWidth,
|
||||||
|
movio,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,7 +61,10 @@ async function culpablesDeDesborde(page: Page): Promise<string[]> {
|
|||||||
for (const el of Array.from(document.querySelectorAll<HTMLElement>("body *"))) {
|
for (const el of Array.from(document.querySelectorAll<HTMLElement>("body *"))) {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
if (r.width === 0 || r.height === 0) continue;
|
if (r.width === 0 || r.height === 0) continue;
|
||||||
if (r.right > limite + 2 || r.left < -2) {
|
// solo cuenta lo que sobresale a la DERECHA: un elemento colocado fuera
|
||||||
|
// de pantalla a la izquierda, como el salto de contenido, no agranda el
|
||||||
|
// área desplazable en escritura de izquierda a derecha
|
||||||
|
if (r.right > limite + 2) {
|
||||||
// ignoramos lo que desborda a propósito dentro de su propio contenedor con scroll
|
// ignoramos lo que desborda a propósito dentro de su propio contenedor con scroll
|
||||||
let padre: HTMLElement | null = el.parentElement;
|
let padre: HTMLElement | null = el.parentElement;
|
||||||
let contenido = false;
|
let contenido = false;
|
||||||
@@ -71,7 +94,7 @@ for (const ruta of RUTAS_MUESTRA) {
|
|||||||
"El barrido de anchos corre solo en los dos proyectos de escritorio; ver la nota de arriba.",
|
"El barrido de anchos corre solo en los dos proyectos de escritorio; ver la nota de arriba.",
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto(ruta, { waitUntil: "networkidle" });
|
await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
for (const ancho of ANCHOS) {
|
for (const ancho of ANCHOS) {
|
||||||
await page.setViewportSize({ width: ancho, height: 900 });
|
await page.setViewportSize({ width: ancho, height: 900 });
|
||||||
@@ -83,7 +106,7 @@ for (const ruta of RUTAS_MUESTRA) {
|
|||||||
const culpables = await culpablesDeDesborde(page);
|
const culpables = await culpablesDeDesborde(page);
|
||||||
expect(
|
expect(
|
||||||
r.desborda,
|
r.desborda,
|
||||||
`${ruta} desborda a ${ancho}px (documento ${r.ancho}px sobre ventana ${r.visible}px). Sospechosos: ${culpables.join(", ") || "no identificados"}`,
|
`${ruta} se desplaza en horizontal a ${ancho}px de ancho: la página se corrió ${r.movio}px (cuerpo ${r.ancho}px sobre ventana ${r.visible}px). Sospechosos: ${culpables.join(", ") || "no identificados"}`,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,13 +143,13 @@ test("el widget de WhatsApp no tapa contenido ni desborda en móvil", async ({ p
|
|||||||
expect(caja!.x + caja!.width, "el panel se sale por la derecha").toBeLessThanOrEqual(321);
|
expect(caja!.x + caja!.width, "el panel se sale por la derecha").toBeLessThanOrEqual(321);
|
||||||
|
|
||||||
const r = await desbordaEnHorizontal(page);
|
const r = await desbordaEnHorizontal(page);
|
||||||
expect(r.desborda, "el widget abierto no debe provocar desborde del documento").toBe(false);
|
expect(r.desborda, "el widget abierto no debe hacer que la página se desplace de lado").toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("las tablas anchas se desplazan dentro de su contenedor", async ({ page }) => {
|
test("las tablas anchas se desplazan dentro de su contenedor", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 375, height: 780 });
|
await page.setViewportSize({ width: 375, height: 780 });
|
||||||
await page.goto("/catalogo", { waitUntil: "networkidle" });
|
await page.goto("/catalogo", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const r = await desbordaEnHorizontal(page);
|
const r = await desbordaEnHorizontal(page);
|
||||||
expect(r.desborda, "el comparador no debe desplazar el cuerpo de la página").toBe(false);
|
expect(r.desborda, "el comparador debe desplazarse dentro de su contenedor, no mover la página").toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -94,7 +94,9 @@ test("un mismo contacto puede enviar varias veces y se acumula", async ({ page }
|
|||||||
await forma.getByRole("button", { name: /recibir cotizaci/i }).click();
|
await forma.getByRole("button", { name: /recibir cotizaci/i }).click();
|
||||||
|
|
||||||
const aviso = page.getByText(/OPP-\d{4}-\d{4}/);
|
const aviso = page.getByText(/OPP-\d{4}-\d{4}/);
|
||||||
await expect(aviso).toBeVisible({ timeout: 15_000 });
|
// 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 texto = (await aviso.first().textContent()) ?? "";
|
||||||
const folio = texto.match(/OPP-\d{4}-\d{4}/)?.[0];
|
const folio = texto.match(/OPP-\d{4}-\d{4}/)?.[0];
|
||||||
expect(folio, `envío ${i + 1} debe devolver folio`).toBeTruthy();
|
expect(folio, `envío ${i + 1} debe devolver folio`).toBeTruthy();
|
||||||
|
|||||||
+111
-35
@@ -29,11 +29,27 @@ async function accesoRapido(page: Page, rol: "admin" | "vendedor"): Promise<stri
|
|||||||
return nombre;
|
return nombre;
|
||||||
}
|
}
|
||||||
|
|
||||||
test("el panel no se enlaza desde ninguna página pública", async ({ page }) => {
|
/**
|
||||||
for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones"]) {
|
* Que el panel no esté enlazado es una propiedad del HTML servido, no del
|
||||||
await page.goto(ruta, { waitUntil: "domcontentloaded" });
|
* navegador: se comprueba leyendo el marcado, sin abrir páginas.
|
||||||
const enlaces = page.locator('a[href^="/admin"]');
|
*
|
||||||
await expect(enlaces, `${ruta} no debe enlazar al panel`).toHaveCount(0);
|
* La versión anterior navegaba las cuatro rutas en un bucle y se caía sola —
|
||||||
|
* `net::ERR_ABORTED` al abortar la navegación previa, y tiempos agotados al
|
||||||
|
* esperar la carga completa de cada una— sin llegar nunca a evaluar lo que
|
||||||
|
* pretendía. Esta versión es determinista y termina en milisegundos.
|
||||||
|
*/
|
||||||
|
test("el panel no se enlaza desde ninguna página pública", async ({ request }) => {
|
||||||
|
for (const ruta of ["/", "/catalogo", "/contacto", "/soluciones", "/producto/enforcer-10"]) {
|
||||||
|
const res = await request.get(ruta);
|
||||||
|
expect(res.status(), `${ruta} debe responder`).toBe(200);
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
const enlaces = [...html.matchAll(/href="(\/admin[^"]*)"/g)].map((m) => m[1]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
enlaces,
|
||||||
|
`${ruta} enlaza al panel, y el panel no debe estar expuesto en el sitio público`,
|
||||||
|
).toEqual([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,6 +75,14 @@ test("el administrador entra de un clic y ve el tablero con datos", async ({ pag
|
|||||||
const numeros = cuerpo.match(/\d+/g) ?? [];
|
const numeros = cuerpo.match(/\d+/g) ?? [];
|
||||||
expect(numeros.length, "el tablero debe mostrar cifras").toBeGreaterThan(8);
|
expect(numeros.length, "el tablero debe mostrar cifras").toBeGreaterThan(8);
|
||||||
|
|
||||||
|
// En móvil la navegación del panel vive en un cajón cerrado, así que los
|
||||||
|
// enlaces existen pero no están visibles. Se abre primero, que es lo que hace
|
||||||
|
// cualquiera, y de paso se comprueba que el cajón funcione.
|
||||||
|
const disparadorMenu = page.getByRole("button", { name: /men[úu]/i }).first();
|
||||||
|
if (await disparadorMenu.isVisible().catch(() => false)) {
|
||||||
|
await disparadorMenu.click();
|
||||||
|
}
|
||||||
|
|
||||||
// navegación completa disponible para el administrador
|
// 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]) {
|
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();
|
await expect(page.getByRole("link", { name: seccion }).first(), `falta ${seccion}`).toBeVisible();
|
||||||
@@ -82,7 +106,7 @@ test("el vendedor no ve las secciones de administración", async ({ page }) => {
|
|||||||
|
|
||||||
test("el vendedor solo ve oportunidades propias", async ({ page }) => {
|
test("el vendedor solo ve oportunidades propias", async ({ page }) => {
|
||||||
const nombreVendedor = await accesoRapido(page, "vendedor");
|
const nombreVendedor = await accesoRapido(page, "vendedor");
|
||||||
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const filas = page.locator("table tbody tr");
|
const filas = page.locator("table tbody tr");
|
||||||
const total = await filas.count();
|
const total = await filas.count();
|
||||||
@@ -101,34 +125,65 @@ test("el vendedor solo ve oportunidades propias", async ({ page }) => {
|
|||||||
expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200);
|
expect(total, "el vendedor debe ver un subconjunto acotado").toBeLessThan(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("el administrador ve más oportunidades que un vendedor", async ({ browser }) => {
|
/**
|
||||||
|
* La propiedad que importa no es cuántos folios se ven en la primera página
|
||||||
|
* —ambos listados están paginados, así que un folio del vendedor puede estar en
|
||||||
|
* la página tres del administrador y la comparación no probaría nada— sino que
|
||||||
|
* un vendedor **no pueda abrir** una oportunidad que no le pertenece.
|
||||||
|
*
|
||||||
|
* Eso es una falla de seguridad si se rompe, no un detalle de interfaz.
|
||||||
|
*/
|
||||||
|
test("un vendedor no puede abrir una oportunidad de otra persona", async ({ browser }) => {
|
||||||
const ctxAdmin = await browser.newContext();
|
const ctxAdmin = await browser.newContext();
|
||||||
const ctxVendedor = await browser.newContext();
|
const ctxVendedor = await browser.newContext();
|
||||||
const pAdmin = await ctxAdmin.newPage();
|
const pAdmin = await ctxAdmin.newPage();
|
||||||
const pVendedor = await ctxVendedor.newPage();
|
const pVendedor = await ctxVendedor.newPage();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// el administrador reúne los identificadores de todo el universo
|
||||||
await accesoRapido(pAdmin, "admin");
|
await accesoRapido(pAdmin, "admin");
|
||||||
await pAdmin.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
await pAdmin.goto("/admin/oportunidades?porPagina=100", { waitUntil: "domcontentloaded" });
|
||||||
const textoAdmin = (await pAdmin.locator("body").textContent()) ?? "";
|
const idsAdmin = await pAdmin.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('a[href*="/admin/oportunidades/"]'))
|
||||||
|
.map((a) => a.getAttribute("href")!.split("/admin/oportunidades/")[1]?.split(/[?#]/)[0] ?? "")
|
||||||
|
.filter((v) => v.length > 6),
|
||||||
|
);
|
||||||
|
expect(idsAdmin.length, "el administrador debe ver oportunidades").toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// el vendedor reúne las suyas
|
||||||
await accesoRapido(pVendedor, "vendedor");
|
await accesoRapido(pVendedor, "vendedor");
|
||||||
await pVendedor.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
await pVendedor.goto("/admin/oportunidades?porPagina=100", { waitUntil: "domcontentloaded" });
|
||||||
const textoVendedor = (await pVendedor.locator("body").textContent()) ?? "";
|
const idsVendedor = new Set(
|
||||||
|
await pVendedor.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('a[href*="/admin/oportunidades/"]'))
|
||||||
|
.map((a) => a.getAttribute("href")!.split("/admin/oportunidades/")[1]?.split(/[?#]/)[0] ?? "")
|
||||||
|
.filter((v) => v.length > 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const foliosAdmin = new Set(textoAdmin.match(/OPP-\d{4}-\d{4}/g) ?? []);
|
// el alcance del vendedor tiene que ser un subconjunto propio
|
||||||
const foliosVendedor = new Set(textoVendedor.match(/OPP-\d{4}-\d{4}/g) ?? []);
|
|
||||||
|
|
||||||
expect(foliosAdmin.size, "el administrador debe ver folios").toBeGreaterThan(0);
|
|
||||||
expect(
|
expect(
|
||||||
foliosAdmin.size,
|
idsVendedor.size,
|
||||||
"el alcance del administrador debe ser mayor o igual al del vendedor",
|
"el vendedor no debería ver todo el universo del administrador",
|
||||||
).toBeGreaterThanOrEqual(foliosVendedor.size);
|
).toBeLessThan(idsAdmin.length);
|
||||||
|
|
||||||
// y ningún folio del vendedor debe estar fuera del universo del administrador
|
// y una oportunidad ajena, pedida por URL directa, debe negarse
|
||||||
for (const f of foliosVendedor) {
|
const ajena = idsAdmin.find((id) => !idsVendedor.has(id));
|
||||||
expect(foliosAdmin.has(f), `el folio ${f} del vendedor no aparece para el administrador`).toBe(true);
|
expect(ajena, "hace falta una oportunidad ajena para probar el aislamiento").toBeTruthy();
|
||||||
}
|
|
||||||
|
const res = await pVendedor.goto(`/admin/oportunidades/${ajena}`, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
const cuerpo = (await pVendedor.locator("body").textContent()) ?? "";
|
||||||
|
const negado =
|
||||||
|
res?.status() === 404 ||
|
||||||
|
!pVendedor.url().includes(ajena!) ||
|
||||||
|
/no encontrad|sin permiso|no autorizado|no tienes/i.test(cuerpo);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
negado,
|
||||||
|
`un vendedor abrió la oportunidad ajena ${ajena} — es una fuga de datos entre roles`,
|
||||||
|
).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
await ctxAdmin.close();
|
await ctxAdmin.close();
|
||||||
await ctxVendedor.close();
|
await ctxVendedor.close();
|
||||||
@@ -137,12 +192,18 @@ test("el administrador ve más oportunidades que un vendedor", async ({ browser
|
|||||||
|
|
||||||
test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => {
|
test("el detalle de una oportunidad muestra la atribución completa", async ({ page }) => {
|
||||||
await accesoRapido(page, "admin");
|
await accesoRapido(page, "admin");
|
||||||
await page.goto("/admin/oportunidades", { waitUntil: "networkidle" });
|
await page.goto("/admin/oportunidades", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const primera = page.locator('a[href^="/admin/oportunidades/"]').first();
|
// se navega por URL con un identificador real: hacer clic en "el primer
|
||||||
await expect(primera).toBeVisible();
|
// enlace que empiece con la ruta" caía en un enlace del propio listado y la
|
||||||
await primera.click();
|
// prueba terminaba comprobando la lista, no el detalle
|
||||||
await page.waitForLoadState("networkidle");
|
const id = await page.evaluate(() => {
|
||||||
|
const a = document.querySelector('a[href*="/admin/oportunidades/"]');
|
||||||
|
return a?.getAttribute("href")?.split("/admin/oportunidades/")[1]?.split(/[?#]/)[0] ?? null;
|
||||||
|
});
|
||||||
|
expect(id, "el listado debe ofrecer al menos una oportunidad").toBeTruthy();
|
||||||
|
await page.goto(`/admin/oportunidades/${id}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||||
|
|
||||||
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
||||||
// los campos que justifican todo el sistema de medición de campañas
|
// los campos que justifican todo el sistema de medición de campañas
|
||||||
@@ -154,12 +215,14 @@ test("el detalle de una oportunidad muestra la atribución completa", async ({ p
|
|||||||
|
|
||||||
test("el historial del contacto acumula sus envíos", async ({ page }) => {
|
test("el historial del contacto acumula sus envíos", async ({ page }) => {
|
||||||
await accesoRapido(page, "admin");
|
await accesoRapido(page, "admin");
|
||||||
await page.goto("/admin/contactos", { waitUntil: "networkidle" });
|
await page.goto("/admin/contactos", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const primera = page.locator('a[href^="/admin/contactos/"]').first();
|
const id = await page.evaluate(() => {
|
||||||
await expect(primera).toBeVisible();
|
const a = document.querySelector('a[href*="/admin/contactos/"]');
|
||||||
await primera.click();
|
return a?.getAttribute("href")?.split("/admin/contactos/")[1]?.split(/[?#]/)[0] ?? null;
|
||||||
await page.waitForLoadState("networkidle");
|
});
|
||||||
|
expect(id, "el listado debe ofrecer al menos un contacto").toBeTruthy();
|
||||||
|
await page.goto(`/admin/contactos/${id}`, { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
const cuerpo = (await page.locator("body").textContent()) ?? "";
|
||||||
expect(cuerpo, "la ficha del contacto debe listar sus oportunidades").toMatch(/OPP-\d{4}-\d{4}/);
|
expect(cuerpo, "la ficha del contacto debe listar sus oportunidades").toMatch(/OPP-\d{4}-\d{4}/);
|
||||||
@@ -168,12 +231,25 @@ test("el historial del contacto acumula sus envíos", async ({ page }) => {
|
|||||||
test("el panel es usable en móvil", async ({ page }) => {
|
test("el panel es usable en móvil", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 375, height: 780 });
|
await page.setViewportSize({ width: 375, height: 780 });
|
||||||
await accesoRapido(page, "admin");
|
await accesoRapido(page, "admin");
|
||||||
|
// se recarga el tablero para medir sin el desplazamiento residual que deja el
|
||||||
|
// envío del formulario de acceso; es además el estado en el que cualquiera ve
|
||||||
|
// el panel a partir de su segunda página
|
||||||
|
await page.goto("/admin", { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("h1").first().waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
// se mide si la página se corre de lado, que es lo único que percibe la
|
||||||
|
// persona; `documentElement.scrollWidth` da falsos positivos cuando hay
|
||||||
|
// contenedores con scroll propio
|
||||||
const r = await page.evaluate(() => {
|
const r = await page.evaluate(() => {
|
||||||
const d = document.documentElement;
|
window.scrollTo(800, window.scrollY);
|
||||||
return { desborda: d.scrollWidth > d.clientWidth + 2, ancho: d.scrollWidth, visible: d.clientWidth };
|
const movio = window.scrollX;
|
||||||
|
window.scrollTo(0, window.scrollY);
|
||||||
|
return { movio, cuerpo: document.body.scrollWidth, visible: document.documentElement.clientWidth };
|
||||||
});
|
});
|
||||||
expect(r.desborda, `el tablero desborda en móvil (${r.ancho} sobre ${r.visible})`).toBe(false);
|
expect(
|
||||||
|
r.movio > 2,
|
||||||
|
`el tablero se desplaza en horizontal en móvil: se corrió ${r.movio}px (cuerpo ${r.cuerpo} sobre ${r.visible})`,
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cerrar sesión devuelve al acceso", async ({ page }) => {
|
test("cerrar sesión devuelve al acceso", async ({ page }) => {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { RUTAS_MUESTRA } from "./rutas";
|
|||||||
|
|
||||||
for (const ruta of RUTAS_MUESTRA) {
|
for (const ruta of RUTAS_MUESTRA) {
|
||||||
test(`accesibilidad básica en ${ruta}`, async ({ page }) => {
|
test(`accesibilidad básica en ${ruta}`, async ({ page }) => {
|
||||||
await page.goto(ruta, { waitUntil: "networkidle" });
|
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 problemas = await page.evaluate(() => {
|
||||||
const fallas: string[] = [];
|
const fallas: string[] = [];
|
||||||
@@ -48,6 +52,12 @@ for (const ruta of RUTAS_MUESTRA) {
|
|||||||
for (const el of Array.from(document.querySelectorAll<HTMLElement>("button, a"))) {
|
for (const el of Array.from(document.querySelectorAll<HTMLElement>("button, a"))) {
|
||||||
const r = el.getBoundingClientRect();
|
const r = el.getBoundingClientRect();
|
||||||
if (r.width === 0 && r.height === 0) continue;
|
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 texto = (el.textContent ?? "").trim();
|
||||||
const nombre = texto || el.getAttribute("aria-label") || el.getAttribute("title") || "";
|
const nombre = texto || el.getAttribute("aria-label") || el.getAttribute("title") || "";
|
||||||
if (!nombre) {
|
if (!nombre) {
|
||||||
@@ -85,7 +95,17 @@ for (const ruta of RUTAS_MUESTRA) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("el salto al contenido funciona con teclado", async ({ page }) => {
|
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.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
await page.keyboard.press("Tab");
|
await page.keyboard.press("Tab");
|
||||||
|
|
||||||
@@ -97,7 +117,12 @@ test("el salto al contenido funciona con teclado", async ({ page }) => {
|
|||||||
expect(enfocado.href).toBe("#contenido");
|
expect(enfocado.href).toBe("#contenido");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("el foco es visible al navegar con teclado", async ({ page }) => {
|
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" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
for (let i = 0; i < 5; i++) await page.keyboard.press("Tab");
|
for (let i = 0; i < 5; i++) await page.keyboard.press("Tab");
|
||||||
|
|
||||||
@@ -131,7 +156,7 @@ test("el widget de WhatsApp se cierra con Escape y devuelve el foco", async ({ p
|
|||||||
|
|
||||||
test("se respeta la preferencia de movimiento reducido", async ({ page }) => {
|
test("se respeta la preferencia de movimiento reducido", async ({ page }) => {
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await page.goto("/", { waitUntil: "networkidle" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
// con movimiento reducido, el contenido revelado debe estar visible, no atorado en opacidad 0
|
// con movimiento reducido, el contenido revelado debe estar visible, no atorado en opacidad 0
|
||||||
const ocultos = await page.evaluate(() => {
|
const ocultos = await page.evaluate(() => {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user