WIP: mejoras de fidelidad, editor, lógica y suite e2e (checkpoint tras reinicio)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "wam-mockup-tests",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wam-mockup-tests",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "wam-mockup-tests",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "Suite e2e (Playwright) del WhatsApp Cloud API Mockup",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:chromium": "playwright test --project=chromium",
|
||||
"test:webkit": "playwright test --project=webkit",
|
||||
"test:movil": "playwright test --project=iphone",
|
||||
"serve": "node serve.mjs",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Configuración Playwright de la suite e2e del WhatsApp Cloud API Mockup.
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const PUERTO = Number(process.env.WAM_PORT || 4173);
|
||||
const BASE = `http://127.0.0.1:${PUERTO}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./test-results",
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 7_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
// WebKit en Windows se vuelve inestable con muchos workers en paralelo
|
||||
workers: process.env.CI ? 2 : Number(process.env.WAM_WORKERS || 6),
|
||||
reporter: [["list"], ["html", { outputFolder: "playwright-report", open: "never" }]],
|
||||
use: {
|
||||
baseURL: BASE,
|
||||
locale: "es-ES",
|
||||
timezoneId: "America/Mexico_City",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
acceptDownloads: true,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
|
||||
grepInvert: /@movil/,
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"], viewport: { width: 1440, height: 900 } },
|
||||
grepInvert: /@movil/,
|
||||
},
|
||||
{
|
||||
// Proyecto móvil (WebKit, táctil): solo las specs etiquetadas @movil
|
||||
name: "iphone",
|
||||
use: { ...devices["iPhone 15"] },
|
||||
grep: /@movil/,
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `node serve.mjs ${PUERTO}`,
|
||||
url: `${BASE}/index.html`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 20_000,
|
||||
stdout: "ignore",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Servidor estático mínimo (sin dependencias) para la suite e2e.
|
||||
// Sirve la raíz del proyecto (carpeta padre de tests/) por HTTP.
|
||||
// Uso: node serve.mjs [puerto] (defecto: env PORT o 4173)
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PUERTO = Number(process.argv[2] || process.env.PORT || 4173);
|
||||
|
||||
const TIPOS = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".mp4": "video/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".ogg": "audio/ogg",
|
||||
".pdf": "application/pdf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
};
|
||||
|
||||
const servidor = http.createServer((req, res) => {
|
||||
let ruta;
|
||||
try {
|
||||
ruta = decodeURIComponent(new URL(req.url, "http://x").pathname);
|
||||
} catch {
|
||||
res.writeHead(400).end("Petición inválida");
|
||||
return;
|
||||
}
|
||||
if (ruta.endsWith("/")) ruta += "index.html";
|
||||
const archivo = path.resolve(RAIZ, "." + ruta);
|
||||
// Evita salir de la raíz (path traversal)
|
||||
if (archivo !== RAIZ && !archivo.startsWith(RAIZ + path.sep)) {
|
||||
res.writeHead(403).end("Prohibido");
|
||||
return;
|
||||
}
|
||||
fs.stat(archivo, (err, st) => {
|
||||
if (err || !st.isFile()) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("No encontrado");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": TIPOS[path.extname(archivo).toLowerCase()] || "application/octet-stream",
|
||||
"Content-Length": st.size,
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
if (req.method === "HEAD") return res.end();
|
||||
fs.createReadStream(archivo).pipe(res);
|
||||
});
|
||||
});
|
||||
|
||||
servidor.listen(PUERTO, "127.0.0.1", () => {
|
||||
console.log(`WAM mockup servido en http://127.0.0.1:${PUERTO}/ (raíz: ${RAIZ})`);
|
||||
});
|
||||
Reference in New Issue
Block a user