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
+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";