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

This commit is contained in:
urieljareth
2026-09-27 20:46:51 -06:00
parent dc6e31a9d7
commit 9a5f21badf
33 changed files with 7719 additions and 1129 deletions
+178
View File
@@ -0,0 +1,178 @@
// Fixtures y utilidades compartidas por la suite e2e.
//
// - `page` se extiende para registrar errores/warnings de consola, errores de
// página y peticiones a red externa; al terminar cada test se exige que no
// haya ninguno (criterio §5.1 y §5.10 de la SPEC). Un test puede tolerar
// mensajes concretos con `consola.permitir(/regex/)`.
// - `app` navega a index.html y espera a que el chat y el editor estén listos.
import { test as base, expect } from "@playwright/test";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export const RAIZ = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
export const URL_FILE = pathToFileURL(path.join(RAIZ, "index.html")).href;
export const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), "archivos");
/** PNG 1×1 válido (para subidas y drops). */
export const PNG_1PX = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64"
);
export const TIPOS = [
"text", "image", "video", "audio", "document", "sticker", "location",
"contact", "template", "buttons", "list", "cta_url", "carousel",
];
export const test = base.extend({
consola: async ({}, use) => {
const registro = {
mensajes: [],
externas: [],
permitidos: [],
permitir(re) { this.permitidos.push(re); },
};
await use(registro);
},
page: async ({ page, consola }, use, testInfo) => {
page.on("console", (m) => {
if (m.type() === "error" || m.type() === "warning") consola.mensajes.push(`[${m.type()}] ${m.text()}`);
});
page.on("pageerror", (e) => consola.mensajes.push(`[pageerror] ${e.message}`));
// Ninguna dependencia de red obligatoria: toda petición externa se registra y se aborta.
await page.route(/^(https?|wss?):\/\/(?!127\.0\.0\.1|localhost)/, (ruta) => {
consola.externas.push(ruta.request().url());
return ruta.abort();
});
await use(page);
if (testInfo.status === testInfo.expectedStatus) {
const reales = consola.mensajes.filter((t) => !consola.permitidos.some((re) => re.test(t)));
expect(reales, "errores/warnings de consola").toEqual([]);
expect(consola.externas, "peticiones a red externa").toEqual([]);
}
},
app: async ({ page }, use) => {
await irA(page, "/index.html");
await use(page);
},
});
export { expect };
/** Navega y espera al arranque completo (chat + editor pintados). */
export async function irA(page, url) {
await page.goto(url);
await esperarListo(page);
}
export async function esperarListo(page) {
await page.waitForFunction(() =>
window.WAM && WAM.estado && document.querySelector("#chat-header #chat-nombre") &&
document.querySelector("#nuevo-tipo [data-tipo]")
);
}
/** Copia profunda del estado vivo. */
export function estado(page) {
return page.evaluate(() => JSON.parse(JSON.stringify(WAM.estado)));
}
/** Estado persistido en localStorage (wam-mockup-v1). */
export function estadoGuardado(page) {
return page.evaluate(() => {
if (typeof WAM.guardarYa === "function") WAM.guardarYa();
return JSON.parse(localStorage.getItem("wam-mockup-v1") || "null");
});
}
/** Deja la demo sin mensajes (como el escenario «en blanco») y repinta todo. */
export async function vaciar(page) {
await page.evaluate(() => {
WAM.estado.mensajes = [];
WAM.guardarYa();
WAM.render();
WAM.renderEditor();
});
await expect(page.locator("#lista-mensajes > .ed-tarjeta")).toHaveCount(0);
}
/** Sustituye el estado entero (normalizado) y repinta todo. */
export async function cargarEstado(page, datos) {
await page.evaluate((d) => {
WAM.estado = WAM.normalizar(d);
WAM.guardarYa();
WAM.setTema(WAM.estado.tema);
WAM.render();
WAM.renderEditor();
}, datos);
}
export const tarjetas = (page) => page.locator("#lista-mensajes > .ed-tarjeta");
export const tarjeta = (page, i) => page.locator(`#lista-mensajes > .ed-tarjeta[data-idx="${i}"]`);
export const filaChat = (page, id) => page.locator(`#chat-cuerpo > li.wa-row[data-id="${id}"]`);
export const filasChat = (page) => page.locator("#chat-cuerpo > li.wa-row");
export const campo = (page, ruta) => page.locator(`#lista-mensajes [data-ruta="${ruta}"]`).first();
/** Despliega la tarjeta i (si está plegada) y la devuelve. */
export async function abrirTarjeta(page, i) {
const t = tarjeta(page, i);
const cab = t.locator(":scope > .ed-tarjeta-cab .ed-cab-principal");
if ((await cab.getAttribute("aria-expanded")) !== "true") await cab.click();
await expect(cab).toHaveAttribute("aria-expanded", "true");
return t;
}
/** Añade un mensaje desde la paleta y devuelve su id. */
export async function anadirTipo(page, tipo) {
const antes = (await estado(page)).mensajes.map((m) => m.id);
await page.locator(`#nuevo-tipo [data-acc="anadir-tipo"][data-tipo="${tipo}"]`).click();
const handle = await page.waitForFunction((prev) => {
const nuevo = WAM.estado.mensajes.find((m) => !prev.includes(m.id));
return nuevo ? nuevo.id : null;
}, antes);
return handle.jsonValue();
}
/** Pone un campo de media en modo URL (los vacíos arrancan en «Subir archivo»). */
export async function modoUrl(page, rutaMedia) {
const inp = campo(page, rutaMedia);
if (await inp.isVisible()) return inp;
const media = page.locator(".ed-media", { has: page.locator(`[data-ruta="${rutaMedia}"]`) }).first();
await media.locator('label.ed-radio:has(input[value="url"])').click();
await expect(inp).toBeVisible();
return inp;
}
/** Acelera los flujos del teléfono (velocidad 2x). */
export async function rapido(page) {
await page.evaluate(() => WAM.setVelocidad(2));
}
/**
* Arrastra con el ratón (HTML5 drag & drop nativo) el asa `asa` hasta `destino`.
* `abajo` = soltar en la mitad inferior del destino (queda detrás de él).
* Coloca antes el destino arriba del todo para que ambos queden a la vista.
*/
export async function arrastrar(page, asa, destino, { abajo = false } = {}) {
await destino.evaluate((el) => el.scrollIntoView({ block: "start" }));
const a = await asa.boundingBox();
const d = await destino.boundingBox();
const vh = page.viewportSize().height;
if (!a || !d || a.y < 0 || a.y + a.height > vh) throw new Error("el asa y el destino no caben a la vez en pantalla");
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
await page.mouse.down();
await page.mouse.move(a.x + a.width / 2 + 4, a.y + a.height / 2 + 4, { steps: 3 });
const y = abajo ? d.y + d.height - 4 : d.y + 4;
await page.mouse.move(d.x + Math.min(40, d.width / 2), y, { steps: 8 });
await page.mouse.up();
}
/** Pulsa una acción de la cabecera de la tarjeta i (las flechas se revelan al pasar el ratón). */
export async function accion(page, i, acc, delta) {
const cab = tarjeta(page, i).locator(":scope > .ed-tarjeta-cab");
await cab.hover();
const sel = `[data-acc="${acc}"]` + (delta === undefined ? "" : `[data-delta="${delta}"]`);
await cab.locator(sel).click();
}