diff --git a/e2e/fixtures/element-auth.ts b/e2e/fixtures/element-auth.ts index 36968dc..a7a0718 100644 --- a/e2e/fixtures/element-auth.ts +++ b/e2e/fixtures/element-auth.ts @@ -1,10 +1,20 @@ import { Page, expect } from "@playwright/test"; +import * as path from "path"; export interface LoginOptions { url: string; user: string; password: string; recoveryKey: string; + /** Directorio donde guardar screenshots de debug (opcional) */ + screenshotsDir?: string; +} + +async function screenshot(page: Page, dir: string | undefined, name: string) { + if (!dir) return; + const filePath = path.join(dir, name); + await page.screenshot({ path: filePath, fullPage: true }); + console.log(`[login] Screenshot: ${name}`); } /** @@ -12,27 +22,206 @@ export interface LoginOptions { * 1. Navegar a Element Web * 2. Click "Sign in" * 3. Ingresar usuario y contraseña - * 4. Manejar verificacion de dispositivo con recovery key - * 5. Verificar login exitoso (lista de rooms visible) + * 4. Detectar errores (M_LIMIT_EXCEEDED, etc.) y reintentar + * 5. Manejar verificacion de dispositivo con recovery key + * 6. Verificar login exitoso (lista de rooms visible) */ export async function loginToElement(page: Page, opts: LoginOptions) { + const ssDir = opts.screenshotsDir; + + console.log(`[login] Navegando a ${opts.url}`); await page.goto(opts.url); + await screenshot(page, ssDir, "01-element-loaded.png"); // Esperar a que cargue Element y aparezca el boton de login - await page.getByRole("link", { name: "Sign in" }).click(); + console.log("[login] Buscando boton 'Sign in'..."); + const signInLink = page.getByRole("link", { name: "Sign in" }); + const hasSignIn = await signInLink + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => true) + .catch(() => false); - // Rellenar credenciales - await page.getByRole("textbox", { name: "Username" }).fill(opts.user); - await page.getByRole("textbox", { name: "Password" }).fill(opts.password); - await page.getByRole("button", { name: "Sign in" }).click(); + if (hasSignIn) { + console.log("[login] Click en 'Sign in'"); + await signInLink.click(); + } else { + console.log( + "[login] No se encontro 'Sign in' link — puede que ya estemos en la pagina de login" + ); + await screenshot(page, ssDir, "01b-no-signin-link.png"); + } + + await screenshot(page, ssDir, "02-signin-page.png"); + + // Intentar login con reintentos para M_LIMIT_EXCEEDED + const maxRetries = 3; + for (let attempt = 1; attempt <= maxRetries; attempt++) { + console.log(`[login] Intento de login ${attempt}/${maxRetries}`); + + // Rellenar credenciales + console.log(`[login] Rellenando credenciales para: ${opts.user}`); + const usernameField = page.getByRole("textbox", { name: "Username" }); + const hasUsername = await usernameField + .waitFor({ state: "visible", timeout: 10_000 }) + .then(() => true) + .catch(() => false); + + if (!hasUsername) { + // Puede que ya hayamos pasado la pantalla de login (sesion activa) + 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 alreadyLoggedIn = await roomsTree + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + + if (alreadyLoggedIn) { + console.log("[login] Ya hay sesion activa, saltando login"); + return; + } + throw new Error("Campo Username no encontrado y no hay sesion activa"); + } + + await usernameField.fill(opts.user); + await page.getByRole("textbox", { name: "Password" }).fill(opts.password); + await screenshot(page, ssDir, `02b-credentials-filled-attempt${attempt}.png`); + + console.log("[login] Click en 'Sign in' button"); + await page.getByRole("button", { name: "Sign in" }).click(); + + await screenshot(page, ssDir, `03-after-signin-click-attempt${attempt}.png`); + + // Esperar resultado: o bien aparece el verify prompt / rooms, + // o bien aparece un error + console.log("[login] Esperando resultado del login..."); + const result = await waitForLoginResult(page); + + if (result === "success") { + console.log("[login] Login exitoso (rooms visibles o verify prompt)"); + break; + } + + if (result === "rate_limited") { + const waitSecs = 10 * attempt; // 10s, 20s, 30s + console.log( + `[login] Rate limited (M_LIMIT_EXCEEDED). Esperando ${waitSecs}s antes de reintentar...` + ); + await screenshot(page, ssDir, `ERROR-rate-limited-attempt${attempt}.png`); + + if (attempt === maxRetries) { + throw new Error( + `Login fallido despues de ${maxRetries} intentos: M_LIMIT_EXCEEDED. ` + + "El homeserver esta limitando los intentos de login. Esperar unos minutos." + ); + } + + await page.waitForTimeout(waitSecs * 1000); + // Recargar pagina para limpiar estado + await page.goto(opts.url + "/#/login"); + await page.waitForTimeout(2_000); + continue; + } + + // Otro error + console.error(`[login] Error de login: ${result}`); + await screenshot(page, ssDir, `ERROR-login-attempt${attempt}.png`); + + if (attempt === maxRetries) { + throw new Error(`Login fallido despues de ${maxRetries} intentos: ${result}`); + } + + // Esperar un poco antes de reintentar + await page.waitForTimeout(3_000); + } // Manejar cross-signing: verificar con recovery key - await handleCrossSigning(page, opts.recoveryKey); + console.log("[login] Esperando prompt de cross-signing..."); + await handleCrossSigning(page, opts.recoveryKey, ssDir); // Verificar login exitoso: rooms visibles en el sidebar - await expect( - page.locator('[role="tree"][aria-label="Rooms"]') - ).toBeVisible({ timeout: 30_000 }); + console.log("[login] Verificando que rooms sidebar es visible..."); + const roomsVisible = await page + .locator('[role="tree"][aria-label="Rooms"]') + .waitFor({ state: "visible", timeout: 30_000 }) + .then(() => true) + .catch(() => false); + + if (!roomsVisible) { + await screenshot(page, ssDir, "ERROR-no-rooms-after-login.png"); + throw new Error("Rooms sidebar no visible despues del login completo"); + } + + await screenshot(page, ssDir, "04-rooms-visible.png"); + console.log("[login] Login completado exitosamente"); +} + +/** + * Espera el resultado del login: exito, rate_limited, u otro error. + * Retorna "success" si el login progresó (verify prompt o rooms visibles), + * "rate_limited" si hay M_LIMIT_EXCEEDED, o el texto del error. + */ +async function waitForLoginResult(page: Page): Promise { + const timeout = 20_000; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + // Verificar si hay un error visible en la pagina de login + const errorAlert = page.locator('[role="alert"], .mx_Login_error, .mx_ErrorMessage'); + const errorCount = await errorAlert.count(); + if (errorCount > 0) { + const errorText = await errorAlert.first().textContent(); + if (errorText) { + console.log(`[login] Error detectado: "${errorText}"`); + if (errorText.includes("M_LIMIT_EXCEEDED") || errorText.includes("rate")) { + return "rate_limited"; + } + return errorText; + } + } + + // Verificar si hay texto de error generico en la pagina + const pageText = await page.locator(".mx_Login_header, .mx_AuthBody").textContent().catch(() => ""); + if (pageText && pageText.includes("M_LIMIT_EXCEEDED")) { + return "rate_limited"; + } + + // Verificar si el login progresó (verify prompt) + const verifyButton = page.getByRole("button", { + name: /verify with security key|use security key/i, + }); + if (await verifyButton.isVisible().catch(() => false)) { + return "success"; + } + + // Verificar si ya estamos en el home (rooms visibles) + const roomsTree = page.locator('[role="tree"][aria-label="Rooms"]'); + if (await roomsTree.isVisible().catch(() => false)) { + return "success"; + } + + // Verificar si hay un dialogo de "Verify this device" u otro post-login + const verifyDialog = page.locator('.mx_AuthPage_modal, .mx_Dialog, [role="dialog"]'); + if (await verifyDialog.isVisible().catch(() => false)) { + return "success"; + } + + await page.waitForTimeout(500); + } + + // Si despues del timeout seguimos en la pagina de Sign In, es error + const stillOnLogin = await page.locator('button:has-text("Sign in")').isVisible().catch(() => false); + if (stillOnLogin) { + // Capturar cualquier texto de error visible + const bodyText = await page.locator(".mx_AuthBody").textContent().catch(() => ""); + if (bodyText && bodyText.includes("M_LIMIT_EXCEEDED")) { + return "rate_limited"; + } + return `Timeout: aun en pagina de login. Body: ${bodyText?.substring(0, 200)}`; + } + + return "success"; } /** @@ -40,7 +229,11 @@ export async function loginToElement(page: Page, opts: LoginOptions) { * Element puede mostrar un dialogo pidiendo verificar el dispositivo * via otro dispositivo o via recovery key. */ -async function handleCrossSigning(page: Page, recoveryKey: string) { +async function handleCrossSigning( + page: Page, + recoveryKey: string, + ssDir?: string +) { // Element muestra un dialogo de verificacion de dispositivo. // Intentar usar "Verify with Security Key" si aparece. const verifyButton = page.getByRole("button", { @@ -54,19 +247,40 @@ async function handleCrossSigning(page: Page, recoveryKey: string) { .catch(() => false); if (!hasVerifyPrompt) { - // No hubo prompt de verificacion — login directo (sesion ya verificada) + console.log( + "[login] No hubo prompt de verificacion — sesion ya verificada o login directo" + ); + await screenshot(page, ssDir, "03b-no-verify-prompt.png"); return; } + console.log("[login] Prompt de verificacion detectado, clickeando..."); + await screenshot(page, ssDir, "03c-verify-prompt.png"); await verifyButton.click(); // Ingresar recovery key en el campo de texto + console.log("[login] Ingresando recovery key..."); const keyInput = page.getByRole("textbox"); await keyInput.fill(recoveryKey); + await screenshot(page, ssDir, "03d-recovery-key-filled.png"); // Confirmar + console.log("[login] Click en 'Continue'..."); await page.getByRole("button", { name: /continue/i }).click(); // Esperar a que se complete la verificacion (el dialogo desaparece) - await page.getByRole("button", { name: /done/i }).click(); + console.log("[login] Esperando boton 'Done'..."); + const doneButton = page.getByRole("button", { name: /done/i }); + const hasDone = await doneButton + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => true) + .catch(() => false); + + if (hasDone) { + await doneButton.click(); + console.log("[login] Verificacion completada (Done)"); + } else { + console.log("[login] No se encontro 'Done' — verificacion puede haber terminado automaticamente"); + await screenshot(page, ssDir, "03e-no-done-button.png"); + } } diff --git a/e2e/fixtures/matrix-room.ts b/e2e/fixtures/matrix-room.ts index db0af63..5ecbde4 100644 --- a/e2e/fixtures/matrix-room.ts +++ b/e2e/fixtures/matrix-room.ts @@ -11,38 +11,77 @@ export interface WaitForReplyOptions { * 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}"`); } /** @@ -54,10 +93,14 @@ export async function waitForBotReply( 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) - // Los mensajes de bots tienen un sender distinto al del usuario autenticado const startTime = Date.now(); + let lastLoggedMsg = ""; + let lastLoggedSender = ""; while (Date.now() - startTime < timeout) { // Detectar mensajes que no se pueden descifrar @@ -66,6 +109,13 @@ export async function waitForBotReply( ); 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 " + @@ -79,11 +129,26 @@ export async function waitForBotReply( // 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 { - // Sin filtro de sender, cualquier mensaje nuevo sirve + console.log( + `[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms)` + ); return lastMessage; } } @@ -91,6 +156,34 @@ export async function waitForBotReply( 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})` : "") @@ -119,6 +212,199 @@ async function getLastMessageSender(page: Page): Promise { 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. diff --git a/e2e/fixtures/persistent-context.ts b/e2e/fixtures/persistent-context.ts new file mode 100644 index 0000000..b0214ed --- /dev/null +++ b/e2e/fixtures/persistent-context.ts @@ -0,0 +1,94 @@ +import { test as base, chromium, BrowserContext, Page } from "@playwright/test"; +import * as path from "path"; + +/** + * Custom test fixture que usa un persistent browser context compartido. + * + * A diferencia de storageState (que solo guarda cookies + localStorage), + * un persistent context preserva IndexedDB — donde Element Web guarda + * las crypto keys de E2EE. Sin esto, cada test ve "Missing session data". + * + * El contexto es worker-scoped: se crea una vez y se reutiliza en todos + * los tests del worker. Esto evita el dialogo "Element is open in another + * window" que aparece cuando se abre/cierra el contexto repetidamente. + */ + +const USER_DATA_DIR = path.resolve(__dirname, "..", ".auth", "chrome-profile"); + +export const test = base.extend< + { page: Page }, + { persistentContext: BrowserContext } +>({ + // Worker-scoped: un solo persistent context para todos los tests + persistentContext: [ + async ({}, use) => { + const context = await chromium.launchPersistentContext(USER_DATA_DIR, { + headless: true, + baseURL: process.env.ELEMENT_URL || "http://localhost:8080", + viewport: { width: 1280, height: 720 }, + }); + + await use(context); + await context.close(); + }, + { scope: "worker" }, + ], + + // Cada test obtiene una pagina del contexto compartido + page: async ({ persistentContext }, use) => { + // Cerrar paginas sobrantes de tests anteriores + for (const p of persistentContext.pages()) { + await p.close(); + } + const page = await persistentContext.newPage(); + + await use(page); + + // Cerrar la pagina al finalizar el test + await page.close(); + }, +}); + +/** + * Maneja dialogos de Element que bloquean la carga: + * - "Element is open in another window" → click Continue + * - Spinner de carga → esperar + * - "Missing session data" → error informativo + * + * Llamar despues de page.goto("/") + */ +export async function handleElementDialogs(page: Page) { + // 1. "Element is open in another window" — click Continue + const continueBtn = page.getByRole("button", { name: "Continue" }); + const hasContinue = await continueBtn + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + + if (hasContinue) { + console.log("[element] 'Element is open in another window' — clicking Continue"); + await continueBtn.click(); + } + + // 2. "Missing session data" — fatal + const missingData = page.locator('text="Missing session data"'); + const hasMissing = await missingData + .waitFor({ state: "visible", timeout: 3_000 }) + .then(() => true) + .catch(() => false); + + if (hasMissing) { + throw new Error( + "Missing session data: crypto keys perdidas. " + + "Borrar .auth/ y re-ejecutar: rm -rf e2e/.auth && ./dev-scripts/e2e/run.sh" + ); + } + + // 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"); +} + +export { expect } from "@playwright/test"; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index aed4f4f..d377720 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -6,14 +6,19 @@ import { loginToElement } from "./fixtures/element-auth"; dotenv.config({ path: path.resolve(__dirname, ".env") }); -const AUTH_DIR = path.resolve(__dirname, ".auth"); -const STATE_PATH = path.join(AUTH_DIR, "state.json"); +const USER_DATA_DIR = path.resolve(__dirname, ".auth", "chrome-profile"); +const MARKER_PATH = path.resolve(__dirname, ".auth", "login-done.marker"); +const SCREENSHOTS_DIR = path.resolve(__dirname, "test-results", "global-setup"); /** - * Global setup: ejecuta login una vez y guarda storageState - * para reutilizar en todos los tests sin repetir el login. + * Global setup: ejecuta login una vez usando persistent context. * - * Si state.json ya existe y no esta expirado, lo reutiliza. + * A diferencia de storageState, el persistent context preserva IndexedDB + * (crypto keys de E2EE). Los tests usan el mismo userDataDir via el + * custom fixture persistent-context.ts. + * + * Si el marker file existe y no esta expirado, asumimos que la sesion + * sigue activa y saltamos el login. */ async function globalSetup() { const elementURL = process.env.ELEMENT_URL || "http://localhost:8080"; @@ -27,20 +32,45 @@ async function globalSetup() { ); } - // Reutilizar sesion cacheada si existe y tiene menos de 12 horas - if (isStateFresh(STATE_PATH, 12 * 60 * 60 * 1000)) { - console.log("[global-setup] Reutilizando sesion cacheada"); + // Reutilizar sesion cacheada si el marker existe y tiene menos de 12 horas + if (isMarkerFresh(MARKER_PATH, 12 * 60 * 60 * 1000)) { + console.log("[global-setup] Reutilizando sesion de persistent context"); return; } - console.log("[global-setup] Ejecutando login en Element Web..."); + console.log("[global-setup] Ejecutando login en Element Web con persistent context..."); + console.log(`[global-setup] URL: ${elementURL}`); + console.log(`[global-setup] User: ${user}`); + console.log(`[global-setup] UserDataDir: ${USER_DATA_DIR}`); - // Asegurar que el directorio .auth existe - fs.mkdirSync(AUTH_DIR, { recursive: true }); + // Limpiar perfil anterior para login fresco + if (fs.existsSync(USER_DATA_DIR)) { + fs.rmSync(USER_DATA_DIR, { recursive: true }); + console.log("[global-setup] Perfil anterior eliminado"); + } - const browser = await chromium.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); + // Asegurar que los directorios existen + fs.mkdirSync(USER_DATA_DIR, { recursive: true }); + fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + + // Usar persistent context — preserva IndexedDB (crypto keys E2EE) + const context = await chromium.launchPersistentContext(USER_DATA_DIR, { + headless: true, + viewport: { width: 1280, height: 720 }, + }); + + const page = context.pages()[0] || (await context.newPage()); + + // Capturar logs de consola del browser + page.on("console", (msg) => { + const type = msg.type(); + if (type === "error" || type === "warning") { + console.log(`[browser-${type}] ${msg.text()}`); + } + }); + page.on("pageerror", (err) => { + console.error(`[browser-error] ${err.message}`); + }); try { await loginToElement(page, { @@ -48,17 +78,36 @@ async function globalSetup() { user, password, recoveryKey, + screenshotsDir: SCREENSHOTS_DIR, }); - await context.storageState({ path: STATE_PATH }); - console.log("[global-setup] Sesion guardada en", STATE_PATH); + // Crear marker de sesion exitosa + fs.writeFileSync(MARKER_PATH, new Date().toISOString()); + console.log("[global-setup] Login completado, marker creado"); + + await page.screenshot({ + path: path.join(SCREENSHOTS_DIR, "05-login-complete.png"), + fullPage: true, + }); + } catch (err) { + console.error("[global-setup] ERROR durante login:", err); + await page.screenshot({ + path: path.join(SCREENSHOTS_DIR, "ERROR-login-failed.png"), + fullPage: true, + }); + const html = await page.content(); + fs.writeFileSync( + path.join(SCREENSHOTS_DIR, "ERROR-page-content.html"), + html + ); + throw err; } finally { - await browser.close(); + await context.close(); } } -/** Verifica si el archivo de estado existe y tiene menos de maxAge ms. */ -function isStateFresh(filePath: string, maxAge: number): boolean { +/** Verifica si el marker file existe y tiene menos de maxAge ms. */ +function isMarkerFresh(filePath: string, maxAge: number): boolean { try { const stat = fs.statSync(filePath); return Date.now() - stat.mtimeMs < maxAge; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index b697cff..aa27473 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -19,12 +19,15 @@ export default defineConfig({ use: { baseURL: process.env.ELEMENT_URL || "http://localhost:8080", headless: true, - screenshot: "only-on-failure", - trace: "on-first-retry", + screenshot: "on", + trace: "retain-on-failure", + video: "retain-on-failure", actionTimeout: 30_000, - storageState: path.resolve(__dirname, ".auth/state.json"), + // NO usamos storageState — usamos persistent context para preservar IndexedDB }, + outputDir: "./test-results", + globalSetup: "./global-setup.ts", projects: [ diff --git a/e2e/tests/asistente-2.spec.ts b/e2e/tests/asistente-2.spec.ts index e6bc093..16ae1ee 100644 --- a/e2e/tests/asistente-2.spec.ts +++ b/e2e/tests/asistente-2.spec.ts @@ -1,20 +1,18 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, handleElementDialogs } from "../fixtures/persistent-context"; import { goToRoom, sendMessage, waitForBotReply, assertNoDecryptionErrors, + startThreadOnLastMessage, + sendThreadMessage, + waitForThreadReply, } from "../fixtures/matrix-room"; test.describe("asistente-2", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - - // Esperar a que la sesion este lista - await expect( - page.locator('[role="tree"][aria-label="Rooms"]') - ).toBeVisible({ timeout: 30_000 }); - + await handleElementDialogs(page); await goToRoom(page, "Asistente 2"); }); @@ -65,6 +63,37 @@ test.describe("asistente-2", () => { expect(reply.toLowerCase()).toContain("ping"); }); + test("responde dentro del thread cuando se le habla por thread", async ({ + page, + }) => { + // 1. Enviar un mensaje normal (sera el thread root) + await sendMessage(page, "Mensaje para iniciar thread"); + + // Esperar a que el bot responda al mensaje original + await waitForBotReply(page, { + timeout: 60_000, + sender: "Asistente 2", + }); + + // 2. Iniciar thread sobre el mensaje del usuario + 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, { + timeout: 60_000, + sender: "Asistente 2", + }); + + expect(threadReply).toBeTruthy(); + expect(threadReply.length).toBeGreaterThan(5); + }); + test("no hay errores de E2EE en el timeline", async ({ page }) => { await assertNoDecryptionErrors(page); }); diff --git a/e2e/tests/assistant-bot.spec.ts b/e2e/tests/assistant-bot.spec.ts index a75b73b..6aa922f 100644 --- a/e2e/tests/assistant-bot.spec.ts +++ b/e2e/tests/assistant-bot.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, handleElementDialogs } from "../fixtures/persistent-context"; import { goToRoom, sendMessage, @@ -9,12 +9,7 @@ import { test.describe("assistant-bot", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - - // Esperar a que la sesion este lista - await expect( - page.locator('[role="tree"][aria-label="Rooms"]') - ).toBeVisible({ timeout: 30_000 }); - + await handleElementDialogs(page); await goToRoom(page, "Assistant"); }); diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts index 9f53e84..f3761f3 100644 --- a/e2e/tests/login.spec.ts +++ b/e2e/tests/login.spec.ts @@ -1,54 +1,38 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, handleElementDialogs } from "../fixtures/persistent-context"; import { assertNoDecryptionErrors } from "../fixtures/matrix-room"; test.describe("Login y sesion E2EE", () => { - test("storageState cargado — rooms visibles en sidebar", async ({ - page, - }) => { + test("sesion cargada — rooms visibles en sidebar", async ({ page }) => { await page.goto("/"); + await handleElementDialogs(page); - // Si la sesion esta cacheada, Element debe mostrar rooms directamente - await expect( - page.locator('[role="tree"][aria-label="Rooms"]') - ).toBeVisible({ timeout: 30_000 }); + // Si llegamos aqui, handleElementDialogs ya verifico rooms sidebar + const rooms = page.locator('[role="treeitem"]'); + const roomCount = await rooms.count(); + expect(roomCount).toBeGreaterThan(0); }); test("no hay mensajes Unable to decrypt en rooms recientes", async ({ page, }) => { await page.goto("/"); - - // Esperar a que cargue la lista de rooms - await expect( - page.locator('[role="tree"][aria-label="Rooms"]') - ).toBeVisible({ timeout: 30_000 }); + await handleElementDialogs(page); // Abrir el primer room visible para verificar mensajes - const firstRoom = page - .locator('[role="treeitem"]') - .first(); + const firstRoom = page.locator('[role="treeitem"]').first(); const roomCount = await firstRoom.count(); if (roomCount > 0) { await firstRoom.click(); - - // Esperar a que cargue el timeline await page.waitForTimeout(3_000); - - // Verificar que no hay errores de desencriptado await assertNoDecryptionErrors(page); } }); test("helpers de room navegan correctamente", async ({ page }) => { await page.goto("/"); + await handleElementDialogs(page); - // Esperar a que la sesion este lista - await expect( - page.locator('[role="tree"][aria-label="Rooms"]') - ).toBeVisible({ timeout: 30_000 }); - - // Verificar que hay al menos un room en el sidebar const rooms = page.locator('[role="treeitem"]'); const roomCount = await rooms.count(); expect(roomCount).toBeGreaterThan(0);