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