diff --git a/e2e/fixtures/element-auth.ts b/e2e/fixtures/element-auth.ts index a7a0718..c06603e 100644 --- a/e2e/fixtures/element-auth.ts +++ b/e2e/fixtures/element-auth.ts @@ -71,7 +71,7 @@ export async function loginToElement(page: Page, opts: LoginOptions) { console.log("[login] Campo Username no encontrado — verificando si ya hay sesion..."); await screenshot(page, ssDir, `ERROR-no-username-attempt${attempt}.png`); - const roomsTree = page.locator('[role="tree"][aria-label="Rooms"]'); + const roomsTree = page.locator('[role="tree"][aria-label="Rooms"], .mx_RoomList, .mx_LeftPanel_roomListContainer, .mx_RoomTile').first(); const alreadyLoggedIn = await roomsTree .waitFor({ state: "visible", timeout: 5_000 }) .then(() => true) @@ -196,7 +196,7 @@ async function waitForLoginResult(page: Page): Promise { } // Verificar si ya estamos en el home (rooms visibles) - const roomsTree = page.locator('[role="tree"][aria-label="Rooms"]'); + const roomsTree = page.locator('[role="tree"][aria-label="Rooms"], .mx_RoomList, .mx_LeftPanel_roomListContainer, .mx_RoomTile').first(); if (await roomsTree.isVisible().catch(() => false)) { return "success"; } diff --git a/e2e/fixtures/element-utils.ts b/e2e/fixtures/element-utils.ts new file mode 100644 index 0000000..6d7186c --- /dev/null +++ b/e2e/fixtures/element-utils.ts @@ -0,0 +1,53 @@ +import { Page } from "@playwright/test"; + +/** + * Cierra todos los toasts/notificaciones de Element que bloquean clicks. + * Incluye: Notifications, Threads Activity Centre, y cualquier toast generico. + */ +export async function dismissAllToasts(page: Page) { + // Dar un momento para que los toasts aparezcan + await page.waitForTimeout(1_500); + + // Estrategia directa: buscar botones conocidos de toasts de Element + const knownDismissButtons = [ + page.getByRole("button", { name: "Dismiss" }), + page.locator("button").filter({ hasText: /^OK$/ }), + page.getByRole("button", { name: "Close" }), + page.getByRole("button", { name: "Not now" }), + page.getByRole("button", { name: "Got it" }), + page.getByRole("button", { name: "Skip" }), + ]; + + for (const btn of knownDismissButtons) { + try { + if (await btn.first().isVisible()) { + const text = await btn.first().textContent().catch(() => "?"); + console.log(`[element] Dismissing toast: clicking "${text}"`); + await btn.first().click({ force: true }); + await page.waitForTimeout(500); + } + } catch { + // Ignorar errores — el boton pudo desaparecer entre check y click + } + } + + // Segunda pasada: verificar si queda algun toast con boton visible + const remainingToastBtns = page.locator( + '.mx_ToastContainer button, .mx_Toast_buttons button' + ); + const remaining = await remainingToastBtns.count(); + if (remaining > 0) { + for (let i = 0; i < remaining; i++) { + try { + if (await remainingToastBtns.nth(i).isVisible()) { + const text = await remainingToastBtns.nth(i).textContent(); + console.log(`[element] Closing remaining toast button: "${text}"`); + await remainingToastBtns.nth(i).click({ force: true }); + await page.waitForTimeout(300); + } + } catch { + // Ignorar + } + } + } +} diff --git a/e2e/fixtures/matrix-room.ts b/e2e/fixtures/matrix-room.ts index 5ecbde4..616f223 100644 --- a/e2e/fixtures/matrix-room.ts +++ b/e2e/fixtures/matrix-room.ts @@ -1,5 +1,28 @@ import { Page, expect } from "@playwright/test"; +/** + * Cierra toasts de Element que bloquean clicks. + * Duplicado aqui para evitar imports circulares con persistent-context.ts. + */ +async function dismissToasts(page: Page) { + await page.waitForTimeout(1_000); + const buttons = [ + page.getByRole("button", { name: "Dismiss" }), + page.locator("button").filter({ hasText: /^OK$/ }), + page.getByRole("button", { name: "Close" }), + page.getByRole("button", { name: "Not now" }), + page.getByRole("button", { name: "Got it" }), + ]; + for (const btn of buttons) { + try { + if (await btn.first().isVisible()) { + await btn.first().click({ force: true }); + await page.waitForTimeout(300); + } + } catch { /* ignore */ } + } +} + export interface WaitForReplyOptions { /** Timeout en ms para esperar la respuesta (default: 30s) */ timeout?: number; @@ -8,25 +31,57 @@ export interface WaitForReplyOptions { } /** - * Navega a un room por nombre usando el buscador de Element. + * Navega a un room por nombre. + * Primero intenta click directo en el sidebar, luego usa el buscador. */ export async function goToRoom(page: Page, roomName: string) { console.log(`[goToRoom] Buscando room: "${roomName}"`); - // Abrir busqueda de rooms (Ctrl+K o click en el search) - const searchButton = page.locator('[aria-label="Search"]').first(); - console.log("[goToRoom] Clickeando boton Search..."); - await searchButton.click(); + // Estrategia 1: Click directo en el room del sidebar (mas robusto) + const sidebarRoom = page.locator( + `.mx_RoomTile, [role="treeitem"]` + ).filter({ hasText: new RegExp(roomName, "i") }).first(); + + const directMatch = await sidebarRoom + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + + if (directMatch) { + console.log(`[goToRoom] Room "${roomName}" encontrado en sidebar, click directo`); + await sidebarRoom.click(); + await waitForRoomLoaded(page, roomName); + return; + } + + // Estrategia 2: Usar busqueda (Ctrl+K es mas confiable que click en Search) + console.log("[goToRoom] Room no visible en sidebar, usando busqueda..."); + await page.keyboard.press("Control+k"); + + // Esperar a que aparezca el dialog de busqueda + const searchInput = page.locator( + '[role="searchbox"], input[type="search"], .mx_SpotlightDialog input' + ).first(); + const hasSearch = await searchInput + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + + if (!hasSearch) { + // Fallback: click en el boton de Search con force para evitar toasts + console.log("[goToRoom] Ctrl+K no abrio busqueda, intentando click en Search..."); + const searchButton = page.locator('[aria-label="Search"]').first(); + await searchButton.click({ force: true }); + await searchInput.waitFor({ state: "visible", timeout: 5_000 }); + } - // Escribir nombre del room en el campo de busqueda - const searchInput = page.getByRole("searchbox").first(); await searchInput.fill(roomName); console.log(`[goToRoom] Texto ingresado: "${roomName}"`); // Seleccionar el room de los resultados - const roomResult = page - .getByRole("option", { name: new RegExp(roomName, "i") }) - .first(); + const roomResult = page.locator( + '[role="option"], .mx_SpotlightDialog_result' + ).filter({ hasText: new RegExp(roomName, "i") }).first(); const hasResult = await roomResult .waitFor({ state: "visible", timeout: 10_000 }) @@ -39,16 +94,37 @@ export async function goToRoom(page: Page, roomName: string) { path: `test-results/ERROR-goToRoom-no-result-${Date.now()}.png`, fullPage: true, }); + // Cerrar el dialog de busqueda + await page.keyboard.press("Escape"); throw new Error(`Room "${roomName}" no encontrado en busqueda`); } console.log(`[goToRoom] Seleccionando room "${roomName}"`); await roomResult.click(); - // Verificar que estamos en el room correcto (header muestra el nombre) - await expect( - page.locator(`[data-testid="room-header-name"], h2`).filter({ hasText: roomName }).first() - ).toBeVisible({ timeout: 10_000 }); + await waitForRoomLoaded(page, roomName); +} + +/** + * Espera a que un room termine de cargar (header visible + composer listo). + */ +async function waitForRoomLoaded(page: Page, roomName: string) { + // Esperar header del room o composer — ambos indican que el room cargo + const roomHeader = page.locator( + `[data-testid="room-header-name"], .mx_RoomHeader_heading, h2` + ).filter({ hasText: new RegExp(roomName, "i") }).first(); + + const headerVisible = await roomHeader + .waitFor({ state: "visible", timeout: 10_000 }) + .then(() => true) + .catch(() => false); + + if (!headerVisible) { + // Fallback: verificar que al menos el composer esta visible + const composer = page.getByRole("textbox", { name: /message/i }); + await composer.waitFor({ state: "visible", timeout: 10_000 }); + } + console.log(`[goToRoom] Estamos en room "${roomName}"`); } @@ -214,45 +290,68 @@ async function getLastMessageSender(page: Page): Promise { /** * Inicia un thread sobre el ultimo mensaje del timeline. - * Hace hover sobre el mensaje, click en el boton de thread, - * y espera a que el panel de thread se abra. + * + * En headless Chromium, la hover action bar de Element Web no se renderiza + * (es React onMouseEnter, no CSS :hover). Usamos el Matrix SDK expuesto + * en window para enviar un mensaje threaded directamente, luego abrimos + * el thread panel via la UI. */ export async function startThreadOnLastMessage(page: Page) { - console.log("[startThread] Hover sobre ultimo mensaje..."); + console.log("[startThread] Dismissing toasts..."); + await dismissToasts(page); - // Hover sobre el ultimo event tile para revelar la action bar - const lastTile = page.locator(".mx_EventTile").last(); - await lastTile.hover(); + // Obtener el event ID del ultimo mensaje y el room ID via el SDK de Element + const threadInfo = await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (window as any).mxMatrixClientPeg?.get?.(); + if (!client) throw new Error("Matrix client no disponible en window"); - // Click en el boton de thread (puede ser "Thread" o "Reply in thread") - const threadBtn = page - .locator( - 'button[aria-label="Thread"], button[aria-label="Reply in thread"], [data-testid="thread-button"]' - ) - .first(); + // Obtener el room actual visible + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dis = (window as any).dis; + const roomId = client.getRooms() + .filter((r: { getMyMembership: () => string }) => r.getMyMembership() === "join") + .map((r: { roomId: string }) => r.roomId)[0]; - const hasBtn = await threadBtn - .waitFor({ state: "visible", timeout: 5_000 }) - .then(() => true) - .catch(() => false); + if (!roomId) throw new Error("No hay room activo"); - if (!hasBtn) { - console.error("[startThread] Boton de thread no encontrado"); - await page.screenshot({ - path: `test-results/ERROR-no-thread-btn-${Date.now()}.png`, - fullPage: true, + const room = client.getRoom(roomId); + if (!room) throw new Error("Room no encontrado"); + + // Obtener el ultimo evento de mensaje en el timeline + const timeline = room.getLiveTimeline().getEvents(); + const lastMsgEvent = [...timeline].reverse().find( + (e: { getType: () => string }) => e.getType() === "m.room.message" + ); + if (!lastMsgEvent) throw new Error("No hay mensajes en el timeline"); + + return { + roomId, + eventId: lastMsgEvent.getId(), + sender: lastMsgEvent.getSender(), + }; + }); + + console.log(`[startThread] Room: ${threadInfo.roomId}, Event: ${threadInfo.eventId}`); + + // Enviar un mensaje threaded via el SDK + await page.evaluate(async ({ roomId, eventId }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (window as any).mxMatrixClientPeg.get(); + await client.sendMessage(roomId, { + msgtype: "m.text", + body: "Hola desde el thread, respondeme aqui por favor", + "m.relates_to": { + rel_type: "m.thread", + event_id: eventId, + is_falling_back: true, + "m.in_reply_to": { event_id: eventId }, + }, }); - throw new Error("Boton de thread no encontrado"); - } + }, threadInfo); - await threadBtn.click({ timeout: 5_000 }); - console.log("[startThread] Click en boton de thread"); - - // Esperar a que el panel de thread se abra - await expect( - page.locator(".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard") - ).toBeVisible({ timeout: 10_000 }); - console.log("[startThread] Panel de thread abierto"); + console.log("[startThread] Mensaje threaded enviado via SDK"); + // El thread ya esta creado. La verificacion de respuesta se hace via SDK. } /** @@ -405,6 +504,80 @@ export async function assertBotDidNotReplyInMainTimeline( } } +/** + * Espera la respuesta del bot en un thread usando el Matrix SDK de Element. + * No depende del panel de thread UI — consulta el timeline directamente. + */ +export async function waitForThreadReplyViaSdk( + page: Page, + options?: WaitForReplyOptions +): Promise { + const timeout = options?.timeout ?? 30_000; + const startTime = Date.now(); + const senderFilter = options?.sender; + + console.log( + `[waitForThreadReplyViaSdk] Esperando respuesta en thread (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...` + ); + + while (Date.now() - startTime < timeout) { + const reply = await page.evaluate(({ senderFilter }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (window as any).mxMatrixClientPeg?.get?.(); + if (!client) return null; + + const rooms = client.getRooms().filter( + (r: { getMyMembership: () => string }) => r.getMyMembership() === "join" + ); + + for (const room of rooms) { + const timeline = room.getLiveTimeline().getEvents(); + // Buscar eventos que sean respuestas de thread (m.relates_to.rel_type === "m.thread") + const threadReplies = timeline.filter((e: { + getType: () => string; + getContent: () => { "m.relates_to"?: { rel_type?: string } }; + getSender: () => string; + }) => { + if (e.getType() !== "m.room.message") return false; + const content = e.getContent(); + const relatesTo = content["m.relates_to"]; + if (!relatesTo || relatesTo.rel_type !== "m.thread") return false; + // Filtrar por sender si se especifico + if (senderFilter) { + const sender = e.getSender(); + // Verificar por display name + const member = room.getMember(sender); + const displayName = member?.name || sender; + if (!displayName.includes(senderFilter)) return false; + } + return true; + }); + + if (threadReplies.length > 0) { + const lastReply = threadReplies[threadReplies.length - 1]; + const content = lastReply.getContent(); + return content.body || content.formatted_body || ""; + } + } + return null; + }, { senderFilter }); + + if (reply) { + console.log( + `[waitForThreadReplyViaSdk] Respuesta encontrada (${Date.now() - startTime}ms): "${reply.substring(0, 80)}..."` + ); + return reply; + } + + await page.waitForTimeout(1_000); + } + + throw new Error( + `Timeout (${timeout}ms): el bot no respondio en el thread` + + (senderFilter ? ` (sender: ${senderFilter})` : "") + ); +} + /** * Verifica que no hay mensajes "Unable to decrypt" en el timeline visible. * Lanza error descriptivo si los encuentra. diff --git a/e2e/fixtures/persistent-context.ts b/e2e/fixtures/persistent-context.ts index b0214ed..dd7af0a 100644 --- a/e2e/fixtures/persistent-context.ts +++ b/e2e/fixtures/persistent-context.ts @@ -1,5 +1,6 @@ import { test as base, chromium, BrowserContext, Page } from "@playwright/test"; import * as path from "path"; +import { dismissAllToasts } from "./element-utils"; /** * Custom test fixture que usa un persistent browser context compartido. @@ -50,10 +51,12 @@ export const test = base.extend< }); /** - * Maneja dialogos de Element que bloquean la carga: + * Maneja dialogos y toasts de Element que bloquean la carga: * - "Element is open in another window" → click Continue - * - Spinner de carga → esperar * - "Missing session data" → error informativo + * - "Notifications" toast → click Dismiss + * - "Threads Activity Centre" toast → click OK + * - Cualquier otro toast → intentar cerrarlo * * Llamar despues de page.goto("/") */ @@ -84,11 +87,53 @@ export async function handleElementDialogs(page: Page) { ); } - // 3. Esperar a que rooms sidebar aparezca (sesion cargada) - console.log("[element] Esperando rooms sidebar..."); - const roomsTree = page.locator('[role="tree"][aria-label="Rooms"]'); - await roomsTree.waitFor({ state: "visible", timeout: 30_000 }); - console.log("[element] Rooms sidebar visible"); + // 3. Esperar a que la sidebar aparezca (sesion cargada) + // Usamos multiples locators porque Element Web cambia la estructura entre versiones + console.log("[element] Esperando sidebar con rooms..."); + const sidebarLocators = [ + page.locator('[role="tree"][aria-label="Rooms"]'), + page.locator(".mx_RoomList"), + page.locator(".mx_LeftPanel_roomListContainer"), + page.locator('[role="treeitem"]'), + // Rooms visibles como items en el sidebar + page.locator(".mx_RoomTile"), + ]; + + let sidebarFound = false; + for (const locator of sidebarLocators) { + const visible = await locator.first() + .waitFor({ state: "visible", timeout: 30_000 }) + .then(() => true) + .catch(() => false); + if (visible) { + console.log("[element] Sidebar visible"); + sidebarFound = true; + break; + } + } + + if (!sidebarFound) { + // Verificar si estamos en la pagina de login + const onLoginPage = await page.locator('text="Welcome to Element!"').isVisible().catch(() => false) + || await page.getByRole("link", { name: "Sign in" }).isVisible().catch(() => false); + + if (onLoginPage) { + throw new Error( + "Sesion no cargada: se muestra la pagina de login. " + + "Borrar .auth/ y re-ejecutar: rm -rf e2e/.auth && ./dev-scripts/e2e/run.sh" + ); + } + + await page.screenshot({ + path: "test-results/ERROR-no-sidebar.png", + fullPage: true, + }); + throw new Error("Sidebar de rooms no encontrado despues de 30s"); + } + + // 4. Cerrar TODOS los toasts que bloquean interacciones + await dismissAllToasts(page); } +export { dismissAllToasts } from "./element-utils"; export { expect } from "@playwright/test"; diff --git a/e2e/tests/asistente-2.spec.ts b/e2e/tests/asistente-2.spec.ts index 16ae1ee..25df3d4 100644 --- a/e2e/tests/asistente-2.spec.ts +++ b/e2e/tests/asistente-2.spec.ts @@ -5,8 +5,7 @@ import { waitForBotReply, assertNoDecryptionErrors, startThreadOnLastMessage, - sendThreadMessage, - waitForThreadReply, + waitForThreadReplyViaSdk, } from "../fixtures/matrix-room"; test.describe("asistente-2", () => { @@ -66,6 +65,8 @@ test.describe("asistente-2", () => { test("responde dentro del thread cuando se le habla por thread", async ({ page, }) => { + // Este test necesita mas tiempo: enviar msg + esperar bot + thread + esperar bot en thread + test.setTimeout(120_000); // 1. Enviar un mensaje normal (sera el thread root) await sendMessage(page, "Mensaje para iniciar thread"); @@ -75,17 +76,12 @@ test.describe("asistente-2", () => { sender: "Asistente 2", }); - // 2. Iniciar thread sobre el mensaje del usuario + // 2. Enviar mensaje threaded via SDK (headless no soporta la hover action bar) await startThreadOnLastMessage(page); - // 3. Enviar mensaje dentro del thread - await sendThreadMessage( - page, - "Hola desde el thread, respondeme aqui por favor" - ); - - // 4. Esperar que el bot responda DENTRO del thread - const threadReply = await waitForThreadReply(page, { + // 3. Esperar que el bot responda DENTRO del thread + // Usar el SDK para verificar que hay una respuesta en el thread + const threadReply = await waitForThreadReplyViaSdk(page, { timeout: 60_000, sender: "Asistente 2", }); diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index f3761f3..d92280f 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -7,7 +7,7 @@ test.describe("Login y sesion E2EE", () => { await handleElementDialogs(page); // Si llegamos aqui, handleElementDialogs ya verifico rooms sidebar - const rooms = page.locator('[role="treeitem"]'); + const rooms = page.locator('[role="treeitem"], .mx_RoomTile'); const roomCount = await rooms.count(); expect(roomCount).toBeGreaterThan(0); }); @@ -19,7 +19,7 @@ test.describe("Login y sesion E2EE", () => { await handleElementDialogs(page); // Abrir el primer room visible para verificar mensajes - const firstRoom = page.locator('[role="treeitem"]').first(); + const firstRoom = page.locator('[role="treeitem"], .mx_RoomTile').first(); const roomCount = await firstRoom.count(); if (roomCount > 0) { @@ -33,7 +33,7 @@ test.describe("Login y sesion E2EE", () => { await page.goto("/"); await handleElementDialogs(page); - const rooms = page.locator('[role="treeitem"]'); + const rooms = page.locator('[role="treeitem"], .mx_RoomTile'); const roomCount = await rooms.count(); expect(roomCount).toBeGreaterThan(0); });