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