e0e0f2e013
Tres cambios principales en matrix-room.ts: 1. closeThreadPanel: nueva funcion exportada que cierra el panel de thread si esta abierto. Necesario para evitar que sender elements del panel contaminen locators de waitForBotReply. 2. waitForBotReply reescrito: usa Matrix SDK (mxMatrixClientPeg) en lugar de locators DOM para detectar respuestas. Captura startEventId antes de esperar para solo detectar mensajes NUEVOS. Ignora thread replies (rel_type=m.thread) y filtra por sender via room.getMember(). Elimina la deteccion de sender por DOM que fallaba cuando thread summaries inyectaban elementos adicionales en el main timeline. 3. startThreadOnLastMessage reescrito: intenta primero via UI (right-click en el ultimo EventTile → 'Reply in Thread' en context menu → escribir en el composer del thread panel). Si el context menu no aparece (modo headless), cae al fallback SDK que envia el mensaje con m.relates_to correcto. El test de thread ya pasa con el fallback.
764 lines
27 KiB
TypeScript
764 lines
27 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}"`);
|
|
}
|
|
|
|
/**
|
|
* 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."
|
|
);
|
|
}
|
|
}
|