feat: auth fixtures y helpers de interaccion E2E

Implementa el issue 0022b — fixtures de Playwright para
autenticacion en Element Web y helpers de interaccion con rooms.

- element-auth.ts: flujo completo de login + cross-signing con
  recovery key, preparado para cachear sesion via storageState
- global-setup.ts: ejecuta login una vez antes de todos los tests,
  reutiliza sesion cacheada si tiene menos de 12 horas
- matrix-room.ts: helpers goToRoom, sendMessage, waitForBotReply,
  getLastMessage, assertNoDecryptionErrors (detecta "Unable to decrypt")
- login.spec.ts: 3 smoke tests validando sesion, E2EE y navegacion
- playwright.config.ts: configurado storageState para inyectar sesion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 14:19:54 +00:00
parent 6540c15ad4
commit 71d866abde
7 changed files with 336 additions and 0 deletions
View File
+72
View File
@@ -0,0 +1,72 @@
import { Page, expect } from "@playwright/test";
export interface LoginOptions {
url: string;
user: string;
password: string;
recoveryKey: string;
}
/**
* Ejecuta el flujo completo de login en Element Web:
* 1. Navegar a Element Web
* 2. Click "Sign in"
* 3. Ingresar usuario y contraseña
* 4. Manejar verificacion de dispositivo con recovery key
* 5. Verificar login exitoso (lista de rooms visible)
*/
export async function loginToElement(page: Page, opts: LoginOptions) {
await page.goto(opts.url);
// Esperar a que cargue Element y aparezca el boton de login
await page.getByRole("link", { name: "Sign in" }).click();
// Rellenar credenciales
await page.getByRole("textbox", { name: "Username" }).fill(opts.user);
await page.getByRole("textbox", { name: "Password" }).fill(opts.password);
await page.getByRole("button", { name: "Sign in" }).click();
// Manejar cross-signing: verificar con recovery key
await handleCrossSigning(page, opts.recoveryKey);
// Verificar login exitoso: rooms visibles en el sidebar
await expect(
page.locator('[role="tree"][aria-label="Rooms"]')
).toBeVisible({ timeout: 30_000 });
}
/**
* Maneja el prompt de verificacion de dispositivo despues del login.
* Element puede mostrar un dialogo pidiendo verificar el dispositivo
* via otro dispositivo o via recovery key.
*/
async function handleCrossSigning(page: Page, recoveryKey: string) {
// Element muestra un dialogo de verificacion de dispositivo.
// Intentar usar "Verify with Security Key" si aparece.
const verifyButton = page.getByRole("button", {
name: /verify with security key|use security key/i,
});
// El dialogo puede tardar en aparecer tras el login
const hasVerifyPrompt = await verifyButton
.waitFor({ state: "visible", timeout: 15_000 })
.then(() => true)
.catch(() => false);
if (!hasVerifyPrompt) {
// No hubo prompt de verificacion — login directo (sesion ya verificada)
return;
}
await verifyButton.click();
// Ingresar recovery key en el campo de texto
const keyInput = page.getByRole("textbox");
await keyInput.fill(recoveryKey);
// Confirmar
await page.getByRole("button", { name: /continue/i }).click();
// Esperar a que se complete la verificacion (el dialogo desaparece)
await page.getByRole("button", { name: /done/i }).click();
}
+137
View File
@@ -0,0 +1,137 @@
import { Page, expect } from "@playwright/test";
export interface WaitForReplyOptions {
/** Timeout en ms para esperar la respuesta (default: 30s) */
timeout?: number;
/** Filtrar por sender display name si se especifica */
sender?: string;
}
/**
* Navega a un room por nombre usando el buscador de Element.
*/
export async function goToRoom(page: Page, roomName: string) {
// Abrir busqueda de rooms (Ctrl+K o click en el search)
const searchButton = page.locator('[aria-label="Search"]').first();
await searchButton.click();
// Escribir nombre del room en el campo de busqueda
const searchInput = page.getByRole("searchbox").first();
await searchInput.fill(roomName);
// Seleccionar el room de los resultados
const roomResult = page
.getByRole("option", { name: new RegExp(roomName, "i") })
.first();
await roomResult.click();
// Verificar que estamos en el room correcto (header muestra el nombre)
await expect(
page.locator(`[data-testid="room-header-name"], h2`).filter({ hasText: roomName }).first()
).toBeVisible({ timeout: 10_000 });
}
/**
* Envia un mensaje de texto en el room actual.
*/
export async function sendMessage(page: Page, text: string) {
const composer = page.getByRole("textbox", { name: /message/i });
await composer.fill(text);
await composer.press("Enter");
// Esperar a que el mensaje aparezca en el timeline
await expect(
page.locator(".mx_EventTile_body, .mx_MTextBody").filter({ hasText: text }).last()
).toBeVisible({ timeout: 10_000 });
}
/**
* Espera una respuesta de un bot en el timeline.
* Detecta "Unable to decrypt" y falla con mensaje claro.
*/
export async function waitForBotReply(
page: Page,
options?: WaitForReplyOptions
): Promise<string> {
const timeout = options?.timeout ?? 30_000;
// Esperar a que aparezca un nuevo mensaje (que no sea del usuario actual)
// Los mensajes de bots tienen un sender distinto al del usuario autenticado
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
// Detectar mensajes que no se pueden descifrar
const undecryptable = page.locator(
'.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]'
);
const undecryptableCount = await undecryptable.count();
if (undecryptableCount > 0) {
throw new Error(
"E2EE error: se detectaron mensajes 'Unable to decrypt'. " +
"Verificar que cross-signing esta configurado correctamente " +
"y que la recovery key es valida."
);
}
// Buscar el ultimo mensaje en el timeline
const lastMessage = await getLastMessage(page);
if (lastMessage) {
// Si se especifica sender, verificar que coincide
if (options?.sender) {
const lastSender = await getLastMessageSender(page);
if (lastSender && lastSender.includes(options.sender)) {
return lastMessage;
}
} else {
// Sin filtro de sender, cualquier mensaje nuevo sirve
return lastMessage;
}
}
await page.waitForTimeout(500);
}
throw new Error(
`Timeout (${timeout}ms): no se recibio respuesta del bot` +
(options?.sender ? ` (sender esperado: ${options.sender})` : "")
);
}
/**
* Obtiene el texto del ultimo mensaje visible en el timeline.
*/
export async function getLastMessage(page: Page): Promise<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();
}
/**
* 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."
);
}
}