diff --git a/e2e/fixtures/.gitkeep b/e2e/fixtures/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/fixtures/element-auth.ts b/e2e/fixtures/element-auth.ts new file mode 100644 index 0000000..36968dc --- /dev/null +++ b/e2e/fixtures/element-auth.ts @@ -0,0 +1,72 @@ +import { Page, expect } from "@playwright/test"; + +export interface LoginOptions { + url: string; + user: string; + password: string; + recoveryKey: string; +} + +/** + * Ejecuta el flujo completo de login en Element Web: + * 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) + */ +export async function loginToElement(page: Page, opts: LoginOptions) { + await page.goto(opts.url); + + // Esperar a que cargue Element y aparezca el boton de login + await page.getByRole("link", { name: "Sign in" }).click(); + + // 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(); + + // Manejar cross-signing: verificar con recovery key + await handleCrossSigning(page, opts.recoveryKey); + + // Verificar login exitoso: rooms visibles en el sidebar + await expect( + page.locator('[role="tree"][aria-label="Rooms"]') + ).toBeVisible({ timeout: 30_000 }); +} + +/** + * Maneja el prompt de verificacion de dispositivo despues del login. + * Element puede mostrar un dialogo pidiendo verificar el dispositivo + * via otro dispositivo o via recovery key. + */ +async function handleCrossSigning(page: Page, recoveryKey: string) { + // Element muestra un dialogo de verificacion de dispositivo. + // Intentar usar "Verify with Security Key" si aparece. + const verifyButton = page.getByRole("button", { + name: /verify with security key|use security key/i, + }); + + // El dialogo puede tardar en aparecer tras el login + const hasVerifyPrompt = await verifyButton + .waitFor({ state: "visible", timeout: 15_000 }) + .then(() => true) + .catch(() => false); + + if (!hasVerifyPrompt) { + // No hubo prompt de verificacion — login directo (sesion ya verificada) + return; + } + + await verifyButton.click(); + + // Ingresar recovery key en el campo de texto + const keyInput = page.getByRole("textbox"); + await keyInput.fill(recoveryKey); + + // Confirmar + 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(); +} diff --git a/e2e/fixtures/matrix-room.ts b/e2e/fixtures/matrix-room.ts new file mode 100644 index 0000000..db0af63 --- /dev/null +++ b/e2e/fixtures/matrix-room.ts @@ -0,0 +1,137 @@ +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) { + // Abrir busqueda de rooms (Ctrl+K o click en el search) + const searchButton = page.locator('[aria-label="Search"]').first(); + await searchButton.click(); + + // Escribir nombre del room en el campo de busqueda + const searchInput = page.getByRole("searchbox").first(); + await searchInput.fill(roomName); + + // Seleccionar el room de los resultados + const roomResult = page + .getByRole("option", { name: new RegExp(roomName, "i") }) + .first(); + 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 }); +} + +/** + * Envia un mensaje de texto en el room actual. + */ +export async function sendMessage(page: Page, text: string) { + const composer = page.getByRole("textbox", { name: /message/i }); + await composer.fill(text); + await composer.press("Enter"); + + // 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 }); +} + +/** + * 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; + + // 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(); + + 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) { + 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); + if (lastSender && lastSender.includes(options.sender)) { + return lastMessage; + } + } else { + // Sin filtro de sender, cualquier mensaje nuevo sirve + return lastMessage; + } + } + + await page.waitForTimeout(500); + } + + 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(); +} + +/** + * 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." + ); + } +} diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..aed4f4f --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,70 @@ +import { chromium } from "@playwright/test"; +import * as path from "path"; +import * as fs from "fs"; +import * as dotenv from "dotenv"; +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"); + +/** + * Global setup: ejecuta login una vez y guarda storageState + * para reutilizar en todos los tests sin repetir el login. + * + * Si state.json ya existe y no esta expirado, lo reutiliza. + */ +async function globalSetup() { + const elementURL = process.env.ELEMENT_URL || "http://localhost:8080"; + const user = process.env.MATRIX_USER; + const password = process.env.MATRIX_PASSWORD; + const recoveryKey = process.env.MATRIX_RECOVERY_KEY; + + if (!user || !password || !recoveryKey) { + throw new Error( + "Faltan variables de entorno: MATRIX_USER, MATRIX_PASSWORD, MATRIX_RECOVERY_KEY" + ); + } + + // 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"); + return; + } + + console.log("[global-setup] Ejecutando login en Element Web..."); + + // Asegurar que el directorio .auth existe + fs.mkdirSync(AUTH_DIR, { recursive: true }); + + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + await loginToElement(page, { + url: elementURL, + user, + password, + recoveryKey, + }); + + await context.storageState({ path: STATE_PATH }); + console.log("[global-setup] Sesion guardada en", STATE_PATH); + } finally { + await browser.close(); + } +} + +/** Verifica si el archivo de estado existe y tiene menos de maxAge ms. */ +function isStateFresh(filePath: string, maxAge: number): boolean { + try { + const stat = fs.statSync(filePath); + return Date.now() - stat.mtimeMs < maxAge; + } catch { + return false; + } +} + +export default globalSetup; diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index dc174ee..b697cff 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ screenshot: "only-on-failure", trace: "on-first-retry", actionTimeout: 30_000, + storageState: path.resolve(__dirname, ".auth/state.json"), }, globalSetup: "./global-setup.ts", diff --git a/e2e/tests/.gitkeep b/e2e/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/e2e/tests/login.spec.ts b/e2e/tests/login.spec.ts new file mode 100644 index 0000000..9f53e84 --- /dev/null +++ b/e2e/tests/login.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "@playwright/test"; +import { assertNoDecryptionErrors } from "../fixtures/matrix-room"; + +test.describe("Login y sesion E2EE", () => { + test("storageState cargado — rooms visibles en sidebar", async ({ + page, + }) => { + await page.goto("/"); + + // Si la sesion esta cacheada, Element debe mostrar rooms directamente + await expect( + page.locator('[role="tree"][aria-label="Rooms"]') + ).toBeVisible({ timeout: 30_000 }); + }); + + 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 }); + + // Abrir el primer room visible para verificar mensajes + 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("/"); + + // 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); + }); +});