diff --git a/tests/e2e/integracion.spec.mjs b/tests/e2e/integracion.spec.mjs new file mode 100644 index 0000000..e3a545e --- /dev/null +++ b/tests/e2e/integracion.spec.mjs @@ -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 = ''; + await importar(app, estadoCon([ + { id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: img + " *hola* " }, + { + 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," }, + ], + }, + { + 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(" 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); + }); +});