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; /** Filtrar por sender display name si se especifica */ sender?: string; } /** * 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}"`); // 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 }); } await searchInput.fill(roomName); console.log(`[goToRoom] Texto ingresado: "${roomName}"`); // Seleccionar el room de los resultados 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 }) .then(() => true) .catch(() => false); if (!hasResult) { console.error(`[goToRoom] No se encontro room "${roomName}" en resultados`); await page.screenshot({ 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(); 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}"`); } /** * Envia un mensaje de texto en el room actual. */ export async function sendMessage(page: Page, text: string) { console.log(`[sendMessage] Enviando: "${text}"`); const composer = page.getByRole("textbox", { name: /message/i }); const hasComposer = await composer .waitFor({ state: "visible", timeout: 10_000 }) .then(() => true) .catch(() => false); if (!hasComposer) { console.error("[sendMessage] Composer no encontrado"); await page.screenshot({ path: `test-results/ERROR-sendMessage-no-composer-${Date.now()}.png`, fullPage: true, }); throw new Error("Composer de mensajes no encontrado"); } await composer.fill(text); await composer.press("Enter"); console.log("[sendMessage] Enter presionado"); // Esperar a que el mensaje aparezca en el timeline await expect( page.locator(".mx_EventTile_body, .mx_MTextBody").filter({ hasText: text }).last() ).toBeVisible({ timeout: 10_000 }); console.log(`[sendMessage] Mensaje visible en timeline: "${text}"`); } /** * 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: ${senderFilter || "any"})...` ); // 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 E2EE errors en el timeline visible const undecryptable = page.locator( '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]' ); 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, }); throw new Error( "E2EE error: se detectaron mensajes 'Unable to decrypt'. " + "Verificar que cross-signing esta configurado correctamente " + "y que la recovery key es valida." ); } 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 ); if (!room) return null; 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; } 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); } console.error(`[waitForBotReply] TIMEOUT despues de ${timeout}ms`); await page.screenshot({ path: `test-results/ERROR-timeout-waitForBotReply-${Date.now()}.png`, fullPage: true, }); throw new Error( `Timeout (${timeout}ms): no se recibio respuesta del bot` + (senderFilter ? ` (sender esperado: ${senderFilter})` : "") ); } /** * Obtiene el texto del ultimo mensaje visible en el timeline. */ export async function getLastMessage(page: Page): Promise { const messages = page.locator(".mx_EventTile_body, .mx_MTextBody"); const count = await messages.count(); if (count === 0) return null; return messages.last().textContent(); } /** * Obtiene el display name del sender del ultimo mensaje. */ async function getLastMessageSender(page: Page): Promise { const senders = page.locator( ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name" ); const count = await senders.count(); if (count === 0) return null; return senders.last().textContent(); } /** * Inicia un thread sobre el ultimo mensaje del timeline via 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); // 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"); 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]); let roomId: string; if (roomIdOrAlias.startsWith("!")) { roomId = roomIdOrAlias; } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias); if (!resolved?.room_id) throw new Error(`No se pudo resolver alias: ${roomIdOrAlias}`); roomId = resolved.room_id; } const room = client.getRoom(roomId); if (!room) throw new Error("Room no encontrado"); 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() }; }); 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 }, }, }); }, threadInfo); console.log("[startThread] Mensaje threaded enviado via SDK (fallback)"); } /** * Envia un mensaje en el panel de thread abierto. */ export async function sendThreadMessage(page: Page, text: string) { console.log(`[sendThreadMessage] Enviando en thread: "${text}"`); // El composer del thread esta dentro del panel derecho const threadPanel = page.locator( ".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard" ); const composer = threadPanel.getByRole("textbox", { name: /message/i }); await composer.fill(text); await composer.press("Enter"); // Esperar a que el mensaje aparezca dentro del thread await expect( threadPanel .locator(".mx_EventTile_body, .mx_MTextBody") .filter({ hasText: text }) .last() ).toBeVisible({ timeout: 10_000 }); console.log("[sendThreadMessage] Mensaje visible en thread"); } /** * Espera la respuesta de un bot dentro del panel de thread. * Similar a waitForBotReply pero busca solo dentro del thread panel. */ export async function waitForThreadReply( page: Page, options?: WaitForReplyOptions ): Promise { const timeout = options?.timeout ?? 30_000; const startTime = Date.now(); console.log( `[waitForThreadReply] Esperando respuesta en thread (timeout: ${timeout}ms)...` ); const threadPanel = page.locator( ".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard" ); while (Date.now() - startTime < timeout) { // Detectar errores de E2EE dentro del thread const undecryptable = threadPanel.locator( '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]' ); if ((await undecryptable.count()) > 0) { console.error("[waitForThreadReply] E2EE error en thread"); await page.screenshot({ path: `test-results/ERROR-e2ee-thread-${Date.now()}.png`, fullPage: true, }); throw new Error( "E2EE error en thread: se detectaron mensajes 'Unable to decrypt'." ); } // Buscar mensajes en el thread panel const messages = threadPanel.locator( ".mx_EventTile_body, .mx_MTextBody" ); const count = await messages.count(); // Necesitamos al menos 2 mensajes (el del usuario + la respuesta del bot) if (count >= 2) { const lastMsg = await messages.last().textContent(); if (lastMsg && options?.sender) { const senders = threadPanel.locator( ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name" ); const senderCount = await senders.count(); if (senderCount > 0) { const lastSender = await senders.last().textContent(); if (lastSender?.includes(options.sender)) { console.log( `[waitForThreadReply] Respuesta de "${options.sender}" en thread (${Date.now() - startTime}ms)` ); return lastMsg; } } } else if (lastMsg) { console.log( `[waitForThreadReply] Respuesta en thread (${Date.now() - startTime}ms)` ); return lastMsg; } } await page.waitForTimeout(500); } console.error( `[waitForThreadReply] TIMEOUT despues de ${timeout}ms` ); await page.screenshot({ path: `test-results/ERROR-timeout-thread-${Date.now()}.png`, fullPage: true, }); throw new Error( `Timeout (${timeout}ms): el bot no respondio dentro del thread` + (options?.sender ? ` (sender esperado: ${options.sender})` : "") ); } /** * Verifica que el bot NO respondio en el timeline principal tras enviar un thread. * Busca mensajes del bot en el timeline principal que no deberian estar ahi. * Retorna true si el timeline principal NO tiene respuesta del bot (correcto). */ export async function assertBotDidNotReplyInMainTimeline( page: Page, botName: string, afterText: string, checkDurationMs: number = 5_000 ): Promise { // Esperar un poco para dar tiempo al bot a responder (incorrectamente) en main await page.waitForTimeout(checkDurationMs); // Buscar mensajes en el timeline principal (fuera del thread panel) const mainTimeline = page.locator(".mx_RoomView_body"); const botMessages = mainTimeline.locator( ".mx_EventTile_body, .mx_MTextBody" ); const senders = mainTimeline.locator( ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name" ); const msgCount = await botMessages.count(); const senderCount = await senders.count(); // Verificar que el ultimo mensaje del timeline principal no es del bot // (despues de nuestro mensaje de thread) if (msgCount > 0 && senderCount > 0) { const lastSender = await senders.last().textContent(); if (lastSender?.includes(botName)) { const lastMsg = await botMessages.last().textContent(); // Si el ultimo mensaje del main timeline es del bot y es posterior // a nuestro mensaje original, el bot respondio fuera del thread if (lastMsg && lastMsg !== afterText) { throw new Error( `El bot respondio en el timeline principal en vez de en el thread. ` + `Ultimo mensaje del bot: "${lastMsg}"` ); } } } } /** * 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; // Scoped to current room only (via URL) to avoid false positives const hash = window.location.hash; const match = hash.match(/#\/room\/([^?/]+)/); const roomIdOrAlias = match ? decodeURIComponent(match[1]) : null; const rooms = client.getRooms().filter( (r: { getMyMembership: () => string; roomId: string }) => { if (r.getMyMembership() !== "join") return false; if (roomIdOrAlias) { return r.roomId === roomIdOrAlias || r.roomId === roomIdOrAlias; // alias resolution handled below } return true; } ); for (const room of rooms) { // Skip rooms that don't match the current URL room if (roomIdOrAlias && !roomIdOrAlias.startsWith("!")) { // For aliases, check if the room has this alias const aliases = room.getAltAliases?.() || []; const canonicalAlias = room.getCanonicalAlias?.(); if (canonicalAlias !== roomIdOrAlias && !aliases.includes(roomIdOrAlias)) { continue; } } 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. */ export async function assertNoDecryptionErrors(page: Page) { const undecryptable = page.locator( '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]' ); const texts = await undecryptable.allTextContents(); if (texts.length > 0) { throw new Error( `E2EE error: ${texts.length} mensaje(s) no pudieron descifrarse. ` + "Verificar cross-signing y recovery key." ); } }