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:
@@ -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();
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
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");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global setup: ejecuta login una vez y guarda storageState
|
||||||
|
* para reutilizar en todos los tests sin repetir el login.
|
||||||
|
*
|
||||||
|
* Si state.json ya existe y no esta expirado, lo reutiliza.
|
||||||
|
*/
|
||||||
|
async function globalSetup() {
|
||||||
|
const elementURL = process.env.ELEMENT_URL || "http://localhost:8080";
|
||||||
|
const user = process.env.MATRIX_USER;
|
||||||
|
const password = process.env.MATRIX_PASSWORD;
|
||||||
|
const recoveryKey = process.env.MATRIX_RECOVERY_KEY;
|
||||||
|
|
||||||
|
if (!user || !password || !recoveryKey) {
|
||||||
|
throw new Error(
|
||||||
|
"Faltan variables de entorno: MATRIX_USER, MATRIX_PASSWORD, MATRIX_RECOVERY_KEY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[global-setup] Ejecutando login en Element Web...");
|
||||||
|
|
||||||
|
// Asegurar que el directorio .auth existe
|
||||||
|
fs.mkdirSync(AUTH_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginToElement(page, {
|
||||||
|
url: elementURL,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
recoveryKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.storageState({ path: STATE_PATH });
|
||||||
|
console.log("[global-setup] Sesion guardada en", STATE_PATH);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verifica si el archivo de estado existe y tiene menos de maxAge ms. */
|
||||||
|
function isStateFresh(filePath: string, maxAge: number): boolean {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
return Date.now() - stat.mtimeMs < maxAge;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default globalSetup;
|
||||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
|||||||
screenshot: "only-on-failure",
|
screenshot: "only-on-failure",
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
actionTimeout: 30_000,
|
actionTimeout: 30_000,
|
||||||
|
storageState: path.resolve(__dirname, ".auth/state.json"),
|
||||||
},
|
},
|
||||||
|
|
||||||
globalSetup: "./global-setup.ts",
|
globalSetup: "./global-setup.ts",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { assertNoDecryptionErrors } from "../fixtures/matrix-room";
|
||||||
|
|
||||||
|
test.describe("Login y sesion E2EE", () => {
|
||||||
|
test("storageState cargado — rooms visibles en sidebar", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Si la sesion esta cacheada, Element debe mostrar rooms directamente
|
||||||
|
await expect(
|
||||||
|
page.locator('[role="tree"][aria-label="Rooms"]')
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Abrir el primer room visible para verificar mensajes
|
||||||
|
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("/");
|
||||||
|
|
||||||
|
// 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