From a0ab88943b4bfd978606a48b653ee8d1757516f9 Mon Sep 17 00:00:00 2001 From: Robin Date: Wed, 22 Jan 2025 12:50:52 -0500 Subject: [PATCH 1/2] Distinguish room state and timeline events when dealing with widgets (#28681) * Distinguish room state and timeline events when dealing with widgets * Upgrade matrix-widget-api * Fix typo * Fix tests * Write more tests * Add more comments --------- Co-authored-by: Hugh Nimmo-Smith --- src/stores/widgets/StopGapWidget.ts | 61 +++++++-- src/stores/widgets/StopGapWidgetDriver.ts | 128 ++++++++++-------- .../stores/widgets/StopGapWidget-test.ts | 91 +++++++++++-- .../widgets/StopGapWidgetDriver-test.ts | 110 ++++++++++++++- 4 files changed, 301 insertions(+), 89 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 40e36473e31..c17aa81aab3 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -6,7 +6,14 @@ * Please see LICENSE files in the repository root for full details. */ -import { Room, MatrixEvent, MatrixEventEvent, MatrixClient, ClientEvent } from "matrix-js-sdk/src/matrix"; +import { + Room, + MatrixEvent, + MatrixEventEvent, + MatrixClient, + ClientEvent, + RoomStateEvent, +} from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { ClientWidgetApi, @@ -26,7 +33,6 @@ import { WidgetApiFromWidgetAction, WidgetKind, } from "matrix-widget-api"; -import { Optional } from "matrix-events-sdk"; import { EventEmitter } from "events"; import { logger } from "matrix-js-sdk/src/logger"; @@ -56,6 +62,7 @@ import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import Modal from "../../Modal"; import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; import { SdkContextClass } from "../../contexts/SDKContext"; +import { UPDATE_EVENT } from "../AsyncStore"; // TODO: Destroy all of this code @@ -151,6 +158,9 @@ export class StopGapWidget extends EventEmitter { private mockWidget: ElementWidget; private scalarToken?: string; private roomId?: string; + // The room that we're currently allowing the widget to interact with. Only + // used for account widgets, which may follow the user to different rooms. + private viewedRoomId: string | null = null; private kind: WidgetKind; private readonly virtual: boolean; private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID @@ -177,17 +187,6 @@ export class StopGapWidget extends EventEmitter { this.stickyPromise = appTileProps.stickyPromise; } - private get eventListenerRoomId(): Optional { - // When widgets are listening to events, we need to make sure they're only - // receiving events for the right room. In particular, room widgets get locked - // to the room they were added in while account widgets listen to the currently - // active room. - - if (this.roomId) return this.roomId; - - return SdkContextClass.instance.roomViewStore.getRoomId(); - } - public get widgetApi(): ClientWidgetApi | null { return this.messaging; } @@ -259,6 +258,17 @@ export class StopGapWidget extends EventEmitter { }); } }; + + // This listener is only active for account widgets, which may follow the + // user to different rooms + private onRoomViewStoreUpdate = (): void => { + const roomId = SdkContextClass.instance.roomViewStore.getRoomId() ?? null; + if (roomId !== this.viewedRoomId) { + this.messaging!.setViewedRoomId(roomId); + this.viewedRoomId = roomId; + } + }; + /** * This starts the messaging for the widget if it is not in the state `started` yet. * @param iframe the iframe the widget should use @@ -285,6 +295,17 @@ export class StopGapWidget extends EventEmitter { this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); + // When widgets are listening to events, we need to make sure they're only + // receiving events for the right room + if (this.roomId === undefined) { + // Account widgets listen to the currently active room + this.messaging.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null); + SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); + } else { + // Room widgets get locked to the room they were added in + this.messaging.setViewedRoomId(this.roomId); + } + // Always attach a handler for ViewRoom, but permission check it internally this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { ev.preventDefault(); // stop the widget API from auto-rejecting this @@ -329,6 +350,7 @@ export class StopGapWidget extends EventEmitter { // Attach listeners for feeding events - the underlying widget classes handle permissions for us this.client.on(ClientEvent.Event, this.onEvent); this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.on(RoomStateEvent.Events, this.onStateUpdate); this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); this.messaging.on( @@ -457,8 +479,11 @@ export class StopGapWidget extends EventEmitter { WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId); this.messaging = null; + SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); + this.client.off(ClientEvent.Event, this.onEvent); this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); + this.client.off(RoomStateEvent.Events, this.onStateUpdate); this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); } @@ -471,6 +496,14 @@ export class StopGapWidget extends EventEmitter { this.feedEvent(ev); }; + private onStateUpdate = (ev: MatrixEvent): void => { + if (this.messaging === null) return; + const raw = ev.getEffectiveEvent(); + this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => { + logger.error("Error sending state update to widget: ", e); + }); + }; + private onToDeviceEvent = async (ev: MatrixEvent): Promise => { await this.client.decryptEventIfNeeded(ev); if (ev.isDecryptionFailure()) return; @@ -570,7 +603,7 @@ export class StopGapWidget extends EventEmitter { this.eventsToFeed.add(ev); } else { const raw = ev.getEffectiveEvent(); - this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId!).catch((e) => { + this.messaging.feedEvent(raw as IRoomEvent).catch((e) => { logger.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index fa5a43f248c..7f5affab0da 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -19,7 +19,6 @@ import { MatrixCapabilities, OpenIDRequestState, SimpleObservable, - Symbols, Widget, WidgetDriver, WidgetEventCapability, @@ -36,7 +35,6 @@ import { IContent, MatrixError, MatrixEvent, - Room, Direction, THREAD_RELATION_TYPE, SendDelayedEventResponse, @@ -469,70 +467,69 @@ export class StopGapWidgetDriver extends WidgetDriver { } } - private pickRooms(roomIds?: (string | Symbols.AnyRoom)[]): Room[] { - const client = MatrixClientPeg.get(); - if (!client) throw new Error("Not attached to a client"); - - const targetRooms = roomIds - ? roomIds.includes(Symbols.AnyRoom) - ? client.getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors")) - : roomIds.map((r) => client.getRoom(r)) - : [client.getRoom(SdkContextClass.instance.roomViewStore.getRoomId()!)]; - return targetRooms.filter((r) => !!r) as Room[]; - } - - public async readRoomEvents( + /** + * Reads all events of the given type, and optionally `msgtype` (if applicable/defined), + * the user has access to. The widget API will have already verified that the widget is + * capable of receiving the events. Less events than the limit are allowed to be returned, + * but not more. + * @param roomId The ID of the room to look within. + * @param eventType The event type to be read. + * @param msgtype The msgtype of the events to be read, if applicable/defined. + * @param stateKey The state key of the events to be read, if applicable/defined. + * @param limit The maximum number of events to retrieve. Will be zero to denote "as many as + * possible". + * @param since When null, retrieves the number of events specified by the "limit" parameter. + * Otherwise, the event ID at which only subsequent events will be returned, as many as specified + * in "limit". + * @returns {Promise} Resolves to the room events, or an empty array. + */ + public async readRoomTimeline( + roomId: string, eventType: string, msgtype: string | undefined, - limitPerRoom: number, - roomIds?: (string | Symbols.AnyRoom)[], + stateKey: string | undefined, + limit: number, + since: string | undefined, ): Promise { - limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary - - const rooms = this.pickRooms(roomIds); - const allResults: IRoomEvent[] = []; - for (const room of rooms) { - const results: MatrixEvent[] = []; - const events = room.getLiveTimeline().getEvents(); // timelines are most recent last - for (let i = events.length - 1; i > 0; i--) { - if (results.length >= limitPerRoom) break; - - const ev = events[i]; - if (ev.getType() !== eventType || ev.isState()) continue; - if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue; - results.push(ev); - } - - results.forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent)); + limit = limit > 0 ? Math.min(limit, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const room = MatrixClientPeg.safeGet().getRoom(roomId); + if (room === null) return []; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i >= 0; i--) { + const ev = events[i]; + if (results.length >= limit) break; + if (since !== undefined && ev.getId() === since) break; + + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()["msgtype"]) continue; + if (ev.getStateKey() !== undefined && stateKey !== undefined && ev.getStateKey() !== stateKey) continue; + results.push(ev); } - return allResults; - } - public async readStateEvents( - eventType: string, - stateKey: string | undefined, - limitPerRoom: number, - roomIds?: (string | Symbols.AnyRoom)[], - ): Promise { - limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary - - const rooms = this.pickRooms(roomIds); - const allResults: IRoomEvent[] = []; - for (const room of rooms) { - const results: MatrixEvent[] = []; - const state = room.currentState.events.get(eventType); - if (state) { - if (stateKey === "" || !!stateKey) { - const forKey = state.get(stateKey); - if (forKey) results.push(forKey); - } else { - results.push(...Array.from(state.values())); - } - } + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } - results.slice(0, limitPerRoom).forEach((e) => allResults.push(e.getEffectiveEvent() as IRoomEvent)); - } - return allResults; + /** + * Reads the current values of all matching room state entries. + * @param roomId The ID of the room. + * @param eventType The event type of the entries to be read. + * @param stateKey The state key of the entry to be read. If undefined, + * all room state entries with a matching event type should be returned. + * @returns {Promise} Resolves to the events representing the + * current values of the room state entries. + */ + public async readRoomState(roomId: string, eventType: string, stateKey: string | undefined): Promise { + const room = MatrixClientPeg.safeGet().getRoom(roomId); + if (room === null) return []; + const state = room.getLiveTimeline().getState(Direction.Forward); + if (state === undefined) return []; + + if (stateKey === undefined) + return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); + const event = state.getStateEvents(eventType, stateKey); + return event === null ? [] : [event.getEffectiveEvent() as IRoomEvent]; } public async askOpenID(observer: SimpleObservable): Promise { @@ -693,6 +690,17 @@ export class StopGapWidgetDriver extends WidgetDriver { return { file: blob }; } + /** + * Gets the IDs of all joined or invited rooms currently known to the + * client. + * @returns The room IDs. + */ + public getKnownRooms(): string[] { + return MatrixClientPeg.safeGet() + .getVisibleRooms(SettingsStore.getValue("feature_dynamic_room_predecessors")) + .map((r) => r.roomId); + } + /** * Expresses a {@link MatrixError} as a JSON payload * for use by Widget API error responses. diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index f767c96a028..61e96886b90 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { mocked, MockedObject } from "jest-mock"; +import { mocked, MockedFunction, MockedObject } from "jest-mock"; import { last } from "lodash"; import { MatrixEvent, @@ -15,15 +15,20 @@ import { EventTimeline, EventType, MatrixEventEvent, + RoomStateEvent, + RoomState, } from "matrix-js-sdk/src/matrix"; import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api"; import { waitFor } from "jest-matrix-react"; +import { Optional } from "matrix-events-sdk"; import { stubClient, mkRoom, mkEvent } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget"; import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore"; import SettingsStore from "../../../../src/settings/SettingsStore"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; +import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore"; jest.mock("matrix-widget-api/lib/ClientWidgetApi"); @@ -53,6 +58,7 @@ describe("StopGapWidget", () => { // Start messaging without an iframe, since ClientWidgetApi is mocked widget.startMessaging(null as unknown as HTMLIFrameElement); messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + messaging.feedStateUpdate.mockResolvedValue(); }); afterEach(() => { @@ -84,6 +90,20 @@ describe("StopGapWidget", () => { expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false); }); + it("feeds incoming state updates to the widget", () => { + const event = mkEvent({ + event: true, + type: "org.example.foo", + skey: "", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + + client.emit(RoomStateEvent.Events, event, {} as unknown as RoomState, null); + expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent()); + }); + describe("feed event", () => { let event1: MatrixEvent; let event2: MatrixEvent; @@ -118,24 +138,24 @@ describe("StopGapWidget", () => { it("feeds incoming event to the widget", async () => { client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); client.emit(ClientEvent.Event, event2); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); }); it("should not feed incoming event to the widget if seen already", async () => { client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); client.emit(ClientEvent.Event, event2); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); client.emit(ClientEvent.Event, event1); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); }); it("feeds decrypted events asynchronously", async () => { @@ -165,7 +185,7 @@ describe("StopGapWidget", () => { decryptingSpy2.mockReturnValue(false); client.emit(MatrixEventEvent.Decrypted, event2Encrypted); expect(messaging.feedEvent).toHaveBeenCalledTimes(1); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent()); // …then event 1 event1Encrypted.event.type = event1.getType(); event1Encrypted.event.content = event1.getContent(); @@ -175,7 +195,7 @@ describe("StopGapWidget", () => { // doesn't have to be blocked on the decryption of event 1 (or // worse, dropped) expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent()); }); it("should not feed incoming event if not in timeline", () => { @@ -191,7 +211,7 @@ describe("StopGapWidget", () => { }); client.emit(ClientEvent.Event, event); - expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent()); }); it("feeds incoming event that is not in timeline but relates to unknown parent to the widget", async () => { @@ -211,18 +231,19 @@ describe("StopGapWidget", () => { }); client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); client.emit(ClientEvent.Event, event); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); client.emit(ClientEvent.Event, event1); expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent(), "!1:example.org"); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); }); }); }); + describe("StopGapWidget with stickyPromise", () => { let client: MockedObject; let widget: StopGapWidget; @@ -288,3 +309,49 @@ describe("StopGapWidget with stickyPromise", () => { waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 }); }); }); + +describe("StopGapWidget as an account widget", () => { + let widget: StopGapWidget; + let messaging: MockedObject; + let getRoomId: MockedFunction<() => Optional>; + + beforeEach(() => { + stubClient(); + // I give up, getting the return type of spyOn right is hopeless + getRoomId = jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") as unknown as MockedFunction< + () => Optional + >; + getRoomId.mockReturnValue("!1:example.org"); + + widget = new StopGapWidget({ + app: { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", + roomId: "!1:example.org", + }, + userId: "@alice:example.org", + creatorUserId: "@alice:example.org", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.startMessaging(null as unknown as HTMLIFrameElement); + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + }); + + afterEach(() => { + widget.stopMessaging(); + getRoomId.mockRestore(); + }); + + it("updates viewed room", () => { + expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(1); + expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!1:example.org"); + getRoomId.mockReturnValue("!2:example.org"); + SdkContextClass.instance.roomViewStore.emit(UPDATE_EVENT); + expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(2); + expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!2:example.org"); + }); +}); diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index e484d0cc33f..ccf2638d506 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -17,6 +17,7 @@ import { MatrixEvent, MsgType, RelationType, + Room, } from "matrix-js-sdk/src/matrix"; import { Widget, @@ -38,7 +39,7 @@ import { import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver"; -import { stubClient } from "../../../test-utils"; +import { mkEvent, stubClient } from "../../../test-utils"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; import dis from "../../../../src/dispatcher/dispatcher"; import Modal from "../../../../src/Modal"; @@ -569,7 +570,7 @@ describe("StopGapWidgetDriver", () => { it("passes the flag through to getVisibleRooms", () => { const driver = mkDefaultDriver(); - driver.readRoomEvents(EventType.CallAnswer, "", 0, ["*"]); + driver.getKnownRooms(); expect(client.getVisibleRooms).toHaveBeenCalledWith(false); }); }); @@ -584,7 +585,7 @@ describe("StopGapWidgetDriver", () => { it("passes the flag through to getVisibleRooms", () => { const driver = mkDefaultDriver(); - driver.readRoomEvents(EventType.CallAnswer, "", 0, ["*"]); + driver.getKnownRooms(); expect(client.getVisibleRooms).toHaveBeenCalledWith(true); }); }); @@ -692,4 +693,107 @@ describe("StopGapWidgetDriver", () => { await expect(file.text()).resolves.toEqual("test contents"); }); }); + + describe("readRoomTimeline", () => { + const event1 = mkEvent({ + event: true, + id: "$event-id1", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + const event2 = mkEvent({ + event: true, + id: "$event-id2", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + let driver: WidgetDriver; + + beforeEach(() => { + driver = mkDefaultDriver(); + client.getRoom.mockReturnValue({ + getLiveTimeline: () => ({ getEvents: () => [event1, event2] }), + } as unknown as Room); + }); + + it("reads all events", async () => { + expect( + await driver.readRoomTimeline("!1:example.org", "org.example.foo", undefined, undefined, 10, undefined), + ).toEqual([event2, event1].map((e) => e.getEffectiveEvent())); + }); + + it("reads up to a limit", async () => { + expect( + await driver.readRoomTimeline("!1:example.org", "org.example.foo", undefined, undefined, 1, undefined), + ).toEqual([event2.getEffectiveEvent()]); + }); + + it("reads up to a specific event", async () => { + expect( + await driver.readRoomTimeline( + "!1:example.org", + "org.example.foo", + undefined, + undefined, + 10, + event1.getId(), + ), + ).toEqual([event2.getEffectiveEvent()]); + }); + }); + + describe("readRoomState", () => { + const event1 = mkEvent({ + event: true, + id: "$event-id1", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + skey: "1", + room: "!1:example.org", + }); + const event2 = mkEvent({ + event: true, + id: "$event-id2", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + skey: "2", + room: "!1:example.org", + }); + let driver: WidgetDriver; + let getStateEvents: jest.Mock; + + beforeEach(() => { + driver = mkDefaultDriver(); + getStateEvents = jest.fn(); + client.getRoom.mockReturnValue({ + getLiveTimeline: () => ({ getState: () => ({ getStateEvents }) }), + } as unknown as Room); + }); + + it("reads a specific state key", async () => { + getStateEvents.mockImplementation((eventType, stateKey) => { + if (eventType === "org.example.foo" && stateKey === "1") return event1; + return undefined; + }); + expect(await driver.readRoomState("!1:example.org", "org.example.foo", "1")).toEqual([ + event1.getEffectiveEvent(), + ]); + }); + + it("reads all state keys", async () => { + getStateEvents.mockImplementation((eventType, stateKey) => { + if (eventType === "org.example.foo" && stateKey === undefined) return [event1, event2]; + return []; + }); + expect(await driver.readRoomState("!1:example.org", "org.example.foo", undefined)).toEqual( + [event1, event2].map((e) => e.getEffectiveEvent()), + ); + }); + }); }); From 9a109cdce8e4fec1d49f07bdb19e3ca6d10cabdc Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 22 Jan 2025 19:47:49 +0100 Subject: [PATCH 2/2] test: remove unused toast screenshot (#29074) --- .../key-storage-out-of-sync-toast-linux.png | Bin 19162 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png diff --git a/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png b/playwright/snapshots/crypto/toasts.spec.ts/key-storage-out-of-sync-toast-linux.png deleted file mode 100644 index 8e335bd2323b56429f8b03fdbcefd5ed77e9d6f2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19162 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