merge: quick/e2e-persistent-context — persistent context E2EE y mejoras de robustez
Migra los tests E2E de storageState a persistent browser context para preservar IndexedDB (crypto keys E2EE). Añade reintentos de login, screenshots de debug, logging detallado, y helpers de threads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+228
-14
@@ -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
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
+68
-19
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
+10
-26
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user