WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)

This commit is contained in:
urieljareth
2026-09-27 20:46:51 -06:00
parent dc6e31a9d7
commit 9a5f21badf
33 changed files with 7719 additions and 1129 deletions
+107
View File
@@ -0,0 +1,107 @@
// Arranque, render de los 13 tipos de la demo, consola limpia y file://.
import { test, expect, irA, estado, filasChat, filaChat, tarjetas, URL_FILE, TIPOS } from "./fixtures.mjs";
test.describe("Carga inicial", () => {
test("la demo carga sin errores de consola ni red externa", async ({ app }) => {
await expect(app).toHaveTitle(/WhatsApp Cloud API/);
await expect(app.locator("#chat-nombre")).toHaveText("Café Aurora");
await expect(app.locator("#chat-verificado")).toBeVisible();
await expect(app.locator("#chat-subtitulo")).toHaveText("en línea");
// «Hoy» + aviso de cifrado
await expect(app.locator("#chat-cuerpo > li.wa-sistema")).toHaveCount(2);
await expect(app.locator(".wa-fecha")).toHaveText("Hoy");
await expect(app.locator(".wa-aviso")).toContainText("servicio seguro de Meta");
// El fixture comprueba al final que no hubo errores ni peticiones externas
});
test("la demo por defecto recorre los 13 tipos, uno por burbuja", async ({ app }) => {
const e = await estado(app);
const tipos = new Set(e.mensajes.map((m) => m.tipo));
for (const t of TIPOS) expect(tipos, `tipo ${t}`).toContain(t);
await expect(filasChat(app)).toHaveCount(e.mensajes.length);
await expect(tarjetas(app)).toHaveCount(e.mensajes.length);
const porTipo = {};
for (const m of e.mensajes) porTipo[m.tipo] ??= m; // el primero de cada tipo
const fila = (t) => filaChat(app, porTipo[t].id);
await expect(fila("text").locator(".wa-texto")).toContainText("Bienvenido a Café Aurora");
await expect(fila("image").locator("img.wa-media")).toBeVisible();
await expect(fila("image").locator(".wa-caption")).toContainText("Nuevo horario");
await expect(fila("video").locator(".wa-play-badge")).toBeVisible();
await expect(fila("audio").locator(".audio-dur")).toHaveText("0:12");
await expect(fila("audio").locator(".audio-mic")).toBeVisible(); // nota de voz
await expect(fila("document").locator(".doc-nombre")).toHaveText("carta-cafe-aurora.pdf");
await expect(fila("document").locator(".doc-sub")).toContainText("PDF", { ignoreCase: true });
await expect(fila("sticker").locator(".sticker-img")).toBeVisible();
await expect(fila("sticker").locator(".bubble")).toHaveCount(0); // sin burbuja
await expect(fila("location").locator(".loc-nombre")).toHaveText("Café Aurora");
await expect(fila("location").locator(".loc-dir")).toContainText("Calle Mayor");
await expect(fila("contact").locator(".contact-nombre")).toHaveText("Luna Mendoza");
await expect(fila("contact").locator(".contact-accion")).toHaveText(["Mensaje", "Guardar contacto"]);
await expect(fila("template").locator(".tpl-media")).toBeVisible();
await expect(fila("template").locator(".qr-btn")).toHaveCount(2);
await expect(fila("template").locator(".template-footer")).toHaveText("Café Aurora · Reservas");
await expect(fila("buttons").locator("button.qr-btn")).toHaveCount(3);
await expect(fila("list").locator("button.qr-btn")).toHaveText("Ver opciones");
await expect(fila("cta_url").locator("a.qr-btn")).toHaveAttribute("href", "https://cafeaurora.example/tienda");
await expect(fila("cta_url").locator("a.qr-btn")).toHaveAttribute("target", "_blank");
await expect(fila("carousel").locator(".carousel .card")).toHaveCount(3);
});
test("solo los mensajes salientes llevan ticks; la cola va en el último del grupo", async ({ app }) => {
const e = await estado(app);
for (const m of e.mensajes) {
const ticks = filaChat(app, m.id).locator(":scope > * .meta .tick");
if (m.dir === "out") await expect(ticks.first()).toBeVisible();
else await expect(ticks).toHaveCount(0);
}
const out = e.mensajes.find((m) => m.dir === "out" && m.estado === "read");
await expect(filaChat(app, out.id).locator(".tick-read")).toHaveCount(1);
// m1 (in) y m2 (out) son grupos de uno → ambos .grupo.ultimo
await expect(filaChat(app, "m1")).toHaveClass(/grupo/);
await expect(filaChat(app, "m1")).toHaveClass(/ultimo/);
await expect(filaChat(app, "m3")).toHaveClass(/grupo/);
await expect(filaChat(app, "m3")).not.toHaveClass(/ultimo/);
});
test("el carrusel se desplaza en horizontal", async ({ app }) => {
const e = await estado(app);
const id = e.mensajes.find((m) => m.tipo === "carousel").id;
const car = filaChat(app, id).locator(".carousel");
await car.scrollIntoViewIfNeeded();
const { ancho, visible } = await car.evaluate((el) => ({ ancho: el.scrollWidth, visible: el.clientWidth }));
expect(ancho).toBeGreaterThan(visible);
await car.evaluate((el) => { el.scrollLeft = 200; });
expect(await car.evaluate((el) => el.scrollLeft)).toBeGreaterThan(0);
});
test("el marco del iPhone tiene isla, status bar e indicador de inicio", async ({ app }) => {
await expect(app.locator("#iphone")).toBeVisible();
await expect(app.locator("#isla")).toBeVisible();
await expect(app.locator("#statusbar .sb-hora")).toHaveText("9:41");
await expect(app.locator("#home-indicator")).toBeVisible();
const caja = await app.locator("#iphone").boundingBox();
// proporción 393×852 (con tolerancia por el escalado)
expect(caja.height / caja.width).toBeGreaterThan(2.0);
expect(caja.height / caja.width).toBeLessThan(2.3);
});
});
test.describe("Apertura por file://", () => {
test("index.html funciona abierto como archivo local", async ({ page }) => {
await irA(page, URL_FILE);
expect(page.url()).toMatch(/^file:\/\//);
await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora");
const n = (await estado(page)).mensajes.length;
await expect(filasChat(page)).toHaveCount(n);
await expect(tarjetas(page)).toHaveCount(n);
// interacción básica: composer y persistencia
await page.locator("#composer-input").fill("Hola desde file://");
await page.locator("#composer-input").press("Enter");
await expect(page.locator("#chat-cuerpo .wa-row.out").last()).toContainText("Hola desde file://");
await page.locator("#btn-tema").click();
await expect(page.locator("body")).toHaveAttribute("data-tema", "oscuro");
await page.reload();
await expect(page.locator("body")).toHaveAttribute("data-tema", "oscuro");
});
});
+110
View File
@@ -0,0 +1,110 @@
// «Reproducir demo»: revelado paso a paso, detener, velocidad y autoPulsar.
import { test, expect, estado, filasChat, cargarEstado, rapido } from "./fixtures.mjs";
const btn = (page) => page.locator("#btn-reproducir");
const GUION_CORTO = {
version: 1, tema: "claro",
cuenta: { nombre: "Demo corta", verificado: false },
mensajes: [
{ id: "m1", dir: "in", tipo: "text", hora: "10:00", texto: "Hola" },
{ id: "m2", dir: "out", tipo: "text", hora: "10:01", texto: "Buenas", estado: "read" },
{
id: "m3", dir: "in", tipo: "buttons", hora: "10:02", body: "¿Qué quieres?", autoPulsar: 1,
botones: [
{ texto: "Precios", respuestas: [] },
{ texto: "Horario", respuestas: [{ id: "m4", dir: "in", tipo: "text", hora: "10:02", texto: "Abrimos a las 9" }] },
],
},
],
};
test.describe("Reproducir demo", () => {
test("vacía el chat, revela los mensajes uno a uno y termina sola", async ({ app }) => {
await cargarEstado(app, GUION_CORTO);
await rapido(app);
// al arrancar el chat se vacía al instante (solo quedan los avisos de sistema)
const filasTrasClic = await app.evaluate(() => {
document.getElementById("btn-reproducir").click();
return document.querySelectorAll("#chat-cuerpo > li.wa-row").length;
});
expect(filasTrasClic).toBe(0);
await expect(btn(app)).toHaveAttribute("aria-pressed", "true");
await expect(btn(app)).toContainText("Detener");
// «escribiendo…» antes del primer IN
await expect(app.locator("#chat-escribiendo")).toBeVisible();
await expect(filasChat(app)).toHaveCount(1, { timeout: 3000 });
await expect(filasChat(app).nth(0)).toContainText("Hola");
await expect(filasChat(app)).toHaveCount(3, { timeout: 5000 });
// autoPulsar: pulsa sola «Horario» y llega su respuesta
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]')).toContainText("Horario", { timeout: 5000 });
await expect(app.locator('#chat-cuerpo li.in[data-id^="s"]')).toContainText("Abrimos a las 9", { timeout: 6000 });
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 10000 }).toBe(false);
await expect(btn(app)).toContainText("Reproducir demo");
await expect(btn(app)).toHaveAttribute("aria-pressed", "false");
});
test("pulsar de nuevo la detiene y vuelve al guion completo", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await btn(app).click();
await expect.poll(() => filasChat(app).count(), { timeout: 4000 }).toBeGreaterThan(0);
await app.waitForTimeout(400); // evita el antirrebote de doble clic
await btn(app).click();
await expect(btn(app)).toContainText("Reproducir demo");
await expect(filasChat(app)).toHaveCount(n);
expect(await app.evaluate(() => WAM.demoActiva())).toBe(false);
// nada sigue apareciendo después de detener
await app.waitForTimeout(1500);
await expect(filasChat(app)).toHaveCount(n);
});
test("un doble clic no arranca y detiene en el mismo gesto", async ({ app }) => {
await btn(app).dblclick();
expect(await app.evaluate(() => WAM.demoActiva())).toBe(true);
await app.waitForTimeout(400);
await btn(app).click();
});
test("la barra espaciadora (fuera de campos) alterna la demo", async ({ app }) => {
await app.locator("#iphone").click({ position: { x: 5, y: 400 } });
await app.keyboard.press("Space");
await expect.poll(() => app.evaluate(() => WAM.demoActiva())).toBe(true);
await app.waitForTimeout(400);
await app.keyboard.press("Space");
await expect.poll(() => app.evaluate(() => WAM.demoActiva())).toBe(false);
});
test("la velocidad se elige en el selector y persiste", async ({ app }) => {
await app.locator("#sel-velocidad").selectOption("2");
expect(await app.evaluate(() => WAM.getVelocidad())).toBe(2);
await app.reload();
await expect(app.locator("#sel-velocidad")).toHaveValue("2");
await app.locator("#sel-velocidad").selectOption("0.5");
expect(await app.evaluate(() => WAM.getVelocidad())).toBe(0.5);
});
test("a 2x la demo es más rápida que a 0,5x", async ({ app }) => {
await cargarEstado(app, { ...GUION_CORTO, mensajes: GUION_CORTO.mensajes.slice(0, 2) });
const medir = async (v) => {
await app.evaluate((x) => WAM.setVelocidad(x), v);
const t0 = Date.now();
await btn(app).click();
await expect.poll(() => app.evaluate(() => WAM.demoActiva()), { timeout: 15000, intervals: [50] }).toBe(false);
await app.waitForTimeout(400);
return Date.now() - t0;
};
const lenta = await medir(0.5);
const rapida = await medir(2);
expect(rapida).toBeLessThan(lenta);
});
test("editar el guion durante la demo la cancela sin errores", async ({ app }) => {
await btn(app).click();
await expect.poll(() => filasChat(app).count(), { timeout: 4000 }).toBeGreaterThan(0);
await app.locator('#nuevo-tipo [data-tipo="text"]').click();
await expect.poll(() => app.evaluate(() => WAM.demoActiva())).toBe(false);
const n = (await estado(app)).mensajes.length;
await expect(filasChat(app)).toHaveCount(n);
await expect(btn(app)).toContainText("Reproducir demo");
});
});
+351
View File
@@ -0,0 +1,351 @@
// Campos del editor: contadores, barra de formato, variables, subida de archivos
// (input y arrastrar-soltar), cuenta, sub-editores y validación.
import {
test, expect, estado, vaciar, anadirTipo, campo, filaChat, tarjeta, abrirTarjeta, irA, PNG_1PX,
} from "./fixtures.mjs";
const contadorDe = (page, ruta) =>
page.locator(".ed-campo", { has: page.locator(`[data-ruta="${ruta}"]`) }).first().locator(".ed-contador");
/** Selecciona [ini, fin) en un input/textarea. */
async function seleccionar(loc, ini, fin) {
await loc.evaluate((el, [a, b]) => { el.focus(); el.setSelectionRange(a, b); }, [ini, fin]);
}
/** Suelta un archivo sobre un elemento (drag & drop del sistema). */
async function soltarArchivo(page, destino, { nombre, tipo, base64 }) {
const dt = await page.evaluateHandle(({ nombre, tipo, base64 }) => {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const d = new DataTransfer();
d.items.add(new File([bytes], nombre, { type: tipo }));
return d;
}, { nombre, tipo, base64 });
await destino.dispatchEvent("dragenter", { dataTransfer: dt });
await destino.dispatchEvent("dragover", { dataTransfer: dt });
await destino.dispatchEvent("drop", { dataTransfer: dt });
}
test.describe("Contadores de límites", () => {
test("n / límite, ámbar al 90 % y rojo al pasarse sin cortar la escritura", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "buttons");
const ruta = "mensajes.0.botones.0.texto";
const cont = contadorDe(app, ruta);
await campo(app, ruta).fill("Hola");
await expect(cont).toHaveText("4 / 20");
await expect(cont).not.toHaveClass(/ed-contador-(alerta|over)/);
await campo(app, ruta).fill("x".repeat(18));
await expect(cont).toHaveText("18 / 20");
await expect(cont).toHaveClass(/ed-contador-alerta/);
await campo(app, ruta).fill("x".repeat(23));
await expect(cont).toHaveText("23 / 20");
await expect(cont).toHaveClass(/ed-contador-over/);
await expect(campo(app, ruta)).toHaveValue("x".repeat(23));
// la tarjeta avisa de que la Cloud API lo rechazaría
await expect(tarjeta(app, 0).locator(".ed-chip-aviso")).toBeVisible();
await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("rechazaría");
await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("supera 20");
await expect(app.locator("#ed-avisos-global")).toBeVisible();
});
test("el cuerpo de texto cuenta hasta 4096", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("abc");
await expect(contadorDe(app, "mensajes.0.texto")).toHaveText("3 / 4096");
});
});
test.describe("Barra de formato y variables", () => {
for (const [boton, marca, etiqueta] of [
["ed-fmt-negrita", "*", "strong"], ["ed-fmt-cursiva", "_", "em"],
["ed-fmt-tachado", "~", "del"], ["ed-fmt-mono", "`", "code"],
]) {
test(`${boton} envuelve la selección con ${marca}`, async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "text");
const ta = campo(app, "mensajes.0.texto");
await ta.fill("Hola mundo feliz");
await seleccionar(ta, 5, 10);
await tarjeta(app, 0).locator(`.ed-fmtbar .${boton}`).click();
await expect(ta).toHaveValue(`Hola ${marca}mundo${marca} feliz`);
// el cursor mantiene la palabra seleccionada
const sel = await ta.evaluate((el) => [el.selectionStart, el.selectionEnd]);
expect(sel).toEqual([6, 11]);
await expect(filaChat(app, id).locator(`.wa-texto ${etiqueta}`)).toHaveText("mundo");
});
}
test("sin selección inserta el par de marcas con el cursor en medio", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
const ta = campo(app, "mensajes.0.texto");
await ta.fill("ab");
await seleccionar(ta, 1, 1);
await tarjeta(app, 0).locator(".ed-fmtbar .ed-fmt-negrita").click();
await expect(ta).toHaveValue("a**b");
expect(await ta.evaluate((el) => [el.selectionStart, el.selectionEnd])).toEqual([2, 2]);
});
test("la barra de formato existe en plantilla y lista", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "template");
await anadirTipo(app, "list");
await expect(tarjeta(app, 0).locator(".ed-fmtbar").first()).toBeVisible();
await expect(tarjeta(app, 1).locator(".ed-fmtbar").first()).toBeVisible();
});
test("«+ Añadir variable» inserta {{n}} siguiente en el cursor", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
const body = campo(app, "mensajes.0.body");
await body.fill("Hola {{1}}, tu pedido");
await seleccionar(body, 21, 21);
const btnVar = app.locator(".ed-campo", { has: app.locator('[data-ruta="mensajes.0.body"]') }).locator('[data-acc="var"]');
await btnVar.click();
await expect(body).toHaveValue("Hola {{1}}, tu pedido{{2}}");
await btnVar.click();
await expect(body).toHaveValue("Hola {{1}}, tu pedido{{2}}{{3}}");
await expect(filaChat(app, id).locator(".tpl-body")).toHaveText("Hola {{1}}, tu pedido{{2}}{{3}}");
});
});
test.describe("Subida de archivos", () => {
test("imagen por archivo: dataURL en el chat y persiste tras recargar", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "image");
const t = tarjeta(app, 0);
await t.locator('input[type="file"]').first().setInputFiles({ name: "foto.png", mimeType: "image/png", buffer: PNG_1PX });
await expect.poll(async () => (await estado(app)).mensajes[0].src).toMatch(/^data:image\/png;base64,/);
await expect(filaChat(app, id).locator("img.wa-media")).toHaveAttribute("src", /^data:image\/png;base64,/);
await expect(t.locator(".ed-media-vista").first()).toBeVisible(); // miniatura en el editor
await app.reload();
await expect(filaChat(app, id).locator("img.wa-media")).toHaveAttribute("src", /^data:image\/png;base64,/);
});
test("clic en la zona de soltado abre el selector de archivos", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "sticker");
const [chooser] = await Promise.all([
app.waitForEvent("filechooser"),
tarjeta(app, 0).locator('[data-acc="drop-clic"]').first().click(),
]);
await chooser.setFiles({ name: "sticker.png", mimeType: "image/png", buffer: PNG_1PX });
await expect.poll(async () => (await estado(app)).mensajes[0].src).toMatch(/^data:image\/png/);
});
test("arrastrar y soltar un archivo en la zona lo carga", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "carousel");
const zona = tarjeta(app, 0).locator('.ed-dropzona[data-media-ruta="mensajes.0.cards.1.media"]');
await zona.scrollIntoViewIfNeeded();
await soltarArchivo(app, zona, { nombre: "card.png", tipo: "image/png", base64: PNG_1PX.toString("base64") });
await expect.poll(async () => (await estado(app)).mensajes[0].cards[1].media).toMatch(/^data:image\/png/);
await expect(filaChat(app, id).locator(".card").nth(1).locator("img.card-media")).toHaveAttribute("src", /^data:image\/png/);
});
test("soltar un archivo fuera de una zona no navega ni cambia nada", async ({ app }) => {
const antes = await estado(app);
const url = app.url();
await soltarArchivo(app, app.locator("#lista-mensajes"), { nombre: "x.png", tipo: "image/png", base64: PNG_1PX.toString("base64") });
expect(app.url()).toBe(url);
expect((await estado(app)).mensajes.length).toBe(antes.mensajes.length);
});
test("documento: subir archivo mantiene el nombre con extensión", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "document");
await campo(app, "mensajes.0.filename").fill("contrato.docx");
await expect(filaChat(app, id).locator(".doc-nombre")).toHaveText("contrato.docx");
await expect(filaChat(app, id).locator(".doc-sub")).toContainText(/docx/i);
});
});
test.describe("Cuenta", () => {
test("nombre, subtítulo y verificado se reflejan en la cabecera del chat", async ({ app }) => {
await app.locator("#bloque-cuenta > summary").click();
await app.locator("#cuenta-nombre").fill("Óptica Luz");
await expect(app.locator("#chat-nombre")).toHaveText("Óptica Luz");
await expect(app.locator("#cuenta-resumen")).toContainText("Óptica Luz");
await app.locator("#cuenta-subtitulo").fill("responde en minutos");
await expect(app.locator("#chat-subtitulo")).toHaveText("responde en minutos");
await expect(app.locator("#chat-verificado")).toBeVisible();
await app.locator("#cuenta-verificado").uncheck({ force: true });
await expect(app.locator("#chat-verificado")).toBeHidden();
expect((await estado(app)).cuenta).toMatchObject({ nombre: "Óptica Luz", subtitulo: "responde en minutos", verificado: false });
});
test("avatar por archivo, por URL y quitar", async ({ app }) => {
await app.locator("#bloque-cuenta > summary").click();
await app.locator("#cuenta-avatar-archivo").setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: PNG_1PX });
await expect(app.locator("#chat-avatar")).toHaveAttribute("src", /^data:image\/png/);
await expect(app.locator("#cuenta-avatar-vista")).toHaveAttribute("src", /^data:image\/png/);
await app.locator('#editor-cuenta [data-acc="avatar-quitar"]').click();
await expect(app.locator("#chat-avatar")).toHaveAttribute("src", /^data:image\/svg\+xml/);
expect((await estado(app)).cuenta.avatar).toBe("");
const dataUrl = "data:image/png;base64," + PNG_1PX.toString("base64");
const url = app.locator("#cuenta-avatar-url");
if (!(await url.isVisible())) await app.locator('#editor-cuenta label.ed-radio:has(input[value="url"])').click();
await url.fill(dataUrl);
await expect(app.locator("#chat-avatar")).toHaveAttribute("src", dataUrl);
});
});
test.describe("Sub-editores de botones, secciones, filas, tarjetas y respuestas", () => {
test("botones rápidos: añadir hasta 3 (máximo de la API) y borrar", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "buttons");
const add = tarjeta(app, 0).locator('[data-acc="add-boton"]');
await add.click();
await add.click();
await expect(filaChat(app, id).locator("button.qr-btn")).toHaveCount(3);
if (await add.isEnabled()) await add.click();
expect((await estado(app)).mensajes[0].botones).toHaveLength(3);
await tarjeta(app, 0).locator('[data-acc="del-boton"]').nth(1).click();
await expect(filaChat(app, id).locator("button.qr-btn")).toHaveCount(2);
});
test("plantilla con >3 botones: 2 visibles + «Ver todas las opciones»", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
const add = tarjeta(app, 0).locator('[data-acc="add-boton"]').first();
for (let k = 0; k < 3; k++) await add.click();
expect((await estado(app)).mensajes[0].botones).toHaveLength(4);
const fila = filaChat(app, id);
await expect(fila.locator(".qr-btn:visible")).toHaveCount(3);
await expect(fila.locator("[data-ver-todas]")).toHaveText(/Ver todas las opciones/);
await fila.locator("[data-ver-todas]").click();
await expect(app.locator("#sheet .sheet-titulo")).toHaveText("Todas las opciones");
await expect(app.locator("#sheet [data-opcion]")).toHaveCount(4);
});
test("plantilla: botón con URL se pinta como enlace", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
await campo(app, "mensajes.0.botones.0.url").fill("https://example.org/pago");
const a = filaChat(app, id).locator("a.qr-btn");
await expect(a).toHaveAttribute("href", "https://example.org/pago");
await expect(filaChat(app, id).locator("button.qr-btn[data-btn]")).toHaveCount(0);
});
test("lista: añadir sección y fila", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "list");
await tarjeta(app, 0).locator('[data-acc="add-fila"]').first().click();
await tarjeta(app, 0).locator('[data-acc="add-sec"]').click();
const e = await estado(app);
expect(e.mensajes[0].secciones).toHaveLength(2);
expect(e.mensajes[0].secciones[0].filas).toHaveLength(2);
await campo(app, "mensajes.0.secciones.1.titulo").fill("Extras");
await campo(app, "mensajes.0.secciones.1.filas.0.titulo").fill("Envío exprés");
await filaChat(app, e.mensajes[0].id).locator("button.qr-btn").click();
await expect(app.locator("#sheet .sheet-seccion").nth(1)).toHaveText("Extras");
await expect(app.locator("#sheet .sheet-fila-titulo").last()).toHaveText("Envío exprés");
});
test("carrusel: añadir y borrar tarjetas (2–5)", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "carousel");
const add = tarjeta(app, 0).locator('[data-acc="add-card"]');
await add.click();
await expect(filaChat(app, id).locator(".card")).toHaveCount(3);
await tarjeta(app, 0).locator('[data-acc="del-card"]').first().click();
await expect(filaChat(app, id).locator(".card")).toHaveCount(2);
// no baja de 2
await tarjeta(app, 0).locator('[data-acc="del-card"]').first().click();
expect((await estado(app)).mensajes[0].cards.length).toBeGreaterThanOrEqual(2);
});
test("respuestas encadenadas: añadir, editar, cambiar de tipo y borrar", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "buttons");
const t = tarjeta(app, 0);
await expect(t.locator(".ed-chip-flujo")).toHaveClass(/ed-chip-flujo-incompleto/);
await t.locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click();
const ta = campo(app, "mensajes.0.botones.0.respuestas.0.texto");
await expect(ta).toBeFocused();
await ta.fill("Respuesta encadenada");
await expect(t.locator(".ed-chip-flujo")).not.toHaveClass(/ed-chip-flujo-incompleto/);
let r = (await estado(app)).mensajes[0].botones[0].respuestas;
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ dir: "in", tipo: "text", texto: "Respuesta encadenada" });
await t.locator('select[data-rol="tipo-resp"]').first().selectOption("image");
await expect.poll(async () => (await estado(app)).mensajes[0].botones[0].respuestas[0].tipo).toBe("image");
r = (await estado(app)).mensajes[0].botones[0].respuestas;
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ dir: "in", tipo: "image", src: "" });
await expect(t.locator('[data-ruta="mensajes.0.botones.0.respuestas.0.caption"]')).toBeVisible();
await t.locator('[data-acc="del-resp"]').first().click();
r = (await estado(app)).mensajes[0].botones[0].respuestas;
expect(r).toHaveLength(0);
});
test("autoPulsar se elige en el editor y se guarda como índice", async ({ app }) => {
const e = await estado(app);
const i = e.mensajes.findIndex((m) => m.tipo === "buttons");
await abrirTarjeta(app, i);
const sel = campo(app, `mensajes.${i}.autoPulsar`);
const valor = await sel.locator("option").nth(2).getAttribute("value");
await sel.selectOption(valor);
await expect.poll(async () => (await estado(app)).mensajes[i].autoPulsar).toBe(Number(valor));
await app.reload();
expect((await estado(app)).mensajes[i].autoPulsar).toBe(Number(valor));
// «vacío» quita la pulsación automática
await abrirTarjeta(app, i);
await campo(app, `mensajes.${i}.autoPulsar`).selectOption("");
await expect.poll(async () => "autoPulsar" in (await estado(app)).mensajes[i]).toBe(false);
});
});
test.describe("Validación", () => {
test("ubicación con latitud fuera de rango y contacto sin nombre muestran avisos", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "location");
await campo(app, "mensajes.0.latitud").fill("123");
await expect(tarjeta(app, 0).locator(".ed-validacion")).toContainText("Latitud no válida");
await anadirTipo(app, "contact");
await campo(app, "mensajes.1.nombre").fill("");
await expect(tarjeta(app, 1).locator(".ed-validacion")).toContainText("necesita un nombre");
await expect(app.locator("#ed-avisos-global")).toBeVisible();
await app.locator("#ed-avisos-global").click();
await expect(tarjeta(app, 0)).toHaveClass(/ed-abierta/);
});
test("la demo de ejemplo no tiene avisos de validación", async ({ app }) => {
await expect(app.locator("#lista-mensajes .ed-chip-aviso")).toHaveCount(0);
await expect(app.locator("#ed-avisos-global")).toBeHidden();
});
});
test.describe("Persistencia", () => {
test("F5 conserva todo el estado editado", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("Persistente");
await anadirTipo(app, "cta_url");
const antes = await estado(app);
await app.reload();
await expect(app.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(2);
expect(await estado(app)).toEqual(antes);
await expect(filaChat(app, antes.mensajes[0].id)).toContainText("Persistente");
});
test("el indicador de guardado pasa por «Guardando…» y vuelve a «Guardado»", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("x");
await expect(app.locator("#estado-guardado")).toHaveAttribute("data-estado", "ok", { timeout: 4000 });
await expect(app.locator("#estado-guardado .tb-guardado-txt")).toContainText("Guardado");
});
test("un localStorage corrupto no rompe el arranque: carga la demo", async ({ page }) => {
await page.addInitScript(() => {
if (!sessionStorage.getItem("sembrado")) {
localStorage.setItem("wam-mockup-v1", "{esto no es json");
sessionStorage.setItem("sembrado", "1");
}
});
await irA(page, "/index.html");
await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora");
});
});
+159
View File
@@ -0,0 +1,159 @@
// Operaciones sobre la lista de mensajes: reordenar (flechas, Alt+↑/↓ y drag & drop),
// duplicar, borrar con deshacer, insertar debajo, plegar y horas en secuencia.
import {
test, expect, estado, tarjeta, arrastrar, accion, tarjetas, filasChat, abrirTarjeta, anadirTipo, vaciar, cargarEstado,
} from "./fixtures.mjs";
const ids = async (page) => (await estado(page)).mensajes.map((m) => m.id);
const idsChat = (page) => filasChat(page).evaluateAll((els) => els.map((e) => e.dataset.id));
test.describe("Lista de mensajes", () => {
test("↑/↓ reordena el guion, las tarjetas y el chat", async ({ app }) => {
const antes = await ids(app);
await accion(app, 0, "mover", 1);
const tras = await ids(app);
expect(tras.slice(0, 2)).toEqual([antes[1], antes[0]]);
expect(await idsChat(app)).toEqual(tras);
await expect(tarjeta(app, 0)).toHaveAttribute("data-id", antes[1]);
// la primera no puede subir y la última no puede bajar
await expect(tarjeta(app, 0).locator('[data-acc="mover"][data-delta="-1"]')).toBeDisabled();
const n = antes.length;
await expect(tarjeta(app, n - 1).locator('[data-acc="mover"][data-delta="1"]')).toBeDisabled();
await accion(app, 1, "mover", -1);
expect(await ids(app)).toEqual(antes);
});
test("Alt+↓ mueve el mensaje con el foco", async ({ app }) => {
const antes = await ids(app);
await tarjeta(app, 0).locator(".ed-cab-principal").focus();
await app.keyboard.press("Alt+ArrowDown");
await expect.poll(() => ids(app)).toEqual([antes[1], antes[0], ...antes.slice(2)]);
});
test("drag & drop por el asa reordena los mensajes", async ({ app }) => {
const antes = await ids(app);
const asa = tarjeta(app, 0).locator(":scope > .ed-tarjeta-cab .ed-asa");
const destino = tarjeta(app, 3).locator(":scope > .ed-tarjeta-cab");
// soltar en la mitad inferior de la 4.ª tarjeta → queda en la posición 3 (0-based)
await arrastrar(app, asa, destino, { abajo: true });
const tras = await ids(app);
expect(tras).toEqual([antes[1], antes[2], antes[3], antes[0], ...antes.slice(4)]);
expect(await idsChat(app)).toEqual(tras);
});
test("drag & drop reordena los botones dentro de un mensaje", async ({ app }) => {
const e = await estado(app);
const i = e.mensajes.findIndex((m) => m.tipo === "buttons");
const textos = e.mensajes[i].botones.map((b) => b.texto);
const t = await abrirTarjeta(app, i);
const items = t.locator(`[data-drag-ruta="mensajes.${i}.botones"]`);
await expect(items).toHaveCount(3);
await arrastrar(app, items.nth(2).locator(".ed-asa").first(), items.nth(0));
await expect.poll(async () => (await estado(app)).mensajes[i].botones.map((b) => b.texto))
.toEqual([textos[2], textos[0], textos[1]]);
const id = e.mensajes[i].id;
await expect(app.locator(`#chat-cuerpo li[data-id="${id}"] .qr-btn .btn-label`)).toHaveText([textos[2], textos[0], textos[1]]);
});
test("duplicar crea un clon justo debajo con IDs nuevos (también en respuestas)", async ({ app }) => {
const e = await estado(app);
const i = e.mensajes.findIndex((m) => m.tipo === "template");
const n = e.mensajes.length;
await accion(app, i, "dup");
await expect(tarjetas(app)).toHaveCount(n + 1);
const e2 = await estado(app);
const orig = e2.mensajes[i], clon = e2.mensajes[i + 1];
expect(clon.id).not.toBe(orig.id);
expect(clon.body).toBe(orig.body);
expect(clon.botones[0].respuestas[0].id).not.toBe(orig.botones[0].respuestas[0].id);
// todos los IDs (incluidos los anidados) son únicos
const todos = [];
const recorrer = (m) => {
todos.push(m.id);
for (const b of m.botones || []) (b.respuestas || []).forEach(recorrer);
for (const s of m.secciones || []) for (const f of s.filas || []) (f.respuestas || []).forEach(recorrer);
for (const c of m.cards || []) (c.respuestas || []).forEach(recorrer);
(m.respuestas || []).forEach(recorrer);
};
e2.mensajes.forEach(recorrer);
expect(new Set(todos).size).toBe(todos.length);
await expect(filasChat(app)).toHaveCount(n + 1);
});
test("borrar quita el mensaje sin confirm() y «Deshacer» del aviso lo recupera", async ({ app }) => {
let dialogos = 0;
app.on("dialog", (d) => { dialogos++; d.dismiss(); });
const antes = await ids(app);
await accion(app, 2, "borrar");
expect(await ids(app)).toEqual(antes.filter((_, k) => k !== 2));
await expect(filasChat(app)).toHaveCount(antes.length - 1);
const toast = app.locator("#ed-toasts .ed-toast", { hasText: "Mensaje borrado" });
await expect(toast).toBeVisible();
await toast.locator(".ed-toast-accion").click();
await expect.poll(() => ids(app)).toEqual(antes);
await expect(filasChat(app)).toHaveCount(antes.length);
expect(dialogos).toBe(0);
});
test("«+» de una tarjeta hace que el siguiente alta se inserte debajo", async ({ app }) => {
const antes = await ids(app);
await accion(app, 1, "insertar-debajo");
await expect(app.locator("#ed-destino-alta")).toContainText("debajo del n.º 2");
const id = await anadirTipo(app, "text");
const tras = await ids(app);
expect(tras.indexOf(id)).toBe(2);
expect(tras.filter((x) => x !== id)).toEqual(antes);
expect(await idsChat(app)).toEqual(tras);
});
test("con una tarjeta abierta el alta va debajo; «Al final» lo restablece", async ({ app }) => {
await abrirTarjeta(app, 0);
await expect(app.locator("#ed-destino-alta")).toContainText("debajo del n.º 1");
await app.locator('#ed-destino-alta [data-acc="destino-final"]').click();
await expect(app.locator("#ed-destino-alta")).toHaveText("Se añade al final");
const id = await anadirTipo(app, "text");
const tras = await ids(app);
expect(tras[tras.length - 1]).toBe(id);
});
test("«+ Otro» añade otra vez el último tipo usado", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "location");
await expect(app.locator("#btn-anadir")).toContainText("Ubicación");
await expect(app.locator("#nuevo-tipo-valor")).toHaveValue("location");
await app.locator("#btn-anadir").click();
await expect.poll(async () => (await estado(app)).mensajes.map((m) => m.tipo)).toEqual(["location", "location"]);
});
test("«Plegar todo» cierra todas las tarjetas abiertas", async ({ app }) => {
await abrirTarjeta(app, 0);
await abrirTarjeta(app, 1);
await expect(app.locator("#lista-mensajes > .ed-tarjeta.ed-abierta")).toHaveCount(2);
await app.locator('[data-acc="plegar-todo"]').click();
await expect(app.locator("#lista-mensajes > .ed-tarjeta.ed-abierta")).toHaveCount(0);
});
test("«Horas en secuencia» reparte +1 min desde el primer mensaje", async ({ app }) => {
await cargarEstado(app, {
version: 1, tema: "claro", cuenta: { nombre: "Horas" },
mensajes: [
{ id: "m1", dir: "in", tipo: "text", hora: "08:59", texto: "a" },
{ id: "m2", dir: "out", tipo: "text", hora: "11:11", texto: "b" },
{ id: "m3", dir: "in", tipo: "text", hora: "07:00", texto: "c" },
],
});
await app.locator('[data-acc="horas-secuencia"]').click();
await expect.poll(async () => (await estado(app)).mensajes.map((m) => m.hora)).toEqual(["08:59", "09:00", "09:01"]);
await expect(app.locator('#chat-cuerpo li[data-id="m3"] .meta')).toContainText("09:01");
});
test("abrir una tarjeta resalta su burbuja; doble clic en la burbuja abre su tarjeta", async ({ app }) => {
await abrirTarjeta(app, 4);
const id = await tarjeta(app, 4).getAttribute("data-id");
await expect(app.locator(`#chat-cuerpo li[data-id="${id}"]`)).toHaveClass(/ed-resaltado/);
await app.locator('[data-acc="plegar-todo"]').click();
const id2 = await tarjeta(app, 0).getAttribute("data-id");
await app.locator(`#chat-cuerpo li[data-id="${id2}"] .bubble`).dblclick();
await expect(tarjeta(app, 0)).toHaveClass(/ed-abierta/);
});
});
+178
View File
@@ -0,0 +1,178 @@
// Fixtures y utilidades compartidas por la suite e2e.
//
// - `page` se extiende para registrar errores/warnings de consola, errores de
// página y peticiones a red externa; al terminar cada test se exige que no
// haya ninguno (criterio §5.1 y §5.10 de la SPEC). Un test puede tolerar
// mensajes concretos con `consola.permitir(/regex/)`.
// - `app` navega a index.html y espera a que el chat y el editor estén listos.
import { test as base, expect } from "@playwright/test";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
export const URL_FILE = pathToFileURL(path.join(RAIZ, "index.html")).href;
export const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "archivos");
/** PNG 1×1 válido (para subidas y drops). */
export const PNG_1PX = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
export const TIPOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel",
];
export const test = base.extend({
consola: async ({}, use) => {
const registro = {
mensajes: [],
externas: [],
permitidos: [],
permitir(re) { this.permitidos.push(re); },
};
await use(registro);
},
page: async ({ page, consola }, use, testInfo) => {
page.on("console", (m) => {
if (m.type() === "error" || m.type() === "warning") consola.mensajes.push(`[${m.type()}] ${m.text()}`);
});
page.on("pageerror", (e) => consola.mensajes.push(`[pageerror] ${e.message}`));
// Ninguna dependencia de red obligatoria: toda petición externa se registra y se aborta.
await page.route(/^(https?|wss?):\/\/(?!127\.0\.0\.1|localhost)/, (ruta) => {
consola.externas.push(ruta.request().url());
return ruta.abort();
});
await use(page);
if (testInfo.status === testInfo.expectedStatus) {
const reales = consola.mensajes.filter((t) => !consola.permitidos.some((re) => re.test(t)));
expect(reales, "errores/warnings de consola").toEqual([]);
expect(consola.externas, "peticiones a red externa").toEqual([]);
}
},
app: async ({ page }, use) => {
await irA(page, "/index.html");
await use(page);
},
});
export { expect };
/** Navega y espera al arranque completo (chat + editor pintados). */
export async function irA(page, url) {
await page.goto(url);
await esperarListo(page);
}
export async function esperarListo(page) {
await page.waitForFunction(() =>
window.WAM && WAM.estado && document.querySelector("#chat-header #chat-nombre") &&
document.querySelector("#nuevo-tipo [data-tipo]")
);
}
/** Copia profunda del estado vivo. */
export function estado(page) {
return page.evaluate(() => JSON.parse(JSON.stringify(WAM.estado)));
}
/** Estado persistido en localStorage (wam-mockup-v1). */
export function estadoGuardado(page) {
return page.evaluate(() => {
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
return JSON.parse(localStorage.getItem("wam-mockup-v1") || "null");
});
}
/** Deja la demo sin mensajes (como el escenario «en blanco») y repinta todo. */
export async function vaciar(page) {
await page.evaluate(() => {
WAM.estado.mensajes = [];
WAM.guardarYa();
WAM.render();
WAM.renderEditor();
});
await expect(page.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(0);
}
/** Sustituye el estado entero (normalizado) y repinta todo. */
export async function cargarEstado(page, datos) {
await page.evaluate((d) => {
WAM.estado = WAM.normalizar(d);
WAM.guardarYa();
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
}, datos);
}
export const tarjetas = (page) => page.locator("#lista-mensajes > .ed-tarjeta");
export const tarjeta = (page, i) => page.locator(`#lista-mensajes > .ed-tarjeta[data-idx="${i}"]`);
export const filaChat = (page, id) => page.locator(`#chat-cuerpo > li.wa-row[data-id="${id}"]`);
export const filasChat = (page) => page.locator("#chat-cuerpo > li.wa-row");
export const campo = (page, ruta) => page.locator(`#lista-mensajes [data-ruta="${ruta}"]`).first();
/** Despliega la tarjeta i (si está plegada) y la devuelve. */
export async function abrirTarjeta(page, i) {
const t = tarjeta(page, i);
const cab = t.locator(":scope > .ed-tarjeta-cab .ed-cab-principal");
if ((await cab.getAttribute("aria-expanded")) !== "true") await cab.click();
await expect(cab).toHaveAttribute("aria-expanded", "true");
return t;
}
/** Añade un mensaje desde la paleta y devuelve su id. */
export async function anadirTipo(page, tipo) {
const antes = (await estado(page)).mensajes.map((m) => m.id);
await page.locator(`#nuevo-tipo [data-acc="anadir-tipo"][data-tipo="${tipo}"]`).click();
const handle = await page.waitForFunction((prev) => {
const nuevo = WAM.estado.mensajes.find((m) => !prev.includes(m.id));
return nuevo ? nuevo.id : null;
}, antes);
return handle.jsonValue();
}
/** Pone un campo de media en modo URL (los vacíos arrancan en «Subir archivo»). */
export async function modoUrl(page, rutaMedia) {
const inp = campo(page, rutaMedia);
if (await inp.isVisible()) return inp;
const media = page.locator(".ed-media", { has: page.locator(`[data-ruta="${rutaMedia}"]`) }).first();
await media.locator('label.ed-radio:has(input[value="url"])').click();
await expect(inp).toBeVisible();
return inp;
}
/** Acelera los flujos del teléfono (velocidad 2x). */
export async function rapido(page) {
await page.evaluate(() => WAM.setVelocidad(2));
}
/**
* Arrastra con el ratón (HTML5 drag & drop nativo) el asa `asa` hasta `destino`.
* `abajo` = soltar en la mitad inferior del destino (queda detrás de él).
* Coloca antes el destino arriba del todo para que ambos queden a la vista.
*/
export async function arrastrar(page, asa, destino, { abajo = false } = {}) {
await destino.evaluate((el) => el.scrollIntoView({ block: "start" }));
const a = await asa.boundingBox();
const d = await destino.boundingBox();
const vh = page.viewportSize().height;
if (!a || !d || a.y < 0 || a.y + a.height > vh) throw new Error("el asa y el destino no caben a la vez en pantalla");
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 4, a.y + a.height / 2 + 4, { steps: 3 });
const y = abajo ? d.y + d.height - 4 : d.y + 4;
await page.mouse.move(d.x + Math.min(40, d.width / 2), y, { steps: 8 });
await page.mouse.up();
}
/** Pulsa una acción de la cabecera de la tarjeta i (las flechas se revelan al pasar el ratón). */
export async function accion(page, i, acc, delta) {
const cab = tarjeta(page, i).locator(":scope > .ed-tarjeta-cab");
await cab.hover();
const sel = `[data-acc="${acc}"]` + (delta === undefined ? "" : `[data-delta="${delta}"]`);
await cab.locator(sel).click();
}
+222
View File
@@ -0,0 +1,222 @@
// Interactividad del teléfono: quick replies, plantilla, lista + hoja, carrusel,
// «Ver todas las opciones», respuestas encadenadas editadas, composer y ticks.
import { test, expect, estado, filaChat, filasChat, vaciar, anadirTipo, campo, tarjeta, rapido } from "./fixtures.mjs";
const salientesSesion = (page) => page.locator('#chat-cuerpo > li.wa-row.out[data-id^="s"]');
const entrantesSesion = (page) => page.locator('#chat-cuerpo > li.wa-row.in[data-id^="s"]');
async function idDe(page, tipo) {
return (await estado(page)).mensajes.find((m) => m.tipo === tipo).id;
}
test.describe("Flujos al pulsar botones", () => {
test("quick reply: burbuja OUT, «escribiendo…» y respuestas encadenadas", async ({ app }) => {
const id = await idDe(app, "buttons");
const boton = filaChat(app, id).locator("button.qr-btn", { hasText: "Promociones" });
await boton.scrollIntoViewIfNeeded();
await boton.click();
// burbuja saliente inmediata con el texto del botón y la cita del mensaje
await expect(salientesSesion(app).last()).toContainText("Promociones");
await expect(salientesSesion(app).last().locator(".wa-cita")).toBeVisible();
await expect(boton).toHaveClass(/usado/);
// ticks sent → delivered → read
await expect(salientesSesion(app).last().locator(".tick-read")).toBeVisible({ timeout: 4000 });
// escribiendo… y llegada de las 2 respuestas
await expect(app.locator("#chat-escribiendo")).toBeVisible({ timeout: 4000 });
await expect(app.locator("#chat-subtitulo")).toHaveText("escribiendo…");
await expect(entrantesSesion(app)).toHaveCount(2, { timeout: 8000 });
await expect(entrantesSesion(app).nth(0)).toContainText("cold brew");
await expect(entrantesSesion(app).nth(1)).toContainText("croissant");
await expect(app.locator("#chat-escribiendo")).toBeHidden();
await expect(app.locator("#chat-subtitulo")).toHaveText("en línea");
// scroll al fondo
const cuerpo = app.locator("#chat-cuerpo");
await expect.poll(() => cuerpo.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(8);
// un botón usado no se puede volver a pulsar
await boton.click({ force: true });
await app.waitForTimeout(300);
await expect(salientesSesion(app)).toHaveCount(1);
// la sesión no toca el guion ni localStorage
const guardado = await app.evaluate(() => JSON.parse(localStorage.getItem("wam-mockup-v1")));
expect((guardado?.mensajes || []).some((m) => String(m.id).startsWith("s"))).toBe(false);
});
test("plantilla: el botón «Confirmar» dispara su respuesta", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "template");
await filaChat(app, id).locator("button.qr-btn", { hasText: "Confirmar" }).click();
await expect(salientesSesion(app).last()).toContainText("Confirmar");
await expect(entrantesSesion(app).last()).toContainText("Reserva confirmada", { timeout: 6000 });
});
test("lista: el botón abre la hoja; una fila dispara su flujo", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "list");
await filaChat(app, id).locator("button.qr-btn").click();
const hoja = app.locator("#sheet");
await expect(hoja).toBeVisible();
await expect(hoja.locator(".sheet-titulo")).toHaveText("Ver opciones");
await expect(hoja.locator(".sheet-seccion")).toHaveText(["Bebidas", "Postres"]);
await expect(hoja.locator(".sheet-fila")).toHaveCount(5);
await expect(hoja.locator(".sheet-fila").nth(1).locator(".sheet-fila-desc")).toHaveText("Suave, con leche de avena");
await hoja.locator(".sheet-fila", { hasText: "Latte de avena" }).click();
await expect(hoja).toHaveCount(0);
await expect(salientesSesion(app).last()).toContainText("Latte de avena");
await expect(entrantesSesion(app).last()).toContainText("Latte de avena anotado", { timeout: 6000 });
});
test("lista: la hoja se cierra con la X, con el fondo y con Escape", async ({ app }) => {
const id = await idDe(app, "list");
const abrir = () => filaChat(app, id).locator("button.qr-btn").click();
await abrir();
await app.locator("#sheet .sheet-cerrar").click();
await expect(app.locator("#sheet")).toHaveCount(0);
await abrir();
await app.locator("#sheet .sheet-fondo").click({ position: { x: 20, y: 20 } });
await expect(app.locator("#sheet")).toHaveCount(0);
await abrir();
await app.locator("#sheet .sheet-fila").first().focus();
await app.keyboard.press("Escape");
await expect(app.locator("#sheet")).toHaveCount(0);
await expect(salientesSesion(app)).toHaveCount(0);
});
test("carrusel: el botón de una card dispara su flujo", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "carousel");
const btn = filaChat(app, id).locator(".card").nth(1).locator("button.qr-btn");
await btn.scrollIntoViewIfNeeded();
await btn.click();
await expect(salientesSesion(app).last()).toContainText("Pedir");
await expect(entrantesSesion(app).last()).toContainText("Cold brew de 1 L", { timeout: 6000 });
});
test("«Ver todas las opciones» reenvía la opción al botón oculto", async ({ app }) => {
await rapido(app);
await vaciar(app);
const id = await anadirTipo(app, "template");
const add = tarjeta(app, 0).locator('[data-acc="add-boton"]').first();
for (let k = 0; k < 3; k++) await add.click();
await campo(app, "mensajes.0.botones.3.texto").fill("Cuarta opción");
await filaChat(app, id).locator("[data-ver-todas]").click();
await app.locator('#sheet [data-opcion="3"]').click();
await expect(app.locator("#sheet")).toHaveCount(0);
await expect(salientesSesion(app).last()).toContainText("Cuarta opción");
await expect(filaChat(app, id).locator('[data-btn="3"]')).toHaveClass(/usado/);
});
test("respuestas editadas en el editor llegan al pulsar (incluidas anidadas)", async ({ app }) => {
await rapido(app);
await vaciar(app);
const id = await anadirTipo(app, "buttons");
await campo(app, "mensajes.0.botones.0.texto").fill("Ver menú");
await tarjeta(app, 0).locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click();
await campo(app, "mensajes.0.botones.0.respuestas.0.texto").fill("Aquí tienes el menú");
// segunda respuesta: una lista (respuesta interactiva encadenada)
await tarjeta(app, 0).locator('[data-acc="add-resp"][data-ruta="mensajes.0.botones.0.respuestas"]').click();
await tarjeta(app, 0).locator('select[data-rol="tipo-resp"]').nth(1).selectOption("buttons");
await expect.poll(async () => (await estado(app)).mensajes[0].botones[0].respuestas[1].tipo).toBe("buttons");
await filaChat(app, id).locator("button.qr-btn", { hasText: "Ver menú" }).click();
await expect(entrantesSesion(app)).toHaveCount(2, { timeout: 6000 });
await expect(entrantesSesion(app).nth(0)).toContainText("Aquí tienes el menú");
// la respuesta de tipo «buttons» es pulsable a su vez
const anidado = entrantesSesion(app).nth(1).locator("button.qr-btn").first();
await expect(anidado).toBeVisible();
await anidado.click();
await expect(salientesSesion(app)).toHaveCount(2);
});
test("botón sin respuestas: solo sale la burbuja con su texto", async ({ app }) => {
await rapido(app);
await vaciar(app);
const id = await anadirTipo(app, "buttons");
await filaChat(app, id).locator("button.qr-btn").click();
await expect(salientesSesion(app)).toHaveCount(1);
await app.waitForTimeout(1500);
await expect(entrantesSesion(app)).toHaveCount(0);
});
test("enlaces cta_url abren en pestaña nueva y no disparan flujo", async ({ app, context }) => {
const id = await idDe(app, "cta_url");
const a = filaChat(app, id).locator("a.qr-btn");
await expect(a).toHaveAttribute("rel", /noopener/);
// la navegación externa está bloqueada por la suite; basta con que no haya flujo
const [popup] = await Promise.all([context.waitForEvent("page").catch(() => null), a.click()]);
if (popup) await popup.close();
await expect(salientesSesion(app)).toHaveCount(0);
});
test("Enter en un botón del chat lo activa desde el teclado", async ({ app }) => {
await rapido(app);
const id = await idDe(app, "buttons");
const b = filaChat(app, id).locator("button.qr-btn").first();
await b.focus();
await app.keyboard.press("Enter");
await expect(salientesSesion(app).last()).toContainText("Horarios");
});
});
test.describe("Composer", () => {
test("Enter envía un mensaje libre con hora actual y ticks automáticos", async ({ app }) => {
const input = app.locator("#composer-input");
await input.fill("¿Tienen terraza?");
await input.press("Enter");
const burbuja = salientesSesion(app).last();
await expect(burbuja).toContainText("¿Tienen terraza?");
const hora = await app.evaluate(() => WAM.horaAhora());
await expect(burbuja.locator(".meta")).toContainText(hora.slice(0, 3));
await expect(burbuja.locator(".tick")).toBeVisible();
await expect(burbuja.locator(".tick-read")).toBeVisible({ timeout: 4000 });
await expect(input).toHaveValue("");
await expect(input).toBeFocused();
});
test("el botón enviar también envía y un texto vacío no envía nada", async ({ app }) => {
await app.locator("#composer-input").fill(" ");
await app.locator("#composer-enviar").click();
await expect(salientesSesion(app)).toHaveCount(0);
await app.locator("#composer-input").fill("Hola");
await app.locator("#composer-enviar").click();
await expect(salientesSesion(app)).toHaveCount(1);
});
test("el texto del composer se pinta como texto, sin inyectar HTML", async ({ app }) => {
await app.locator("#composer-input").fill('<img src=x onerror="window.__xss=1">');
await app.locator("#composer-input").press("Enter");
await expect(salientesSesion(app).last()).toContainText("<img src=x");
expect(await app.evaluate(() => window.__xss)).toBeUndefined();
});
test("«Reiniciar chat» descarta la sesión y vuelve al guion", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.locator("#composer-input").fill("temporal");
await app.locator("#composer-input").press("Enter");
await expect(filasChat(app)).toHaveCount(n + 1);
await app.locator("#btn-reiniciar").click();
await expect(filasChat(app)).toHaveCount(n);
await expect(salientesSesion(app)).toHaveCount(0);
});
test("editar el guion descarta la sesión del teléfono", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.locator("#composer-input").fill("temporal");
await app.locator("#composer-input").press("Enter");
await expect(filasChat(app)).toHaveCount(n + 1);
await app.locator('#nuevo-tipo [data-tipo="text"]').click();
await expect(filasChat(app)).toHaveCount(n + 1); // +1 del nuevo mensaje, sin la sesión
await expect(salientesSesion(app)).toHaveCount(0);
});
test("WAM.sesionAGuion() convierte la sesión en mensajes del guion", async ({ app }) => {
const n = (await estado(app)).mensajes.length;
await app.locator("#composer-input").fill("para el guion");
await app.locator("#composer-input").press("Enter");
const añadidos = await app.evaluate(() => WAM.sesionAGuion());
expect(añadidos).toBe(1);
const e = await estado(app);
expect(e.mensajes).toHaveLength(n + 1);
expect(e.mensajes[n]).toMatchObject({ dir: "out", tipo: "text", texto: "para el guion" });
expect(e.mensajes[n].id).toMatch(/^m/);
await expect(app.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(n + 1);
});
});
+133
View File
@@ -0,0 +1,133 @@
// Responsive: ventanas estrechas de escritorio (pestañas Editor / Vista previa,
// menú «⋯») y un iPhone real (proyecto «iphone», specs @movil).
import { test, expect, estado, filasChat, filaChat, anadirTipo, irA } from "./fixtures.mjs";
async function sinScrollHorizontal(page) {
const { sw, cw } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }));
expect(sw, "scroll horizontal de la página").toBeLessThanOrEqual(cw + 1);
}
async function dentroDeVentana(page, loc) {
const caja = await loc.boundingBox();
const vw = page.viewportSize().width;
expect(caja.x).toBeGreaterThanOrEqual(-1);
expect(caja.x + caja.width).toBeLessThanOrEqual(vw + 1);
}
test.describe("Escritorio ancho", () => {
test("dos columnas: editor a la izquierda y teléfono a la derecha, sin pestañas", async ({ app }) => {
await expect(app.locator("#tabs-movil")).toBeHidden();
const ed = await app.locator("#panel-editor").boundingBox();
const mv = await app.locator("#panel-movil").boundingBox();
expect(ed.x + ed.width).toBeLessThanOrEqual(mv.x + 1);
await expect(app.locator("#btn-exportar")).toBeVisible();
await sinScrollHorizontal(app);
});
});
for (const ancho of [1024, 900, 700]) {
test.describe(`Ventana de ${ancho}px`, () => {
test.use({ viewport: { width: ancho, height: 800 } });
test("sin scroll horizontal y el iPhone cabe entero", async ({ app }) => {
await sinScrollHorizontal(app);
if (ancho <= 900) await app.locator("#tab-vista").click();
await dentroDeVentana(app, app.locator("#iphone"));
await expect(app.locator("#composer-input")).toBeVisible();
});
if (ancho <= 900) {
test("pestañas Editor / Vista previa y menú «⋯»", async ({ app }) => {
await expect(app.locator("#tabs-movil")).toBeVisible();
await app.locator("#tab-editor").click();
await expect(app.locator("#tab-editor")).toHaveAttribute("aria-selected", "true");
await expect(app.locator("#panel-editor")).toBeVisible();
await expect(app.locator("#panel-movil")).toBeHidden();
const id = await anadirTipo(app, "text");
await app.locator("#btn-ver-telefono").click();
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
await expect(app.locator("#panel-movil")).toBeVisible();
await expect(filaChat(app, id)).toBeVisible();
// la pestaña elegida se recuerda
await app.locator("#tab-editor").click();
await app.reload();
await expect(app.locator("#tab-editor")).toHaveAttribute("aria-selected", "true");
// menú «⋯» con las acciones secundarias
await app.locator("#btn-mas").click();
await expect(app.locator("#btn-mas")).toHaveAttribute("aria-expanded", "true");
await expect(app.locator("#btn-exportar")).toBeVisible();
await app.keyboard.press("Escape");
await expect(app.locator("#btn-mas")).toHaveAttribute("aria-expanded", "false");
});
}
});
}
test.describe("iPhone @movil", () => {
test("carga sin scroll horizontal con la vista previa por defecto @movil", async ({ app }) => {
await expect(app.locator("#tabs-movil")).toBeVisible();
await expect(app.locator("#tab-vista")).toHaveAttribute("aria-selected", "true");
await sinScrollHorizontal(app);
await dentroDeVentana(app, app.locator("#iphone"));
const n = (await estado(app)).mensajes.length;
await expect(filasChat(app)).toHaveCount(n);
});
test("tocar un quick reply y una fila de la lista funciona con el dedo @movil", async ({ app }) => {
await app.evaluate(() => WAM.setVelocidad(2));
const e = await estado(app);
const btns = e.mensajes.find((m) => m.tipo === "buttons");
const b = filaChat(app, btns.id).locator("button.qr-btn").first();
await b.scrollIntoViewIfNeeded();
await b.tap();
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]').last()).toContainText("Horarios");
await expect(app.locator('#chat-cuerpo li.in[data-id^="s"]').last()).toContainText("lunes a viernes", { timeout: 6000 });
const lista = e.mensajes.find((m) => m.tipo === "list");
const bl = filaChat(app, lista.id).locator("button.qr-btn");
await bl.scrollIntoViewIfNeeded();
await bl.tap();
await expect(app.locator("#sheet")).toBeVisible();
await dentroDeVentana(app, app.locator("#sheet .sheet-panel"));
await app.locator("#sheet .sheet-fila").first().tap();
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]').last()).toContainText("Espresso double");
});
test("composer desde el teclado del móvil @movil", async ({ app }) => {
await app.locator("#composer-input").tap();
await app.locator("#composer-input").fill("Hola desde el iPhone");
await app.locator("#composer-enviar").tap();
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]').last()).toContainText("Hola desde el iPhone");
});
test("editar en la pestaña Editor y ver el resultado en Vista previa @movil", async ({ app }) => {
await app.locator("#tab-editor").tap();
await expect(app.locator("#panel-editor")).toBeVisible();
await sinScrollHorizontal(app);
const id = await anadirTipo(app, "location");
await app.locator('#lista-mensajes [data-ruta$=".nombre"]').last().fill("Local móvil");
await app.locator("#tab-vista").tap();
await expect(filaChat(app, id).locator(".loc-nombre")).toHaveText("Local móvil");
});
test("menú «⋯», tema y presentación en el móvil @movil", async ({ app }) => {
await app.locator("#btn-tema").tap();
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await app.locator("#btn-mas").tap();
await expect(app.locator("#btn-demos")).toBeVisible();
await app.locator("#btn-demos").tap();
await expect(app.locator("#dlg-demos")).toBeVisible();
await dentroDeVentana(app, app.locator("#dlg-demos"));
await app.locator("#dlg-demos .wam-dlg-cerrar").tap();
await app.locator("#btn-presentacion").tap();
await expect(app.locator("body")).toHaveClass(/presentacion/);
await dentroDeVentana(app, app.locator("#iphone"));
await app.locator("#btn-presentacion").tap();
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
});
test("por file:// también funciona en el móvil @movil", async ({ page }) => {
const { URL_FILE } = await import("./fixtures.mjs");
await irA(page, URL_FILE);
await expect(page.locator("#chat-nombre")).toHaveText("Café Aurora");
});
});
+219
View File
@@ -0,0 +1,219 @@
// Los 13 tipos: alta desde la paleta, edición en el formulario y reflejo en el chat.
import {
test, expect, estado, vaciar, anadirTipo, campo, filaChat, tarjetas, modoUrl, PNG_1PX, TIPOS,
} from "./fixtures.mjs";
const DATA_PNG = "data:image/png;base64," + PNG_1PX.toString("base64");
/**
* Por tipo: selector que debe existir en la burbuja recién creada, ediciones
* [ruta relativa, valor] y comprobaciones sobre la fila del chat tras editar.
*/
const CASOS = {
text: {
base: ".bubble .wa-texto",
editar: [["texto", "Pedido *listo* para _recoger_"]],
comprobar: async (fila) => {
await expect(fila.locator(".wa-texto strong")).toHaveText("listo");
await expect(fila.locator(".wa-texto em")).toHaveText("recoger");
},
},
image: {
base: "img.wa-media",
url: "src",
editar: [["src", DATA_PNG], ["caption", "Foto del local"]],
comprobar: async (fila) => {
await expect(fila.locator("img.wa-media")).toHaveAttribute("src", DATA_PNG);
await expect(fila.locator(".wa-caption")).toContainText("Foto del local");
},
},
video: {
base: ".wa-media-wrap .wa-play-badge",
editar: [["caption", "Tour virtual"]],
comprobar: async (fila) => {
await expect(fila.locator(".wa-caption")).toContainText("Tour virtual");
},
},
audio: {
base: ".wa-audio .audio-wave",
editar: [["duracion", "1:05"]],
comprobar: async (fila) => {
await expect(fila.locator(".audio-dur")).toHaveText("1:05");
await expect(fila.locator(".audio-mic")).toBeVisible();
},
},
document: {
base: ".wa-doc .doc-panel",
editar: [["filename", "informe-trimestral.xlsx"], ["caption", "Resultados"]],
comprobar: async (fila) => {
await expect(fila.locator(".doc-nombre")).toHaveText("informe-trimestral.xlsx");
await expect(fila.locator(".doc-sub")).toContainText(/xlsx/i);
await expect(fila.locator(".wa-caption")).toContainText("Resultados");
},
},
sticker: {
base: ".sticker-box .sticker-img",
url: "src",
editar: [["src", DATA_PNG]],
comprobar: async (fila) => {
await expect(fila.locator(".sticker-img")).toHaveAttribute("src", DATA_PNG);
},
},
location: {
base: ".wa-loc .loc-mapa",
editar: [["nombre", "Sucursal Norte"], ["direccion", "Av. Reforma 500"]],
comprobar: async (fila) => {
await expect(fila.locator(".loc-nombre")).toHaveText("Sucursal Norte");
await expect(fila.locator(".loc-dir")).toHaveText("Av. Reforma 500");
},
},
contact: {
base: ".wa-contact .contact-nombre",
editar: [["nombre", "Ana Torres"], ["telefono", "+52 55 1234 5678"]],
comprobar: async (fila) => {
await expect(fila.locator(".contact-nombre")).toHaveText("Ana Torres");
},
},
template: {
base: ".bubble.tpl .tpl-body",
editar: [["body", "Tu cita es mañana"], ["footer", "Clínica Demo"], ["botones.0.texto", "Confirmo"]],
comprobar: async (fila) => {
await expect(fila.locator(".tpl-body")).toHaveText("Tu cita es mañana");
await expect(fila.locator(".template-footer")).toHaveText("Clínica Demo");
await expect(fila.locator(".qr-btn .btn-label")).toHaveText(["Confirmo"]);
},
},
buttons: {
base: ".bubble.tpl button.qr-btn",
editar: [["body", "¿Te ayudo?"], ["botones.0.texto", "Sí, por favor"]],
comprobar: async (fila) => {
await expect(fila.locator(".tpl-body")).toHaveText("¿Te ayudo?");
await expect(fila.locator("button.qr-btn .btn-label")).toHaveText(["Sí, por favor"]);
},
},
list: {
base: ".bubble.tpl button.qr-btn[data-flujo]",
editar: [["header", "Catálogo"], ["body", "Elige tu plan"], ["boton", "Ver planes"]],
comprobar: async (fila) => {
await expect(fila.locator(".tpl-header-txt")).toHaveText("Catálogo");
await expect(fila.locator(".tpl-body")).toHaveText("Elige tu plan");
await expect(fila.locator("button.qr-btn")).toHaveText("Ver planes");
},
},
cta_url: {
base: ".bubble.tpl .qr-btn",
editar: [["textoBoton", "Ir a la web"], ["url", "https://example.org/promo"], ["body", "Promo de temporada"]],
comprobar: async (fila) => {
const a = fila.locator("a.qr-btn");
await expect(a).toHaveAttribute("href", "https://example.org/promo");
await expect(a).toHaveAttribute("target", "_blank");
await expect(a).toContainText("Ir a la web");
await expect(fila.locator(".tpl-body")).toHaveText("Promo de temporada");
},
},
carousel: {
base: ".carousel .card",
editar: [["body", "Novedades"], ["cards.0.texto", "Plan Básico"], ["cards.1.textoBoton", "Lo quiero"]],
comprobar: async (fila) => {
await expect(fila.locator(".card")).toHaveCount(2);
await expect(fila.locator(".wa-stack > .bubble .wa-texto")).toContainText("Novedades");
await expect(fila.locator(".card").nth(0).locator(".card-body")).toHaveText("Plan Básico");
await expect(fila.locator(".card").nth(1).locator(".btn-label")).toHaveText("Lo quiero");
},
},
};
test.describe("Los 13 tipos de mensaje", () => {
test("la paleta ofrece exactamente los 13 tipos", async ({ app }) => {
const tipos = await app.locator('#nuevo-tipo [data-acc="anadir-tipo"]').evaluateAll((els) => els.map((e) => e.dataset.tipo));
expect([...tipos].sort()).toEqual([...TIPOS].sort());
});
for (const tipo of TIPOS) {
test(`${tipo}: añadir, editar y ver el reflejo en el chat`, async ({ app }) => {
const caso = CASOS[tipo];
await vaciar(app);
const id = await anadirTipo(app, tipo);
// Tarjeta abierta y burbuja en el chat
const t = app.locator(`#lista-mensajes > .ed-tarjeta[data-id="${id}"]`);
await expect(tarjetas(app)).toHaveCount(1);
await expect(t).toHaveClass(/ed-abierta/);
const fila = filaChat(app, id);
await expect(fila).toBeVisible();
await expect(fila.locator(caso.base).first()).toBeVisible();
// Alta: el foco va a un campo de la tarjeta nueva
await expect.poll(() => app.evaluate((i) => {
const a = document.activeElement;
return !!(a && a.closest(`.ed-tarjeta[data-id="${i}"]`));
}, id)).toBe(true);
// Edición
for (const [rel, valor] of caso.editar) {
const ruta = `mensajes.0.${rel}`;
const inp = caso.url === rel ? await modoUrl(app, ruta) : campo(app, ruta);
await inp.fill(valor);
}
await caso.comprobar(fila);
// El estado vivo y el persistido reflejan la edición
const e = await estado(app);
for (const [rel, valor] of caso.editar) {
const v = rel.split(".").reduce((o, k) => (o == null ? o : o[k]), e.mensajes[0]);
expect(v, rel).toBe(valor);
}
const guardado = await app.evaluate(() => { WAM.guardarYa(); return JSON.parse(localStorage.getItem("wam-mockup-v1")); });
expect(guardado.mensajes[0].tipo).toBe(tipo);
});
}
test("cambiar la dirección a «Salida» pinta ticks y el selector de estado", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "text");
const fila = filaChat(app, id);
await expect(fila).toHaveClass(/\bin\b/);
await app.locator('#lista-mensajes label.ed-seg-op:has(input[data-rol="dir"][value="out"])').click();
await expect(fila).toHaveClass(/\bout\b/);
await expect(fila.locator(".tick")).toHaveCount(1);
const sel = campo(app, "mensajes.0.estado");
await sel.selectOption("read");
await expect(filaChat(app, id).locator(".tick-read")).toBeVisible();
await sel.selectOption("delivered");
await expect(filaChat(app, id).locator(".tick")).toBeVisible();
await expect(filaChat(app, id).locator(".tick-read")).toHaveCount(0);
expect((await estado(app)).mensajes[0]).toMatchObject({ dir: "out", estado: "delivered" });
});
test("editar la hora se refleja en la burbuja", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "text");
await campo(app, "mensajes.0.hora").fill("09:41");
await expect(filaChat(app, id).locator(".meta")).toContainText("09:41");
});
test("audio: desactivar «nota de voz» cambia a audio con auriculares", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "audio");
await campo(app, "mensajes.0.voz").uncheck();
await expect(filaChat(app, id).locator(".audio-musica")).toBeVisible();
await expect(filaChat(app, id).locator(".audio-mic")).toHaveCount(0);
expect((await estado(app)).mensajes[0].voz).toBe(false);
});
test("plantilla: cabecera opcional de texto, imagen y documento", async ({ app }) => {
await vaciar(app);
const id = await anadirTipo(app, "template");
const fila = filaChat(app, id);
await app.locator("#lista-mensajes label.ed-switch", { hasText: "Mostrar cabecera" }).click();
await expect(app.locator('#lista-mensajes input[data-rol="header-toggle"]')).toBeChecked();
const tipoCab = (v) => app.locator(`#lista-mensajes [data-acc="header-tipo"][data-valor="${v}"]`);
await tipoCab("texto").click();
await campo(app, "mensajes.0.headerTexto").fill("Aviso importante");
await expect(fila.locator(".tpl-header-txt")).toHaveText("Aviso importante");
await tipoCab("imagen").click();
await expect(fila.locator("img.tpl-media")).toBeVisible();
await tipoCab("documento").click();
await expect(fila.locator(".tpl-header-doc .doc-nombre")).toBeVisible();
expect((await estado(app)).mensajes[0].headerTipo).toBe("documento");
});
});
+315
View File
@@ -0,0 +1,315 @@
// Barra superior: tema, presentación, exportar/importar, restablecer, deshacer/rehacer,
// atajos de teclado, demos guardadas y escenarios por industria.
import fs from "node:fs";
import {
test, expect, estado, vaciar, anadirTipo, campo, filasChat, tarjetas, cargarEstado,
} from "./fixtures.mjs";
async function exportar(page, disparar) {
const [descarga] = await Promise.all([page.waitForEvent("download"), disparar()]);
const ruta = await descarga.path();
return { nombre: descarga.suggestedFilename(), json: JSON.parse(fs.readFileSync(ruta, "utf8")) };
}
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 });
}
test.describe("Tema", () => {
test("alterna claro/oscuro en editor y chat y persiste", async ({ app }) => {
const b = app.locator("#btn-tema");
await b.click();
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await expect(app.locator("#pantalla")).toHaveClass(/oscuro/);
await expect(b).toHaveAttribute("aria-pressed", "true");
const fondo = (sel) => app.locator(sel).evaluate((el) => getComputedStyle(el).backgroundColor);
const oscuroEditor = await fondo("body");
await app.reload();
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
expect((await estado(app)).tema).toBe("oscuro");
await app.locator("#btn-tema").click();
await expect(app.locator("body")).toHaveAttribute("data-tema", "claro");
await expect(app.locator("#pantalla")).not.toHaveClass(/oscuro/);
expect(await fondo("body")).not.toBe(oscuroEditor);
});
test("la tecla T alterna el tema (y no lo hace escribiendo en un campo)", async ({ app }) => {
await app.locator("#chat-header").click();
await app.keyboard.press("t");
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await app.locator("#composer-input").fill("");
await app.locator("#composer-input").press("t");
await expect(app.locator("body")).toHaveAttribute("data-tema", "oscuro");
await expect(app.locator("#composer-input")).toHaveValue("t");
});
});
test.describe("Modo presentación", () => {
test("oculta el editor y centra el iPhone; Esc sale", async ({ app }) => {
await app.locator("#btn-presentacion").click();
await expect(app.locator("body")).toHaveClass(/presentacion/);
await expect(app.locator("#btn-presentacion")).toHaveAttribute("aria-pressed", "true");
await expect(app.locator("#panel-editor")).toBeHidden();
const vw = app.viewportSize().width;
const caja = await app.locator("#iphone").boundingBox();
expect(Math.abs(caja.x + caja.width / 2 - vw / 2)).toBeLessThan(40);
// el iPhone cabe entero en la ventana
expect(caja.y).toBeGreaterThanOrEqual(0);
expect(caja.y + caja.height).toBeLessThanOrEqual(app.viewportSize().height + 1);
// el chat sigue funcionando en presentación
await app.locator("#composer-input").fill("en vivo");
await app.locator("#composer-input").press("Enter");
await expect(app.locator('#chat-cuerpo li.out[data-id^="s"]')).toContainText("en vivo");
await app.locator("#chat-header").click();
await app.keyboard.press("Escape");
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
await expect(app.locator("#panel-editor")).toBeVisible();
});
test("la tecla P entra y sale", async ({ app }) => {
await app.locator("#chat-header").click();
await app.keyboard.press("p");
await expect(app.locator("body")).toHaveClass(/presentacion/);
await app.keyboard.press("p");
await expect(app.locator("body")).not.toHaveClass(/presentacion/);
});
});
test.describe("Exportar e importar", () => {
test("exporta <slug>.json con el estado completo", async ({ app }) => {
const { nombre, json } = await exportar(app, () => app.locator("#btn-exportar").click());
expect(nombre).toBe("cafe-aurora.json");
expect(json).toEqual(await estado(app));
expect(json.mensajes.find((m) => m.tipo === "list").secciones[0].filas[0].respuestas).toHaveLength(1);
});
test("sin nombre de cuenta exporta wam-demo.json; Ctrl+S también exporta", async ({ app }) => {
await app.evaluate(() => { WAM.estado.cuenta.nombre = ""; WAM.guardarYa(); });
await app.locator("#chat-header").click();
const { nombre } = await exportar(app, () => app.keyboard.press("Control+s"));
expect(nombre).toBe("wam-demo.json");
});
test("importar el mismo archivo restaura un estado idéntico", async ({ app }) => {
const { json } = await exportar(app, () => app.locator("#btn-exportar").click());
await vaciar(app);
await anadirTipo(app, "text");
await importar(app, json);
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(json.mensajes.length);
expect(await estado(app)).toEqual(json);
await expect(filasChat(app)).toHaveCount(json.mensajes.length);
await expect(tarjetas(app)).toHaveCount(json.mensajes.length);
await app.reload();
expect(await estado(app)).toEqual(json);
});
test("importar normaliza: array de mensajes, IDs duplicados, URLs peligrosas", async ({ app }) => {
await importar(app, [
{ id: "x", dir: "in", tipo: "text", texto: "uno" },
{ id: "x", dir: "out", tipo: "cta_url", body: "b", textoBoton: "Ir", url: "javascript:alert(1)" },
{ dir: "in", tipo: "desconocido", texto: "tres" },
]);
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(3);
const e = await estado(app);
const idsUnicos = new Set(e.mensajes.map((m) => m.id));
expect(idsUnicos.size).toBe(3);
expect(e.mensajes[1].url).toBe("");
await expect(app.locator(`#chat-cuerpo li[data-id="${e.mensajes[1].id}"] a[href^="javascript"]`)).toHaveCount(0);
});
test("un JSON inválido avisa y no cambia la demo", async ({ app, consola }) => {
consola.permitir(/JSON|Unexpected token|SyntaxError/i);
const antes = await estado(app);
await importar(app, "{ no es json", "roto.json");
await expect(app.locator("#ed-toasts .ed-toast-error, #wam-aviso").first()).toBeVisible();
expect(await estado(app)).toEqual(antes);
});
test("cancelar el diálogo de importar no abre el selector", async ({ app }) => {
let abierto = false;
app.on("filechooser", () => { abierto = true; });
await app.locator("#btn-importar").click();
await app.locator("#dlg-confirmar").getByRole("button", { name: "Cancelar" }).click();
await expect(app.locator("#dlg-confirmar")).toHaveCount(0);
await app.waitForTimeout(300);
expect(abierto).toBe(false);
});
});
test.describe("Restablecer, deshacer y rehacer", () => {
test("restablecer pide confirmación, restaura la demo y se puede deshacer", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
await campo(app, "mensajes.0.texto").fill("Mi cambio");
const mio = await estado(app);
await app.locator("#btn-reset").click();
const dlg = app.locator("#dlg-confirmar");
await expect(dlg).toBeVisible();
await expect(dlg).toContainText("1 mensaje");
await dlg.getByRole("button", { name: "Restablecer" }).click();
await expect(app.locator("#chat-nombre")).toHaveText("Café Aurora");
const e = await estado(app);
expect(e.mensajes).toHaveLength(14);
expect(e.mensajes[0].id).toBe("m1");
const toast = app.locator("#ed-toasts .ed-toast", { hasText: "restablecida" });
await toast.locator(".ed-toast-accion").click();
await expect.poll(async () => (await estado(app)).mensajes).toEqual(mio.mensajes);
});
test("«Exportar antes» del diálogo descarga la demo actual", async ({ app }) => {
await app.locator("#btn-reset").click();
const { nombre } = await exportar(app, () => app.locator("#dlg-confirmar").getByRole("button", { name: "Exportar antes" }).click());
expect(nombre).toBe("cafe-aurora.json");
await expect(app.locator("#dlg-confirmar").getByRole("button", { name: "Exportada ✓" })).toBeDisabled();
await app.locator("#dlg-confirmar").getByRole("button", { name: "Cancelar" }).click();
});
test("sin mensajes restablece sin preguntar", async ({ app }) => {
await vaciar(app);
await app.locator("#btn-reset").click();
await expect(app.locator("#dlg-confirmar")).toHaveCount(0);
await expect(tarjetas(app)).toHaveCount(14);
});
test("botones y atajos de deshacer / rehacer", async ({ app }) => {
await expect(app.locator("#btn-deshacer")).toBeDisabled();
const n = (await estado(app)).mensajes.length;
await anadirTipo(app, "contact");
await expect(app.locator("#btn-deshacer")).toBeEnabled();
await app.locator("#btn-deshacer").click();
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n);
await expect(app.locator("#btn-rehacer")).toBeEnabled();
await app.locator("#btn-rehacer").click();
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n + 1);
await app.locator("#chat-header").click();
await app.keyboard.press("Control+z");
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n);
await app.keyboard.press("Control+Shift+z");
await expect.poll(async () => (await estado(app)).mensajes.length).toBe(n + 1);
await expect(tarjetas(app)).toHaveCount(n + 1);
await expect(filasChat(app)).toHaveCount(n + 1);
});
test("deshacer una ráfaga de escritura vuelve al texto previo", async ({ app }) => {
await vaciar(app);
await anadirTipo(app, "text");
const ta = campo(app, "mensajes.0.texto");
const original = await ta.inputValue();
await ta.fill("uno");
await ta.pressSequentially(" dos");
await app.locator("#btn-deshacer").click();
await expect.poll(async () => (await estado(app)).mensajes[0]?.texto).toBe(original);
});
});
test.describe("Atajos y demos", () => {
test("«?» y el botón de atajos abren la ayuda", async ({ app }) => {
await app.locator("#btn-atajos").click();
const dlg = app.locator("#dlg-atajos");
await expect(dlg).toBeVisible();
await expect(dlg).toContainText("Modo presentación");
await dlg.locator(".wam-dlg-cerrar").click();
await expect(dlg).toHaveCount(0);
await app.locator("#chat-header").click();
await app.keyboard.press("?");
await expect(app.locator("#dlg-atajos")).toBeVisible();
await app.keyboard.press("Escape");
await expect(app.locator("#dlg-atajos")).toHaveCount(0);
});
test("escenarios por industria: cargar uno y deshacer", async ({ app }) => {
const escenarios = await app.evaluate(() => WAM.ESCENARIOS.map((e) => ({ id: e.id, nombre: e.nombre })));
expect(escenarios.length).toBeGreaterThanOrEqual(7);
await app.locator("#btn-demos").click();
const dlg = app.locator("#dlg-demos");
await expect(dlg).toBeVisible();
await expect(dlg.locator(".wam-esc")).toHaveCount(escenarios.length + 1);
const elegido = escenarios.find((e) => e.id !== "blanco" && !/blanco/i.test(e.nombre)) || escenarios[1];
const datos = await app.evaluate((id) => WAM.normalizar(WAM.datosEscenario(id)), elegido.id);
await dlg.locator(`.wam-esc[data-esc="${elegido.id}"]`).click();
await expect(dlg).toHaveCount(0);
await expect(app.locator("#chat-nombre")).toHaveText(datos.cuenta.nombre || "Chat");
await expect(filasChat(app)).toHaveCount(datos.mensajes.length);
await app.locator("#ed-toasts .ed-toast", { hasText: elegido.nombre }).locator(".ed-toast-accion").click();
await expect(app.locator("#chat-nombre")).toHaveText("Café Aurora");
});
test("todos los escenarios cargan sin avisos de validación ni errores", async ({ app }) => {
const ids = await app.evaluate(() => WAM.ESCENARIOS.map((e) => e.id));
for (const id of ids) {
await app.locator("#btn-demos").click();
await app.locator(`#dlg-demos .wam-esc[data-esc="${id}"]`).click();
const e = await estado(app);
await expect(filasChat(app)).toHaveCount(e.mensajes.length);
await expect(tarjetas(app)).toHaveCount(e.mensajes.length);
await expect(app.locator("#lista-mensajes .ed-chip-aviso"), `escenario ${id}`).toHaveCount(0);
}
});
test("demos guardadas: guardar, abrir, renombrar, duplicar y borrar", async ({ app }) => {
// 1) guardar la actual con nombre
await app.locator("#btn-demos").click();
let dlg = app.locator("#dlg-demos");
await dlg.locator("#dlg-demo-nombre").fill("Cliente Aurora");
await dlg.getByRole("button", { name: /Guardar/ }).click();
await expect(dlg.locator(".wam-demo")).toHaveCount(1);
await expect(dlg.locator(".wam-demo-nombre")).toHaveText("Cliente Aurora");
await expect(dlg.locator(".wam-demo")).toHaveClass(/wam-demo-activa/);
// el export usa el nombre de la demo activa
await dlg.locator(".wam-dlg-pie").getByRole("button", { name: "Cerrar" }).click();
const { nombre } = await exportar(app, () => app.locator("#btn-exportar").click());
expect(nombre).toBe("cliente-aurora.json");
// 2) cambiar la demo y volver a abrir la guardada
await vaciar(app);
await app.locator("#btn-demos").click();
dlg = app.locator("#dlg-demos");
await dlg.locator(".wam-demo").getByRole("button", { name: "Abrir" }).click();
await expect(tarjetas(app)).toHaveCount(14);
// 3) renombrar, duplicar y borrar
await app.locator("#btn-demos").click();
dlg = app.locator("#dlg-demos");
await dlg.locator(".wam-demo").getByRole("button", { name: "Renombrar" }).click();
const inp = dlg.locator(".wam-demo input.wam-dlg-input");
await inp.fill("Aurora v2");
await inp.press("Enter");
await expect(dlg.locator(".wam-demo-nombre")).toHaveText("Aurora v2");
await dlg.locator(".wam-demo").getByRole("button", { name: "Duplicar" }).click();
await expect(dlg.locator(".wam-demo")).toHaveCount(2);
await expect(dlg.locator(".wam-demo-nombre").first()).toHaveText("Aurora v2 (copia)");
const borrar = dlg.locator(".wam-demo").first().getByRole("button", { name: "Borrar" });
await borrar.click();
await expect(dlg.locator(".wam-demo").first().getByRole("button", { name: "¿Seguro?" })).toBeVisible();
await dlg.locator(".wam-demo").first().getByRole("button", { name: "¿Seguro?" }).click();
await expect(dlg.locator(".wam-demo")).toHaveCount(1);
// persiste en localStorage
const indice = await app.evaluate(() => JSON.parse(localStorage.getItem("wam-demos")));
expect(indice.map((d) => d.nombre)).toEqual(["Aurora v2"]);
});
test("la tecla N abre las demos; F pide pantalla completa sin errores", async ({ app }) => {
await app.locator("#chat-header").click();
await app.keyboard.press("n");
await expect(app.locator("#dlg-demos")).toBeVisible();
await app.keyboard.press("Escape");
await expect(app.locator("#dlg-demos")).toHaveCount(0);
await app.keyboard.press("f");
await app.waitForTimeout(300);
if (await app.evaluate(() => !!document.fullscreenElement)) await app.evaluate(() => document.exitFullscreen());
});
test("cargar un estado con avisoCifrado:false oculta los avisos del sistema", async ({ app }) => {
await cargarEstado(app, { version: 1, tema: "claro", cuenta: { nombre: "X", avisoCifrado: false }, mensajes: [] });
await expect(app.locator("#chat-cuerpo > li.wa-sistema")).toHaveCount(0);
});
});