feat: mejoras de robustez en fixtures de login y room

element-auth.ts:
- Reintentos con backoff para M_LIMIT_EXCEEDED (max 3 intentos)
- Screenshots de debug en cada paso del login
- Deteccion de sesion activa (skip login si ya logueado)
- Manejo robusto de cross-signing con fallback si no hay Done button
- waitForLoginResult() detecta errores, rate limits y exito

matrix-room.ts:
- Logging detallado en goToRoom, sendMessage, waitForBotReply
- Screenshots automaticas en errores y timeouts
- Dump del timeline en timeout para diagnóstico
- Nuevos helpers: startThreadOnLastMessage, sendThreadMessage,
  waitForThreadReply, assertBotDidNotReplyInMainTimeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 15:47:43 +00:00
parent d4e31ab315
commit 947bb70eba
2 changed files with 516 additions and 16 deletions
+228 -14
View File
@@ -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<string> {
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");
}
}
+288 -2
View File
@@ -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<string> {
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<string | 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<string> {
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<void> {
// 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.