// 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); }); });