You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-09 10:22:46 +03:00
* WIP doodles on MembershipManager test cases
* .
* initial membership manager test setup.
* Updates from discussion
* revert renaming comments
* remove unused import
* fix leave delayed event resend test.
It was missing a flush.
* comment out and remove unused variables
* es lint
* use jsdom instead of node test environment
* remove unused variables
* remove unused export
* temp
* review
* fixup tests
* more review
* remove wait for expect dependency
* temp
* fix wrong mocked meberhsip template
* rename MembershipManager -> LegacyMembershipManager
And remove the IMembershipManager from it
* Add new memberhsip manager
* fix tests to be compatible with old and new membership manager
* Comment cleanup
* Allow join to throw
- Add tests for throwing cases
- Fixs based on tests
* introduce membershipExpiryTimeoutSlack
* more detailed comments and cleanup
* warn if slack is misconfigured and use default values instead
* fix action resets.
* flatten MembershipManager.spec.ts
* rename testEnvironment to memberManagerTestEnvironment
* allow configuring Legacy manager in the matrixRTC session
* deprecate LegacyMembershipManager
* remove usage of waitForExpect
* flatten tests and add comments
* clean up leave logic branch
* add more leave test cases
* use defer
* review ("Some minor tidying things for now.")
* add onError for join method and cleanup
* use pop instead of filter
* fixes
* simplify error handling and MembershipAction
Only use one membership action enum
* Add diagram
* fix new error api in rtc session
* fix up retry counter
* fix lints
* make unrecoverable errors more explicit
* fix tests
* Allow multiple retries on the rtc state event http requests.
* use then catch for startup
* no try catch 1
* update expire headroom logic
transition from try catch to .then .catch
* replace flushPromise with advanceTimersByTimeAsync
* fix leaving special cases
* more unrecoverable errors special cases
* move to MatrixRTCSessionManager logger
* add state reset and add another unhandleable error
The error occurs if we want to cancel the delayed event we still have an id for but get a non expected error.
* missed review fixes
* remove @jest/environment dependency
* Cleanup awaits and Make mock types more correct.
Make every mock return a Promise if the real implementation does return a pormise.
* remove flush promise dependency
* fix not recreating default state on reset
This broke all tests since we only created the state once and than passed by ref
* Use per action rate limit and retry counter
There can be multiple retries at once so we need to store counters per action
e.g. the send update membership and the restart delayed could be rate limited at the same time.
* add linting to matrixrtc tests
* Add fix async lints and use matrix rtc logger for test environment.
* prettier
* review step 1
* change to MatrixRTCSession logger
* review step 2
* make LoopHandler Private
* update config to use NewManager wording
* emit error on rtc session if the membership manager encounters one
* network error and throw refactor
* make accessing the full room deprecated
* remove deprecated usage of full room
* Clean up the deprecation
* add network error handler and cleanup
* better logging, another test, make maximumNetworkErrorRetryCount configurable
* more logging & refactor leave promise
* add ConnectionError as possible retry cause
* Make it work in embedded mode with a server that does not support delayed events
* review iteration 1
* review iteration 2
* first step in improving widget error handling
* make the embedded client throw ConnectionErrors where desired.
* fix tests
* delayed event sending widget mode stop gap fix.
* improve comment
* fix unrecoverable error joinState (and add JoinStateChanged) emission.
* check that we do not add multipe sendFirstDelayed Events
* also check insertions queue
* always log "Missing own membership: force re-join"
* Do not update the membership if we are in any (a later) state of sending our own state.
The scheduled states MembershipActionType.SendFirstDelayedEvent and MembershipActionType.SendJoinEvent both imply that we are already trying to send our own membership state event.
* make leave reset actually stop the manager.
The reset case was not covered properly. There are cases where it is not allowed to add additional events after a reset and cases where we want to add more events after the reset. We need to allow this as a reset property.
* fix tests (and implementation)
* Allow MembershipManger to be set at runtime via JoinConfig.membershipManagerFactory
* Map actions into status as a sanity check
* Log status change after applying actions
* Add todo
* Cleanup
* Log transition from earlier status
* remove redundant status implementation
also add TODO comment to not forget about this.
* More cleanup
* Consider insertions in status()
* Log duration for emitting MatrixRTCSessionEvent.MembershipsChanged
* add another valid condition for connected
* some TODO cleanup
* review add warning when using addAction while the scheduler is not running.
* es lint
* refactor to return based handler approach (remove insertions array)
* refactor: Move action scheduler
* refactor: move different handler cases into separate functions
* linter
* review: delayed events endpoint error
* review
* Suggestions from pair review
* resetState is actually only used internally
* Revert "resetState is actually only used internally"
This reverts commit 6af4730919
.
* refactor: running is part of the scheduler (not state)
* refactor: move everything state related from schduler to manager.
* review
* Update src/matrixrtc/NewMembershipManager.ts
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
* review
* public -> private + missed review fiexes (comment typos)
---------
Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
881 lines
39 KiB
TypeScript
881 lines
39 KiB
TypeScript
/**
|
|
* @jest-environment jsdom
|
|
*/
|
|
|
|
/*
|
|
Copyright 2022 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.
|
|
*/
|
|
|
|
// We have to use EventEmitter here to mock part of the matrix-widget-api
|
|
// project, which doesn't know about our TypeEventEmitter implementation at all
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { EventEmitter } from "events";
|
|
import { type MockedObject } from "jest-mock";
|
|
import {
|
|
type WidgetApi,
|
|
WidgetApiToWidgetAction,
|
|
MatrixCapabilities,
|
|
type ITurnServer,
|
|
type IRoomEvent,
|
|
type IOpenIDCredentials,
|
|
type ISendEventFromWidgetResponseData,
|
|
WidgetApiResponseError,
|
|
} from "matrix-widget-api";
|
|
|
|
import { createRoomWidgetClient, MatrixError, MsgType, UpdateDelayedEventAction } from "../../src/matrix";
|
|
import { MatrixClient, ClientEvent, type ITurnServer as IClientTurnServer } from "../../src/client";
|
|
import { SyncState } from "../../src/sync";
|
|
import { type ICapabilities, type RoomWidgetClient } from "../../src/embedded";
|
|
import { MatrixEvent } from "../../src/models/event";
|
|
import { type ToDeviceBatch } from "../../src/models/ToDeviceMessage";
|
|
import { sleep } from "../../src/utils";
|
|
|
|
const testOIDCToken = {
|
|
access_token: "12345678",
|
|
expires_in: "10",
|
|
matrix_server_name: "homeserver.oabc",
|
|
token_type: "Bearer",
|
|
};
|
|
class MockWidgetApi extends EventEmitter {
|
|
public start = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapability = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilities = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityForRoomTimeline = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToSendEvent = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToReceiveEvent = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToSendMessage = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToReceiveMessage = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToSendState = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToReceiveState = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToSendToDevice = jest.fn().mockResolvedValue(undefined);
|
|
public requestCapabilityToReceiveToDevice = jest.fn().mockResolvedValue(undefined);
|
|
public sendRoomEvent = jest.fn(
|
|
async (eventType: string, content: unknown, roomId?: string, delay?: number, parentDelayId?: string) =>
|
|
delay === undefined && parentDelayId === undefined
|
|
? { event_id: `$${Math.random()}` }
|
|
: { delay_id: `id-${Math.random()}` },
|
|
);
|
|
public sendStateEvent = jest.fn(
|
|
async (
|
|
eventType: string,
|
|
stateKey: string,
|
|
content: unknown,
|
|
roomId?: string,
|
|
delay?: number,
|
|
parentDelayId?: string,
|
|
) =>
|
|
delay === undefined && parentDelayId === undefined
|
|
? { event_id: `$${Math.random()}` }
|
|
: { delay_id: `id-${Math.random()}` },
|
|
);
|
|
public updateDelayedEvent = jest.fn().mockResolvedValue(undefined);
|
|
public sendToDevice = jest.fn().mockResolvedValue(undefined);
|
|
public requestOpenIDConnectToken = jest.fn(async () => {
|
|
return testOIDCToken;
|
|
return new Promise<IOpenIDCredentials>(() => {
|
|
return testOIDCToken;
|
|
});
|
|
});
|
|
public readStateEvents = jest.fn(async () => []);
|
|
public getTurnServers = jest.fn(async () => []);
|
|
public sendContentLoaded = jest.fn().mockResolvedValue(undefined);
|
|
|
|
public transport = {
|
|
reply: jest.fn(),
|
|
send: jest.fn(),
|
|
sendComplete: jest.fn(),
|
|
};
|
|
}
|
|
|
|
declare module "../../src/types" {
|
|
interface StateEvents {
|
|
"org.example.foo": {
|
|
hello: string;
|
|
};
|
|
}
|
|
|
|
interface TimelineEvents {
|
|
"org.matrix.rageshake_request": {
|
|
request_id: number;
|
|
};
|
|
}
|
|
}
|
|
|
|
describe("RoomWidgetClient", () => {
|
|
let widgetApi: MockedObject<WidgetApi>;
|
|
let client: MatrixClient;
|
|
|
|
beforeEach(() => {
|
|
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
|
|
});
|
|
|
|
afterEach(() => {
|
|
client.stopClient();
|
|
});
|
|
|
|
const makeClient = async (
|
|
capabilities: ICapabilities,
|
|
sendContentLoaded: boolean | undefined = undefined,
|
|
userId?: string,
|
|
): Promise<void> => {
|
|
const baseUrl = "https://example.org";
|
|
client = createRoomWidgetClient(
|
|
widgetApi,
|
|
capabilities,
|
|
"!1:example.org",
|
|
{ baseUrl, userId },
|
|
sendContentLoaded,
|
|
);
|
|
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
|
|
widgetApi.emit("ready");
|
|
await client.startClient();
|
|
};
|
|
|
|
describe("events", () => {
|
|
it("sends", async () => {
|
|
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
|
|
await client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 });
|
|
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
"!1:example.org",
|
|
);
|
|
});
|
|
|
|
it("send handles wrong field in response", async () => {
|
|
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
|
|
widgetApi.sendRoomEvent.mockResolvedValueOnce({
|
|
room_id: "!1:example.org",
|
|
delay_id: `id-${Math.random}`,
|
|
});
|
|
await expect(
|
|
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 }),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("receives", async () => {
|
|
const event = new MatrixEvent({
|
|
type: "org.matrix.rageshake_request",
|
|
event_id: "$pduhfiidph",
|
|
room_id: "!1:example.org",
|
|
sender: "@alice:example.org",
|
|
content: { request_id: 123 },
|
|
}).getEffectiveEvent();
|
|
|
|
await makeClient({ receiveEvent: ["org.matrix.rageshake_request"] });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToReceiveEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
|
|
|
|
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
|
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
|
widgetApi.emit(
|
|
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
|
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
|
);
|
|
|
|
// The client should've emitted about the received event
|
|
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
|
expect(await emittedSync).toEqual(SyncState.Syncing);
|
|
// It should've also inserted the event into the room object
|
|
const room = client.getRoom("!1:example.org");
|
|
expect(room).not.toBeNull();
|
|
expect(
|
|
room!
|
|
.getLiveTimeline()
|
|
.getEvents()
|
|
.map((e) => e.getEffectiveEvent()),
|
|
).toEqual([event]);
|
|
});
|
|
describe("local echos", () => {
|
|
const setupRemoteEcho = () => {
|
|
makeClient(
|
|
{
|
|
receiveEvent: ["org.matrix.rageshake_request"],
|
|
sendEvent: ["org.matrix.rageshake_request"],
|
|
},
|
|
undefined,
|
|
"@me:example.org",
|
|
);
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToReceiveEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
|
|
const injectSpy = jest.spyOn((client as any).syncApi, "injectRoomEvents");
|
|
const widgetSendEmitter = new EventEmitter();
|
|
const widgetSendPromise = new Promise<void>((resolve) =>
|
|
widgetSendEmitter.once("send", () => resolve()),
|
|
);
|
|
const resolveWidgetSend = () => widgetSendEmitter.emit("send");
|
|
widgetApi.sendRoomEvent.mockImplementation(
|
|
async (eType, content, roomId): Promise<ISendEventFromWidgetResponseData> => {
|
|
await widgetSendPromise;
|
|
return { room_id: "!1:example.org", event_id: "event_id" };
|
|
},
|
|
);
|
|
return { injectSpy, resolveWidgetSend };
|
|
};
|
|
const remoteEchoEvent = new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
|
|
detail: {
|
|
data: {
|
|
type: "org.matrix.rageshake_request",
|
|
|
|
room_id: "!1:example.org",
|
|
event_id: "event_id",
|
|
sender: "@me:example.org",
|
|
state_key: "bar",
|
|
content: { hello: "world" },
|
|
unsigned: { transaction_id: "1234" },
|
|
},
|
|
},
|
|
cancelable: true,
|
|
});
|
|
it("get response then local echo", async () => {
|
|
await sleep(600);
|
|
const { injectSpy, resolveWidgetSend } = await setupRemoteEcho();
|
|
|
|
// Begin by sending an event:
|
|
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 12 }, "widgetTxId");
|
|
// we do not expect it to be send -- until we call `resolveWidgetSend`
|
|
expect(injectSpy).not.toHaveBeenCalled();
|
|
|
|
// We first get the response from the widget
|
|
resolveWidgetSend();
|
|
// We then get the remote echo from the widget
|
|
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, remoteEchoEvent);
|
|
|
|
// gets emitted after the event got injected
|
|
await new Promise<void>((resolve) => client.once(ClientEvent.Event, () => resolve()));
|
|
expect(injectSpy).toHaveBeenCalled();
|
|
|
|
const call = injectSpy.mock.calls[0] as any;
|
|
const injectedEv = call[3][0];
|
|
expect(injectedEv.getType()).toBe("org.matrix.rageshake_request");
|
|
expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId");
|
|
});
|
|
|
|
it("get local echo then response", async () => {
|
|
await sleep(600);
|
|
const { injectSpy, resolveWidgetSend } = await setupRemoteEcho();
|
|
|
|
// Begin by sending an event:
|
|
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 12 }, "widgetTxId");
|
|
// we do not expect it to be send -- until we call `resolveWidgetSend`
|
|
expect(injectSpy).not.toHaveBeenCalled();
|
|
|
|
// We first get the remote echo from the widget
|
|
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, remoteEchoEvent);
|
|
expect(injectSpy).not.toHaveBeenCalled();
|
|
|
|
// We then get the response from the widget
|
|
resolveWidgetSend();
|
|
|
|
// Gets emitted after the event got injected
|
|
await new Promise<void>((resolve) => client.once(ClientEvent.Event, () => resolve()));
|
|
expect(injectSpy).toHaveBeenCalled();
|
|
|
|
const call = injectSpy.mock.calls[0] as any;
|
|
const injectedEv = call[3][0];
|
|
expect(injectedEv.getType()).toBe("org.matrix.rageshake_request");
|
|
expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId");
|
|
});
|
|
it("__ local echo then response", async () => {
|
|
await sleep(600);
|
|
const { injectSpy, resolveWidgetSend } = await setupRemoteEcho();
|
|
|
|
// Begin by sending an event:
|
|
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 12 }, "widgetTxId");
|
|
// we do not expect it to be send -- until we call `resolveWidgetSend`
|
|
expect(injectSpy).not.toHaveBeenCalled();
|
|
|
|
// We first get the remote echo from the widget
|
|
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, remoteEchoEvent);
|
|
const otherRemoteEcho = new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, {
|
|
detail: { data: { ...remoteEchoEvent.detail.data } },
|
|
});
|
|
otherRemoteEcho.detail.data.unsigned.transaction_id = "4567";
|
|
otherRemoteEcho.detail.data.event_id = "other_id";
|
|
widgetApi.emit(`action:${WidgetApiToWidgetAction.SendEvent}`, otherRemoteEcho);
|
|
|
|
// Simulate the wait time while the widget is waiting for a response
|
|
// after we already received the remote echo
|
|
await sleep(20);
|
|
// even after the wait we do not want any event to be injected.
|
|
// we do not know their event_id and cannot know if they are the remote echo
|
|
// where we need to update the txId because they are send by this client
|
|
expect(injectSpy).not.toHaveBeenCalled();
|
|
// We then get the response from the widget
|
|
resolveWidgetSend();
|
|
|
|
// Gets emitted after the event got injected
|
|
await new Promise<void>((resolve) => client.once(ClientEvent.Event, () => resolve()));
|
|
// Now we want both events to be injected since we know the txId - event_id match
|
|
expect(injectSpy).toHaveBeenCalled();
|
|
|
|
// it has been called with the event sent by ourselves
|
|
const call = injectSpy.mock.calls[0] as any;
|
|
const injectedEv = call[3][0];
|
|
expect(injectedEv.getType()).toBe("org.matrix.rageshake_request");
|
|
expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId");
|
|
|
|
// It has been called by the event we blocked because of our send right afterwards
|
|
const call2 = injectSpy.mock.calls[1] as any;
|
|
const injectedEv2 = call2[3][0];
|
|
expect(injectedEv2.getType()).toBe("org.matrix.rageshake_request");
|
|
expect(injectedEv2.getUnsigned().transaction_id).toBe("4567");
|
|
});
|
|
});
|
|
|
|
it("handles widget errors with generic error data", async () => {
|
|
const error = new Error("failed to send");
|
|
widgetApi.transport.send.mockRejectedValue(error);
|
|
|
|
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
|
|
widgetApi.sendRoomEvent.mockImplementation(widgetApi.transport.send);
|
|
|
|
await expect(
|
|
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 }),
|
|
).rejects.toThrow(error);
|
|
});
|
|
|
|
it("handles widget errors with Matrix API error response data", async () => {
|
|
const errorStatusCode = 400;
|
|
const errorUrl = "http://example.org";
|
|
const errorData = {
|
|
errcode: "M_BAD_JSON",
|
|
error: "Invalid body",
|
|
};
|
|
|
|
const widgetError = new WidgetApiResponseError("failed to send", {
|
|
matrix_api_error: {
|
|
http_status: errorStatusCode,
|
|
http_headers: {},
|
|
url: errorUrl,
|
|
response: errorData,
|
|
},
|
|
});
|
|
const matrixError = new MatrixError(errorData, errorStatusCode, errorUrl);
|
|
|
|
widgetApi.transport.send.mockRejectedValue(widgetError);
|
|
|
|
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
|
|
widgetApi.sendRoomEvent.mockImplementation(widgetApi.transport.send);
|
|
|
|
await expect(
|
|
client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 }),
|
|
).rejects.toThrow(matrixError);
|
|
});
|
|
});
|
|
|
|
describe("delayed events", () => {
|
|
describe("when supported", () => {
|
|
const doesServerSupportUnstableFeatureMock = jest.fn((feature) =>
|
|
Promise.resolve(feature === "org.matrix.msc4140"),
|
|
);
|
|
|
|
beforeAll(() => {
|
|
MatrixClient.prototype.doesServerSupportUnstableFeature = doesServerSupportUnstableFeatureMock;
|
|
});
|
|
|
|
afterAll(() => {
|
|
doesServerSupportUnstableFeatureMock.mockReset();
|
|
});
|
|
|
|
it("sends delayed message events", async () => {
|
|
await makeClient({ sendDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
|
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent);
|
|
await client._unstable_sendDelayedEvent(
|
|
"!1:example.org",
|
|
{ delay: 2000 },
|
|
null,
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
);
|
|
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
"!1:example.org",
|
|
2000,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("sends child action delayed message events", async () => {
|
|
await makeClient({ sendDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
|
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent);
|
|
const parentDelayId = `id-${Math.random()}`;
|
|
await client._unstable_sendDelayedEvent(
|
|
"!1:example.org",
|
|
{ parent_delay_id: parentDelayId },
|
|
null,
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
);
|
|
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
"!1:example.org",
|
|
undefined,
|
|
parentDelayId,
|
|
);
|
|
});
|
|
|
|
it("sends delayed state events", async () => {
|
|
await makeClient({
|
|
sendDelayedEvents: true,
|
|
sendState: [{ eventType: "org.example.foo", stateKey: "bar" }],
|
|
});
|
|
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent);
|
|
await client._unstable_sendDelayedStateEvent(
|
|
"!1:example.org",
|
|
{ delay: 2000 },
|
|
"org.example.foo",
|
|
{ hello: "world" },
|
|
"bar",
|
|
);
|
|
expect(widgetApi.sendStateEvent).toHaveBeenCalledWith(
|
|
"org.example.foo",
|
|
"bar",
|
|
{ hello: "world" },
|
|
"!1:example.org",
|
|
2000,
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("sends child action delayed state events", async () => {
|
|
await makeClient({
|
|
sendDelayedEvents: true,
|
|
sendState: [{ eventType: "org.example.foo", stateKey: "bar" }],
|
|
});
|
|
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157SendDelayedEvent);
|
|
const parentDelayId = `fg-${Math.random()}`;
|
|
await client._unstable_sendDelayedStateEvent(
|
|
"!1:example.org",
|
|
{ parent_delay_id: parentDelayId },
|
|
"org.example.foo",
|
|
{ hello: "world" },
|
|
"bar",
|
|
);
|
|
expect(widgetApi.sendStateEvent).toHaveBeenCalledWith(
|
|
"org.example.foo",
|
|
"bar",
|
|
{ hello: "world" },
|
|
"!1:example.org",
|
|
undefined,
|
|
parentDelayId,
|
|
);
|
|
});
|
|
|
|
it("send delayed message events handles wrong field in response", async () => {
|
|
await makeClient({ sendDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
|
widgetApi.sendRoomEvent.mockResolvedValueOnce({
|
|
room_id: "!1:example.org",
|
|
event_id: `$${Math.random()}`,
|
|
});
|
|
await expect(
|
|
client._unstable_sendDelayedEvent(
|
|
"!1:example.org",
|
|
{ delay: 2000 },
|
|
null,
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("send delayed state events handles wrong field in response", async () => {
|
|
await makeClient({
|
|
sendDelayedEvents: true,
|
|
sendState: [{ eventType: "org.example.foo", stateKey: "bar" }],
|
|
});
|
|
widgetApi.sendStateEvent.mockResolvedValueOnce({
|
|
room_id: "!1:example.org",
|
|
event_id: `$${Math.random()}`,
|
|
});
|
|
await expect(
|
|
client._unstable_sendDelayedStateEvent(
|
|
"!1:example.org",
|
|
{ delay: 2000 },
|
|
"org.example.foo",
|
|
{ hello: "world" },
|
|
"bar",
|
|
),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("updates delayed events", async () => {
|
|
await makeClient({ updateDelayedEvents: true, sendEvent: ["org.matrix.rageshake_request"] });
|
|
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC4157UpdateDelayedEvent);
|
|
for (const action of [
|
|
UpdateDelayedEventAction.Cancel,
|
|
UpdateDelayedEventAction.Restart,
|
|
UpdateDelayedEventAction.Send,
|
|
]) {
|
|
await client._unstable_updateDelayedEvent("id", action);
|
|
expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith("id", action);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("when unsupported", () => {
|
|
it("fails to send delayed message events", async () => {
|
|
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
|
|
await expect(
|
|
client._unstable_sendDelayedEvent(
|
|
"!1:example.org",
|
|
{ delay: 2000 },
|
|
null,
|
|
"org.matrix.rageshake_request",
|
|
{ request_id: 123 },
|
|
),
|
|
).rejects.toThrow("Server does not support");
|
|
});
|
|
|
|
it("fails to send delayed state events", async () => {
|
|
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
|
await expect(
|
|
client._unstable_sendDelayedStateEvent(
|
|
"!1:example.org",
|
|
{ delay: 2000 },
|
|
"org.example.foo",
|
|
{ hello: "world" },
|
|
"bar",
|
|
),
|
|
).rejects.toThrow("Server does not support");
|
|
});
|
|
|
|
it("fails to update delayed state events", async () => {
|
|
await makeClient({});
|
|
for (const action of [
|
|
UpdateDelayedEventAction.Cancel,
|
|
UpdateDelayedEventAction.Restart,
|
|
UpdateDelayedEventAction.Send,
|
|
]) {
|
|
await expect(client._unstable_updateDelayedEvent("id", action)).rejects.toThrow(
|
|
"Server does not support",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("initialization", () => {
|
|
it("requests permissions for specific message types", async () => {
|
|
await makeClient({ sendMessage: [MsgType.Text], receiveMessage: [MsgType.Text] });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToSendMessage).toHaveBeenCalledWith(MsgType.Text);
|
|
expect(widgetApi.requestCapabilityToReceiveMessage).toHaveBeenCalledWith(MsgType.Text);
|
|
});
|
|
|
|
it("requests permissions for all message types", async () => {
|
|
await makeClient({ sendMessage: true, receiveMessage: true });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToSendMessage).toHaveBeenCalledWith();
|
|
expect(widgetApi.requestCapabilityToReceiveMessage).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it("sends content loaded when configured", async () => {
|
|
await makeClient({});
|
|
expect(widgetApi.sendContentLoaded).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not sent content loaded when configured", async () => {
|
|
await makeClient({}, false);
|
|
expect(widgetApi.sendContentLoaded).not.toHaveBeenCalled();
|
|
});
|
|
// No point in testing sending and receiving since it's done exactly the
|
|
// same way as non-message events
|
|
});
|
|
|
|
describe("state events", () => {
|
|
const event = new MatrixEvent({
|
|
type: "org.example.foo",
|
|
event_id: "$sfkjfsksdkfsd",
|
|
room_id: "!1:example.org",
|
|
sender: "@alice:example.org",
|
|
state_key: "bar",
|
|
content: { hello: "world" },
|
|
}).getEffectiveEvent();
|
|
|
|
it("sends", async () => {
|
|
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToSendState).toHaveBeenCalledWith("org.example.foo", "bar");
|
|
await client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar");
|
|
expect(widgetApi.sendStateEvent).toHaveBeenCalledWith(
|
|
"org.example.foo",
|
|
"bar",
|
|
{ hello: "world" },
|
|
"!1:example.org",
|
|
);
|
|
});
|
|
|
|
it("send handles incorrect response", async () => {
|
|
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
|
widgetApi.sendStateEvent.mockResolvedValueOnce({
|
|
room_id: "!1:example.org",
|
|
delay_id: `id-${Math.random}`,
|
|
});
|
|
await expect(
|
|
client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar"),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("receives", async () => {
|
|
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
|
|
|
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.Event, resolve));
|
|
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
|
widgetApi.emit(
|
|
`action:${WidgetApiToWidgetAction.SendEvent}`,
|
|
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
|
|
);
|
|
|
|
// The client should've emitted about the received event
|
|
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
|
|
expect(await emittedSync).toEqual(SyncState.Syncing);
|
|
// It should've also inserted the event into the room object
|
|
const room = client.getRoom("!1:example.org");
|
|
expect(room).not.toBeNull();
|
|
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
|
});
|
|
|
|
it("backfills", async () => {
|
|
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
|
|
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
|
|
? [event as IRoomEvent]
|
|
: [],
|
|
);
|
|
|
|
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
|
|
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
|
|
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
|
|
|
|
const room = client.getRoom("!1:example.org");
|
|
expect(room).not.toBeNull();
|
|
expect(room!.currentState.getStateEvents("org.example.foo", "bar")?.getEffectiveEvent()).toEqual(event);
|
|
});
|
|
});
|
|
|
|
describe("to-device messages", () => {
|
|
const unencryptedContentMap = new Map([
|
|
["@alice:example.org", new Map([["*", { hello: "alice!" }]])],
|
|
["@bob:example.org", new Map([["bobDesktop", { hello: "bob!" }]])],
|
|
]);
|
|
|
|
const expectedRequestData = {
|
|
["@alice:example.org"]: { ["*"]: { hello: "alice!" } },
|
|
["@bob:example.org"]: { ["bobDesktop"]: { hello: "bob!" } },
|
|
};
|
|
|
|
const encryptedContentMap = new Map<string, Map<string, object>>([
|
|
["@alice:example.org", new Map([["aliceMobile", { hello: "alice!" }]])],
|
|
["@bob:example.org", new Map([["bobDesktop", { hello: "bob!" }]])],
|
|
]);
|
|
|
|
it("sends unencrypted (sendToDeviceViaWidgetApi)", async () => {
|
|
await makeClient({ sendToDevice: ["org.example.foo"] });
|
|
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
|
|
|
await (client as RoomWidgetClient).sendToDeviceViaWidgetApi(
|
|
"org.example.foo",
|
|
false,
|
|
unencryptedContentMap,
|
|
);
|
|
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
|
});
|
|
|
|
it("sends unencrypted (sendToDevice)", async () => {
|
|
await makeClient({ sendToDevice: ["org.example.foo"] });
|
|
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
|
|
|
await client.sendToDevice("org.example.foo", unencryptedContentMap);
|
|
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
|
});
|
|
|
|
it("sends unencrypted (queueToDevice)", async () => {
|
|
await makeClient({ sendToDevice: ["org.example.foo"] });
|
|
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
|
|
|
const batch: ToDeviceBatch = {
|
|
eventType: "org.example.foo",
|
|
batch: [
|
|
{ userId: "@alice:example.org", deviceId: "*", payload: { hello: "alice!" } },
|
|
{ userId: "@bob:example.org", deviceId: "bobDesktop", payload: { hello: "bob!" } },
|
|
],
|
|
};
|
|
await client.queueToDevice(batch);
|
|
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
|
|
});
|
|
|
|
it("sends encrypted (encryptAndSendToDevices)", async () => {
|
|
await makeClient({ sendToDevice: ["org.example.foo"] });
|
|
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
|
|
|
const payload = { type: "org.example.foo", hello: "world" };
|
|
const embeddedClient = client as RoomWidgetClient;
|
|
await embeddedClient.encryptAndSendToDevices(
|
|
[
|
|
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
|
|
{ userId: "@bob:example.org", deviceId: "bobDesktop" },
|
|
],
|
|
payload,
|
|
);
|
|
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", true, {
|
|
"@alice:example.org": { aliceWeb: payload },
|
|
"@bob:example.org": { bobDesktop: payload },
|
|
});
|
|
});
|
|
|
|
it("sends encrypted (sendToDeviceViaWidgetApi)", async () => {
|
|
await makeClient({ sendToDevice: ["org.example.foo"] });
|
|
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
|
|
|
|
await (client as RoomWidgetClient).sendToDeviceViaWidgetApi("org.example.foo", true, encryptedContentMap);
|
|
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", true, {
|
|
"@alice:example.org": { aliceMobile: { hello: "alice!" } },
|
|
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ encrypted: false, title: "unencrypted" },
|
|
{ encrypted: true, title: "encrypted" },
|
|
])("receives $title", async ({ encrypted }) => {
|
|
await makeClient({ receiveToDevice: ["org.example.foo"] });
|
|
expect(widgetApi.requestCapabilityToReceiveToDevice).toHaveBeenCalledWith("org.example.foo");
|
|
|
|
const event = {
|
|
type: "org.example.foo",
|
|
sender: "@alice:example.org",
|
|
encrypted,
|
|
content: { hello: "world" },
|
|
};
|
|
|
|
const emittedEvent = new Promise<MatrixEvent>((resolve) => client.once(ClientEvent.ToDeviceEvent, resolve));
|
|
const emittedSync = new Promise<SyncState>((resolve) => client.once(ClientEvent.Sync, resolve));
|
|
widgetApi.emit(
|
|
`action:${WidgetApiToWidgetAction.SendToDevice}`,
|
|
new CustomEvent(`action:${WidgetApiToWidgetAction.SendToDevice}`, { detail: { data: event } }),
|
|
);
|
|
|
|
expect((await emittedEvent).getEffectiveEvent()).toEqual({
|
|
type: event.type,
|
|
sender: event.sender,
|
|
content: event.content,
|
|
});
|
|
expect((await emittedEvent).isEncrypted()).toEqual(encrypted);
|
|
expect(await emittedSync).toEqual(SyncState.Syncing);
|
|
});
|
|
});
|
|
|
|
describe("oidc token", () => {
|
|
it("requests an oidc token", async () => {
|
|
await makeClient({});
|
|
expect(await client.getOpenIdToken()).toStrictEqual(testOIDCToken);
|
|
});
|
|
|
|
it("handles widget errors with generic error data", async () => {
|
|
const error = new Error("failed to get token");
|
|
widgetApi.transport.sendComplete.mockRejectedValue(error);
|
|
|
|
await makeClient({});
|
|
widgetApi.requestOpenIDConnectToken.mockImplementation(widgetApi.transport.sendComplete as any);
|
|
|
|
await expect(client.getOpenIdToken()).rejects.toThrow(error);
|
|
});
|
|
|
|
it("handles widget errors with Matrix API error response data", async () => {
|
|
const errorStatusCode = 400;
|
|
const errorUrl = "http://example.org";
|
|
const errorData = {
|
|
errcode: "M_UNKNOWN",
|
|
error: "Bad request",
|
|
};
|
|
|
|
const widgetError = new WidgetApiResponseError("failed to get token", {
|
|
matrix_api_error: {
|
|
http_status: errorStatusCode,
|
|
http_headers: {},
|
|
url: errorUrl,
|
|
response: errorData,
|
|
},
|
|
});
|
|
const matrixError = new MatrixError(errorData, errorStatusCode, errorUrl);
|
|
|
|
widgetApi.transport.sendComplete.mockRejectedValue(widgetError);
|
|
|
|
await makeClient({});
|
|
widgetApi.requestOpenIDConnectToken.mockImplementation(widgetApi.transport.sendComplete as any);
|
|
|
|
await expect(client.getOpenIdToken()).rejects.toThrow(matrixError);
|
|
});
|
|
});
|
|
|
|
it("gets TURN servers", async () => {
|
|
const server1: ITurnServer = {
|
|
uris: [
|
|
"turn:turn.example.com:3478?transport=udp",
|
|
"turn:10.20.30.40:3478?transport=tcp",
|
|
"turns:10.20.30.40:443?transport=tcp",
|
|
],
|
|
username: "1443779631:@user:example.com",
|
|
password: "JlKfBy1QwLrO20385QyAtEyIv0=",
|
|
};
|
|
const server2: ITurnServer = {
|
|
uris: [
|
|
"turn:turn.example.com:3478?transport=udp",
|
|
"turn:10.20.30.40:3478?transport=tcp",
|
|
"turns:10.20.30.40:443?transport=tcp",
|
|
],
|
|
username: "1448999322:@user:example.com",
|
|
password: "hunter2",
|
|
};
|
|
const clientServer1: IClientTurnServer = {
|
|
urls: server1.uris,
|
|
username: server1.username,
|
|
credential: server1.password,
|
|
};
|
|
const clientServer2: IClientTurnServer = {
|
|
urls: server2.uris,
|
|
username: server2.username,
|
|
credential: server2.password,
|
|
};
|
|
|
|
let emitServer2: () => void;
|
|
const getServer2 = new Promise<ITurnServer>((resolve) => (emitServer2 = () => resolve(server2)));
|
|
widgetApi.getTurnServers.mockImplementation(async function* () {
|
|
yield server1;
|
|
yield await getServer2;
|
|
});
|
|
|
|
await makeClient({ turnServers: true });
|
|
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC3846TurnServers);
|
|
|
|
// The first server should've arrived immediately
|
|
expect(client.getTurnServers()).toEqual([clientServer1]);
|
|
|
|
// Subsequent servers arrive asynchronously and should emit an event
|
|
const emittedServer = new Promise<IClientTurnServer[]>((resolve) =>
|
|
client.once(ClientEvent.TurnServers, resolve),
|
|
);
|
|
emitServer2!();
|
|
expect(await emittedServer).toEqual([clientServer2]);
|
|
expect(client.getTurnServers()).toEqual([clientServer2]);
|
|
});
|
|
});
|