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:
@@ -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
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
Generated
+92
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Executable
+116
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user