Merge remote-tracking branch 'origin/develop' into feat/add-message-edition-wysiwyg-composer
This commit is contained in:
commit
e77f333fb6
124 changed files with 4370 additions and 1039 deletions
|
@ -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();
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -72,7 +72,6 @@ describe('<SendMessageComposer/>', () => {
|
|||
statusBarVisible: false,
|
||||
canReact: false,
|
||||
canSendMessages: false,
|
||||
canSendVoiceBroadcasts: false,
|
||||
layout: Layout.Group,
|
||||
lowBandwidth: false,
|
||||
alwaysShowTimestamps: false,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -123,7 +123,6 @@ describe('message', () => {
|
|||
statusBarVisible: false,
|
||||
canReact: false,
|
||||
canSendMessages: false,
|
||||
canSendVoiceBroadcasts: false,
|
||||
layout: Layout.Group,
|
||||
lowBandwidth: false,
|
||||
alwaysShowTimestamps: false,
|
||||
|
|
|
@ -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();
|
||||
|
|
297
test/components/views/settings/devices/LoginWithQR-test.tsx
Normal file
297
test/components/views/settings/devices/LoginWithQR-test.tsx
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -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(() => {
|
||||
|
|
|
@ -92,6 +92,7 @@ describe('<SessionManagerTab />', () => {
|
|||
getPushers: jest.fn(),
|
||||
setPusher: jest.fn(),
|
||||
setLocalNotificationSettings: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({}),
|
||||
});
|
||||
|
||||
const defaultProps = {};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue