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:
2026-03-08 15:48:06 +00:00
8 changed files with 732 additions and 78 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.
+94
View File
@@ -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
View File
@@ -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;
+6 -3
View File
@@ -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: [
+36 -7
View File
@@ -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);
});
+2 -7
View File
@@ -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
View File
@@ -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);