import { Page, expect } from "@playwright/test"; 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 usando el buscador de Element. */ 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(); // 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 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, }); 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 }); 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}"`); } /** * Espera una respuesta de un bot en el timeline. * Detecta "Unable to decrypt" y falla con mensaje claro. */ export async function waitForBotReply( page: Page, options?: WaitForReplyOptions ): Promise { const timeout = options?.timeout ?? 30_000; console.log( `[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${options?.sender || "any"})...` ); // Esperar a que aparezca un nuevo mensaje (que no sea del usuario actual) const startTime = Date.now(); let lastLoggedMsg = ""; let lastLoggedSender = ""; while (Date.now() - startTime < timeout) { // Detectar mensajes que no se pueden descifrar 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` ); 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." ); } // 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}"` ); lastLoggedMsg = lastMessage; lastLoggedSender = lastSender || ""; } if (lastSender && lastSender.includes(options.sender)) { console.log( `[waitForBotReply] Respuesta recibida de "${options.sender}" (${Date.now() - startTime}ms)` ); return lastMessage; } } else { console.log( `[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms)` ); return lastMessage; } } await page.waitForTimeout(500); } // Timeout — tomar screenshot antes de lanzar error console.error( `[waitForBotReply] TIMEOUT despues de ${timeout}ms esperando respuesta` ); 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})` : "") ); } /** * 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. * Hace hover sobre el mensaje, click en el boton de thread, * y espera a que el panel de thread se abra. */ export async function startThreadOnLastMessage(page: Page) { console.log("[startThread] Hover sobre ultimo mensaje..."); // Hover sobre el ultimo event tile para revelar la action bar const lastTile = page.locator(".mx_EventTile").last(); await lastTile.hover(); // 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(); const hasBtn = await threadBtn .waitFor({ state: "visible", timeout: 5_000 }) .then(() => true) .catch(() => false); 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, }); throw new Error("Boton de thread no encontrado"); } 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"); } /** * 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}"` ); } } } } /** * 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." ); } }