feat: import agents_and_robots platform as unibots (Matrix-out, unibus transport)

Reemplaza el scaffold del echobot por la plataforma completa de bots traida
desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out:
los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms +
E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client).

- go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths
  relativos reajustados a la nueva ubicacion dentro de fn_registry).
- app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales.
- modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports).

agents_and_robots queda archivado como museo de la era Matrix.
This commit is contained in:
agent
2026-06-07 11:50:13 +02:00
parent bb5b0e09b1
commit fc644ecd6e
308 changed files with 38829 additions and 474 deletions
+5
View File
@@ -0,0 +1,5 @@
ELEMENT_URL=http://localhost:8090
MATRIX_HOMESERVER=https://matrix-af2f3d.organic-machine.com
MATRIX_USER=@test-user:matrix-af2f3d.organic-machine.com
MATRIX_PASSWORD=
MATRIX_RECOVERY_KEY=
+130
View File
@@ -0,0 +1,130 @@
# E2E Tests — agents_and_robots
Tests end-to-end con Playwright para verificar que los agentes Matrix responden correctamente via Element Web.
## Requisitos
- Node.js v18+
- Agentes corriendo contra el homeserver (`./dev-scripts/server/start.sh`)
- Credenciales de un usuario de test en el homeserver
## Instalacion
```bash
./dev-scripts/e2e/install.sh
```
Esto instala dependencias npm y Chromium para Playwright.
## Configuracion
```bash
cp e2e/.env.example e2e/.env
```
Editar `e2e/.env` con las credenciales del usuario de test:
| Variable | Descripcion |
|----------|-------------|
| `ELEMENT_URL` | URL de Element Web local (default: `http://localhost:8090`) |
| `MATRIX_HOMESERVER` | URL del homeserver Matrix |
| `MATRIX_USER` | MXID del usuario de test (`@user:server`) |
| `MATRIX_PASSWORD` | Password del usuario de test |
| `MATRIX_RECOVERY_KEY` | Recovery key para cross-signing/E2EE |
## Ejecucion
```bash
# Ejecutar todos los tests (headless)
./dev-scripts/e2e/run.sh
# Con browser visible (requiere DISPLAY)
./dev-scripts/e2e/run.sh --headed
# Ejecutar un spec especifico
./dev-scripts/e2e/run.sh assistant-bot
# Directamente con Playwright
cd e2e && npx playwright test
cd e2e && npx playwright test --headed
cd e2e && npx playwright test assistant-bot.spec.ts
```
El script `run.sh` se encarga de:
1. Verificar que los agentes estan corriendo
2. Levantar Element Web si no esta activo
3. Ejecutar los tests
4. Generar reporte en caso de fallos
5. Teardown de Element Web (si lo levanto)
## Estructura
```
e2e/
├── package.json dependencias (Playwright, dotenv)
├── playwright.config.ts configuracion de Playwright
├── global-setup.ts login unico antes de todos los tests
├── .env.example template de credenciales
├── fixtures/
│ ├── element-auth.ts login y verificacion E2EE
│ └── matrix-room.ts helpers: goToRoom, sendMessage, waitForBotReply
├── tests/
│ ├── login.spec.ts smoke test: sesion y E2EE
│ ├── assistant-bot.spec.ts tests del assistant-bot
│ └── asistente-2.spec.ts tests del asistente-2 (con tools)
├── scripts/
│ └── setup-element.sh descarga y sirve Element Web local
└── element-web/ Element Web descargado (gitignored)
dev-scripts/e2e/
├── install.sh instalacion de dependencias
└── run.sh orquestacion completa de tests
```
## Debug de fallos
### Screenshots
Cuando un test falla, Playwright captura screenshot automaticamente en `e2e/test-results/`. Revisarlos para entender el estado de la UI al momento del fallo.
### Reporte HTML
Si hay fallos, `run.sh` genera un reporte HTML:
```bash
cd e2e && npx playwright show-report
```
### Modo headed
Para ver el browser en tiempo real (requiere entorno grafico):
```bash
./dev-scripts/e2e/run.sh --headed
```
### Traces
En el primer retry, Playwright captura un trace completo. Verlo con:
```bash
cd e2e && npx playwright show-trace test-results/<test-name>/trace.zip
```
### Login cacheado
El global-setup cachea la sesion autenticada en `e2e/.auth/state.json` por 12 horas. Si hay problemas de autenticacion:
```bash
rm -rf e2e/.auth/
```
Y re-ejecutar los tests para forzar login fresco.
## Notas de diseno
- **Assertions flexibles para LLM**: las respuestas de los bots son no-deterministicas. Solo se verifica que responde, que no esta vacio, y longitud razonable.
- **Commands con assertions estrictas**: `!help` y `!ping` tienen respuestas deterministicas y se validan con mayor precision.
- **Tests secuenciales**: `fullyParallel: false` y `workers: 1` para evitar race conditions en el timeline de Matrix.
- **Timeouts generosos**: 60s por test, 30s para expect. Los LLMs pueden tardar 5-20s en responder.
- **Retry en CI**: 1 retry en CI para manejar timeouts ocasionales.
+286
View File
@@ -0,0 +1,286 @@
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}`);
}
/**
* 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. 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
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);
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"], .mx_RoomList, .mx_LeftPanel_roomListContainer, .mx_RoomTile').first();
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
console.log("[login] Esperando prompt de cross-signing...");
await handleCrossSigning(page, opts.recoveryKey, ssDir);
// Verificar login exitoso: rooms visibles en el sidebar
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"], .mx_RoomList, .mx_LeftPanel_roomListContainer, .mx_RoomTile').first();
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";
}
/**
* 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,
ssDir?: 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) {
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)
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");
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Page } from "@playwright/test";
/**
* Cierra todos los toasts/notificaciones de Element que bloquean clicks.
* Incluye: Notifications, Threads Activity Centre, y cualquier toast generico.
*/
export async function dismissAllToasts(page: Page) {
// Dar un momento para que los toasts aparezcan
await page.waitForTimeout(1_500);
// Estrategia directa: buscar botones conocidos de toasts de Element
const knownDismissButtons = [
page.getByRole("button", { name: "Dismiss" }),
page.locator("button").filter({ hasText: /^OK$/ }),
page.getByRole("button", { name: "Close" }),
page.getByRole("button", { name: "Not now" }),
page.getByRole("button", { name: "Got it" }),
page.getByRole("button", { name: "Skip" }),
];
for (const btn of knownDismissButtons) {
try {
if (await btn.first().isVisible()) {
const text = await btn.first().textContent().catch(() => "?");
console.log(`[element] Dismissing toast: clicking "${text}"`);
await btn.first().click({ force: true });
await page.waitForTimeout(500);
}
} catch {
// Ignorar errores — el boton pudo desaparecer entre check y click
}
}
// Segunda pasada: verificar si queda algun toast con boton visible
const remainingToastBtns = page.locator(
'.mx_ToastContainer button, .mx_Toast_buttons button'
);
const remaining = await remainingToastBtns.count();
if (remaining > 0) {
for (let i = 0; i < remaining; i++) {
try {
if (await remainingToastBtns.nth(i).isVisible()) {
const text = await remainingToastBtns.nth(i).textContent();
console.log(`[element] Closing remaining toast button: "${text}"`);
await remainingToastBtns.nth(i).click({ force: true });
await page.waitForTimeout(300);
}
} catch {
// Ignorar
}
}
}
}
+763
View File
@@ -0,0 +1,763 @@
import { Page, expect } from "@playwright/test";
/**
* Cierra toasts de Element que bloquean clicks.
* Duplicado aqui para evitar imports circulares con persistent-context.ts.
*/
async function dismissToasts(page: Page) {
await page.waitForTimeout(1_000);
const buttons = [
page.getByRole("button", { name: "Dismiss" }),
page.locator("button").filter({ hasText: /^OK$/ }),
page.getByRole("button", { name: "Close" }),
page.getByRole("button", { name: "Not now" }),
page.getByRole("button", { name: "Got it" }),
];
for (const btn of buttons) {
try {
if (await btn.first().isVisible()) {
await btn.first().click({ force: true });
await page.waitForTimeout(300);
}
} catch { /* ignore */ }
}
}
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.
* Primero intenta click directo en el sidebar, luego usa el buscador.
*/
export async function goToRoom(page: Page, roomName: string) {
console.log(`[goToRoom] Buscando room: "${roomName}"`);
// Estrategia 1: Click directo en el room del sidebar (mas robusto)
const sidebarRoom = page.locator(
`.mx_RoomTile, [role="treeitem"]`
).filter({ hasText: new RegExp(roomName, "i") }).first();
const directMatch = await sidebarRoom
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (directMatch) {
console.log(`[goToRoom] Room "${roomName}" encontrado en sidebar, click directo`);
await sidebarRoom.click();
await waitForRoomLoaded(page, roomName);
return;
}
// Estrategia 2: Usar busqueda (Ctrl+K es mas confiable que click en Search)
console.log("[goToRoom] Room no visible en sidebar, usando busqueda...");
await page.keyboard.press("Control+k");
// Esperar a que aparezca el dialog de busqueda
const searchInput = page.locator(
'[role="searchbox"], input[type="search"], .mx_SpotlightDialog input'
).first();
const hasSearch = await searchInput
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (!hasSearch) {
// Fallback: click en el boton de Search con force para evitar toasts
console.log("[goToRoom] Ctrl+K no abrio busqueda, intentando click en Search...");
const searchButton = page.locator('[aria-label="Search"]').first();
await searchButton.click({ force: true });
await searchInput.waitFor({ state: "visible", timeout: 5_000 });
}
await searchInput.fill(roomName);
console.log(`[goToRoom] Texto ingresado: "${roomName}"`);
// Seleccionar el room de los resultados
const roomResult = page.locator(
'[role="option"], .mx_SpotlightDialog_result'
).filter({ hasText: 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,
});
// Cerrar el dialog de busqueda
await page.keyboard.press("Escape");
throw new Error(`Room "${roomName}" no encontrado en busqueda`);
}
console.log(`[goToRoom] Seleccionando room "${roomName}"`);
await roomResult.click();
await waitForRoomLoaded(page, roomName);
}
/**
* Espera a que un room termine de cargar (header visible + composer listo).
*/
async function waitForRoomLoaded(page: Page, roomName: string) {
// Esperar header del room o composer — ambos indican que el room cargo
const roomHeader = page.locator(
`[data-testid="room-header-name"], .mx_RoomHeader_heading, h2`
).filter({ hasText: new RegExp(roomName, "i") }).first();
const headerVisible = await roomHeader
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (!headerVisible) {
// Fallback: verificar que al menos el composer esta visible
const composer = page.getByRole("textbox", { name: /message/i });
await composer.waitFor({ state: "visible", 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}"`);
}
/**
* Cierra el thread panel de Element Web si esta abierto.
*/
export async function closeThreadPanel(page: Page) {
const closeBtn = page.locator(
".mx_BaseCard_close, [data-testid='base-card-close-button'], .mx_RightPanel_closeButton"
).first();
try {
if (await closeBtn.isVisible({ timeout: 1_000 })) {
await closeBtn.click({ force: true });
console.log("[closeThreadPanel] Thread panel cerrado");
await page.waitForTimeout(300);
}
} catch { /* panel ya estaba cerrado */ }
}
/**
* Obtiene el room ID del room actual desde la URL + SDK.
* Resuelve aliases si la URL contiene uno.
*/
async function getCurrentRoomId(page: Page): Promise<string | null> {
return page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = (window as any).mxMatrixClientPeg?.get?.();
if (!client) return null;
const hash = window.location.hash;
const match = hash.match(/#\/room\/([^?/]+)/);
if (!match) return null;
const roomIdOrAlias = decodeURIComponent(match[1]);
if (roomIdOrAlias.startsWith("!")) return roomIdOrAlias;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias);
return resolved?.room_id || null;
});
}
/**
* Espera una respuesta del bot en el main timeline usando el Matrix SDK.
* Rastrea el ultimo event ID ANTES de llamar esta funcion para solo detectar
* mensajes NUEVOS, evitando falsos positivos de mensajes historicos.
*
* Usa SDK en lugar de locators DOM porque los thread summaries en la main
* timeline inyectan sender elements adicionales que confunden la deteccion.
*/
export async function waitForBotReply(
page: Page,
options?: WaitForReplyOptions
): Promise<string> {
const timeout = options?.timeout ?? 30_000;
const senderFilter = options?.sender;
console.log(
`[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...`
);
// Capturar el ultimo event ID ANTES de que el bot pueda responder.
// Solo detectaremos mensajes que lleguen DESPUES de este punto.
const startEventId = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = (window as any).mxMatrixClientPeg?.get?.();
if (!client) return null;
const hash = window.location.hash;
const match = hash.match(/#\/room\/([^?/]+)/);
if (!match) return null;
const roomIdOrAlias = decodeURIComponent(match[1]);
let roomId = roomIdOrAlias;
if (!roomId.startsWith("!")) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias);
roomId = resolved?.room_id;
}
if (!roomId) return null;
const room = client.getRoom(roomId);
if (!room) return null;
const events = room.getLiveTimeline().getEvents();
return events.length > 0
? (events[events.length - 1] as { getId: () => string }).getId()
: null;
});
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
// Detectar E2EE errors en el timeline visible
const undecryptable = page.locator(
'.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]'
);
if ((await undecryptable.count()) > 0) {
console.error("[waitForBotReply] E2EE ERROR: mensajes sin descifrar");
await page.screenshot({
path: `test-results/ERROR-e2ee-${Date.now()}.png`,
fullPage: true,
});
throw new Error(
"E2EE error: se detectaron mensajes 'Unable to decrypt'. " +
"Verificar que cross-signing esta configurado correctamente " +
"y que la recovery key es valida."
);
}
const reply = await page.evaluate(({ senderFilter, startEventId }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = (window as any).mxMatrixClientPeg?.get?.();
if (!client) return null;
const hash = window.location.hash;
const match = hash.match(/#\/room\/([^?/]+)/);
if (!match) return null;
const roomIdOrAlias = decodeURIComponent(match[1]);
// Only synchronous room lookup here — getRoomIdForAlias is async but evaluate
// can be async too. Using direct room lookup for speed.
const rooms = client.getRooms();
const room = roomIdOrAlias.startsWith("!")
? client.getRoom(roomIdOrAlias)
: rooms.find((r: { roomId: string; getCanonicalAlias: () => string | null }) =>
r.getCanonicalAlias() === roomIdOrAlias ||
r.roomId === roomIdOrAlias
);
if (!room) return null;
const events: Array<{
getId: () => string;
getType: () => string;
getSender: () => string;
getContent: () => Record<string, unknown>;
}> = room.getLiveTimeline().getEvents();
// Encontrar la posicion del startEventId
const startIdx = startEventId
? events.findIndex((e) => e.getId() === startEventId)
: -1;
// Solo eventos NUEVOS (posteriores al startEventId)
const newEvents = events.slice(startIdx + 1);
for (let i = newEvents.length - 1; i >= 0; i--) {
const evt = newEvents[i];
if (evt.getType() !== "m.room.message") continue;
const content = evt.getContent() as {
body?: string;
"m.relates_to"?: { rel_type?: string };
msgtype?: string;
};
// Ignorar thread replies — solo mensajes de la main timeline
if (content["m.relates_to"]?.rel_type === "m.thread") continue;
const sender = evt.getSender();
if (senderFilter) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const member = (room as any).getMember(sender);
const displayName = member?.name || sender;
if (!displayName.includes(senderFilter)) continue;
}
return content.body || "";
}
return null;
}, { senderFilter: senderFilter ?? null, startEventId });
if (reply) {
console.log(
`[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms): "${reply.substring(0, 60)}..."`
);
return reply;
}
await page.waitForTimeout(500);
}
console.error(`[waitForBotReply] TIMEOUT despues de ${timeout}ms`);
await page.screenshot({
path: `test-results/ERROR-timeout-waitForBotReply-${Date.now()}.png`,
fullPage: true,
});
throw new Error(
`Timeout (${timeout}ms): no se recibio respuesta del bot` +
(senderFilter ? ` (sender esperado: ${senderFilter})` : "")
);
}
/**
* Obtiene el texto del ultimo mensaje visible en el timeline.
*/
export async function getLastMessage(page: Page): Promise<string | null> {
const messages = page.locator(".mx_EventTile_body, .mx_MTextBody");
const count = await messages.count();
if (count === 0) return null;
return messages.last().textContent();
}
/**
* Obtiene el display name del sender del ultimo mensaje.
*/
async function getLastMessageSender(page: Page): Promise<string | null> {
const senders = page.locator(
".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name"
);
const count = await senders.count();
if (count === 0) return null;
return senders.last().textContent();
}
/**
* Inicia un thread sobre el ultimo mensaje del timeline via UI.
*
* Flujo:
* 1. Right-click en el ultimo EventTile del main timeline
* 2. Click en "Reply in Thread" del context menu
* 3. Esperar a que aparezca el thread panel (panel derecho)
* 4. Escribir un mensaje en el composer del thread panel
* 5. Enviar con Enter
*
* Fallback SDK: si el context menu no aparece (headless), envia via SDK.
*/
export async function startThreadOnLastMessage(page: Page) {
console.log("[startThread] Dismissing toasts...");
await dismissToasts(page);
// Localizar el ultimo EventTile en el main timeline (no en thread panel)
const mainTimeline = page.locator(".mx_RoomView_body");
const eventTiles = mainTimeline.locator(".mx_EventTile").filter({
has: page.locator(".mx_EventTile_body, .mx_MTextBody"),
});
const tileCount = await eventTiles.count();
if (tileCount === 0) throw new Error("[startThread] No hay EventTiles en el timeline");
const lastTile = eventTiles.last();
console.log(`[startThread] Right-click en ultimo EventTile (${tileCount} tiles)`);
// Scroll hasta el ultimo tile para asegurarnos de que es visible
await lastTile.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
// Right-click para abrir el context menu
await lastTile.click({ button: "right", force: true });
console.log("[startThread] Context menu abierto via right-click");
// Esperar a que aparezca el context menu
const contextMenu = page.locator(
".mx_ContextualMenu, .mx_IconizedContextMenu, [role='menu']"
);
const menuVisible = await contextMenu
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (menuVisible) {
console.log("[startThread] Context menu visible, buscando 'Reply in Thread'...");
// Buscar la opcion "Reply in Thread" (puede variar por idioma)
const threadOption = page.locator(
"[role='menuitem'], .mx_IconizedContextMenu_option, .mx_ContextualMenu_item"
).filter({
hasText: /reply in thread|thread|responder en hilo/i,
}).first();
const optionVisible = await threadOption
.waitFor({ state: "visible", timeout: 3_000 })
.then(() => true)
.catch(() => false);
if (optionVisible) {
await threadOption.click({ force: true });
console.log("[startThread] Click en 'Reply in Thread'");
// Esperar a que aparezca el thread panel en el lado derecho
const threadPanel = page.locator(
".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard"
);
await threadPanel.waitFor({ state: "visible", timeout: 10_000 });
console.log("[startThread] Thread panel visible");
// Escribir en el composer del thread panel
const threadComposer = threadPanel.getByRole("textbox", { name: /message/i });
await threadComposer.waitFor({ state: "visible", timeout: 5_000 });
await threadComposer.fill("Hola desde el thread, respondeme aqui por favor");
await threadComposer.press("Enter");
console.log("[startThread] Mensaje enviado via UI en el thread panel");
return;
}
// Context menu abierto pero sin opcion de thread — cerrar y usar fallback
console.warn("[startThread] Opcion 'Reply in Thread' no encontrada en context menu");
await page.keyboard.press("Escape");
await page.waitForTimeout(300);
} else {
console.warn("[startThread] Context menu no aparecio, usando fallback SDK");
}
// --- Fallback SDK (si la UI no funciono en headless) ---
console.log("[startThread] Fallback: enviando mensaje threaded via SDK");
const threadInfo = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = (window as any).mxMatrixClientPeg?.get?.();
if (!client) throw new Error("Matrix client no disponible en window");
const hash = window.location.hash;
const match = hash.match(/#\/room\/([^?/]+)/);
if (!match) throw new Error(`No se pudo obtener room ID de la URL: ${hash}`);
const roomIdOrAlias = decodeURIComponent(match[1]);
let roomId: string;
if (roomIdOrAlias.startsWith("!")) {
roomId = roomIdOrAlias;
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias);
if (!resolved?.room_id) throw new Error(`No se pudo resolver alias: ${roomIdOrAlias}`);
roomId = resolved.room_id;
}
const room = client.getRoom(roomId);
if (!room) throw new Error("Room no encontrado");
const timeline = room.getLiveTimeline().getEvents();
const lastMsgEvent = [...timeline].reverse().find(
(e: { getType: () => string }) => e.getType() === "m.room.message"
);
if (!lastMsgEvent) throw new Error("No hay mensajes en el timeline");
return { roomId, eventId: lastMsgEvent.getId() };
});
await page.evaluate(async ({ roomId, eventId }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = (window as any).mxMatrixClientPeg.get();
await client.sendMessage(roomId, {
msgtype: "m.text",
body: "Hola desde el thread, respondeme aqui por favor",
"m.relates_to": {
rel_type: "m.thread",
event_id: eventId,
is_falling_back: true,
"m.in_reply_to": { event_id: eventId },
},
});
}, threadInfo);
console.log("[startThread] Mensaje threaded enviado via SDK (fallback)");
}
/**
* 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}"`
);
}
}
}
}
/**
* Espera la respuesta del bot en un thread usando el Matrix SDK de Element.
* No depende del panel de thread UI — consulta el timeline directamente.
*/
export async function waitForThreadReplyViaSdk(
page: Page,
options?: WaitForReplyOptions
): Promise<string> {
const timeout = options?.timeout ?? 30_000;
const startTime = Date.now();
const senderFilter = options?.sender;
console.log(
`[waitForThreadReplyViaSdk] Esperando respuesta en thread (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...`
);
while (Date.now() - startTime < timeout) {
const reply = await page.evaluate(({ senderFilter }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const client = (window as any).mxMatrixClientPeg?.get?.();
if (!client) return null;
// Scoped to current room only (via URL) to avoid false positives
const hash = window.location.hash;
const match = hash.match(/#\/room\/([^?/]+)/);
const roomIdOrAlias = match ? decodeURIComponent(match[1]) : null;
const rooms = client.getRooms().filter(
(r: { getMyMembership: () => string; roomId: string }) => {
if (r.getMyMembership() !== "join") return false;
if (roomIdOrAlias) {
return r.roomId === roomIdOrAlias ||
r.roomId === roomIdOrAlias; // alias resolution handled below
}
return true;
}
);
for (const room of rooms) {
// Skip rooms that don't match the current URL room
if (roomIdOrAlias && !roomIdOrAlias.startsWith("!")) {
// For aliases, check if the room has this alias
const aliases = room.getAltAliases?.() || [];
const canonicalAlias = room.getCanonicalAlias?.();
if (canonicalAlias !== roomIdOrAlias && !aliases.includes(roomIdOrAlias)) {
continue;
}
}
const timeline = room.getLiveTimeline().getEvents();
// Buscar eventos que sean respuestas de thread (m.relates_to.rel_type === "m.thread")
const threadReplies = timeline.filter((e: {
getType: () => string;
getContent: () => { "m.relates_to"?: { rel_type?: string } };
getSender: () => string;
}) => {
if (e.getType() !== "m.room.message") return false;
const content = e.getContent();
const relatesTo = content["m.relates_to"];
if (!relatesTo || relatesTo.rel_type !== "m.thread") return false;
// Filtrar por sender si se especifico
if (senderFilter) {
const sender = e.getSender();
// Verificar por display name
const member = room.getMember(sender);
const displayName = member?.name || sender;
if (!displayName.includes(senderFilter)) return false;
}
return true;
});
if (threadReplies.length > 0) {
const lastReply = threadReplies[threadReplies.length - 1];
const content = lastReply.getContent();
return content.body || content.formatted_body || "";
}
}
return null;
}, { senderFilter });
if (reply) {
console.log(
`[waitForThreadReplyViaSdk] Respuesta encontrada (${Date.now() - startTime}ms): "${reply.substring(0, 80)}..."`
);
return reply;
}
await page.waitForTimeout(1_000);
}
throw new Error(
`Timeout (${timeout}ms): el bot no respondio en el thread` +
(senderFilter ? ` (sender: ${senderFilter})` : "")
);
}
/**
* 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."
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import { test as base, chromium, BrowserContext, Page } from "@playwright/test";
import * as path from "path";
import { dismissAllToasts } from "./element-utils";
/**
* 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 y toasts de Element que bloquean la carga:
* - "Element is open in another window" → click Continue
* - "Missing session data" → error informativo
* - "Notifications" toast → click Dismiss
* - "Threads Activity Centre" toast → click OK
* - Cualquier otro toast → intentar cerrarlo
*
* 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 la sidebar aparezca (sesion cargada)
// Usamos multiples locators porque Element Web cambia la estructura entre versiones
console.log("[element] Esperando sidebar con rooms...");
const sidebarLocators = [
page.locator('[role="tree"][aria-label="Rooms"]'),
page.locator(".mx_RoomList"),
page.locator(".mx_LeftPanel_roomListContainer"),
page.locator('[role="treeitem"]'),
// Rooms visibles como items en el sidebar
page.locator(".mx_RoomTile"),
];
let sidebarFound = false;
for (const locator of sidebarLocators) {
const visible = await locator.first()
.waitFor({ state: "visible", timeout: 30_000 })
.then(() => true)
.catch(() => false);
if (visible) {
console.log("[element] Sidebar visible");
sidebarFound = true;
break;
}
}
if (!sidebarFound) {
// Verificar si estamos en la pagina de login
const onLoginPage = await page.locator('text="Welcome to Element!"').isVisible().catch(() => false)
|| await page.getByRole("link", { name: "Sign in" }).isVisible().catch(() => false);
if (onLoginPage) {
throw new Error(
"Sesion no cargada: se muestra la pagina de login. " +
"Borrar .auth/ y re-ejecutar: rm -rf e2e/.auth && ./dev-scripts/e2e/run.sh"
);
}
await page.screenshot({
path: "test-results/ERROR-no-sidebar.png",
fullPage: true,
});
throw new Error("Sidebar de rooms no encontrado despues de 30s");
}
// 4. Cerrar TODOS los toasts que bloquean interacciones
await dismissAllToasts(page);
}
export { dismissAllToasts } from "./element-utils";
export { expect } from "@playwright/test";
+119
View File
@@ -0,0 +1,119 @@
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 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 usando persistent context.
*
* 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";
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 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 con persistent context...");
console.log(`[global-setup] URL: ${elementURL}`);
console.log(`[global-setup] User: ${user}`);
console.log(`[global-setup] UserDataDir: ${USER_DATA_DIR}`);
// 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");
}
// 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, {
url: elementURL,
user,
password,
recoveryKey,
screenshotsDir: SCREENSHOTS_DIR,
});
// 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 context.close();
}
}
/** 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;
} catch {
return false;
}
}
export default globalSetup;
+92
View File
@@ -0,0 +1,92 @@
{
"name": "agents-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agents-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.50.0",
"dotenv": "^16.4.7"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "agents-e2e",
"version": "1.0.0",
"private": true,
"description": "E2E tests for agents_and_robots via Playwright + Element Web",
"scripts": {
"test": "npx playwright test",
"test:headed": "npx playwright test --headed",
"test:debug": "npx playwright test --debug"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"dotenv": "^16.4.7"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig, devices } from "@playwright/test";
import * as dotenv from "dotenv";
import * as path from "path";
dotenv.config({ path: path.resolve(__dirname, ".env") });
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: "list",
// LLMs son lentos — timeouts generosos
timeout: 60_000,
expect: { timeout: 30_000 },
use: {
baseURL: process.env.ELEMENT_URL || "http://localhost:8080",
headless: true,
screenshot: "on",
trace: "retain-on-failure",
video: "retain-on-failure",
actionTimeout: 30_000,
// NO usamos storageState — usamos persistent context para preservar IndexedDB
},
outputDir: "./test-results",
globalSetup: "./global-setup.ts",
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# setup-element.sh — descargar y servir Element Web localmente
set -euo pipefail
ELEMENT_VERSION="v1.11.92"
ELEMENT_DIR="$(cd "$(dirname "$0")/.." && pwd)/element-web"
PORT="${ELEMENT_PORT:-8090}"
PIDFILE="$ELEMENT_DIR/.server.pid"
HOMESERVER="${MATRIX_HOMESERVER:-https://matrix-af2f3d.organic-machine.com}"
SERVER_NAME="${MATRIX_SERVER_NAME:-matrix-af2f3d.organic-machine.com}"
usage() {
echo "Uso: $0 {start|stop|status}"
echo ""
echo " start Descargar Element Web (si falta) y servir en puerto $PORT"
echo " stop Detener el servidor local"
echo " status Verificar si el servidor esta corriendo"
exit 1
}
download_element() {
if [ -d "$ELEMENT_DIR" ] && [ -f "$ELEMENT_DIR/index.html" ]; then
echo "Element Web ya descargado en $ELEMENT_DIR"
return 0
fi
local tarball="element-${ELEMENT_VERSION}.tar.gz"
local url="https://github.com/element-hq/element-web/releases/download/${ELEMENT_VERSION}/element-${ELEMENT_VERSION}.tar.gz"
echo "Descargando Element Web ${ELEMENT_VERSION}..."
mkdir -p "$ELEMENT_DIR"
curl -fSL "$url" -o "/tmp/$tarball"
tar xzf "/tmp/$tarball" --strip-components=1 -C "$ELEMENT_DIR"
rm -f "/tmp/$tarball"
echo "Generando config.json para homeserver $HOMESERVER..."
cat > "$ELEMENT_DIR/config.json" <<CONF
{
"default_server_config": {
"m.homeserver": {
"base_url": "$HOMESERVER",
"server_name": "$SERVER_NAME"
}
},
"brand": "Element",
"disable_guests": true,
"disable_3pid_login": true
}
CONF
echo "Element Web ${ELEMENT_VERSION} listo en $ELEMENT_DIR"
}
start_server() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Element Web ya corriendo (PID $(cat "$PIDFILE")) en http://localhost:$PORT"
return 0
fi
download_element
echo "Iniciando servidor en http://localhost:$PORT ..."
if command -v python3 &>/dev/null; then
(cd "$ELEMENT_DIR" && python3 -m http.server "$PORT" --bind 0.0.0.0) &>/dev/null &
elif command -v npx &>/dev/null; then
npx --yes serve -s "$ELEMENT_DIR" -l "$PORT" &>/dev/null &
else
echo "Error: necesitas python3 o npx (Node.js) para servir archivos"
exit 1
fi
echo $! > "$PIDFILE"
# Esperar a que el servidor arranque
for i in 1 2 3 4 5; do
if curl -sf "http://localhost:$PORT/" >/dev/null 2>&1; then
echo "Element Web serving en http://localhost:$PORT (PID $!)"
return 0
fi
sleep 1
done
echo "WARN: servidor iniciado (PID $!) pero no responde aun en http://localhost:$PORT"
}
stop_server() {
if [ ! -f "$PIDFILE" ]; then
echo "No hay servidor corriendo (no se encontro pidfile)"
return 0
fi
local pid
pid=$(cat "$PIDFILE")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
echo "Servidor detenido (PID $pid)"
else
echo "Proceso $pid ya no existe"
fi
rm -f "$PIDFILE"
}
server_status() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Element Web corriendo (PID $(cat "$PIDFILE")) en http://localhost:$PORT"
else
echo "Element Web no esta corriendo"
[ -f "$PIDFILE" ] && rm -f "$PIDFILE"
fi
}
case "${1:-}" in
start) start_server ;;
stop) stop_server ;;
status) server_status ;;
*) usage ;;
esac
+100
View File
@@ -0,0 +1,100 @@
import { test, expect, handleElementDialogs } from "../fixtures/persistent-context";
import {
goToRoom,
sendMessage,
waitForBotReply,
assertNoDecryptionErrors,
startThreadOnLastMessage,
waitForThreadReplyViaSdk,
closeThreadPanel,
} from "../fixtures/matrix-room";
test.describe("asistente-2", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await handleElementDialogs(page);
await goToRoom(page, "Asistente 2");
// Cerrar thread panel si estaba abierto de sesiones previas.
// Si queda abierto, sus sender elements contaminan los locators de waitForBotReply.
await closeThreadPanel(page);
});
test("responde a un saludo", async ({ page }) => {
await sendMessage(page, "Hola, que tal?");
const reply = await waitForBotReply(page, {
timeout: 60_000,
sender: "Asistente 2",
});
expect(reply).toBeTruthy();
expect(reply.length).toBeGreaterThan(10);
});
test("!tools muestra herramientas disponibles", async ({ page }) => {
await sendMessage(page, "!tools");
const reply = await waitForBotReply(page, {
timeout: 10_000,
sender: "Asistente 2",
});
expect(reply).toBeTruthy();
// asistente-2 tiene al menos current_time
expect(reply.toLowerCase()).toMatch(/current_time|hora|herramienta|tool/);
});
test("pregunta que activa tool use (que hora es?)", async ({ page }) => {
await sendMessage(page, "Que hora es ahora mismo?");
const reply = await waitForBotReply(page, {
timeout: 60_000,
sender: "Asistente 2",
});
expect(reply).toBeTruthy();
// La respuesta debe contener algo relacionado con tiempo/hora
expect(reply.length).toBeGreaterThan(5);
});
test("!help muestra comandos", async ({ page }) => {
await sendMessage(page, "!help");
const reply = await waitForBotReply(page, {
timeout: 10_000,
sender: "Asistente 2",
});
expect(reply).toBeTruthy();
expect(reply.toLowerCase()).toContain("help");
expect(reply.toLowerCase()).toContain("ping");
});
test("responde dentro del thread cuando se le habla por thread", async ({
page,
}) => {
// Este test necesita mas tiempo: enviar msg + esperar bot + thread + esperar bot en thread
test.setTimeout(120_000);
// 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. Enviar mensaje threaded via SDK (headless no soporta la hover action bar)
await startThreadOnLastMessage(page);
// 3. Esperar que el bot responda DENTRO del thread
// Usar el SDK para verificar que hay una respuesta en el thread
const threadReply = await waitForThreadReplyViaSdk(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);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { test, expect, handleElementDialogs } from "../fixtures/persistent-context";
import {
goToRoom,
sendMessage,
waitForBotReply,
assertNoDecryptionErrors,
} from "../fixtures/matrix-room";
test.describe("assistant-bot", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await handleElementDialogs(page);
await goToRoom(page, "Assistant");
});
test("responde a un saludo en DM", async ({ page }) => {
await sendMessage(page, "Hola, como estas?");
const reply = await waitForBotReply(page, {
timeout: 60_000,
sender: "Assistant",
});
expect(reply).toBeTruthy();
expect(reply.length).toBeGreaterThan(10);
});
test("responde a una pregunta con contenido coherente", async ({ page }) => {
await sendMessage(page, "Que es la fotosintesis? Responde en una frase.");
const reply = await waitForBotReply(page, {
timeout: 60_000,
sender: "Assistant",
});
expect(reply).toBeTruthy();
expect(reply.length).toBeGreaterThan(10);
});
test("!help muestra lista de comandos", async ({ page }) => {
await sendMessage(page, "!help");
const reply = await waitForBotReply(page, {
timeout: 10_000,
sender: "Assistant",
});
expect(reply).toBeTruthy();
expect(reply.toLowerCase()).toContain("help");
expect(reply.toLowerCase()).toContain("ping");
});
test("!ping responde", async ({ page }) => {
await sendMessage(page, "!ping");
const reply = await waitForBotReply(page, {
timeout: 10_000,
sender: "Assistant",
});
expect(reply).toBeTruthy();
});
test("no hay errores de E2EE en el timeline", async ({ page }) => {
await assertNoDecryptionErrors(page);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { test, expect, handleElementDialogs } from "../fixtures/persistent-context";
import { assertNoDecryptionErrors } from "../fixtures/matrix-room";
test.describe("Login y sesion E2EE", () => {
test("sesion cargada — rooms visibles en sidebar", async ({ page }) => {
await page.goto("/");
await handleElementDialogs(page);
// Si llegamos aqui, handleElementDialogs ya verifico rooms sidebar
const rooms = page.locator('[role="treeitem"], .mx_RoomTile');
const roomCount = await rooms.count();
expect(roomCount).toBeGreaterThan(0);
});
test("no hay mensajes Unable to decrypt en rooms recientes", async ({
page,
}) => {
await page.goto("/");
await handleElementDialogs(page);
// Abrir el primer room visible para verificar mensajes
const firstRoom = page.locator('[role="treeitem"], .mx_RoomTile').first();
const roomCount = await firstRoom.count();
if (roomCount > 0) {
await firstRoom.click();
await page.waitForTimeout(3_000);
await assertNoDecryptionErrors(page);
}
});
test("helpers de room navegan correctamente", async ({ page }) => {
await page.goto("/");
await handleElementDialogs(page);
const rooms = page.locator('[role="treeitem"], .mx_RoomTile');
const roomCount = await rooms.count();
expect(roomCount).toBeGreaterThan(0);
});
});