Files
agents_and_robots/e2e/fixtures/matrix-room.ts
T
egutierrez 80014e8fb8 fix(e2e): obtener room ID desde URL en startThreadOnLastMessage y waitForThreadReplyViaSdk
Corrige dos bugs en los fixtures de Playwright:

1. startThreadOnLastMessage usaba client.getRooms()[0] (el primer room unido) en lugar del room activo, causando que el mensaje threaded se enviara al room equivocado. Ahora lee el room ID de window.location.hash (#/room/!xxx:server), con resolucion de alias si la URL contiene un alias en lugar de un ID.

2. waitForThreadReplyViaSdk iteraba todos los rooms unidos, lo que podia devolver falsos positivos de otros rooms. Ahora esta acotado al room de la URL actual, con logica de fallback para aliases canonicos y alternativos.
2026-03-08 18:06:05 +00:00

628 lines
21 KiB
TypeScript

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}"`);
}
/**
* Espera una respuesta de un bot en el timeline.
* Detecta "Unable to decrypt" y falla con mensaje claro.
*/
export async function waitForBotReply(
page: Page,
options?: WaitForReplyOptions
): Promise<string> {
const timeout = options?.timeout ?? 30_000;
console.log(
`[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${options?.sender || "any"})...`
);
// Esperar a que aparezca un nuevo mensaje (que no sea del usuario actual)
const startTime = Date.now();
let lastLoggedMsg = "";
let lastLoggedSender = "";
while (Date.now() - startTime < timeout) {
// Detectar mensajes que no se pueden descifrar
const undecryptable = page.locator(
'.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]'
);
const undecryptableCount = await undecryptable.count();
if (undecryptableCount > 0) {
console.error(
`[waitForBotReply] E2EE ERROR: ${undecryptableCount} 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."
);
}
// Buscar el ultimo mensaje en el timeline
const lastMessage = await getLastMessage(page);
if (lastMessage) {
// Si se especifica sender, verificar que coincide
if (options?.sender) {
const lastSender = await getLastMessageSender(page);
// Log periodico para debug (cada mensaje nuevo)
if (lastMessage !== lastLoggedMsg || lastSender !== lastLoggedSender) {
console.log(
`[waitForBotReply] Ultimo msg: "${lastMessage?.substring(0, 80)}..." | sender: "${lastSender}"`
);
lastLoggedMsg = lastMessage;
lastLoggedSender = lastSender || "";
}
if (lastSender && lastSender.includes(options.sender)) {
console.log(
`[waitForBotReply] Respuesta recibida de "${options.sender}" (${Date.now() - startTime}ms)`
);
return lastMessage;
}
} else {
console.log(
`[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms)`
);
return lastMessage;
}
}
await page.waitForTimeout(500);
}
// Timeout — tomar screenshot antes de lanzar error
console.error(
`[waitForBotReply] TIMEOUT despues de ${timeout}ms esperando respuesta`
);
await page.screenshot({
path: `test-results/ERROR-timeout-waitForBotReply-${Date.now()}.png`,
fullPage: true,
});
// Log del estado actual del timeline para debug
const allMessages = page.locator(".mx_EventTile_body, .mx_MTextBody");
const msgCount = await allMessages.count();
console.log(`[waitForBotReply] Mensajes en timeline: ${msgCount}`);
for (let i = Math.max(0, msgCount - 5); i < msgCount; i++) {
const text = await allMessages.nth(i).textContent();
console.log(`[waitForBotReply] [${i}]: "${text?.substring(0, 100)}"`);
}
const allSenders = page.locator(
".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name"
);
const senderCount = await allSenders.count();
console.log(`[waitForBotReply] Senders en timeline: ${senderCount}`);
for (let i = Math.max(0, senderCount - 5); i < senderCount; i++) {
const name = await allSenders.nth(i).textContent();
console.log(`[waitForBotReply] [${i}]: "${name}"`);
}
throw new Error(
`Timeout (${timeout}ms): no se recibio respuesta del bot` +
(options?.sender ? ` (sender esperado: ${options.sender})` : "")
);
}
/**
* Obtiene el texto del ultimo mensaje visible en el timeline.
*/
export async function getLastMessage(page: Page): Promise<string | null> {
const messages = page.locator(".mx_EventTile_body, .mx_MTextBody");
const count = await messages.count();
if (count === 0) return null;
return messages.last().textContent();
}
/**
* Obtiene el display name del sender del ultimo mensaje.
*/
async function getLastMessageSender(page: Page): Promise<string | null> {
const senders = page.locator(
".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name"
);
const count = await senders.count();
if (count === 0) return null;
return senders.last().textContent();
}
/**
* Inicia un thread sobre el ultimo mensaje del timeline.
*
* En headless Chromium, la hover action bar de Element Web no se renderiza
* (es React onMouseEnter, no CSS :hover). Usamos el Matrix SDK expuesto
* en window para enviar un mensaje threaded directamente, luego abrimos
* el thread panel via la UI.
*/
export async function startThreadOnLastMessage(page: Page) {
console.log("[startThread] Dismissing toasts...");
await dismissToasts(page);
// Obtener el event ID del ultimo mensaje y el room ID via el SDK de Element
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");
// Obtener el room actual desde la URL (mas fiable que getRooms()[0])
const hash = window.location.hash; // e.g. "#/room/!xxx:server" or "#/room/#alias:server"
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;
}
if (!roomId) throw new Error("No hay room activo");
const room = client.getRoom(roomId);
if (!room) throw new Error("Room no encontrado");
// Obtener el ultimo evento de mensaje en el timeline
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(),
sender: lastMsgEvent.getSender(),
};
});
console.log(`[startThread] Room: ${threadInfo.roomId}, Event: ${threadInfo.eventId}`);
// Enviar un mensaje threaded via el SDK
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");
// El thread ya esta creado. La verificacion de respuesta se hace via SDK.
}
/**
* 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."
);
}
}