/* Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { render, waitFor, screen } from "@testing-library/react"; import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { EventTimelineSet, EventType, MatrixClient, MatrixEvent, PendingEventOrdering, Room, RoomEvent, RoomMember, RoomState, TimelineWindow, } from "matrix-js-sdk/src/matrix"; import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent, ThreadFilterType, } from "matrix-js-sdk/src/models/thread"; import React, { createRef } from "react"; import { mocked } from "jest-mock"; import { forEachRight } from "lodash"; import TimelinePanel from "../../../src/components/structures/TimelinePanel"; import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { isCallEvent } from "../../../src/components/structures/LegacyCallEventGrouper"; import { flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; import { createMessageEventContent } from "../../test-utils/events"; import ScrollPanel from "../../../src/components/structures/ScrollPanel"; // ScrollPanel calls this, but jsdom doesn't mock it for us HTMLDivElement.prototype.scrollBy = () => {}; const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => { const receiptContent = { [eventId]: { [ReceiptType.Read]: { [userId]: { ts: readTs } }, [ReceiptType.ReadPrivate]: { [userId]: { ts: readTs } }, [ReceiptType.FullyRead]: { [userId]: { ts: fullyReadTs } }, }, }; return new MatrixEvent({ content: receiptContent, type: EventType.Receipt }); }; const mkTimeline = (room: Room, events: MatrixEvent[]): [EventTimeline, EventTimelineSet] => { const timelineSet = { room: room as Room, getLiveTimeline: () => timeline, getTimelineForEvent: () => timeline, getPendingEvents: () => [], } as unknown as EventTimelineSet; const timeline = new EventTimeline(timelineSet); events.forEach((event) => timeline.addEvent(event, { toStartOfTimeline: false })); return [timeline, timelineSet]; }; const getProps = (room: Room, events: MatrixEvent[]): TimelinePanel["props"] => { const [, timelineSet] = mkTimeline(room, events); return { timelineSet, manageReadReceipts: true, sendReadReceiptOnLoad: true, }; }; const mockEvents = (room: Room, count = 2): MatrixEvent[] => { const events: MatrixEvent[] = []; for (let index = 0; index < count; index++) { events.push( new MatrixEvent({ room_id: room.roomId, event_id: `${room.roomId}_event_${index}`, type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("`Event${index}`"), }), ); } return events; }; const setupTestData = (): [MatrixClient, Room, MatrixEvent[]] => { const client = MatrixClientPeg.get(); const room = mkRoom(client, "roomId"); const events = mockEvents(room); return [client, room, events]; }; const setupOverlayTestData = (client: MatrixClient, mainEvents: MatrixEvent[]): [Room, MatrixEvent[]] => { const virtualRoom = mkRoom(client, "virtualRoomId"); const overlayEvents = mockEvents(virtualRoom, 5); // Set the event order that we'll be looking for in the timeline overlayEvents[0].localTimestamp = 1000; mainEvents[0].localTimestamp = 2000; overlayEvents[1].localTimestamp = 3000; overlayEvents[2].localTimestamp = 4000; overlayEvents[3].localTimestamp = 5000; mainEvents[1].localTimestamp = 6000; overlayEvents[4].localTimestamp = 7000; return [virtualRoom, overlayEvents]; }; const expectEvents = (container: HTMLElement, events: MatrixEvent[]): void => { const eventTiles = container.querySelectorAll(".mx_EventTile"); const eventTileIds = [...eventTiles].map((tileElement) => tileElement.getAttribute("data-event-id")); expect(eventTileIds).toEqual(events.map((ev) => ev.getId())); }; const withScrollPanelMountSpy = async ( continuation: (mountSpy: jest.SpyInstance) => Promise, ): Promise => { const mountSpy = jest.spyOn(ScrollPanel.prototype, "componentDidMount"); try { await continuation(mountSpy); } finally { mountSpy.mockRestore(); } }; const setupPagination = ( client: MatrixClient, timeline: EventTimeline, previousPage: MatrixEvent[] | null, nextPage: MatrixEvent[] | null, ): void => { timeline.setPaginationToken(previousPage === null ? null : "start", EventTimeline.BACKWARDS); timeline.setPaginationToken(nextPage === null ? null : "end", EventTimeline.FORWARDS); mocked(client).paginateEventTimeline.mockImplementation(async (tl, { backwards }) => { if (tl === timeline) { if (backwards) { forEachRight(previousPage ?? [], (event) => tl.addEvent(event, { toStartOfTimeline: true })); } else { (nextPage ?? []).forEach((event) => tl.addEvent(event, { toStartOfTimeline: false })); } // Prevent any further pagination attempts in this direction tl.setPaginationToken(null, backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); return true; } else { return false; } }); }; describe("TimelinePanel", () => { beforeEach(() => { stubClient(); }); describe("read receipts and markers", () => { it("should forget the read marker when asked to", () => { const cli = MatrixClientPeg.get(); const readMarkersSent: string[] = []; // Track calls to setRoomReadMarkers cli.setRoomReadMarkers = (_roomId, rmEventId, _a, _b) => { readMarkersSent.push(rmEventId); return Promise.resolve({}); }; const ev0 = new MatrixEvent({ event_id: "ev0", sender: "@u2:m.org", origin_server_ts: 111, type: EventType.RoomMessage, content: createMessageEventContent("hello 1"), }); const ev1 = new MatrixEvent({ event_id: "ev1", sender: "@u2:m.org", origin_server_ts: 222, type: EventType.RoomMessage, content: createMessageEventContent("hello 2"), }); const roomId = "#room:example.com"; const userId = cli.credentials.userId!; const room = new Room(roomId, cli, userId, { pendingEventOrdering: PendingEventOrdering.Detached }); // Create a TimelinePanel with ev0 already present const timelineSet = new EventTimelineSet(room, {}); timelineSet.addLiveEvent(ev0); const ref = createRef(); render( , ); const timelinePanel = ref.current!; // An event arrived, and we read it timelineSet.addLiveEvent(ev1); room.addEphemeralEvents([newReceipt("ev1", userId, 222, 220)]); // Sanity: We have not sent any read marker yet expect(readMarkersSent).toEqual([]); // This is what we are testing: forget the read marker - this should // update our read marker to match the latest receipt we sent timelinePanel.forgetReadMarker(); // We sent off a read marker for the new event expect(readMarkersSent).toEqual(["ev1"]); }); }); it("should scroll event into view when props.eventId changes", () => { const client = MatrixClientPeg.get(); const room = mkRoom(client, "roomId"); const events = mockEvents(room); const props = { ...getProps(room, events), onEventScrolledIntoView: jest.fn(), }; const { rerender } = render(); expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(undefined); props.eventId = events[1].getId(); rerender(); expect(props.onEventScrolledIntoView).toHaveBeenCalledWith(events[1].getId()); }); it("paginates", async () => { const [client, room, events] = setupTestData(); const eventsPage1 = events.slice(0, 1); const eventsPage2 = events.slice(1, 2); // Start with only page 2 of the main events in the window const [timeline, timelineSet] = mkTimeline(room, eventsPage2); setupPagination(client, timeline, eventsPage1, null); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render(); await waitFor(() => expectEvents(container, [events[1]])); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the fill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onFillRequest!(true); await waitFor(() => expectEvents(container, [events[0], events[1]])); }); }); it("unpaginates", async () => { const [, room, events] = setupTestData(); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render(); await waitFor(() => expectEvents(container, [events[0], events[1]])); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the unfill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onUnfillRequest!(true, events[0].getId()!); await waitFor(() => expectEvents(container, [events[1]])); }); }); describe("onRoomTimeline", () => { it("ignores events for other timelines", () => { const [client, room, events] = setupTestData(); const otherTimelineSet = { room: room as Room } as EventTimelineSet; const otherTimeline = new EventTimeline(otherTimelineSet); const props = { ...getProps(room, events), onEventScrolledIntoView: jest.fn(), }; const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(); const event = new MatrixEvent({ type: RoomEvent.Timeline }); const data = { timeline: otherTimeline, liveEvent: true }; client.emit(RoomEvent.Timeline, event, room, false, false, data); expect(paginateSpy).not.toHaveBeenCalled(); }); it("ignores timeline updates without a live event", () => { const [client, room, events] = setupTestData(); const props = getProps(room, events); const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(); const event = new MatrixEvent({ type: RoomEvent.Timeline }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: false }; client.emit(RoomEvent.Timeline, event, room, false, false, data); expect(paginateSpy).not.toHaveBeenCalled(); }); it("ignores timeline where toStartOfTimeline is true", () => { const [client, room, events] = setupTestData(); const props = getProps(room, events); const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(); const event = new MatrixEvent({ type: RoomEvent.Timeline }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: false }; const toStartOfTimeline = true; client.emit(RoomEvent.Timeline, event, room, toStartOfTimeline, false, data); expect(paginateSpy).not.toHaveBeenCalled(); }); it("advances the timeline window", () => { const [client, room, events] = setupTestData(); const props = getProps(room, events); const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(); const event = new MatrixEvent({ type: RoomEvent.Timeline }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; client.emit(RoomEvent.Timeline, event, room, false, false, data); expect(paginateSpy).toHaveBeenCalledWith(EventTimeline.FORWARDS, 1, false); }); it("advances the overlay timeline window", async () => { const [client, room, events] = setupTestData(); const virtualRoom = mkRoom(client, "virtualRoomId"); const virtualEvents = mockEvents(virtualRoom); const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); const props = { ...getProps(room, events), overlayTimelineSet, }; const paginateSpy = jest.spyOn(TimelineWindow.prototype, "paginate").mockClear(); render(); await flushPromises(); const event = new MatrixEvent({ type: RoomEvent.Timeline }); const data = { timeline: props.timelineSet.getLiveTimeline(), liveEvent: true }; client.emit(RoomEvent.Timeline, event, room, false, false, data); await flushPromises(); expect(paginateSpy).toHaveBeenCalledTimes(2); }); }); describe("with overlayTimeline", () => { it("renders merged timeline", async () => { const [client, room, events] = setupTestData(); const virtualRoom = mkRoom(client, "virtualRoomId"); const virtualCallInvite = new MatrixEvent({ type: "m.call.invite", room_id: virtualRoom.roomId, event_id: `virtualCallEvent1`, }); const virtualCallMetaEvent = new MatrixEvent({ type: "org.matrix.call.sdp_stream_metadata_changed", room_id: virtualRoom.roomId, event_id: `virtualCallEvent2`, }); const virtualEvents = [virtualCallInvite, ...mockEvents(virtualRoom), virtualCallMetaEvent]; const { timelineSet: overlayTimelineSet } = getProps(virtualRoom, virtualEvents); const { container } = render( , ); await waitFor(() => expectEvents(container, [ // main timeline events are included events[0], events[1], // virtual timeline call event is included virtualCallInvite, // virtual call event has no tile renderer => not rendered ]), ); }); it.each([ ["when it starts with no overlay events", true], ["to get enough overlay events", false], ])("expands the initial window %s", async (_s, startWithEmptyOverlayWindow) => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); let overlayEventsPage1: MatrixEvent[]; let overlayEventsPage2: MatrixEvent[]; let overlayEventsPage3: MatrixEvent[]; if (startWithEmptyOverlayWindow) { overlayEventsPage1 = overlayEvents.slice(0, 3); overlayEventsPage2 = []; overlayEventsPage3 = overlayEvents.slice(3, 5); } else { overlayEventsPage1 = overlayEvents.slice(0, 2); overlayEventsPage2 = overlayEvents.slice(2, 3); overlayEventsPage3 = overlayEvents.slice(3, 5); } // Start with only page 2 of the overlay events in the window const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage2); setupPagination(client, overlayTimeline, overlayEventsPage1, overlayEventsPage3); const { container } = render( , ); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], overlayEvents[4], ]), ); }); it("extends overlay window beyond main window at the start of the timeline", async () => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); // Delete event 0 so the TimelinePanel will still leave some stuff // unloaded for us to test with events.shift(); const overlayEventsPage1 = overlayEvents.slice(0, 2); const overlayEventsPage2 = overlayEvents.slice(2, 5); // Start with only page 2 of the overlay events in the window const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage2); setupPagination(client, overlayTimeline, overlayEventsPage1, null); const { container } = render( , ); await waitFor(() => expectEvents(container, [ // These first two are the newly loaded events overlayEvents[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[0], overlayEvents[4], ]), ); }); it("extends overlay window beyond main window at the end of the timeline", async () => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); // Delete event 1 so the TimelinePanel will still leave some stuff // unloaded for us to test with events.pop(); const overlayEventsPage1 = overlayEvents.slice(0, 2); const overlayEventsPage2 = overlayEvents.slice(2, 5); // Start with only page 1 of the overlay events in the window const [overlayTimeline, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEventsPage1); setupPagination(client, overlayTimeline, null, overlayEventsPage2); const { container } = render( , ); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], // These are the newly loaded events overlayEvents[2], overlayEvents[3], overlayEvents[4], ]), ); }); it("paginates", async () => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); const eventsPage1 = events.slice(0, 1); const eventsPage2 = events.slice(1, 2); // Start with only page 1 of the main events in the window const [timeline, timelineSet] = mkTimeline(room, eventsPage1); const [, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEvents); setupPagination(client, timeline, null, eventsPage2); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render( , ); await waitFor(() => expectEvents(container, [overlayEvents[0], events[0]])); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the fill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onFillRequest!(false); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], overlayEvents[4], ]), ); }); }); it.each([ ["down", "main", true, false], ["down", "overlay", true, true], ["up", "main", false, false], ["up", "overlay", false, true], ])("unpaginates %s to an event from the %s timeline", async (_s1, _s2, backwards, fromOverlay) => { const [client, room, events] = setupTestData(); const [virtualRoom, overlayEvents] = setupOverlayTestData(client, events); let marker: MatrixEvent; let expectedEvents: MatrixEvent[]; if (backwards) { if (fromOverlay) { marker = overlayEvents[1]; // Overlay events 0−1 and event 0 should be unpaginated // Overlay events 2−3 should be hidden since they're at the edge of the window expectedEvents = [events[1], overlayEvents[4]]; } else { marker = events[0]; // Overlay event 0 and event 0 should be unpaginated // Overlay events 1−3 should be hidden since they're at the edge of the window expectedEvents = [events[1], overlayEvents[4]]; } } else { if (fromOverlay) { marker = overlayEvents[4]; // Only the last overlay event should be unpaginated expectedEvents = [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], ]; } else { // Get rid of overlay event 4 so we can test the case where no overlay events get unpaginated overlayEvents.pop(); marker = events[1]; // Only event 1 should be unpaginated // Overlay events 1−2 should be hidden since they're at the edge of the window expectedEvents = [overlayEvents[0], events[0]]; } } const [, overlayTimelineSet] = mkTimeline(virtualRoom, overlayEvents); await withScrollPanelMountSpy(async (mountSpy) => { const { container } = render( , ); await waitFor(() => expectEvents(container, [ overlayEvents[0], events[0], overlayEvents[1], overlayEvents[2], overlayEvents[3], events[1], ...(!backwards && !fromOverlay ? [] : [overlayEvents[4]]), ]), ); // ScrollPanel has no chance of working in jsdom, so we've no choice // but to do some shady stuff to trigger the unfill callback by hand const scrollPanel = mountSpy.mock.contexts[0] as ScrollPanel; scrollPanel.props.onUnfillRequest!(backwards, marker.getId()!); await waitFor(() => expectEvents(container, expectedEvents)); }); }); }); describe("when a thread updates", () => { let client: MatrixClient; let room: Room; let allThreads: EventTimelineSet; let root: MatrixEvent; let reply1: MatrixEvent; let reply2: MatrixEvent; beforeEach(() => { client = MatrixClientPeg.get(); Thread.hasServerSideSupport = FeatureSupport.Stable; room = new Room("roomId", client, "userId"); allThreads = new EventTimelineSet( room, { pendingEvents: false, }, undefined, undefined, ThreadFilterType.All, ); const timeline = new EventTimeline(allThreads); allThreads.getLiveTimeline = () => timeline; allThreads.getTimelineForEvent = () => timeline; reply1 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_1", type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("ReplyEvent1"), }); reply2 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_2", type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("ReplyEvent2"), }); root = new MatrixEvent({ room_id: room.roomId, event_id: "event_root_1", type: EventType.RoomMessage, sender: "userId", content: createMessageEventContent("RootEvent"), }); const eventMap: { [key: string]: MatrixEvent } = { [root.getId()!]: root, [reply1.getId()!]: reply1, [reply2.getId()!]: reply2, }; room.findEventById = (eventId: string) => eventMap[eventId]; client.fetchRoomEvent = async (roomId: string, eventId: string) => roomId === room.roomId ? eventMap[eventId]?.event : {}; }); it("updates thread previews", async () => { root.setUnsigned({ "m.relations": { [THREAD_RELATION_TYPE.name]: { latest_event: reply1.event, count: 1, current_user_participated: true, }, }, }); const thread = room.createThread(root.getId()!, root, [], true); // So that we do not have to mock the thread loading thread.initialEventsFetched = true; // @ts-ignore thread.fetchEditsWhereNeeded = () => Promise.resolve(); await thread.addEvent(reply1, true); await allThreads.getLiveTimeline().addEvent(thread.rootEvent!, { toStartOfTimeline: true }); const replyToEvent = jest.spyOn(thread, "replyToEvent", "get"); const dom = render( , ); await dom.findByText("RootEvent"); await dom.findByText("ReplyEvent1"); expect(replyToEvent).toHaveBeenCalled(); root.setUnsigned({ "m.relations": { [THREAD_RELATION_TYPE.name]: { latest_event: reply2.event, count: 2, current_user_participated: true, }, }, }); replyToEvent.mockClear(); await thread.addEvent(reply2, false, true); await dom.findByText("RootEvent"); await dom.findByText("ReplyEvent2"); expect(replyToEvent).toHaveBeenCalled(); }); it("ignores thread updates for unknown threads", async () => { root.setUnsigned({ "m.relations": { [THREAD_RELATION_TYPE.name]: { latest_event: reply1.event, count: 1, current_user_participated: true, }, }, }); const realThread = room.createThread(root.getId()!, root, [], true); // So that we do not have to mock the thread loading realThread.initialEventsFetched = true; // @ts-ignore realThread.fetchEditsWhereNeeded = () => Promise.resolve(); await realThread.addEvent(reply1, true); await allThreads.getLiveTimeline().addEvent(realThread.rootEvent!, { toStartOfTimeline: true }); const replyToEvent = jest.spyOn(realThread, "replyToEvent", "get"); // @ts-ignore const fakeThread1: Thread = { id: undefined!, get roomId(): string { return room.roomId; }, }; const fakeRoom = new Room("thisroomdoesnotexist", client, "userId"); // @ts-ignore const fakeThread2: Thread = { id: root.getId()!, get roomId(): string { return fakeRoom.roomId; }, }; const dom = render( , ); await dom.findByText("RootEvent"); await dom.findByText("ReplyEvent1"); expect(replyToEvent).toHaveBeenCalled(); replyToEvent.mockClear(); room.emit(ThreadEvent.Update, fakeThread1); room.emit(ThreadEvent.Update, fakeThread2); await dom.findByText("ReplyEvent1"); expect(replyToEvent).not.toHaveBeenCalled(); replyToEvent.mockClear(); }); }); it("renders when the last message is an undecryptable thread root", async () => { const client = MatrixClientPeg.get(); client.isRoomEncrypted = () => true; client.supportsThreads = () => true; client.decryptEventIfNeeded = () => Promise.resolve(); const authorId = client.getUserId()!; const room = new Room("roomId", client, authorId, { lazyLoadMembers: false, pendingEventOrdering: PendingEventOrdering.Detached, }); const events = mockEvents(room); const timelineSet = room.getUnfilteredTimelineSet(); const { rootEvent } = mkThread({ room, client, authorId, participantUserIds: [authorId], }); events.push(rootEvent); events.forEach((event) => timelineSet.getLiveTimeline().addEvent(event, { toStartOfTimeline: true })); const roomMembership = mkMembership({ mship: "join", prevMship: "join", user: authorId, room: room.roomId, event: true, skey: "123", }); events.push(roomMembership); const member = new RoomMember(room.roomId, authorId); member.membership = "join"; const roomState = new RoomState(room.roomId); jest.spyOn(roomState, "getMember").mockReturnValue(member); jest.spyOn(timelineSet.getLiveTimeline(), "getState").mockReturnValue(roomState); timelineSet.addEventToTimeline(roomMembership, timelineSet.getLiveTimeline(), { toStartOfTimeline: false }); for (const event of events) { jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true); jest.spyOn(event, "shouldAttemptDecryption").mockReturnValue(false); } const { container } = render( , ); await waitFor(() => expect(screen.queryByRole("progressbar")).toBeNull()); await waitFor(() => expect(container.querySelector(".mx_RoomView_MessageList")).not.toBeEmptyDOMElement()); }); });