947bb70eba
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>
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
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<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)
|
|
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<string | null> {
|
|
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<string | null> {
|
|
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<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.
|
|
*/
|
|
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."
|
|
);
|
|
}
|
|
}
|