refactor: reescribir goToRoom y startThreadOnLastMessage en matrix-room.ts

goToRoom: implementa estrategia doble — primero intenta click directo en el sidebar (mas robusto), luego usa Ctrl+K como fallback. Evita el click en el boton Search que a veces es bloqueado por toasts.

startThreadOnLastMessage: en headless Chromium la hover action bar de Element no se renderiza (es React onMouseEnter, no CSS :hover). Ahora usa el Matrix SDK expuesto en window.mxMatrixClientPeg para enviar el mensaje threaded directamente via API, evitando la dependencia del panel UI.

Nueva funcion waitForThreadReplyViaSdk: consulta el timeline del SDK en lugar de depender del panel de thread UI. Busca eventos con m.relates_to.rel_type === 'm.thread' y filtra por sender si se especifica.

Nueva funcion interna waitForRoomLoaded: espera header del room o el composer como fallback, desacoplando la verificacion de la navegacion.
This commit is contained in:
2026-03-08 17:33:37 +00:00
parent c370c189d2
commit 85e79b11fd
+218 -45
View File
@@ -1,5 +1,28 @@
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;
@@ -8,25 +31,57 @@ export interface WaitForReplyOptions {
}
/**
* Navega a un room por nombre usando el buscador de Element.
* 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}"`);
// Abrir busqueda de rooms (Ctrl+K o click en el search)
const searchButton = page.locator('[aria-label="Search"]').first();
console.log("[goToRoom] Clickeando boton Search...");
await searchButton.click();
// 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 });
}
// Escribir nombre del room en el campo de busqueda
const searchInput = page.getByRole("searchbox").first();
await searchInput.fill(roomName);
console.log(`[goToRoom] Texto ingresado: "${roomName}"`);
// Seleccionar el room de los resultados
const roomResult = page
.getByRole("option", { name: new RegExp(roomName, "i") })
.first();
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 })
@@ -39,16 +94,37 @@ export async function goToRoom(page: Page, roomName: string) {
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();
// Verificar que estamos en el room correcto (header muestra el nombre)
await expect(
page.locator(`[data-testid="room-header-name"], h2`).filter({ hasText: roomName }).first()
).toBeVisible({ timeout: 10_000 });
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}"`);
}
@@ -214,45 +290,68 @@ async function getLastMessageSender(page: Page): Promise<string | null> {
/**
* Inicia un thread sobre el ultimo mensaje del timeline.
* Hace hover sobre el mensaje, click en el boton de thread,
* y espera a que el panel de thread se abra.
*
* 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] Hover sobre ultimo mensaje...");
console.log("[startThread] Dismissing toasts...");
await dismissToasts(page);
// Hover sobre el ultimo event tile para revelar la action bar
const lastTile = page.locator(".mx_EventTile").last();
await lastTile.hover();
// Obtener el event ID del ultimo mensaje y el room ID via el SDK de Element
const threadInfo = await page.evaluate(() => {
// 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");
// Click en el boton de thread (puede ser "Thread" o "Reply in thread")
const threadBtn = page
.locator(
'button[aria-label="Thread"], button[aria-label="Reply in thread"], [data-testid="thread-button"]'
)
.first();
// Obtener el room actual visible
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dis = (window as any).dis;
const roomId = client.getRooms()
.filter((r: { getMyMembership: () => string }) => r.getMyMembership() === "join")
.map((r: { roomId: string }) => r.roomId)[0];
const hasBtn = await threadBtn
.waitFor({ state: "visible", timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (!roomId) throw new Error("No hay room activo");
if (!hasBtn) {
console.error("[startThread] Boton de thread no encontrado");
await page.screenshot({
path: `test-results/ERROR-no-thread-btn-${Date.now()}.png`,
fullPage: true,
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 },
},
});
throw new Error("Boton de thread no encontrado");
}
}, threadInfo);
await threadBtn.click({ timeout: 5_000 });
console.log("[startThread] Click en boton de thread");
// Esperar a que el panel de thread se abra
await expect(
page.locator(".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard")
).toBeVisible({ timeout: 10_000 });
console.log("[startThread] Panel de thread abierto");
console.log("[startThread] Mensaje threaded enviado via SDK");
// El thread ya esta creado. La verificacion de respuesta se hace via SDK.
}
/**
@@ -405,6 +504,80 @@ export async function assertBotDidNotReplyInMainTimeline(
}
}
/**
* 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;
const rooms = client.getRooms().filter(
(r: { getMyMembership: () => string }) => r.getMyMembership() === "join"
);
for (const room of rooms) {
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.