Merge remote-tracking branch 'origin/develop' into feat/add-message-edition-wysiwyg-composer

This commit is contained in:
Florian Duros 2022-10-19 16:34:22 +02:00
commit e77f333fb6
No known key found for this signature in database
GPG key ID: 9700AA5870258A0B
124 changed files with 4370 additions and 1039 deletions

View file

@ -21,9 +21,9 @@ import { Command, Commands, getCommand } from '../src/SlashCommands';
import { createTestClient } from './test-utils';
import { MatrixClientPeg } from '../src/MatrixClientPeg';
import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from '../src/models/LocalRoom';
import { RoomViewStore } from '../src/stores/RoomViewStore';
import SettingsStore from '../src/settings/SettingsStore';
import LegacyCallHandler from '../src/LegacyCallHandler';
import { SdkContextClass } from '../src/contexts/SDKContext';
describe('SlashCommands', () => {
let client: MatrixClient;
@ -38,14 +38,14 @@ describe('SlashCommands', () => {
};
const setCurrentRoom = (): void => {
mocked(RoomViewStore.instance.getRoomId).mockReturnValue(roomId);
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId);
mocked(client.getRoom).mockImplementation((rId: string): Room => {
if (rId === roomId) return room;
});
};
const setCurrentLocalRoon = (): void => {
mocked(RoomViewStore.instance.getRoomId).mockReturnValue(localRoomId);
mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(localRoomId);
mocked(client.getRoom).mockImplementation((rId: string): Room => {
if (rId === localRoomId) return localRoom;
});
@ -60,7 +60,7 @@ describe('SlashCommands', () => {
room = new Room(roomId, client, client.getUserId());
localRoom = new LocalRoom(localRoomId, client, client.getUserId());
jest.spyOn(RoomViewStore.instance, "getRoomId");
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId");
});
describe('/topic', () => {

44
test/TestStores.ts Normal file
View file

@ -0,0 +1,44 @@
/*
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.
*/
import { SdkContextClass } from "../src/contexts/SDKContext";
import { PosthogAnalytics } from "../src/PosthogAnalytics";
import { SlidingSyncManager } from "../src/SlidingSyncManager";
import { RoomNotificationStateStore } from "../src/stores/notifications/RoomNotificationStateStore";
import RightPanelStore from "../src/stores/right-panel/RightPanelStore";
import { RoomViewStore } from "../src/stores/RoomViewStore";
import { SpaceStoreClass } from "../src/stores/spaces/SpaceStore";
import { WidgetLayoutStore } from "../src/stores/widgets/WidgetLayoutStore";
import WidgetStore from "../src/stores/WidgetStore";
/**
* A class which provides the same API as Stores but adds additional unsafe setters which can
* replace individual stores. This is useful for tests which need to mock out stores.
*/
export class TestStores extends SdkContextClass {
public _RightPanelStore?: RightPanelStore;
public _RoomNotificationStateStore?: RoomNotificationStateStore;
public _RoomViewStore?: RoomViewStore;
public _WidgetLayoutStore?: WidgetLayoutStore;
public _WidgetStore?: WidgetStore;
public _PosthogAnalytics?: PosthogAnalytics;
public _SlidingSyncManager?: SlidingSyncManager;
public _SpaceStore?: SpaceStoreClass;
constructor() {
super();
}
}

View file

@ -32,17 +32,16 @@ import { defaultDispatcher } from "../../../src/dispatcher/dispatcher";
import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload";
import { RoomView as _RoomView } from "../../../src/components/structures/RoomView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { NotificationState } from "../../../src/stores/notifications/NotificationState";
import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom";
import { DirectoryMember } from "../../../src/utils/direct-messages";
import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { SdkContextClass, SDKContext } from "../../../src/contexts/SDKContext";
const RoomView = wrapInMatrixClientContext(_RoomView);
@ -50,6 +49,7 @@ describe("RoomView", () => {
let cli: MockedObject<MatrixClient>;
let room: Room;
let roomCount = 0;
let stores: SdkContextClass;
beforeEach(async () => {
mockPlatformPeg({ reload: () => {} });
@ -64,7 +64,9 @@ describe("RoomView", () => {
room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args));
DMRoomMap.makeShared();
RightPanelStore.instance.useUnitTestClient(cli);
stores = new SdkContextClass();
stores.client = cli;
stores.rightPanelStore.useUnitTestClient(cli);
});
afterEach(async () => {
@ -73,15 +75,15 @@ describe("RoomView", () => {
});
const mountRoomView = async (): Promise<ReactWrapper> => {
if (RoomViewStore.instance.getRoomId() !== room.roomId) {
if (stores.roomViewStore.getRoomId() !== room.roomId) {
const switchedRoom = new Promise<void>(resolve => {
const subFn = () => {
if (RoomViewStore.instance.getRoomId()) {
RoomViewStore.instance.off(UPDATE_EVENT, subFn);
if (stores.roomViewStore.getRoomId()) {
stores.roomViewStore.off(UPDATE_EVENT, subFn);
resolve();
}
};
RoomViewStore.instance.on(UPDATE_EVENT, subFn);
stores.roomViewStore.on(UPDATE_EVENT, subFn);
});
defaultDispatcher.dispatch<ViewRoomPayload>({
@ -94,15 +96,16 @@ describe("RoomView", () => {
}
const roomView = mount(
<RoomView
mxClient={cli}
threepidInvite={null}
oobData={null}
resizeNotifier={new ResizeNotifier()}
justCreatedOpts={null}
forceTimeline={false}
onRegistered={null}
/>,
<SDKContext.Provider value={stores}>
<RoomView
threepidInvite={null}
oobData={null}
resizeNotifier={new ResizeNotifier()}
justCreatedOpts={null}
forceTimeline={false}
onRegistered={null}
/>
</SDKContext.Provider>,
);
await act(() => Promise.resolve()); // Allow state to settle
return roomView;
@ -162,14 +165,14 @@ describe("RoomView", () => {
it("normally doesn't open the chat panel", async () => {
jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false);
await mountRoomView();
expect(RightPanelStore.instance.isOpen).toEqual(false);
expect(stores.rightPanelStore.isOpen).toEqual(false);
});
it("opens the chat panel if there are unread messages", async () => {
jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true);
await mountRoomView();
expect(RightPanelStore.instance.isOpen).toEqual(true);
expect(RightPanelStore.instance.currentCard.phase).toEqual(RightPanelPhases.Timeline);
expect(stores.rightPanelStore.isOpen).toEqual(true);
expect(stores.rightPanelStore.currentCard.phase).toEqual(RightPanelPhases.Timeline);
});
});

View file

@ -42,8 +42,8 @@ import RoomCallBanner from "../../../../src/components/views/beacon/RoomCallBann
import { CallStore } from "../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { RoomViewStore } from "../../../../src/stores/RoomViewStore";
import { ConnectionState } from "../../../../src/models/Call";
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
describe("<RoomCallBanner />", () => {
let client: Mocked<MatrixClient>;
@ -132,7 +132,8 @@ describe("<RoomCallBanner />", () => {
});
it("doesn't show banner if the call is shown", async () => {
jest.spyOn(RoomViewStore.instance, 'isViewingCall').mockReturnValue(true);
jest.spyOn(SdkContextClass.instance.roomViewStore, "isViewingCall");
mocked(SdkContextClass.instance.roomViewStore.isViewingCall).mockReturnValue(true);
await renderBanner();
const banner = await screen.queryByText("Video call");
expect(banner).toBeFalsy();

View file

@ -16,11 +16,9 @@ limitations under the License.
import React from "react";
import { render, RenderResult } from "@testing-library/react";
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { Features } from "../../../../src/settings/Settings";
import SettingsStore, { CallbackFn } from "../../../../src/settings/SettingsStore";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { mkEvent, mkRoom, stubClient } from "../../../test-utils";
import MessageEvent from "../../../../src/components/views/messages/MessageEvent";
@ -57,8 +55,7 @@ describe("MessageEvent", () => {
});
describe("when a voice broadcast start event occurs", () => {
const voiceBroadcastSettingWatcherRef = "vb ref";
let onVoiceBroadcastSettingChanged: CallbackFn;
let result: RenderResult;
beforeEach(() => {
event = mkEvent({
@ -70,64 +67,11 @@ describe("MessageEvent", () => {
state: VoiceBroadcastInfoState.Started,
},
});
mocked(SettingsStore.watchSetting).mockImplementation(
(settingName: string, roomId: string | null, callbackFn: CallbackFn) => {
if (settingName === Features.VoiceBroadcast) {
onVoiceBroadcastSettingChanged = callbackFn;
return voiceBroadcastSettingWatcherRef;
}
},
);
result = renderMessageEvent();
});
describe("and the voice broadcast feature is enabled", () => {
let result: RenderResult;
beforeEach(() => {
mocked(SettingsStore.getValue).mockImplementation((settingName: string) => {
return settingName === Features.VoiceBroadcast;
});
result = renderMessageEvent();
});
it("should render a VoiceBroadcast component", () => {
result.getByTestId("voice-broadcast-body");
});
describe("and switching the voice broadcast feature off", () => {
beforeEach(() => {
onVoiceBroadcastSettingChanged(Features.VoiceBroadcast, null, null, null, false);
});
it("should render an UnknownBody component", () => {
const result = renderMessageEvent();
result.getByTestId("unknown-body");
});
});
describe("and unmounted", () => {
beforeEach(() => {
result.unmount();
});
it("should unregister the settings watcher", () => {
expect(SettingsStore.unwatchSetting).toHaveBeenCalled();
});
});
});
describe("and the voice broadcast feature is disabled", () => {
beforeEach(() => {
mocked(SettingsStore.getValue).mockImplementation((settingName: string) => {
return false;
});
});
it("should render an UnknownBody component", () => {
const result = renderMessageEvent();
result.getByTestId("unknown-body");
});
it("should render a VoiceBroadcast component", () => {
result.getByTestId("voice-broadcast-body");
});
});
});

View file

@ -147,7 +147,7 @@ describe("MessageComposer", () => {
beforeEach(() => {
SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value);
wrapper = wrapAndRender({ room, showVoiceBroadcastButton: true });
wrapper = wrapAndRender({ room });
});
it(`should pass the prop ${prop} = ${value}`, () => {
@ -174,17 +174,6 @@ describe("MessageComposer", () => {
});
});
[false, undefined].forEach((value) => {
it(`should pass showVoiceBroadcastButton = false if the MessageComposer prop is ${value}`, () => {
SettingsStore.setValue(Features.VoiceBroadcast, null, SettingLevel.DEVICE, true);
const wrapper = wrapAndRender({
room,
showVoiceBroadcastButton: value,
});
expect(wrapper.find(MessageComposerButtons).props().showVoiceBroadcastButton).toBe(false);
});
});
it("should not render the send button", () => {
const wrapper = wrapAndRender({ room });
expect(wrapper.find("SendButton")).toHaveLength(0);

View file

@ -250,7 +250,6 @@ function createRoomState(room: Room, narrow: boolean): IRoomState {
statusBarVisible: false,
canReact: false,
canSendMessages: false,
canSendVoiceBroadcasts: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,

View file

@ -72,7 +72,6 @@ describe('<SendMessageComposer/>', () => {
statusBarVisible: false,
canReact: false,
canSendMessages: false,
canSendVoiceBroadcasts: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,

View file

@ -17,6 +17,7 @@ limitations under the License.
import "@testing-library/jest-dom";
import React from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
@ -26,13 +27,31 @@ import { IRoomState } from "../../../../../src/components/structures/RoomView";
import { Layout } from "../../../../../src/settings/enums/Layout";
import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
// Work around missing ClipboardEvent type
class MyClipbardEvent {}
window.ClipboardEvent = MyClipbardEvent as any;
let inputEventProcessor: InputEventProcessor | null = null;
// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
jest.mock("@matrix-org/matrix-wysiwyg", () => ({
useWysiwyg: () => {
return { ref: { current: null }, content: '<b>html</b>', isWysiwygReady: true, wysiwyg: { clear: () => void 0 },
formattingStates: { bold: 'enabled', italic: 'enabled', underline: 'enabled', strikeThrough: 'enabled' } };
useWysiwyg: (props: WysiwygProps) => {
inputEventProcessor = props.inputEventProcessor ?? null;
return {
ref: { current: null },
content: '<b>html</b>',
isWysiwygReady: true,
wysiwyg: { clear: () => void 0 },
formattingStates: {
bold: 'enabled',
italic: 'enabled',
underline: 'enabled',
strikeThrough: 'enabled',
},
};
},
}));
@ -72,7 +91,6 @@ describe('WysiwygComposer', () => {
statusBarVisible: false,
canReact: false,
canSendMessages: false,
canSendVoiceBroadcasts: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,
@ -196,5 +214,62 @@ describe('WysiwygComposer', () => {
// Then we don't get it because we are disabled
expect(screen.getByRole('textbox')).not.toHaveFocus();
});
it('sends a message when Enter is pressed', async () => {
// Given a composer
customRender(() => {}, false);
// When we tell its inputEventProcesser that the user pressed Enter
const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" });
const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
inputEventProcessor(event, wysiwyg);
// Then it sends a message
expect(mockClient.sendMessage).toBeCalledWith(
"myfakeroom",
null,
{
"body": "<b>html</b>",
"format": "org.matrix.custom.html",
"formatted_body": "<b>html</b>",
"msgtype": "m.text",
},
);
// TODO: plain text body above is wrong - will be fixed when we provide markdown for it
});
describe('when settings require Ctrl+Enter to send', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
if (name === "MessageComposerInput.ctrlEnterToSend") return true;
});
});
it('does not send a message when Enter is pressed', async () => {
// Given a composer
customRender(() => {}, false);
// When we tell its inputEventProcesser that the user pressed Enter
const event = new InputEvent("input", { inputType: "insertParagraph" });
const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
inputEventProcessor(event, wysiwyg);
// Then it does not send a message
expect(mockClient.sendMessage).toBeCalledTimes(0);
});
it('sends a message when Ctrl+Enter is pressed', async () => {
// Given a composer
customRender(() => {}, false);
// When we tell its inputEventProcesser that the user pressed Ctrl+Enter
const event = new InputEvent("input", { inputType: "sendMessage" });
const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
inputEventProcessor(event, wysiwyg);
// Then it sends a message
expect(mockClient.sendMessage).toBeCalledTimes(1);
});
});
});

View file

@ -123,7 +123,6 @@ describe('message', () => {
statusBarVisible: false,
canReact: false,
canSendMessages: false,
canSendVoiceBroadcasts: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,

View file

@ -28,6 +28,7 @@ import {
mkPusher,
mockClientMethodsUser,
} from "../../../test-utils";
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
describe('<DevicesPanel />', () => {
const userId = '@alice:server.org';
@ -46,7 +47,10 @@ describe('<DevicesPanel />', () => {
setPusher: jest.fn(),
});
const getComponent = () => <DevicesPanel />;
const getComponent = () =>
<MatrixClientContext.Provider value={mockClient}>
<DevicesPanel />
</MatrixClientContext.Provider>;
beforeEach(() => {
jest.clearAllMocks();

View file

@ -0,0 +1,297 @@
/*
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.
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { mocked } from 'jest-mock';
import React from 'react';
import { MSC3886SimpleHttpRendezvousTransport } from 'matrix-js-sdk/src/rendezvous/transports';
import { MSC3906Rendezvous, RendezvousFailureReason } from 'matrix-js-sdk/src/rendezvous';
import LoginWithQR, { Mode } from '../../../../../src/components/views/auth/LoginWithQR';
import type { MatrixClient } from 'matrix-js-sdk/src/matrix';
import { flushPromisesWithFakeTimers } from '../../../../test-utils';
jest.useFakeTimers();
jest.mock('matrix-js-sdk/src/rendezvous');
jest.mock('matrix-js-sdk/src/rendezvous/transports');
jest.mock('matrix-js-sdk/src/rendezvous/channels');
function makeClient() {
return mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
isCryptoEnabled: jest.fn(),
getUserId: jest.fn(),
on: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(true),
removeListener: jest.fn(),
requestLoginToken: jest.fn(),
currentState: {
on: jest.fn(),
},
} as unknown as MatrixClient);
}
describe('<LoginWithQR />', () => {
const client = makeClient();
const defaultProps = {
mode: Mode.Show,
onFinished: jest.fn(),
};
const mockConfirmationDigits = 'mock-confirmation-digits';
const newDeviceId = 'new-device-id';
const getComponent = (props: { client: MatrixClient, onFinished?: () => void }) =>
(<LoginWithQR {...defaultProps} {...props} />);
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRestore();
jest.spyOn(MSC3906Rendezvous.prototype, 'cancel').mockResolvedValue();
jest.spyOn(MSC3906Rendezvous.prototype, 'declineLoginOnExistingDevice').mockResolvedValue();
jest.spyOn(MSC3906Rendezvous.prototype, 'startAfterShowingCode').mockResolvedValue(mockConfirmationDigits);
jest.spyOn(MSC3906Rendezvous.prototype, 'approveLoginOnExistingDevice').mockResolvedValue(newDeviceId);
client.requestLoginToken.mockResolvedValue({
login_token: 'token',
expires_in: 1000,
});
// @ts-ignore
client.crypto = undefined;
});
it('no content in case of no support', async () => {
// simulate no support
jest.spyOn(MSC3906Rendezvous.prototype, 'generateCode').mockRejectedValue('');
const { container } = render(getComponent({ client }));
await waitFor(() => screen.getAllByTestId('cancellation-message').length === 1);
expect(container).toMatchSnapshot();
});
it('renders spinner while generating code', async () => {
const { container } = render(getComponent({ client }));
expect(container).toMatchSnapshot();
});
it('cancels rendezvous after user goes back', async () => {
const { getByTestId } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// flush generate code promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('back-button'));
// wait for cancel
await flushPromisesWithFakeTimers();
expect(rendezvous.cancel).toHaveBeenCalledWith(RendezvousFailureReason.UserCancelled);
});
it('displays qr code after it is created', async () => {
const { container, getByText } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
await flushPromisesWithFakeTimers();
expect(rendezvous.generateCode).toHaveBeenCalled();
expect(getByText('Sign in with QR code')).toBeTruthy();
expect(container).toMatchSnapshot();
});
it('displays confirmation digits after connected to rendezvous', async () => {
const { container, getByText } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
expect(container).toMatchSnapshot();
expect(getByText(mockConfirmationDigits)).toBeTruthy();
});
it('displays unknown error if connection to rendezvous fails', async () => {
const { container } = render(getComponent({ client }));
expect(MSC3886SimpleHttpRendezvousTransport).toHaveBeenCalledWith({
onFailure: expect.any(Function),
client,
});
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
mocked(rendezvous).startAfterShowingCode.mockRejectedValue('oups');
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
expect(container).toMatchSnapshot();
});
it('declines login', async () => {
const { getByTestId } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('decline-login-button'));
expect(rendezvous.declineLoginOnExistingDevice).toHaveBeenCalled();
});
it('displays error when approving login fails', async () => {
const { container, getByTestId } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
client.requestLoginToken.mockRejectedValue('oups');
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('approve-login-button'));
expect(client.requestLoginToken).toHaveBeenCalled();
// flush token request promise
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
expect(container).toMatchSnapshot();
});
it('approves login and waits for new device', async () => {
const { container, getByTestId, getByText } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('approve-login-button'));
expect(client.requestLoginToken).toHaveBeenCalled();
// flush token request promise
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
expect(getByText('Waiting for device to sign in')).toBeTruthy();
expect(container).toMatchSnapshot();
});
it('does not continue with verification when user denies login', async () => {
const onFinished = jest.fn();
const { getByTestId } = render(getComponent({ client, onFinished }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// no device id returned => user denied
mocked(rendezvous).approveLoginOnExistingDevice.mockReturnValue(undefined);
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('approve-login-button'));
// flush token request promise
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled();
await flushPromisesWithFakeTimers();
expect(onFinished).not.toHaveBeenCalled();
expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled();
});
it('waits for device approval on existing device and finishes when crypto is not setup', async () => {
const { getByTestId } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('approve-login-button'));
// flush token request promise
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled();
await flushPromisesWithFakeTimers();
expect(defaultProps.onFinished).toHaveBeenCalledWith(true);
// didnt attempt verification
expect(rendezvous.verifyNewDeviceOnExistingDevice).not.toHaveBeenCalled();
});
it('waits for device approval on existing device and verifies device', async () => {
const { getByTestId } = render(getComponent({ client }));
const rendezvous = mocked(MSC3906Rendezvous).mock.instances[0];
// @ts-ignore assign to private prop
rendezvous.code = 'rendezvous-code';
// we just check for presence of crypto
// pretend it is set up
// @ts-ignore
client.crypto = {};
// flush generate code promise
await flushPromisesWithFakeTimers();
// flush waiting for connection promise
await flushPromisesWithFakeTimers();
fireEvent.click(getByTestId('approve-login-button'));
// flush token request promise
await flushPromisesWithFakeTimers();
await flushPromisesWithFakeTimers();
expect(rendezvous.approveLoginOnExistingDevice).toHaveBeenCalled();
// flush login approval
await flushPromisesWithFakeTimers();
expect(rendezvous.verifyNewDeviceOnExistingDevice).toHaveBeenCalled();
// flush verification
await flushPromisesWithFakeTimers();
expect(defaultProps.onFinished).toHaveBeenCalledWith(true);
});
});

View file

@ -0,0 +1,94 @@
/*
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.
*/
import { render } from '@testing-library/react';
import { mocked } from 'jest-mock';
import { IServerVersions, MatrixClient } from 'matrix-js-sdk/src/matrix';
import React from 'react';
import LoginWithQRSection from '../../../../../src/components/views/settings/devices/LoginWithQRSection';
import { MatrixClientPeg } from '../../../../../src/MatrixClientPeg';
import { SettingLevel } from '../../../../../src/settings/SettingLevel';
import SettingsStore from '../../../../../src/settings/SettingsStore';
function makeClient() {
return mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
isCryptoEnabled: jest.fn(),
getUserId: jest.fn(),
on: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
} as unknown as MatrixClient);
}
function makeVersions(unstableFeatures: Record<string, boolean>): IServerVersions {
return {
versions: [],
unstable_features: unstableFeatures,
};
}
describe('<LoginWithQRSection />', () => {
beforeAll(() => {
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(makeClient());
});
const defaultProps = {
onShowQr: () => {},
versions: undefined,
};
const getComponent = (props = {}) =>
(<LoginWithQRSection {...defaultProps} {...props} />);
describe('should not render', () => {
it('no support at all', () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('feature enabled', async () => {
await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true);
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('only feature + MSC3882 enabled', async () => {
await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true);
const { container } = render(getComponent({ versions: makeVersions({ 'org.matrix.msc3882': true }) }));
expect(container).toMatchSnapshot();
});
});
describe('should render panel', () => {
it('enabled by feature + MSC3882 + MSC3886', async () => {
await SettingsStore.setValue('feature_qr_signin_reciprocate_show', null, SettingLevel.DEVICE, true);
const { container } = render(getComponent({ versions: makeVersions({
'org.matrix.msc3882': true,
'org.matrix.msc3886': true,
}) }));
expect(container).toMatchSnapshot();
});
});
});

View file

@ -181,18 +181,6 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = `
my-device
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Client
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Firefox 100
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
@ -269,6 +257,18 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = `
Windows 95
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Browser
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Firefox 100
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"

View file

@ -0,0 +1,367 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQR /> approves login and waits for new device 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<div
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
title="Back"
>
<div />
</div>
<h1 />
</div>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<p>
Waiting for device to sign in
</p>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> displays confirmation digits after connected to rendezvous 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<h1>
<div
class="normal"
/>
Devices connected
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p>
Check that the code below matches with your other device:
</p>
<div
class="mx_LoginWithQR_confirmationDigits"
>
mock-confirmation-digits
</div>
<div
class="mx_LoginWithQR_confirmationAlert"
>
<div>
<div />
</div>
<div>
By approving access for this device, it will have full access to your account.
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
data-testid="decline-login-button"
role="button"
tabindex="0"
>
Cancel
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
data-testid="approve-login-button"
role="button"
tabindex="0"
>
Approve
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> displays error when approving login fails 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class="mx_LoginWithQR_centreTitle"
>
<h1>
<div
class="error"
/>
Connection failed
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p
data-testid="cancellation-message"
>
An unexpected error occurred.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> displays qr code after it is created 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<div
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
title="Back"
>
<div />
</div>
<h1>
Sign in with QR code
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p>
Scan the QR code below with your device that's signed out.
</p>
<ol>
<li>
Start at the sign in screen
</li>
<li>
Select 'Scan QR code'
</li>
<li>
Review and approve the sign in
</li>
</ol>
<div
class="mx_LoginWithQR_qrWrapper"
>
<div
class="mx_QRCode mx_QRCode"
>
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;
exports[`<LoginWithQR /> displays unknown error if connection to rendezvous fails 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class="mx_LoginWithQR_centreTitle"
>
<h1>
<div
class="error"
/>
Connection failed
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p
data-testid="cancellation-message"
>
An unexpected error occurred.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> no content in case of no support 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class="mx_LoginWithQR_centreTitle"
>
<h1>
<div
class="error"
/>
Connection failed
</h1>
</div>
<div
class="mx_LoginWithQR_main"
>
<p
data-testid="cancellation-message"
>
The homeserver doesn't support signing in another device.
</p>
</div>
<div
class="mx_LoginWithQR_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Try again
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="0"
>
Cancel
</div>
</div>
</div>
</div>
`;
exports[`<LoginWithQR /> renders spinner while generating code 1`] = `
<div>
<div
class="mx_LoginWithQR"
>
<div
class=""
>
<div
class="mx_AccessibleButton mx_LoginWithQR_BackButton"
data-testid="back-button"
role="button"
tabindex="0"
title="Back"
>
<div />
</div>
<h1 />
</div>
<div
class="mx_LoginWithQR_main"
>
<div
class="mx_LoginWithQR_spinner"
>
<div>
<div
class="mx_Spinner"
>
<div
aria-label="Loading..."
class="mx_Spinner_icon"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
<div
class="mx_LoginWithQR_buttons"
/>
</div>
</div>
`;

View file

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginWithQRSection /> should not render feature enabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> should not render no support at all 1`] = `<div />`;
exports[`<LoginWithQRSection /> should not render only feature + MSC3882 enabled 1`] = `<div />`;
exports[`<LoginWithQRSection /> should render panel enabled by feature + MSC3882 + MSC3886 1`] = `
<div>
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Sign in with QR code
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_LoginWithQRSection"
>
<p
class="mx_SettingsTab_subsectionText"
>
You can use this device to sign in a new device with a QR code. You will need to scan the QR code shown on this device with your device that's signed out.
</p>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Show QR code
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -17,6 +17,7 @@ import { render } from '@testing-library/react';
import React from 'react';
import SecurityUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/SecurityUserSettingsTab";
import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext';
import SettingsStore from '../../../../../../src/settings/SettingsStore';
import {
getMockClientWithEventEmitter,
@ -31,11 +32,10 @@ describe('<SecurityUserSettingsTab />', () => {
const defaultProps = {
closeSettingsFn: jest.fn(),
};
const getComponent = () => <SecurityUserSettingsTab {...defaultProps} />;
const userId = '@alice:server.org';
const deviceId = 'alices-device';
getMockClientWithEventEmitter({
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsServer(),
...mockClientMethodsDevice(deviceId),
@ -44,6 +44,11 @@ describe('<SecurityUserSettingsTab />', () => {
getIgnoredUsers: jest.fn(),
});
const getComponent = () =>
<MatrixClientContext.Provider value={mockClient}>
<SecurityUserSettingsTab {...defaultProps} />
</MatrixClientContext.Provider>;
const settingsValueSpy = jest.spyOn(SettingsStore, 'getValue');
beforeEach(() => {

View file

@ -92,6 +92,7 @@ describe('<SessionManagerTab />', () => {
getPushers: jest.fn(),
setPusher: jest.fn(),
setLocalNotificationSettings: jest.fn(),
getVersions: jest.fn().mockResolvedValue({}),
});
const defaultProps = {};

View file

@ -21,10 +21,21 @@ import { Action } from '../../src/dispatcher/actions';
import { getMockClientWithEventEmitter, untilDispatch, untilEmission } from '../test-utils';
import SettingsStore from '../../src/settings/SettingsStore';
import { SlidingSyncManager } from '../../src/SlidingSyncManager';
import { PosthogAnalytics } from '../../src/PosthogAnalytics';
import { TimelineRenderingType } from '../../src/contexts/RoomContext';
import { MatrixDispatcher } from '../../src/dispatcher/dispatcher';
import { UPDATE_EVENT } from '../../src/stores/AsyncStore';
import { ActiveRoomChangedPayload } from '../../src/dispatcher/payloads/ActiveRoomChangedPayload';
import { SpaceStoreClass } from '../../src/stores/spaces/SpaceStore';
import { TestStores } from '../TestStores';
// mock out the injected classes
jest.mock('../../src/PosthogAnalytics');
const MockPosthogAnalytics = <jest.Mock<PosthogAnalytics>><unknown>PosthogAnalytics;
jest.mock('../../src/SlidingSyncManager');
const MockSlidingSyncManager = <jest.Mock<SlidingSyncManager>><unknown>SlidingSyncManager;
jest.mock('../../src/stores/spaces/SpaceStore');
const MockSpaceStore = <jest.Mock<SpaceStoreClass>><unknown>SpaceStoreClass;
jest.mock('../../src/utils/DMRoomMap', () => {
const mock = {
@ -51,6 +62,9 @@ describe('RoomViewStore', function() {
isGuest: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
let roomViewStore: RoomViewStore;
let slidingSyncManager: SlidingSyncManager;
let dis: MatrixDispatcher;
beforeEach(function() {
@ -60,10 +74,17 @@ describe('RoomViewStore', function() {
mockClient.getRoom.mockReturnValue(room);
mockClient.isGuest.mockReturnValue(false);
// Reset the state of the store
// Make the RVS to test
dis = new MatrixDispatcher();
RoomViewStore.instance.reset();
RoomViewStore.instance.resetDispatcher(dis);
slidingSyncManager = new MockSlidingSyncManager();
const stores = new TestStores();
stores._SlidingSyncManager = slidingSyncManager;
stores._PosthogAnalytics = new MockPosthogAnalytics();
stores._SpaceStore = new MockSpaceStore();
roomViewStore = new RoomViewStore(
dis, stores,
);
stores._RoomViewStore = roomViewStore;
});
it('can be used to view a room by ID and join', async () => {
@ -71,14 +92,14 @@ describe('RoomViewStore', function() {
dis.dispatch({ action: Action.JoinRoom });
await untilDispatch(Action.JoinRoomReady, dis);
expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] });
expect(RoomViewStore.instance.isJoining()).toBe(true);
expect(roomViewStore.isJoining()).toBe(true);
});
it('can auto-join a room', async () => {
dis.dispatch({ action: Action.ViewRoom, room_id: roomId, auto_join: true });
await untilDispatch(Action.JoinRoomReady, dis);
expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { viaServers: [] });
expect(RoomViewStore.instance.isJoining()).toBe(true);
expect(roomViewStore.isJoining()).toBe(true);
});
it('emits ActiveRoomChanged when the viewed room changes', async () => {
@ -97,7 +118,7 @@ describe('RoomViewStore', function() {
it('invokes room activity listeners when the viewed room changes', async () => {
const roomId2 = "!roomid:2";
const callback = jest.fn();
RoomViewStore.instance.addRoomListener(roomId, callback);
roomViewStore.addRoomListener(roomId, callback);
dis.dispatch({ action: Action.ViewRoom, room_id: roomId });
await untilDispatch(Action.ActiveRoomChanged, dis) as ActiveRoomChangedPayload;
expect(callback).toHaveBeenCalledWith(true);
@ -116,14 +137,14 @@ describe('RoomViewStore', function() {
}, dis);
// roomId is set to id of the room alias
expect(RoomViewStore.instance.getRoomId()).toBe(roomId);
expect(roomViewStore.getRoomId()).toBe(roomId);
// join the room
dis.dispatch({ action: Action.JoinRoom }, true);
await untilDispatch(Action.JoinRoomReady, dis);
expect(RoomViewStore.instance.isJoining()).toBeTruthy();
expect(roomViewStore.isJoining()).toBeTruthy();
expect(mockClient.joinRoom).toHaveBeenCalledWith(alias, { viaServers: [] });
});
@ -134,7 +155,7 @@ describe('RoomViewStore', function() {
const payload = await untilDispatch(Action.ViewRoomError, dis);
expect(payload.room_id).toBeNull();
expect(payload.room_alias).toEqual(alias);
expect(RoomViewStore.instance.getRoomAlias()).toEqual(alias);
expect(roomViewStore.getRoomAlias()).toEqual(alias);
});
it('emits JoinRoomError if joining the room fails', async () => {
@ -143,8 +164,8 @@ describe('RoomViewStore', function() {
dis.dispatch({ action: Action.ViewRoom, room_id: roomId });
dis.dispatch({ action: Action.JoinRoom });
await untilDispatch(Action.JoinRoomError, dis);
expect(RoomViewStore.instance.isJoining()).toBe(false);
expect(RoomViewStore.instance.getJoinError()).toEqual(joinErr);
expect(roomViewStore.isJoining()).toBe(false);
expect(roomViewStore.getJoinError()).toEqual(joinErr);
});
it('remembers the event being replied to when swapping rooms', async () => {
@ -154,13 +175,13 @@ describe('RoomViewStore', function() {
getRoomId: () => roomId,
};
dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room });
await untilEmission(RoomViewStore.instance, UPDATE_EVENT);
expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent);
await untilEmission(roomViewStore, UPDATE_EVENT);
expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent);
// view the same room, should remember the event.
// set the highlighed flag to make sure there is a state change so we get an update event
dis.dispatch({ action: Action.ViewRoom, room_id: roomId, highlighted: true });
await untilEmission(RoomViewStore.instance, UPDATE_EVENT);
expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent);
await untilEmission(roomViewStore, UPDATE_EVENT);
expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent);
});
it('swaps to the replied event room if it is not the current room', async () => {
@ -172,18 +193,18 @@ describe('RoomViewStore', function() {
};
dis.dispatch({ action: 'reply_to_event', event: replyToEvent, context: TimelineRenderingType.Room });
await untilDispatch(Action.ViewRoom, dis);
expect(RoomViewStore.instance.getQuotingEvent()).toEqual(replyToEvent);
expect(RoomViewStore.instance.getRoomId()).toEqual(roomId2);
expect(roomViewStore.getQuotingEvent()).toEqual(replyToEvent);
expect(roomViewStore.getRoomId()).toEqual(roomId2);
});
it('removes the roomId on ViewHomePage', async () => {
dis.dispatch({ action: Action.ViewRoom, room_id: roomId });
await untilDispatch(Action.ActiveRoomChanged, dis);
expect(RoomViewStore.instance.getRoomId()).toEqual(roomId);
expect(roomViewStore.getRoomId()).toEqual(roomId);
dis.dispatch({ action: Action.ViewHomePage });
await untilEmission(RoomViewStore.instance, UPDATE_EVENT);
expect(RoomViewStore.instance.getRoomId()).toBeNull();
await untilEmission(roomViewStore, UPDATE_EVENT);
expect(roomViewStore.getRoomId()).toBeNull();
});
describe('Sliding Sync', function() {
@ -191,23 +212,22 @@ describe('RoomViewStore', function() {
jest.spyOn(SettingsStore, 'getValue').mockImplementation((settingName, roomId, value) => {
return settingName === "feature_sliding_sync"; // this is enabled, everything else is disabled.
});
RoomViewStore.instance.reset();
});
it("subscribes to the room", async () => {
const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue(
const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue(
Promise.resolve(""),
);
const subscribedRoomId = "!sub1:localhost";
dis.dispatch({ action: Action.ViewRoom, room_id: subscribedRoomId });
await untilDispatch(Action.ActiveRoomChanged, dis);
expect(RoomViewStore.instance.getRoomId()).toBe(subscribedRoomId);
expect(roomViewStore.getRoomId()).toBe(subscribedRoomId);
expect(setRoomVisible).toHaveBeenCalledWith(subscribedRoomId, true);
});
// Regression test for an in-the-wild bug where rooms would rapidly switch forever in sliding sync mode
it("doesn't get stuck in a loop if you view rooms quickly", async () => {
const setRoomVisible = jest.spyOn(SlidingSyncManager.instance, "setRoomVisible").mockReturnValue(
const setRoomVisible = jest.spyOn(slidingSyncManager, "setRoomVisible").mockReturnValue(
Promise.resolve(""),
);
const subscribedRoomId = "!sub1:localhost";

View file

@ -20,8 +20,8 @@ import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api";
import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import { StopGapWidgetDriver } from "../../../src/stores/widgets/StopGapWidgetDriver";
import { stubClient } from "../../test-utils";
@ -201,7 +201,7 @@ describe("StopGapWidgetDriver", () => {
beforeEach(() => { driver = mkDefaultDriver(); });
it('reads related events from the current room', async () => {
jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id');
jest.spyOn(SdkContextClass.instance.roomViewStore, 'getRoomId').mockReturnValue('!this-room-id');
client.relations.mockResolvedValue({
originalEvent: new MatrixEvent(),

View file

@ -104,6 +104,7 @@ export const mockClientMethodsServer = (): Partial<Record<MethodKeysOf<MatrixCli
getCapabilities: jest.fn().mockReturnValue({}),
getClientWellKnown: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
getVersions: jest.fn().mockResolvedValue({}),
isFallbackICEServerAllowed: jest.fn(),
});

View file

@ -0,0 +1,70 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of another user should show an info dialog 1`] = `
[MockFunction] {
"calls": Array [
Array [
[Function],
Object {
"description": <p>
Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;
exports[`startNewVoiceBroadcastRecording when the current user is allowed to send voice broadcast info state events when there already is a live broadcast of the current user should show an info dialog 1`] = `
[MockFunction] {
"calls": Array [
Array [
[Function],
Object {
"description": <p>
You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;
exports[`startNewVoiceBroadcastRecording when the current user is not allowed to send voice broadcast info state events should show an info dialog 1`] = `
[MockFunction] {
"calls": Array [
Array [
[Function],
Object {
"description": <p>
You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.
</p>,
"hasCloseButton": true,
"title": "Can't start a new voice broadcast",
},
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -0,0 +1,144 @@
/*
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.
*/
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import {
hasRoomLiveVoiceBroadcast,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
} from "../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
describe("hasRoomLiveVoiceBroadcast", () => {
const otherUserId = "@other:example.com";
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
const addVoiceBroadcastInfoEvent = (
state: VoiceBroadcastInfoState,
sender: string,
) => {
room.currentState.setStateEvents([
mkVoiceBroadcastInfoStateEvent(room.roomId, state, sender),
]);
};
const itShouldReturnTrueTrue = () => {
it("should return true/true", () => {
expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({
hasBroadcast: true,
startedByUser: true,
});
});
};
const itShouldReturnTrueFalse = () => {
it("should return true/false", () => {
expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({
hasBroadcast: true,
startedByUser: false,
});
});
};
const itShouldReturnFalseFalse = () => {
it("should return false/false", () => {
expect(hasRoomLiveVoiceBroadcast(room, client.getUserId())).toEqual({
hasBroadcast: false,
startedByUser: false,
});
});
};
beforeAll(() => {
client = stubClient();
});
beforeEach(() => {
room = new Room(roomId, client, client.getUserId());
});
describe("when there is no voice broadcast info at all", () => {
itShouldReturnFalseFalse();
});
describe("when the »state« prop is missing", () => {
beforeEach(() => {
room.currentState.setStateEvents([
mkEvent({
event: true,
room: room.roomId,
user: client.getUserId(),
type: VoiceBroadcastInfoEventType,
skey: client.getUserId(),
content: {},
}),
]);
});
itShouldReturnFalseFalse();
});
describe("when there is a live broadcast from the current and another user", () => {
beforeEach(() => {
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, client.getUserId());
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Started, otherUserId);
});
itShouldReturnTrueTrue();
});
describe("when there are only stopped info events", () => {
beforeEach(() => {
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getUserId());
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, otherUserId);
});
itShouldReturnFalseFalse();
});
describe.each([
// all there are kind of live states
VoiceBroadcastInfoState.Started,
VoiceBroadcastInfoState.Paused,
VoiceBroadcastInfoState.Running,
])("when there is a live broadcast (%s) from the current user", (state: VoiceBroadcastInfoState) => {
beforeEach(() => {
addVoiceBroadcastInfoEvent(state, client.getUserId());
});
itShouldReturnTrueTrue();
});
describe("when there was a live broadcast, that has been stopped", () => {
beforeEach(() => {
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Running, client.getUserId());
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Stopped, client.getUserId());
});
itShouldReturnFalseFalse();
});
describe("when there is a live broadcast from another user", () => {
beforeEach(() => {
addVoiceBroadcastInfoEvent(VoiceBroadcastInfoState.Running, otherUserId);
});
itShouldReturnTrueFalse();
});
});

View file

@ -15,8 +15,9 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixEvent, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { EventType, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import Modal from "../../../src/Modal";
import {
startNewVoiceBroadcastRecording,
VoiceBroadcastInfoEventType,
@ -25,46 +26,29 @@ import {
VoiceBroadcastRecording,
} from "../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
jest.mock("../../../src/voice-broadcast/models/VoiceBroadcastRecording", () => ({
VoiceBroadcastRecording: jest.fn(),
}));
jest.mock("../../../src/Modal");
describe("startNewVoiceBroadcastRecording", () => {
const roomId = "!room:example.com";
const otherUserId = "@other:example.com";
let client: MatrixClient;
let recordingsStore: VoiceBroadcastRecordingsStore;
let room: Room;
let roomOnStateEventsCallbackRegistered: Promise<void>;
let roomOnStateEventsCallbackRegisteredResolver: Function;
let roomOnStateEventsCallback: () => void;
let infoEvent: MatrixEvent;
let otherEvent: MatrixEvent;
let stateEvent: MatrixEvent;
let result: VoiceBroadcastRecording | null;
beforeEach(() => {
roomOnStateEventsCallbackRegistered = new Promise((resolve) => {
roomOnStateEventsCallbackRegisteredResolver = resolve;
});
room = {
currentState: {
getStateEvents: jest.fn().mockImplementation((type, userId) => {
if (type === VoiceBroadcastInfoEventType && userId === client.getUserId()) {
return stateEvent;
}
}),
},
on: jest.fn().mockImplementation((eventType, callback) => {
if (eventType === RoomStateEvent.Events) {
roomOnStateEventsCallback = callback;
roomOnStateEventsCallbackRegisteredResolver();
}
}),
off: jest.fn(),
} as unknown as Room;
client = stubClient();
room = new Room(roomId, client, client.getUserId());
jest.spyOn(room.currentState, "maySendStateEvent");
mocked(client.getRoom).mockImplementation((getRoomId: string) => {
if (getRoomId === roomId) {
return room;
@ -85,22 +69,14 @@ describe("startNewVoiceBroadcastRecording", () => {
setCurrent: jest.fn(),
} as unknown as VoiceBroadcastRecordingsStore;
infoEvent = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
content: {
device_id: client.getDeviceId(),
state: VoiceBroadcastInfoState.Started,
},
user: client.getUserId(),
room: roomId,
});
infoEvent = mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Started, client.getUserId());
otherEvent = mkEvent({
event: true,
type: EventType.RoomMember,
content: {},
user: client.getUserId(),
room: roomId,
skey: "",
});
mocked(VoiceBroadcastRecording).mockImplementation((
@ -115,29 +91,96 @@ describe("startNewVoiceBroadcastRecording", () => {
});
});
it("should create a new Voice Broadcast", (done) => {
let ok = false;
afterEach(() => {
jest.clearAllMocks();
});
startNewVoiceBroadcastRecording(roomId, client, recordingsStore).then((recording) => {
expect(ok).toBe(true);
expect(mocked(room.off)).toHaveBeenCalledWith(RoomStateEvent.Events, roomOnStateEventsCallback);
expect(recording.infoEvent).toBe(infoEvent);
expect(recording.start).toHaveBeenCalled();
done();
describe("when the current user is allowed to send voice broadcast info state events", () => {
beforeEach(() => {
mocked(room.currentState.maySendStateEvent).mockReturnValue(true);
});
roomOnStateEventsCallbackRegistered.then(() => {
// no state event, yet
roomOnStateEventsCallback();
describe("when there currently is no other broadcast", () => {
it("should create a new Voice Broadcast", async () => {
mocked(client.sendStateEvent).mockImplementation(async (
_roomId: string,
_eventType: string,
_content: any,
_stateKey = "",
) => {
setTimeout(() => {
// emit state events after resolving the promise
room.currentState.setStateEvents([otherEvent]);
room.currentState.setStateEvents([infoEvent]);
}, 0);
return { event_id: infoEvent.getId() };
});
const recording = await startNewVoiceBroadcastRecording(room, client, recordingsStore);
// other state event
stateEvent = otherEvent;
roomOnStateEventsCallback();
expect(client.sendStateEvent).toHaveBeenCalledWith(
roomId,
VoiceBroadcastInfoEventType,
{
chunk_length: 300,
device_id: client.getDeviceId(),
state: VoiceBroadcastInfoState.Started,
},
client.getUserId(),
);
expect(recording.infoEvent).toBe(infoEvent);
expect(recording.start).toHaveBeenCalled();
});
});
// the expected Voice Broadcast Info event
stateEvent = infoEvent;
ok = true;
roomOnStateEventsCallback();
describe("when there already is a live broadcast of the current user", () => {
beforeEach(async () => {
room.currentState.setStateEvents([
mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, client.getUserId()),
]);
result = await startNewVoiceBroadcastRecording(room, client, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when there already is a live broadcast of another user", () => {
beforeEach(async () => {
room.currentState.setStateEvents([
mkVoiceBroadcastInfoStateEvent(roomId, VoiceBroadcastInfoState.Running, otherUserId),
]);
result = await startNewVoiceBroadcastRecording(room, client, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
});
describe("when the current user is not allowed to send voice broadcast info state events", () => {
beforeEach(async () => {
mocked(room.currentState.maySendStateEvent).mockReturnValue(false);
result = await startNewVoiceBroadcastRecording(room, client, recordingsStore);
});
it("should not start a voice broadcast", () => {
expect(result).toBeNull();
});
it("should show an info dialog", () => {
expect(Modal.createDialog).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,37 @@
/*
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.
*/
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../src/voice-broadcast";
import { mkEvent } from "../../test-utils";
export const mkVoiceBroadcastInfoStateEvent = (
roomId: string,
state: VoiceBroadcastInfoState,
sender: string,
): MatrixEvent => {
return mkEvent({
event: true,
room: roomId,
user: sender,
type: VoiceBroadcastInfoEventType,
skey: sender,
content: {
state,
},
});
};