diff --git a/e2e/fixtures/matrix-room.ts b/e2e/fixtures/matrix-room.ts index e1a6ee4..ac3193f 100644 --- a/e2e/fixtures/matrix-room.ts +++ b/e2e/fixtures/matrix-room.ts @@ -161,33 +161,92 @@ export async function sendMessage(page: Page, text: string) { } /** - * Espera una respuesta de un bot en el timeline. - * Detecta "Unable to decrypt" y falla con mensaje claro. + * Cierra el thread panel de Element Web si esta abierto. + */ +export async function closeThreadPanel(page: Page) { + const closeBtn = page.locator( + ".mx_BaseCard_close, [data-testid='base-card-close-button'], .mx_RightPanel_closeButton" + ).first(); + try { + if (await closeBtn.isVisible({ timeout: 1_000 })) { + await closeBtn.click({ force: true }); + console.log("[closeThreadPanel] Thread panel cerrado"); + await page.waitForTimeout(300); + } + } catch { /* panel ya estaba cerrado */ } +} + +/** + * Obtiene el room ID del room actual desde la URL + SDK. + * Resuelve aliases si la URL contiene uno. + */ +async function getCurrentRoomId(page: Page): Promise { + return page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (window as any).mxMatrixClientPeg?.get?.(); + if (!client) return null; + const hash = window.location.hash; + const match = hash.match(/#\/room\/([^?/]+)/); + if (!match) return null; + const roomIdOrAlias = decodeURIComponent(match[1]); + if (roomIdOrAlias.startsWith("!")) return roomIdOrAlias; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias); + return resolved?.room_id || null; + }); +} + +/** + * Espera una respuesta del bot en el main timeline usando el Matrix SDK. + * Rastrea el ultimo event ID ANTES de llamar esta funcion para solo detectar + * mensajes NUEVOS, evitando falsos positivos de mensajes historicos. + * + * Usa SDK en lugar de locators DOM porque los thread summaries en la main + * timeline inyectan sender elements adicionales que confunden la deteccion. */ export async function waitForBotReply( page: Page, options?: WaitForReplyOptions ): Promise { const timeout = options?.timeout ?? 30_000; + const senderFilter = options?.sender; console.log( - `[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${options?.sender || "any"})...` + `[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...` ); - // Esperar a que aparezca un nuevo mensaje (que no sea del usuario actual) - const startTime = Date.now(); - let lastLoggedMsg = ""; - let lastLoggedSender = ""; + // Capturar el ultimo event ID ANTES de que el bot pueda responder. + // Solo detectaremos mensajes que lleguen DESPUES de este punto. + const startEventId = await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (window as any).mxMatrixClientPeg?.get?.(); + if (!client) return null; + const hash = window.location.hash; + const match = hash.match(/#\/room\/([^?/]+)/); + if (!match) return null; + const roomIdOrAlias = decodeURIComponent(match[1]); + let roomId = roomIdOrAlias; + if (!roomId.startsWith("!")) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias); + roomId = resolved?.room_id; + } + if (!roomId) return null; + const room = client.getRoom(roomId); + if (!room) return null; + const events = room.getLiveTimeline().getEvents(); + return events.length > 0 + ? (events[events.length - 1] as { getId: () => string }).getId() + : null; + }); + const startTime = Date.now(); while (Date.now() - startTime < timeout) { - // Detectar mensajes que no se pueden descifrar + // Detectar E2EE errors en el timeline visible const undecryptable = page.locator( '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]' ); - const undecryptableCount = await undecryptable.count(); - if (undecryptableCount > 0) { - console.error( - `[waitForBotReply] E2EE ERROR: ${undecryptableCount} mensajes sin descifrar` - ); + if ((await undecryptable.count()) > 0) { + console.error("[waitForBotReply] E2EE ERROR: mensajes sin descifrar"); await page.screenshot({ path: `test-results/ERROR-e2ee-${Date.now()}.png`, fullPage: true, @@ -199,70 +258,81 @@ export async function waitForBotReply( ); } - // Buscar el ultimo mensaje en el timeline - const lastMessage = await getLastMessage(page); - if (lastMessage) { - // Si se especifica sender, verificar que coincide - if (options?.sender) { - const lastSender = await getLastMessageSender(page); - - // Log periodico para debug (cada mensaje nuevo) - if (lastMessage !== lastLoggedMsg || lastSender !== lastLoggedSender) { - console.log( - `[waitForBotReply] Ultimo msg: "${lastMessage?.substring(0, 80)}..." | sender: "${lastSender}"` + const reply = await page.evaluate(({ senderFilter, startEventId }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const client = (window as any).mxMatrixClientPeg?.get?.(); + if (!client) return null; + const hash = window.location.hash; + const match = hash.match(/#\/room\/([^?/]+)/); + if (!match) return null; + const roomIdOrAlias = decodeURIComponent(match[1]); + // Only synchronous room lookup here — getRoomIdForAlias is async but evaluate + // can be async too. Using direct room lookup for speed. + const rooms = client.getRooms(); + const room = roomIdOrAlias.startsWith("!") + ? client.getRoom(roomIdOrAlias) + : rooms.find((r: { roomId: string; getCanonicalAlias: () => string | null }) => + r.getCanonicalAlias() === roomIdOrAlias || + r.roomId === roomIdOrAlias ); - lastLoggedMsg = lastMessage; - lastLoggedSender = lastSender || ""; - } + if (!room) return null; - if (lastSender && lastSender.includes(options.sender)) { - console.log( - `[waitForBotReply] Respuesta recibida de "${options.sender}" (${Date.now() - startTime}ms)` - ); - return lastMessage; + const events: Array<{ + getId: () => string; + getType: () => string; + getSender: () => string; + getContent: () => Record; + }> = room.getLiveTimeline().getEvents(); + + // Encontrar la posicion del startEventId + const startIdx = startEventId + ? events.findIndex((e) => e.getId() === startEventId) + : -1; + + // Solo eventos NUEVOS (posteriores al startEventId) + const newEvents = events.slice(startIdx + 1); + + for (let i = newEvents.length - 1; i >= 0; i--) { + const evt = newEvents[i]; + if (evt.getType() !== "m.room.message") continue; + const content = evt.getContent() as { + body?: string; + "m.relates_to"?: { rel_type?: string }; + msgtype?: string; + }; + // Ignorar thread replies — solo mensajes de la main timeline + if (content["m.relates_to"]?.rel_type === "m.thread") continue; + + const sender = evt.getSender(); + if (senderFilter) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const member = (room as any).getMember(sender); + const displayName = member?.name || sender; + if (!displayName.includes(senderFilter)) continue; } - } else { - console.log( - `[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms)` - ); - return lastMessage; + return content.body || ""; } + return null; + }, { senderFilter: senderFilter ?? null, startEventId }); + + if (reply) { + console.log( + `[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms): "${reply.substring(0, 60)}..."` + ); + return reply; } await page.waitForTimeout(500); } - // Timeout — tomar screenshot antes de lanzar error - console.error( - `[waitForBotReply] TIMEOUT despues de ${timeout}ms esperando respuesta` - ); + console.error(`[waitForBotReply] TIMEOUT despues de ${timeout}ms`); await page.screenshot({ path: `test-results/ERROR-timeout-waitForBotReply-${Date.now()}.png`, fullPage: true, }); - - // Log del estado actual del timeline para debug - const allMessages = page.locator(".mx_EventTile_body, .mx_MTextBody"); - const msgCount = await allMessages.count(); - console.log(`[waitForBotReply] Mensajes en timeline: ${msgCount}`); - for (let i = Math.max(0, msgCount - 5); i < msgCount; i++) { - const text = await allMessages.nth(i).textContent(); - console.log(`[waitForBotReply] [${i}]: "${text?.substring(0, 100)}"`); - } - - const allSenders = page.locator( - ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name" - ); - const senderCount = await allSenders.count(); - console.log(`[waitForBotReply] Senders en timeline: ${senderCount}`); - for (let i = Math.max(0, senderCount - 5); i < senderCount; i++) { - const name = await allSenders.nth(i).textContent(); - console.log(`[waitForBotReply] [${i}]: "${name}"`); - } - throw new Error( `Timeout (${timeout}ms): no se recibio respuesta del bot` + - (options?.sender ? ` (sender esperado: ${options.sender})` : "") + (senderFilter ? ` (sender esperado: ${senderFilter})` : "") ); } @@ -289,25 +359,102 @@ async function getLastMessageSender(page: Page): Promise { } /** - * Inicia un thread sobre el ultimo mensaje del timeline. + * Inicia un thread sobre el ultimo mensaje del timeline via UI. * - * 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. + * Flujo: + * 1. Right-click en el ultimo EventTile del main timeline + * 2. Click en "Reply in Thread" del context menu + * 3. Esperar a que aparezca el thread panel (panel derecho) + * 4. Escribir un mensaje en el composer del thread panel + * 5. Enviar con Enter + * + * Fallback SDK: si el context menu no aparece (headless), envia via SDK. */ export async function startThreadOnLastMessage(page: Page) { console.log("[startThread] Dismissing toasts..."); await dismissToasts(page); - // Obtener el event ID del ultimo mensaje y el room ID via el SDK de Element + // Localizar el ultimo EventTile en el main timeline (no en thread panel) + const mainTimeline = page.locator(".mx_RoomView_body"); + const eventTiles = mainTimeline.locator(".mx_EventTile").filter({ + has: page.locator(".mx_EventTile_body, .mx_MTextBody"), + }); + + const tileCount = await eventTiles.count(); + if (tileCount === 0) throw new Error("[startThread] No hay EventTiles en el timeline"); + + const lastTile = eventTiles.last(); + console.log(`[startThread] Right-click en ultimo EventTile (${tileCount} tiles)`); + + // Scroll hasta el ultimo tile para asegurarnos de que es visible + await lastTile.scrollIntoViewIfNeeded(); + await page.waitForTimeout(300); + + // Right-click para abrir el context menu + await lastTile.click({ button: "right", force: true }); + console.log("[startThread] Context menu abierto via right-click"); + + // Esperar a que aparezca el context menu + const contextMenu = page.locator( + ".mx_ContextualMenu, .mx_IconizedContextMenu, [role='menu']" + ); + const menuVisible = await contextMenu + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + + if (menuVisible) { + console.log("[startThread] Context menu visible, buscando 'Reply in Thread'..."); + + // Buscar la opcion "Reply in Thread" (puede variar por idioma) + const threadOption = page.locator( + "[role='menuitem'], .mx_IconizedContextMenu_option, .mx_ContextualMenu_item" + ).filter({ + hasText: /reply in thread|thread|responder en hilo/i, + }).first(); + + const optionVisible = await threadOption + .waitFor({ state: "visible", timeout: 3_000 }) + .then(() => true) + .catch(() => false); + + if (optionVisible) { + await threadOption.click({ force: true }); + console.log("[startThread] Click en 'Reply in Thread'"); + + // Esperar a que aparezca el thread panel en el lado derecho + const threadPanel = page.locator( + ".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard" + ); + await threadPanel.waitFor({ state: "visible", timeout: 10_000 }); + console.log("[startThread] Thread panel visible"); + + // Escribir en el composer del thread panel + const threadComposer = threadPanel.getByRole("textbox", { name: /message/i }); + await threadComposer.waitFor({ state: "visible", timeout: 5_000 }); + await threadComposer.fill("Hola desde el thread, respondeme aqui por favor"); + await threadComposer.press("Enter"); + console.log("[startThread] Mensaje enviado via UI en el thread panel"); + return; + } + + // Context menu abierto pero sin opcion de thread — cerrar y usar fallback + console.warn("[startThread] Opcion 'Reply in Thread' no encontrada en context menu"); + await page.keyboard.press("Escape"); + await page.waitForTimeout(300); + } else { + console.warn("[startThread] Context menu no aparecio, usando fallback SDK"); + } + + // --- Fallback SDK (si la UI no funciono en headless) --- + console.log("[startThread] Fallback: enviando mensaje threaded via SDK"); + const threadInfo = await page.evaluate(async () => { // 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"); - // Obtener el room actual desde la URL (mas fiable que getRooms()[0]) - const hash = window.location.hash; // e.g. "#/room/!xxx:server" or "#/room/#alias:server" + const hash = window.location.hash; const match = hash.match(/#\/room\/([^?/]+)/); if (!match) throw new Error(`No se pudo obtener room ID de la URL: ${hash}`); const roomIdOrAlias = decodeURIComponent(match[1]); @@ -322,28 +469,18 @@ export async function startThreadOnLastMessage(page: Page) { roomId = resolved.room_id; } - if (!roomId) throw new Error("No hay room activo"); - 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(), - }; + return { roomId, eventId: lastMsgEvent.getId() }; }); - 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(); @@ -359,8 +496,7 @@ export async function startThreadOnLastMessage(page: Page) { }); }, threadInfo); - console.log("[startThread] Mensaje threaded enviado via SDK"); - // El thread ya esta creado. La verificacion de respuesta se hace via SDK. + console.log("[startThread] Mensaje threaded enviado via SDK (fallback)"); } /**