Tests: cubrir scroll inicial, zoom, JSON malicioso, recortes, contador de pasos, horas e IDs de sesión, formato, citas y documentos
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
17e672e45f
commit
1176003b42
@@ -0,0 +1,311 @@
|
|||||||
|
// Cobertura de lo integrado tras los parches de render, editor/toolbar y lógica:
|
||||||
|
// scroll inicial, zoom manual, JSON malicioso, recortes al importar, contador
|
||||||
|
// de pasos, horas e IDs de sesión, formato WhatsApp, citas y documentos.
|
||||||
|
import {
|
||||||
|
test, expect, estado, filasChat, filaChat, cargarEstado, rapido, irA, URL_FILE, tarjetas,
|
||||||
|
} from "./fixtures.mjs";
|
||||||
|
|
||||||
|
async function importar(page, datos, nombre = "demo.json") {
|
||||||
|
const buffer = Buffer.from(typeof datos === "string" ? datos : JSON.stringify(datos));
|
||||||
|
await page.locator("#btn-importar").click();
|
||||||
|
const confirmar = page.locator("#dlg-confirmar");
|
||||||
|
const [chooser] = await Promise.all([
|
||||||
|
page.waitForEvent("filechooser"),
|
||||||
|
(async () => {
|
||||||
|
if (await confirmar.isVisible().catch(() => false)) {
|
||||||
|
await confirmar.getByRole("button", { name: "Elegir archivo…" }).click();
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
await chooser.setFiles({ name: nombre, mimeType: "application/json", buffer });
|
||||||
|
}
|
||||||
|
|
||||||
|
const alFondo = (page) => page.evaluate(() => {
|
||||||
|
const c = document.getElementById("chat-cuerpo");
|
||||||
|
return Math.abs(c.scrollHeight - c.clientHeight - c.scrollTop) <= 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
function estadoCon(mensajes, extra = {}) {
|
||||||
|
return { version: 1, tema: "claro", cuenta: { nombre: "Prueba", verificado: false }, mensajes, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Scroll inicial del chat", () => {
|
||||||
|
test("al cargar, el chat queda pegado al último mensaje", async ({ app }) => {
|
||||||
|
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
|
||||||
|
// y sigue abajo tras recargar (imágenes data:SVG que decodifican tarde)
|
||||||
|
await app.reload();
|
||||||
|
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("por file:// también arranca abajo", async ({ page }) => {
|
||||||
|
await irA(page, URL_FILE);
|
||||||
|
await expect.poll(() => alFondo(page), { timeout: 5000 }).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Zoom manual", () => {
|
||||||
|
const ancho = (page) => page.locator("#iphone").evaluate((el) => el.getBoundingClientRect().width);
|
||||||
|
|
||||||
|
test("Ajustar / 100 % / 125 % cambian la escala y se recuerda", async ({ app }) => {
|
||||||
|
const sel = app.locator("#sel-zoom");
|
||||||
|
await expect(sel).toBeVisible();
|
||||||
|
await expect(sel).toHaveValue("");
|
||||||
|
const ajustado = await ancho(app);
|
||||||
|
await sel.selectOption("125");
|
||||||
|
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "125");
|
||||||
|
await expect.poll(() => ancho(app)).toBeGreaterThan(ajustado);
|
||||||
|
const grande = await ancho(app);
|
||||||
|
await sel.selectOption("100");
|
||||||
|
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "100");
|
||||||
|
await expect.poll(() => ancho(app)).toBeLessThan(grande);
|
||||||
|
await app.reload();
|
||||||
|
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "100");
|
||||||
|
await expect(app.locator("#sel-zoom")).toHaveValue("100");
|
||||||
|
await app.locator("#sel-zoom").selectOption("");
|
||||||
|
await expect(app.locator("body")).not.toHaveAttribute("data-wam-zoom", /.*/);
|
||||||
|
await expect.poll(() => ancho(app)).toBeCloseTo(ajustado, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("en Presentación manda el ajuste automático", async ({ app }) => {
|
||||||
|
await app.locator("#sel-zoom").selectOption("125");
|
||||||
|
await app.locator("#btn-presentacion").click();
|
||||||
|
await expect(app.locator("body")).toHaveClass(/presentacion/);
|
||||||
|
const caja = await app.locator("#iphone").boundingBox();
|
||||||
|
const vh = app.viewportSize().height;
|
||||||
|
expect(caja.y + caja.height).toBeLessThanOrEqual(vh + 1);
|
||||||
|
await app.keyboard.press("Escape");
|
||||||
|
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("JSON malicioso", () => {
|
||||||
|
test("importar HTML y URLs peligrosas no ejecuta nada ni inyecta nodos", async ({ app }) => {
|
||||||
|
const img = '<img src=x onerror="window.__xss=1">';
|
||||||
|
await importar(app, estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: img + " *hola* <script>window.__xss=2</script>" },
|
||||||
|
{
|
||||||
|
id: "m2", dir: "in", tipo: "template", hora: "10:01", headerTipo: "texto", headerTexto: img, body: img,
|
||||||
|
botones: [
|
||||||
|
{ texto: img, url: "javascript:window.__xss=3" },
|
||||||
|
{ texto: "Datos", url: "data:text/html,<script>window.__xss=4</script>" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m3", dir: "in", tipo: "carousel", hora: "10:02", body: "",
|
||||||
|
cards: [
|
||||||
|
{ media: "javascript:alert(1)", texto: img, textoBoton: "Ver", url: "vbscript:msgbox(1)" },
|
||||||
|
{ media: "", texto: "ok", textoBoton: "Ver" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "m4", dir: "in", tipo: "list", hora: "10:03", body: "Menú", boton: img,
|
||||||
|
secciones: [{ titulo: img, filas: [{ id: "f1", titulo: img, descripcion: img }] }],
|
||||||
|
},
|
||||||
|
{ id: "m5", dir: "in", tipo: "image", hora: "10:04", src: "javascript:window.__xss=5", caption: img },
|
||||||
|
], { cuenta: { nombre: img, avatar: "javascript:window.__xss=6", verificado: false } }));
|
||||||
|
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(5);
|
||||||
|
const e = await estado(app);
|
||||||
|
// URLs peligrosas bloqueadas y marcadas
|
||||||
|
expect(e.mensajes[1].botones[0]).toMatchObject({ url: "", urlBloqueada: true });
|
||||||
|
expect(e.mensajes[1].botones[1]).toMatchObject({ url: "", urlBloqueada: true });
|
||||||
|
expect(e.mensajes[2].cards[0]).toMatchObject({ url: "", urlBloqueada: true });
|
||||||
|
expect(e.mensajes[4].src).toBe("");
|
||||||
|
// nada ejecutable en el DOM del chat ni del editor
|
||||||
|
const sospechosos = await app.evaluate(() => ({
|
||||||
|
scripts: document.querySelectorAll("#pantalla script, #panel-editor script").length,
|
||||||
|
onerror: document.querySelectorAll("[onerror]").length,
|
||||||
|
jsHref: document.querySelectorAll('a[href^="javascript"], a[href^="data:text"], a[href^="vbscript"]').length,
|
||||||
|
jsSrc: Array.from(document.querySelectorAll("img, video")).filter((el) => /^javascript:/i.test(el.getAttribute("src") || "")).length,
|
||||||
|
}));
|
||||||
|
expect(sospechosos).toEqual({ scripts: 0, onerror: 0, jsHref: 0, jsSrc: 0 });
|
||||||
|
// el texto se ve literal
|
||||||
|
await expect(filaChat(app, "m1")).toContainText("<img src=x");
|
||||||
|
await expect(app.locator("#chat-nombre")).toContainText("<img src=x");
|
||||||
|
// botones bloqueados: inertes, sin flujo
|
||||||
|
await expect(filaChat(app, "m2").locator(".qr-btn-inactiva")).toHaveCount(2);
|
||||||
|
await expect(filaChat(app, "m2").locator("[data-flujo]")).toHaveCount(0);
|
||||||
|
await expect(filaChat(app, "m3").locator(".card").first().locator(".qr-btn-inactiva")).toHaveCount(1);
|
||||||
|
const n = await filasChat(app).count();
|
||||||
|
await filaChat(app, "m2").locator(".qr-btn-inactiva").first().click({ force: true });
|
||||||
|
await app.waitForTimeout(600);
|
||||||
|
await expect(filasChat(app)).toHaveCount(n);
|
||||||
|
// la hoja de lista también pinta texto literal
|
||||||
|
await filaChat(app, "m4").locator("[data-flujo]").click();
|
||||||
|
await expect(app.locator(".sheet-panel").first()).toContainText("<img src=x");
|
||||||
|
expect(await app.evaluate(() => window.__xss)).toBeUndefined();
|
||||||
|
expect(await app.evaluate(() => document.querySelectorAll("[onerror]").length)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editar a mano la URL de un botón bloqueado retira la marca", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "b", botones: [{ texto: "Ir", url: "javascript:1" }] },
|
||||||
|
]));
|
||||||
|
expect((await estado(app)).mensajes[0].botones[0].urlBloqueada).toBe(true);
|
||||||
|
await app.evaluate(() => WAM.setCampo("mensajes.0.botones.0.url", "https://example.com"));
|
||||||
|
const b = (await estado(app)).mensajes[0].botones[0];
|
||||||
|
expect(b.urlBloqueada).toBeUndefined();
|
||||||
|
await expect(filaChat(app, "m1").locator('a.qr-btn[href="https://example.com"]')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un JSON enorme se recorta y el aviso lo cuenta", async ({ app }) => {
|
||||||
|
const mensajes = Array.from({ length: 505 }, (_, i) => ({ id: "m" + (i + 1), dir: "in", tipo: "text", hora: "10:00", texto: "n" + i }));
|
||||||
|
await importar(app, estadoCon(mensajes));
|
||||||
|
await expect.poll(async () => (await estado(app)).mensajes.length, { timeout: 15000 }).toBe(500);
|
||||||
|
await expect(app.getByText("Se importaron 500 de 505 mensajes")).toBeVisible();
|
||||||
|
// el resumen no se guarda ni se exporta
|
||||||
|
const guardado = await app.evaluate(() => { WAM.guardarYa(); return localStorage.getItem("wam-mockup-v1"); });
|
||||||
|
expect(guardado).not.toContain("__recortes");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Demo: contador, horas e IDs de sesión", () => {
|
||||||
|
const GUION = estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "text", hora: "10:30", texto: "Hola" },
|
||||||
|
{ id: "m2", dir: "out", tipo: "text", hora: "10:31", texto: "Buenas", estado: "read" },
|
||||||
|
{ id: "m3", dir: "in", tipo: "text", hora: "10:33", texto: "¿Qué tal?" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
test("mientras corre se ve «Paso N/M» y el botón muestra Detener", async ({ app }) => {
|
||||||
|
await cargarEstado(app, GUION);
|
||||||
|
await rapido(app);
|
||||||
|
const paso = app.locator("#demo-paso");
|
||||||
|
await expect(paso).toBeHidden();
|
||||||
|
await app.locator("#btn-reproducir").click();
|
||||||
|
await expect(paso).toBeVisible();
|
||||||
|
await expect(paso).toHaveText(/Paso \d+\/3/);
|
||||||
|
await expect(app.locator("#btn-reproducir")).toContainText("Detener");
|
||||||
|
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 10000 }).toBe(false);
|
||||||
|
await expect(paso).toBeHidden();
|
||||||
|
await expect(app.locator("#btn-reproducir")).toContainText("Reproducir demo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la saliente de un botón toma la hora del último mensaje + 1 min", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "text", hora: "10:30", texto: "Hola" },
|
||||||
|
{
|
||||||
|
id: "m2", dir: "in", tipo: "buttons", hora: "10:33", body: "¿Qué quieres?",
|
||||||
|
botones: [{ texto: "Precios", respuestas: [{ id: "m3", dir: "in", tipo: "text", hora: "", texto: "Barato" }] }],
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
await rapido(app);
|
||||||
|
await filaChat(app, "m2").locator("[data-flujo]").first().click();
|
||||||
|
const out = app.locator('#chat-cuerpo li.out[data-id^="s"]').last();
|
||||||
|
await expect(out).toContainText("Precios");
|
||||||
|
await expect(out.locator(".meta")).toContainText("10:34");
|
||||||
|
const resp = app.locator('#chat-cuerpo li.in[data-id^="s"]').last();
|
||||||
|
await expect(resp).toContainText("Barato", { timeout: 6000 });
|
||||||
|
await expect(resp.locator(".meta")).toContainText("10:35");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("los IDs de sesión no chocan con los del guion", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{ id: "s1", dir: "in", tipo: "text", hora: "10:00", texto: "uno" },
|
||||||
|
{ id: "s2", dir: "in", tipo: "text", hora: "10:01", texto: "dos" },
|
||||||
|
]));
|
||||||
|
await app.locator("#composer-input").fill("tres");
|
||||||
|
await app.locator("#composer-input").press("Enter");
|
||||||
|
const ids = await app.locator("#chat-cuerpo > li.wa-row").evaluateAll((els) => els.map((e) => e.getAttribute("data-id")));
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
expect(ids[2]).toMatch(/^s/);
|
||||||
|
expect(["s1", "s2"]).not.toContain(ids[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("«Repetir desde el inicio» vuelve al guion tras pulsar un botón", async ({ app }) => {
|
||||||
|
const n = (await estado(app)).mensajes.length;
|
||||||
|
const btn = app.locator("#btn-reiniciar");
|
||||||
|
await expect(btn).toContainText("Repetir desde el inicio");
|
||||||
|
await rapido(app);
|
||||||
|
await app.locator("#chat-cuerpo [data-flujo][data-btn]").first().click();
|
||||||
|
await expect.poll(() => filasChat(app).count()).toBeGreaterThan(n);
|
||||||
|
await btn.click();
|
||||||
|
await expect(filasChat(app)).toHaveCount(n);
|
||||||
|
await expect(app.locator("#chat-cuerpo .qr-btn.usado")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Formato, citas y documentos", () => {
|
||||||
|
test("negrita solo con marcas pegadas al texto; «2 * 3 * 4» queda literal", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: "*negrita* y _cursiva_ y ~tachado~" },
|
||||||
|
{ id: "m2", dir: "in", tipo: "text", hora: "10:01", texto: "2 * 3 * 4 y *negrita *" },
|
||||||
|
]));
|
||||||
|
await expect(filaChat(app, "m1").locator("strong, b")).toHaveText("negrita");
|
||||||
|
await expect(filaChat(app, "m1").locator("em, i")).toHaveText("cursiva");
|
||||||
|
await expect(filaChat(app, "m1").locator("s, del, strike")).toHaveText("tachado");
|
||||||
|
await expect(filaChat(app, "m2").locator("strong, b")).toHaveCount(0);
|
||||||
|
await expect(filaChat(app, "m2")).toContainText("2 * 3 * 4 y *negrita *");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la cita de la respuesta quita el formato con la misma regla", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{
|
||||||
|
id: "m1", dir: "in", tipo: "buttons", hora: "10:00", body: "*Hola* 2 * 3",
|
||||||
|
botones: [{ texto: "Vale", respuestas: [] }],
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
await rapido(app);
|
||||||
|
await filaChat(app, "m1").locator("[data-flujo]").click();
|
||||||
|
const cita = app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-extracto').last();
|
||||||
|
await expect(cita).toHaveText("Hola 2 * 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("citar una plantilla con cabecera de documento muestra icono y nombre", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{
|
||||||
|
id: "m1", dir: "in", tipo: "template", hora: "10:00", headerTipo: "documento", headerTexto: "carta.pdf",
|
||||||
|
body: "Te enviamos la carta", botones: [{ texto: "Gracias", respuestas: [] }],
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
await rapido(app);
|
||||||
|
await filaChat(app, "m1").locator("[data-flujo]").click();
|
||||||
|
const tit = app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-titulo').last();
|
||||||
|
await expect(tit).toHaveText("carta.pdf");
|
||||||
|
await expect(tit.locator("svg.wa-cita-doc")).toHaveCount(1);
|
||||||
|
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"] .wa-cita-mini')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("documento: «tamaño • ext» con tamano; si no, la extensión en mayúsculas", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "document", hora: "10:00", filename: "menu.pdf", tamano: "243 KB" },
|
||||||
|
{ id: "m2", dir: "in", tipo: "document", hora: "10:01", filename: "hoja.xlsx" },
|
||||||
|
{
|
||||||
|
id: "m3", dir: "in", tipo: "template", hora: "10:02", headerTipo: "documento", headerTexto: "cat.pdf",
|
||||||
|
headerTamano: "1,2 MB", body: "b", botones: [],
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
await expect(filaChat(app, "m1").locator(".doc-sub")).toHaveText("243 KB • pdf");
|
||||||
|
await expect(filaChat(app, "m2").locator(".doc-sub")).toHaveText("XLSX");
|
||||||
|
await expect(filaChat(app, "m3").locator(".doc-sub")).toHaveText("1,2 MB • pdf");
|
||||||
|
const e = await estado(app);
|
||||||
|
expect(e.mensajes[1].tamano).toBeUndefined();
|
||||||
|
expect(e.mensajes[2].headerTamano).toBe("1,2 MB");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("botón con URL sin esquema abre con https:// y no da aviso de validación", async ({ app }) => {
|
||||||
|
await cargarEstado(app, estadoCon([
|
||||||
|
{ id: "m1", dir: "in", tipo: "template", hora: "10:00", body: "b", botones: [{ texto: "Web", url: "www.cafe-aurora.mx" }] },
|
||||||
|
]));
|
||||||
|
await expect(filaChat(app, "m1").locator('a.qr-btn[href="https://www.cafe-aurora.mx"]')).toHaveCount(1);
|
||||||
|
await expect(tarjetas(app).first().locator(".ed-validacion")).toBeHidden();
|
||||||
|
await expect(tarjetas(app).first()).not.toContainText("URL no válida");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Móvil sin marco @movil", () => {
|
||||||
|
test("pantalla a todo el ancho, sin isla ni zoom, y el chat arranca abajo @movil", async ({ app }) => {
|
||||||
|
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(app.locator("#sel-zoom")).toBeHidden();
|
||||||
|
await expect(app.locator("#isla")).toBeHidden();
|
||||||
|
const caja = await app.locator("#pantalla").boundingBox();
|
||||||
|
expect(Math.round(caja.width)).toBeGreaterThanOrEqual(app.viewportSize().width - 1);
|
||||||
|
await expect.poll(() => alFondo(app), { timeout: 5000 }).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un zoom guardado en escritorio no afecta al móvil @movil", async ({ app }) => {
|
||||||
|
await app.evaluate(() => localStorage.setItem("wam-zoom", "125"));
|
||||||
|
await app.reload();
|
||||||
|
await expect(app.locator("body")).toHaveAttribute("data-wam-zoom", "125");
|
||||||
|
const caja = await app.locator("#pantalla").boundingBox();
|
||||||
|
expect(caja.width).toBeLessThanOrEqual(app.viewportSize().width + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user