refactor: reescribir fixtures E2E para deteccion robusta via SDK
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.
This commit is contained in:
+219
-83
@@ -161,33 +161,92 @@ export async function sendMessage(page: Page, text: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Espera una respuesta de un bot en el timeline.
|
||||
* Detecta "Unable to decrypt" y falla con mensaje claro.
|
||||
* 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: ${options?.sender || "any"})...`
|
||||
`[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...`
|
||||
);
|
||||
|
||||
// Esperar a que aparezca un nuevo mensaje (que no sea del usuario actual)
|
||||
const startTime = Date.now();
|
||||
let lastLoggedMsg = "";
|
||||
let lastLoggedSender = "";
|
||||
// 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 mensajes que no se pueden descifrar
|
||||
// Detectar E2EE errors en el timeline visible
|
||||
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`
|
||||
);
|
||||
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,
|
||||
@@ -199,70 +258,81 @@ export async function waitForBotReply(
|
||||
);
|
||||
}
|
||||
|
||||
// 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}"`
|
||||
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
|
||||
);
|
||||
lastLoggedMsg = lastMessage;
|
||||
lastLoggedSender = lastSender || "";
|
||||
}
|
||||
if (!room) return null;
|
||||
|
||||
if (lastSender && lastSender.includes(options.sender)) {
|
||||
console.log(
|
||||
`[waitForBotReply] Respuesta recibida de "${options.sender}" (${Date.now() - startTime}ms)`
|
||||
);
|
||||
return lastMessage;
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms)`
|
||||
);
|
||||
return lastMessage;
|
||||
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);
|
||||
}
|
||||
|
||||
// Timeout — tomar screenshot antes de lanzar error
|
||||
console.error(
|
||||
`[waitForBotReply] TIMEOUT despues de ${timeout}ms esperando respuesta`
|
||||
);
|
||||
console.error(`[waitForBotReply] TIMEOUT despues de ${timeout}ms`);
|
||||
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})` : "")
|
||||
(senderFilter ? ` (sender esperado: ${senderFilter})` : "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -289,25 +359,102 @@ async function getLastMessageSender(page: Page): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia un thread sobre el ultimo mensaje del timeline.
|
||||
* Inicia un thread sobre el ultimo mensaje del timeline via UI.
|
||||
*
|
||||
* 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.
|
||||
* 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);
|
||||
|
||||
// Obtener el event ID del ultimo mensaje y el room ID via el SDK de Element
|
||||
// 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");
|
||||
|
||||
// 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 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]);
|
||||
@@ -322,28 +469,18 @@ export async function startThreadOnLastMessage(page: Page) {
|
||||
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(),
|
||||
};
|
||||
return { roomId, eventId: lastMsgEvent.getId() };
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -359,8 +496,7 @@ export async function startThreadOnLastMessage(page: Page) {
|
||||
});
|
||||
}, threadInfo);
|
||||
|
||||
console.log("[startThread] Mensaje threaded enviado via SDK");
|
||||
// El thread ya esta creado. La verificacion de respuesta se hace via SDK.
|
||||
console.log("[startThread] Mensaje threaded enviado via SDK (fallback)");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user