mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-06-08 15:21:53 +03:00
* Switch sliding sync support to simplified sliding sync Experimental PR to test js-sdk with simlified sliding sync. This does not maintain support for regulaer sliding sync. * Remove txn_id handling, ensure we always resend when req params change * Fix some tests * Fix remaining tests * Mark TODOs on tests which need to die * Linting * Make comments lie less * void * Always sent full extension request * Fix test * Remove usage of deprecated field * Hopefully fix DM names * Refactor how heroes are handled in Room * Fix how heroes work * Linting * Ensure that when SSS omits heroes we don't forget we had heroes Otherwise when the room next appears the name/avatar reset to 'Empty Room' with no avatar. * Check the right flag when doing timeline trickling * Also change when the backpagination token is set * Remove list ops and server-provided sort positions SSS doesn't have them. * Linting * Add Room.bumpStamp * Update crypto wasm lib For new functions * Add performance logging * Fix breaking change in crypto wasm v8 * Update crypto wasm for breaking changes See https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/releases/tag/v8.0.0 for how this was mapped from the previous API. * Mark all tracked users as dirty on expired SSS connections See https://github.com/matrix-org/matrix-rust-sdk/pull/3965 for more information. Requires `Extension.onRequest` to be `async`. * add ts extension * Fix typedoc ref * Add method to interface * Don't force membership to invite The membership was set correctly from the stripped state anyway so this was redundant and was breaking rooms where we'd knocked. * Missed merge * Type import * Make coverage happier * More test coverage * Grammar & formatting Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Remove markAllTrackedUsersAsDirty from crypto API Not sure why this was in there, seems like it just needed to be in crypto sync callbacks, which it already was. * Remove I from interface * API doc * Move Hero definition to room-summary * make comment more specific * Move internal details into room.ts and make the comment a proper tsdoc comment * Use terser arrow function syntax Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Move comment to where we do the lookup * Clarify comment also prettier says hi * Add comment Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Add tsdoc explaining that the summary event will be modified * more comment * Remove unrelated changes * Add docs & make fields optional * Type import * Clarify sync versions Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Make tsdoc comment & add info on when it's used. Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Rephrase comment Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Prettier * Only fetch member for hero in legacy sync mode * Split out a separate method to set SSS room summary Rather than trying to fudge up an object that looked enough like the old one that we could pass it in. * Type import * Make link work * Nope, linter treats it as an unused import * Add link the other way * Add more detail to doc Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Remove unnecessary cast Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Remove length > 0 check as it wasn't really necessary and may cause heroes not to be cleared? * Doc params * Remove unnecessary undefined comparison Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Put the comparison back as it's necessary to stop typescript complaining * Fix comment * Fix comment --------- Co-authored-by: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
1108 lines
45 KiB
TypeScript
1108 lines
45 KiB
TypeScript
/*
|
|
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.
|
|
*/
|
|
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { fail } from "assert";
|
|
|
|
import type MockHttpBackend from "matrix-mock-request";
|
|
import {
|
|
SlidingSync,
|
|
SlidingSyncEvent,
|
|
type MSC3575RoomData,
|
|
SlidingSyncState,
|
|
type Extension,
|
|
} from "../../src/sliding-sync";
|
|
import { TestClient } from "../TestClient";
|
|
import { type IRoomEvent, type IStateEvent } from "../../src";
|
|
import {
|
|
type MatrixClient,
|
|
type MatrixEvent,
|
|
NotificationCountType,
|
|
JoinRule,
|
|
MatrixError,
|
|
EventType,
|
|
type IPushRules,
|
|
PushRuleKind,
|
|
TweakName,
|
|
ClientEvent,
|
|
RoomMemberEvent,
|
|
RoomEvent,
|
|
type Room,
|
|
type IRoomTimelineData,
|
|
} from "../../src";
|
|
import { SlidingSyncSdk } from "../../src/sliding-sync-sdk";
|
|
import { type SyncApiOptions, SyncState } from "../../src/sync";
|
|
import { type IStoredClientOpts } from "../../src";
|
|
import { logger } from "../../src/logger";
|
|
import { emitPromise } from "../test-utils/test-utils";
|
|
import { defer } from "../../src/utils";
|
|
import { KnownMembership } from "../../src/@types/membership";
|
|
import { type SyncCryptoCallbacks } from "../../src/common-crypto/CryptoBackend";
|
|
|
|
declare module "../../src/@types/event" {
|
|
interface AccountDataEvents {
|
|
global_test: {};
|
|
tester: {};
|
|
}
|
|
}
|
|
|
|
describe("SlidingSyncSdk", () => {
|
|
let client: MatrixClient | undefined;
|
|
let httpBackend: MockHttpBackend | undefined;
|
|
let sdk: SlidingSyncSdk | undefined;
|
|
let mockSlidingSync: SlidingSync | undefined;
|
|
let syncCryptoCallback: SyncCryptoCallbacks | undefined;
|
|
const selfUserId = "@alice:localhost";
|
|
const selfAccessToken = "aseukfgwef";
|
|
|
|
const mockifySlidingSync = (s: SlidingSync): SlidingSync => {
|
|
s.getListParams = jest.fn();
|
|
s.getListData = jest.fn();
|
|
s.getRoomSubscriptions = jest.fn();
|
|
s.modifyRoomSubscriptionInfo = jest.fn();
|
|
s.modifyRoomSubscriptions = jest.fn();
|
|
s.registerExtension = jest.fn();
|
|
s.setList = jest.fn();
|
|
s.setListRanges = jest.fn();
|
|
s.start = jest.fn();
|
|
s.stop = jest.fn();
|
|
s.resend = jest.fn();
|
|
return s;
|
|
};
|
|
|
|
// shorthand way to make events without filling in all the fields
|
|
let eventIdCounter = 0;
|
|
const mkOwnEvent = (evType: string, content: object): IRoomEvent => {
|
|
eventIdCounter++;
|
|
return {
|
|
type: evType,
|
|
content: content,
|
|
sender: selfUserId,
|
|
origin_server_ts: Date.now(),
|
|
event_id: "$" + eventIdCounter,
|
|
};
|
|
};
|
|
const mkOwnStateEvent = (evType: string, content: object, stateKey = ""): IStateEvent => {
|
|
eventIdCounter++;
|
|
return {
|
|
type: evType,
|
|
state_key: stateKey,
|
|
content: content,
|
|
sender: selfUserId,
|
|
origin_server_ts: Date.now(),
|
|
event_id: "$" + eventIdCounter,
|
|
};
|
|
};
|
|
const assertTimelineEvents = (got: MatrixEvent[], want: IRoomEvent[]): void => {
|
|
expect(got.length).toEqual(want.length);
|
|
got.forEach((m, i) => {
|
|
expect(m.getType()).toEqual(want[i].type);
|
|
expect(m.getSender()).toEqual(want[i].sender);
|
|
expect(m.getId()).toEqual(want[i].event_id);
|
|
expect(m.getContent()).toEqual(want[i].content);
|
|
expect(m.getTs()).toEqual(want[i].origin_server_ts);
|
|
if (want[i].unsigned) {
|
|
expect(m.getUnsigned()).toEqual(want[i].unsigned);
|
|
}
|
|
const maybeStateEvent = want[i] as IStateEvent;
|
|
if (maybeStateEvent.state_key) {
|
|
expect(m.getStateKey()).toEqual(maybeStateEvent.state_key);
|
|
}
|
|
});
|
|
};
|
|
|
|
// assign client/httpBackend globals
|
|
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
|
|
testOpts = testOpts || {};
|
|
const syncOpts: SyncApiOptions = {};
|
|
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
|
|
httpBackend = testClient.httpBackend;
|
|
client = testClient.client;
|
|
mockSlidingSync = mockifySlidingSync(new SlidingSync("", new Map(), {}, client, 0));
|
|
if (testOpts.withCrypto) {
|
|
httpBackend!.when("GET", "/room_keys/version").respond(404, {});
|
|
await client!.initRustCrypto({ useIndexedDB: false });
|
|
syncCryptoCallback = client!.getCrypto() as unknown as SyncCryptoCallbacks;
|
|
syncOpts.cryptoCallbacks = syncCryptoCallback;
|
|
}
|
|
httpBackend!.when("GET", "/_matrix/client/v3/pushrules").respond(200, {});
|
|
sdk = new SlidingSyncSdk(mockSlidingSync, client, testOpts, syncOpts);
|
|
};
|
|
|
|
// tear down client/httpBackend globals
|
|
const teardownClient = () => {
|
|
client!.stopClient();
|
|
return httpBackend!.stop();
|
|
};
|
|
|
|
// find an extension on a SlidingSyncSdk instance
|
|
const findExtension = (name: string): Extension<any, any> => {
|
|
expect(mockSlidingSync!.registerExtension).toHaveBeenCalled();
|
|
const mockFn = mockSlidingSync!.registerExtension as jest.Mock;
|
|
// find the extension
|
|
for (let i = 0; i < mockFn.mock.calls.length; i++) {
|
|
const calledExtension = mockFn.mock.calls[i][0] as Extension<any, any>;
|
|
if (calledExtension?.name() === name) {
|
|
return calledExtension;
|
|
}
|
|
}
|
|
fail("cannot find extension " + name);
|
|
};
|
|
|
|
describe("sync/stop", () => {
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
});
|
|
afterAll(teardownClient);
|
|
it("can sync()", async () => {
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
expect(mockSlidingSync!.start).toHaveBeenCalled();
|
|
});
|
|
it("can stop()", async () => {
|
|
sdk!.stop();
|
|
expect(mockSlidingSync!.stop).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("rooms", () => {
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
});
|
|
afterAll(teardownClient);
|
|
|
|
describe("initial", () => {
|
|
beforeAll(async () => {
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
});
|
|
// inject some rooms with different fields set.
|
|
// All rooms are new so they all have initial: true
|
|
const roomA = "!a_state_and_timeline:localhost";
|
|
const roomB = "!b_timeline_only:localhost";
|
|
const roomC = "!c_with_highlight_count:localhost";
|
|
const roomD = "!d_with_notif_count:localhost";
|
|
const roomE = "!e_with_invite:localhost";
|
|
const roomF = "!f_calc_room_name:localhost";
|
|
const roomG = "!g_join_invite_counts:localhost";
|
|
const roomH = "!g_num_live:localhost";
|
|
const data: Record<string, MSC3575RoomData> = {
|
|
[roomA]: {
|
|
name: "A",
|
|
required_state: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnStateEvent(EventType.RoomName, { name: "A" }, ""),
|
|
],
|
|
timeline: [
|
|
mkOwnEvent(EventType.RoomMessage, { body: "hello A" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "world A" }),
|
|
],
|
|
initial: true,
|
|
},
|
|
[roomB]: {
|
|
name: "B",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "hello B" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "world B" }),
|
|
],
|
|
initial: true,
|
|
},
|
|
[roomC]: {
|
|
name: "C",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "hello C" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "world C" }),
|
|
],
|
|
highlight_count: 5,
|
|
initial: true,
|
|
},
|
|
[roomD]: {
|
|
name: "D",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "hello D" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "world D" }),
|
|
],
|
|
notification_count: 5,
|
|
initial: true,
|
|
},
|
|
[roomE]: {
|
|
name: "E",
|
|
required_state: [],
|
|
timeline: [],
|
|
invite_state: [
|
|
{
|
|
type: EventType.RoomMember,
|
|
content: { membership: KnownMembership.Invite },
|
|
state_key: selfUserId,
|
|
sender: "@bob:localhost",
|
|
event_id: "$room_e_invite",
|
|
origin_server_ts: 123456,
|
|
},
|
|
{
|
|
type: "m.room.join_rules",
|
|
content: { join_rule: "invite" },
|
|
state_key: "",
|
|
sender: "@bob:localhost",
|
|
event_id: "$room_e_join_rule",
|
|
origin_server_ts: 123456,
|
|
},
|
|
],
|
|
initial: true,
|
|
},
|
|
[roomF]: {
|
|
name: "#foo:localhost",
|
|
required_state: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnStateEvent(EventType.RoomCanonicalAlias, { alias: "#foo:localhost" }, ""),
|
|
mkOwnStateEvent(EventType.RoomName, { name: "This should be ignored" }, ""),
|
|
],
|
|
timeline: [
|
|
mkOwnEvent(EventType.RoomMessage, { body: "hello A" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "world A" }),
|
|
],
|
|
initial: true,
|
|
},
|
|
[roomG]: {
|
|
name: "G",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
],
|
|
joined_count: 5,
|
|
invited_count: 2,
|
|
initial: true,
|
|
},
|
|
[roomH]: {
|
|
name: "H",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "live event" }),
|
|
],
|
|
initial: true,
|
|
num_live: 1,
|
|
},
|
|
};
|
|
|
|
it("can be created with required_state and timeline", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomA);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.name).toEqual(data[roomA].name);
|
|
expect(gotRoom!.getMyMembership()).toEqual(KnownMembership.Join);
|
|
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
|
|
});
|
|
|
|
it("can be created with timeline only", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomB);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.name).toEqual(data[roomB].name);
|
|
expect(gotRoom!.getMyMembership()).toEqual(KnownMembership.Join);
|
|
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
|
|
});
|
|
|
|
it("can be created with a highlight_count", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomC);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
|
|
data[roomC].highlight_count,
|
|
);
|
|
});
|
|
|
|
it("can be created with a notification_count", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomD);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
|
|
data[roomD].notification_count,
|
|
);
|
|
});
|
|
|
|
it("can be created with an invited/joined_count", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomG);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
|
|
expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
|
|
});
|
|
|
|
it("can be created with live events", async () => {
|
|
const seenLiveEventDeferred = defer<boolean>();
|
|
const listener = (
|
|
ev: MatrixEvent,
|
|
room?: Room,
|
|
toStartOfTimeline?: boolean,
|
|
deleted?: boolean,
|
|
timelineData?: IRoomTimelineData,
|
|
) => {
|
|
if (timelineData?.liveEvent) {
|
|
assertTimelineEvents([ev], data[roomH].timeline.slice(-1));
|
|
seenLiveEventDeferred.resolve(true);
|
|
}
|
|
};
|
|
client!.on(RoomEvent.Timeline, listener);
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
client!.off(RoomEvent.Timeline, listener);
|
|
const gotRoom = client!.getRoom(roomH);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.name).toEqual(data[roomH].name);
|
|
expect(gotRoom!.getMyMembership()).toEqual(KnownMembership.Join);
|
|
// check the entire timeline is correct
|
|
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline);
|
|
await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy();
|
|
});
|
|
|
|
it("can be created with invite_state", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomE);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.getMyMembership()).toEqual(KnownMembership.Invite);
|
|
expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite);
|
|
});
|
|
|
|
it("uses the 'name' field to caluclate the room name", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const gotRoom = client!.getRoom(roomF);
|
|
expect(gotRoom).toBeTruthy();
|
|
expect(gotRoom!.name).toEqual(data[roomF].name);
|
|
});
|
|
|
|
describe("updating", () => {
|
|
it("can update with a new timeline event", async () => {
|
|
const newEvent = mkOwnEvent(EventType.RoomMessage, { body: "new event A" });
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, {
|
|
timeline: [newEvent],
|
|
required_state: [],
|
|
name: data[roomA].name,
|
|
});
|
|
const gotRoom = client!.getRoom(roomA);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
const newTimeline = data[roomA].timeline;
|
|
newTimeline.push(newEvent);
|
|
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline);
|
|
});
|
|
|
|
it("can update with a new required_state event", async () => {
|
|
let gotRoom = client!.getRoom(roomB);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, {
|
|
required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")],
|
|
timeline: [],
|
|
name: data[roomB].name,
|
|
});
|
|
gotRoom = client!.getRoom(roomB);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted);
|
|
});
|
|
|
|
it("can update with a new highlight_count", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, {
|
|
name: data[roomC].name,
|
|
required_state: [],
|
|
timeline: [],
|
|
highlight_count: 1,
|
|
});
|
|
const gotRoom = client!.getRoom(roomC);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
|
|
});
|
|
|
|
it("can update with a new notification_count", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, {
|
|
name: data[roomD].name,
|
|
required_state: [],
|
|
timeline: [],
|
|
notification_count: 1,
|
|
});
|
|
const gotRoom = client!.getRoom(roomD);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
|
|
});
|
|
|
|
it("can update with a new joined_count", () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, {
|
|
name: data[roomD].name,
|
|
required_state: [],
|
|
timeline: [],
|
|
joined_count: 1,
|
|
});
|
|
const gotRoom = client!.getRoom(roomG);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
expect(gotRoom!.getJoinedMemberCount()).toEqual(1);
|
|
});
|
|
|
|
// Regression test for a bug which caused the timeline entries to be out-of-order
|
|
// when the same room appears twice with different timeline limits. E.g appears in
|
|
// the list with timeline_limit:1 then appears again as a room subscription with
|
|
// timeline_limit:50
|
|
it("can return history with a larger timeline_limit", async () => {
|
|
const timeline = data[roomA].timeline;
|
|
const oldTimeline = [
|
|
mkOwnEvent(EventType.RoomMessage, { body: "old event A" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "old event B" }),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "old event C" }),
|
|
...timeline,
|
|
];
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, {
|
|
timeline: oldTimeline,
|
|
required_state: [],
|
|
name: data[roomA].name,
|
|
initial: true, // e.g requested via room subscription
|
|
});
|
|
const gotRoom = client!.getRoom(roomA);
|
|
expect(gotRoom).toBeTruthy();
|
|
if (gotRoom == null) {
|
|
return;
|
|
}
|
|
|
|
logger.log(
|
|
"want:",
|
|
oldTimeline.map((e) => e.type + " : " + (e.content || {}).body),
|
|
);
|
|
logger.log(
|
|
"got:",
|
|
gotRoom
|
|
.getLiveTimeline()
|
|
.getEvents()
|
|
.map((e) => e.getType() + " : " + e.getContent().body),
|
|
);
|
|
|
|
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
|
|
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("lifecycle", () => {
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
});
|
|
const FAILED_SYNC_ERROR_THRESHOLD = 3; // would be nice to export the const in the actual class...
|
|
|
|
it("emits SyncState.Reconnecting when < FAILED_SYNC_ERROR_THRESHOLD & SyncState.Error when over", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, {
|
|
pos: "h",
|
|
lists: {},
|
|
rooms: {},
|
|
extensions: {},
|
|
});
|
|
expect(sdk!.getSyncState()).toEqual(SyncState.Syncing);
|
|
|
|
mockSlidingSync!.emit(
|
|
SlidingSyncEvent.Lifecycle,
|
|
SlidingSyncState.RequestFinished,
|
|
null,
|
|
new Error("generic"),
|
|
);
|
|
expect(sdk!.getSyncState()).toEqual(SyncState.Reconnecting);
|
|
|
|
for (let i = 0; i < FAILED_SYNC_ERROR_THRESHOLD; i++) {
|
|
mockSlidingSync!.emit(
|
|
SlidingSyncEvent.Lifecycle,
|
|
SlidingSyncState.RequestFinished,
|
|
null,
|
|
new Error("generic"),
|
|
);
|
|
}
|
|
expect(sdk!.getSyncState()).toEqual(SyncState.Error);
|
|
});
|
|
|
|
it("emits SyncState.Syncing after a previous SyncState.Error", async () => {
|
|
mockSlidingSync!.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, {
|
|
pos: "i",
|
|
lists: {},
|
|
rooms: {},
|
|
extensions: {},
|
|
});
|
|
expect(sdk!.getSyncState()).toEqual(SyncState.Syncing);
|
|
});
|
|
|
|
it("emits SyncState.Error immediately when receiving M_UNKNOWN_TOKEN and stops syncing", async () => {
|
|
expect(mockSlidingSync!.stop).not.toHaveBeenCalled();
|
|
mockSlidingSync!.emit(
|
|
SlidingSyncEvent.Lifecycle,
|
|
SlidingSyncState.RequestFinished,
|
|
null,
|
|
new MatrixError({
|
|
errcode: "M_UNKNOWN_TOKEN",
|
|
message: "Oh no your access token is no longer valid",
|
|
}),
|
|
);
|
|
expect(sdk!.getSyncState()).toEqual(SyncState.Error);
|
|
expect(mockSlidingSync!.stop).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("opts", () => {
|
|
afterEach(teardownClient);
|
|
it("can resolveProfilesToInvites", async () => {
|
|
await setupClient({
|
|
resolveInvitesToProfiles: true,
|
|
});
|
|
const roomId = "!resolveProfilesToInvites:localhost";
|
|
const invitee = "@invitee:localhost";
|
|
const inviteeProfile = {
|
|
avatar_url: "mxc://foobar",
|
|
displayname: "The Invitee",
|
|
};
|
|
httpBackend!.when("GET", "/profile").respond(200, inviteeProfile);
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, {
|
|
initial: true,
|
|
name: "Room with Invite",
|
|
required_state: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Invite }, invitee),
|
|
],
|
|
timeline: [],
|
|
});
|
|
await httpBackend!.flush("/profile", 1, 1000);
|
|
await emitPromise(client!, RoomMemberEvent.Name);
|
|
const room = client!.getRoom(roomId)!;
|
|
expect(room).toBeTruthy();
|
|
const inviteeMember = room.getMember(invitee)!;
|
|
expect(inviteeMember).toBeTruthy();
|
|
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
|
|
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
|
|
});
|
|
});
|
|
|
|
describe("ExtensionE2EE", () => {
|
|
let ext: Extension<any, any>;
|
|
|
|
beforeAll(async () => {
|
|
await setupClient({
|
|
withCrypto: true,
|
|
});
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
ext = findExtension("e2ee");
|
|
});
|
|
|
|
it("gets enabled all the time", async () => {
|
|
expect(await ext.onRequest(true)).toEqual({
|
|
enabled: true,
|
|
});
|
|
expect(await ext.onRequest(false)).toEqual({
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("can update device lists", () => {
|
|
syncCryptoCallback!.processDeviceLists = jest.fn();
|
|
ext.onResponse({
|
|
device_lists: {
|
|
changed: ["@alice:localhost"],
|
|
left: ["@bob:localhost"],
|
|
},
|
|
});
|
|
expect(syncCryptoCallback!.processDeviceLists).toHaveBeenCalledWith({
|
|
changed: ["@alice:localhost"],
|
|
left: ["@bob:localhost"],
|
|
});
|
|
});
|
|
|
|
it("can update OTK counts and unused fallback keys", () => {
|
|
syncCryptoCallback!.processKeyCounts = jest.fn();
|
|
ext.onResponse({
|
|
device_one_time_keys_count: {
|
|
signed_curve25519: 42,
|
|
},
|
|
device_unused_fallback_key_types: ["signed_curve25519"],
|
|
});
|
|
expect(syncCryptoCallback!.processKeyCounts).toHaveBeenCalledWith({ signed_curve25519: 42 }, [
|
|
"signed_curve25519",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("ExtensionAccountData", () => {
|
|
let ext: Extension<any, any>;
|
|
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
ext = findExtension("account_data");
|
|
});
|
|
|
|
it("gets enabled all the time", async () => {
|
|
expect(await ext.onRequest(true)).toEqual({
|
|
enabled: true,
|
|
});
|
|
expect(await ext.onRequest(false)).toEqual({
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("processes global account data", async () => {
|
|
const globalType = "global_test";
|
|
const globalContent = {
|
|
info: "here",
|
|
};
|
|
let globalData = client!.getAccountData(globalType);
|
|
expect(globalData).toBeUndefined();
|
|
ext.onResponse({
|
|
global: [
|
|
{
|
|
type: globalType,
|
|
content: globalContent,
|
|
},
|
|
],
|
|
});
|
|
globalData = client!.getAccountData(globalType)!;
|
|
expect(globalData).toBeTruthy();
|
|
expect(globalData.getContent()).toEqual(globalContent);
|
|
});
|
|
|
|
it("processes rooms account data", async () => {
|
|
const roomId = "!room:id";
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, {
|
|
name: "Room with account data",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
mkOwnEvent(EventType.RoomMessage, { body: "hello" }),
|
|
],
|
|
initial: true,
|
|
});
|
|
const roomContent = {
|
|
foo: "bar",
|
|
};
|
|
const roomType = "test";
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
ext.onResponse({
|
|
rooms: {
|
|
[roomId]: [
|
|
{
|
|
type: roomType,
|
|
content: roomContent,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const room = client!.getRoom(roomId)!;
|
|
expect(room).toBeTruthy();
|
|
const event = room.getAccountData(roomType)!;
|
|
expect(event).toBeTruthy();
|
|
expect(event.getContent()).toEqual(roomContent);
|
|
});
|
|
|
|
it("doesn't crash for unknown room account data", async () => {
|
|
const unknownRoomId = "!unknown:id";
|
|
const roomType = "tester";
|
|
ext.onResponse({
|
|
rooms: {
|
|
[unknownRoomId]: [
|
|
{
|
|
type: roomType,
|
|
content: {
|
|
foo: "Bar",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
const room = client!.getRoom(unknownRoomId);
|
|
expect(room).toBeNull();
|
|
expect(client!.getAccountData(roomType)).toBeUndefined();
|
|
});
|
|
|
|
it("can update push rules via account data", async () => {
|
|
const roomId = "!foo:bar";
|
|
const pushRulesContent: IPushRules = {
|
|
global: {
|
|
[PushRuleKind.RoomSpecific]: [
|
|
{
|
|
enabled: true,
|
|
default: true,
|
|
pattern: "monkey",
|
|
actions: [
|
|
{
|
|
set_tweak: TweakName.Sound,
|
|
value: "default",
|
|
},
|
|
],
|
|
rule_id: roomId,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
let pushRule = client!.getRoomPushRule("global", roomId);
|
|
expect(pushRule).toBeUndefined();
|
|
ext.onResponse({
|
|
global: [
|
|
{
|
|
type: EventType.PushRules,
|
|
content: pushRulesContent,
|
|
},
|
|
],
|
|
});
|
|
pushRule = client!.getRoomPushRule("global", roomId)!;
|
|
expect(pushRule).toEqual(pushRulesContent.global[PushRuleKind.RoomSpecific]![0]);
|
|
});
|
|
});
|
|
|
|
describe("ExtensionToDevice", () => {
|
|
let ext: Extension<any, any>;
|
|
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
ext = findExtension("to_device");
|
|
});
|
|
|
|
it("gets enabled all the time", async () => {
|
|
let reqJson: any = await ext.onRequest(true);
|
|
expect(reqJson.enabled).toEqual(true);
|
|
expect(reqJson.limit).toBeGreaterThan(0);
|
|
expect(reqJson.since).toBeUndefined();
|
|
reqJson = await ext.onRequest(false);
|
|
expect(reqJson.enabled).toEqual(true);
|
|
expect(reqJson.limit).toBeGreaterThan(0);
|
|
expect(reqJson.since).toBeUndefined();
|
|
});
|
|
|
|
it("updates the since value", async () => {
|
|
ext.onResponse({
|
|
next_batch: "12345",
|
|
events: [],
|
|
});
|
|
expect(await ext.onRequest(false)).toMatchObject({
|
|
since: "12345",
|
|
});
|
|
});
|
|
|
|
it("can handle missing fields", async () => {
|
|
ext.onResponse({
|
|
next_batch: "23456",
|
|
// no events array
|
|
});
|
|
});
|
|
|
|
it("emits to-device events on the client", async () => {
|
|
const toDeviceType = "custom_test";
|
|
const toDeviceContent = {
|
|
foo: "bar",
|
|
};
|
|
let called = false;
|
|
client!.once(ClientEvent.ToDeviceEvent, (ev) => {
|
|
expect(ev.getContent()).toEqual(toDeviceContent);
|
|
expect(ev.getType()).toEqual(toDeviceType);
|
|
called = true;
|
|
});
|
|
ext.onResponse({
|
|
next_batch: "34567",
|
|
events: [
|
|
{
|
|
type: toDeviceType,
|
|
content: toDeviceContent,
|
|
},
|
|
],
|
|
});
|
|
expect(called).toBe(true);
|
|
});
|
|
|
|
it("can cancel key verification requests", async () => {
|
|
const seen: Record<string, boolean> = {};
|
|
client!.on(ClientEvent.ToDeviceEvent, (ev) => {
|
|
const evType = ev.getType();
|
|
expect(seen[evType]).toBeFalsy();
|
|
seen[evType] = true;
|
|
expect(ev.isCancelled()).toEqual(
|
|
evType === "m.key.verification.start" || evType === "m.key.verification.request",
|
|
);
|
|
});
|
|
ext.onResponse({
|
|
next_batch: "45678",
|
|
events: [
|
|
// someone tries to verify keys
|
|
{
|
|
type: "m.key.verification.start",
|
|
content: {
|
|
transaction_id: "a",
|
|
},
|
|
},
|
|
{
|
|
type: "m.key.verification.request",
|
|
content: {
|
|
transaction_id: "a",
|
|
},
|
|
},
|
|
// then gives up
|
|
{
|
|
type: "m.key.verification.cancel",
|
|
content: {
|
|
transaction_id: "a",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("ExtensionTyping", () => {
|
|
let ext: Extension<any, any>;
|
|
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
ext = findExtension("typing");
|
|
});
|
|
|
|
it("gets enabled all the time", async () => {
|
|
expect(await ext.onRequest(true)).toEqual({
|
|
enabled: true,
|
|
});
|
|
expect(await ext.onRequest(false)).toEqual({
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("processes typing notifications", async () => {
|
|
const roomId = "!room:id";
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, {
|
|
name: "Room with typing",
|
|
required_state: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
],
|
|
timeline: [mkOwnEvent(EventType.RoomMessage, { body: "hello" })],
|
|
initial: true,
|
|
});
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const room = client!.getRoom(roomId)!;
|
|
expect(room).toBeTruthy();
|
|
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
|
ext.onResponse({
|
|
rooms: {
|
|
[roomId]: {
|
|
type: EventType.Typing,
|
|
content: {
|
|
user_ids: [selfUserId],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(room.getMember(selfUserId)?.typing).toEqual(true);
|
|
ext.onResponse({
|
|
rooms: {
|
|
[roomId]: {
|
|
type: EventType.Typing,
|
|
content: {
|
|
user_ids: [],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
|
});
|
|
|
|
it("gracefully handles missing rooms and members when typing", async () => {
|
|
const roomId = "!room:id";
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, {
|
|
name: "Room with typing",
|
|
required_state: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
],
|
|
timeline: [mkOwnEvent(EventType.RoomMessage, { body: "hello" })],
|
|
initial: true,
|
|
});
|
|
const room = client!.getRoom(roomId)!;
|
|
expect(room).toBeTruthy();
|
|
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
|
ext.onResponse({
|
|
rooms: {
|
|
[roomId]: {
|
|
type: EventType.Typing,
|
|
content: {
|
|
user_ids: ["@someone:else"],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
|
ext.onResponse({
|
|
rooms: {
|
|
"!something:else": {
|
|
type: EventType.Typing,
|
|
content: {
|
|
user_ids: [selfUserId],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(room.getMember(selfUserId)?.typing).toEqual(false);
|
|
});
|
|
});
|
|
|
|
describe("ExtensionReceipts", () => {
|
|
let ext: Extension<any, any>;
|
|
|
|
const generateReceiptResponse = (
|
|
userId: string,
|
|
roomId: string,
|
|
eventId: string,
|
|
recType: string,
|
|
ts: number,
|
|
) => {
|
|
return {
|
|
rooms: {
|
|
[roomId]: {
|
|
type: EventType.Receipt,
|
|
content: {
|
|
[eventId]: {
|
|
[recType]: {
|
|
[userId]: {
|
|
ts: ts,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
await setupClient();
|
|
const hasSynced = sdk!.sync();
|
|
await httpBackend!.flushAllExpected();
|
|
await hasSynced;
|
|
ext = findExtension("receipts");
|
|
});
|
|
|
|
it("gets enabled all the time", async () => {
|
|
expect(await ext.onRequest(true)).toEqual({
|
|
enabled: true,
|
|
});
|
|
expect(await ext.onRequest(false)).toEqual({
|
|
enabled: true,
|
|
});
|
|
});
|
|
|
|
it("processes receipts", async () => {
|
|
const roomId = "!room:id";
|
|
const alice = "@alice:alice";
|
|
const lastEvent = mkOwnEvent(EventType.RoomMessage, { body: "hello" });
|
|
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, {
|
|
name: "Room with receipts",
|
|
required_state: [],
|
|
timeline: [
|
|
mkOwnStateEvent(EventType.RoomCreate, {}, ""),
|
|
mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId),
|
|
mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""),
|
|
{
|
|
type: EventType.RoomMember,
|
|
state_key: alice,
|
|
content: { membership: KnownMembership.Join },
|
|
sender: alice,
|
|
origin_server_ts: Date.now(),
|
|
event_id: "$alice",
|
|
},
|
|
lastEvent,
|
|
],
|
|
initial: true,
|
|
});
|
|
await emitPromise(client!, ClientEvent.Room);
|
|
const room = client!.getRoom(roomId)!;
|
|
expect(room).toBeTruthy();
|
|
expect(room.getReadReceiptForUserId(alice, true)).toBeNull();
|
|
ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567));
|
|
const receipt = room.getReadReceiptForUserId(alice);
|
|
expect(receipt).toBeTruthy();
|
|
expect(receipt?.eventId).toEqual(lastEvent.event_id);
|
|
expect(receipt?.data.ts).toEqual(1234567);
|
|
expect(receipt?.data.thread_id).toBeFalsy();
|
|
});
|
|
|
|
it("gracefully handles missing rooms when receiving receipts", async () => {
|
|
const roomId = "!room:id";
|
|
const alice = "@alice:alice";
|
|
const eventId = "$something";
|
|
ext.onResponse(generateReceiptResponse(alice, roomId, eventId, "m.read", 1234567));
|
|
// we expect it not to crash
|
|
});
|
|
});
|
|
});
|