Merge remote-tracking branch 'origin/develop' into florianduros/fix-white-black-theme-switch

This commit is contained in:
Florian Duros 2022-10-06 10:08:02 +02:00
commit ba783b8441
No known key found for this signature in database
GPG key ID: 9700AA5870258A0B
131 changed files with 4015 additions and 1215 deletions

View file

@ -18,6 +18,7 @@ limitations under the License.
import { EventEmitter } from "events";
import { mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import DeviceListener from "../src/DeviceListener";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
@ -27,6 +28,9 @@ import * as BulkUnverifiedSessionsToast from "../src/toasts/BulkUnverifiedSessio
import { isSecretStorageBeingAccessed } from "../src/SecurityManager";
import dis from "../src/dispatcher/dispatcher";
import { Action } from "../src/dispatcher/actions";
import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel";
import { mockPlatformPeg } from "./test-utils";
// don't litter test console with logs
jest.mock("matrix-js-sdk/src/logger");
@ -40,7 +44,10 @@ jest.mock("../src/SecurityManager", () => ({
isSecretStorageBeingAccessed: jest.fn(), accessSecretStorage: jest.fn(),
}));
const deviceId = 'my-device-id';
class MockClient extends EventEmitter {
isGuest = jest.fn();
getUserId = jest.fn();
getKeyBackupVersion = jest.fn().mockResolvedValue(undefined);
getRooms = jest.fn().mockReturnValue([]);
@ -57,6 +64,8 @@ class MockClient extends EventEmitter {
downloadKeys = jest.fn();
isRoomEncrypted = jest.fn();
getClientWellKnown = jest.fn();
getDeviceId = jest.fn().mockReturnValue(deviceId);
setAccountData = jest.fn();
}
const mockDispatcher = mocked(dis);
const flushPromises = async () => await new Promise(process.nextTick);
@ -75,8 +84,12 @@ describe('DeviceListener', () => {
beforeEach(() => {
jest.resetAllMocks();
mockPlatformPeg({
getAppVersion: jest.fn().mockResolvedValue('1.2.3'),
});
mockClient = new MockClient();
jest.spyOn(MatrixClientPeg, 'get').mockReturnValue(mockClient);
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
});
const createAndStart = async (): Promise<DeviceListener> => {
@ -86,6 +99,115 @@ describe('DeviceListener', () => {
return instance;
};
describe('client information', () => {
it('watches device client information setting', async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting');
const unwatchSettingSpy = jest.spyOn(SettingsStore, 'unwatchSetting');
const deviceListener = await createAndStart();
expect(watchSettingSpy).toHaveBeenCalledWith(
'deviceClientInformationOptIn', null, expect.any(Function),
);
deviceListener.stop();
expect(unwatchSettingSpy).toHaveBeenCalled();
});
describe('when device client information feature is enabled', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockImplementation(
settingName => settingName === 'deviceClientInformationOptIn',
);
});
it('saves client information on start', async () => {
await createAndStart();
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
it('catches error and logs when saving client information fails', async () => {
const errorLogSpy = jest.spyOn(logger, 'error');
const error = new Error('oups');
mockClient.setAccountData.mockRejectedValue(error);
// doesn't throw
await createAndStart();
expect(errorLogSpy).toHaveBeenCalledWith(
'Failed to record client information',
error,
);
});
it('saves client information on logged in action', async () => {
const instance = await createAndStart();
mockClient.setAccountData.mockClear();
// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });
await flushPromises();
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
});
describe('when device client information feature is disabled', () => {
beforeEach(() => {
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
});
it('does not save client information on start', async () => {
await createAndStart();
expect(mockClient.setAccountData).not.toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
it('does not save client information on logged in action', async () => {
const instance = await createAndStart();
// @ts-ignore calling private function
instance.onAction({ action: Action.OnLoggedIn });
await flushPromises();
expect(mockClient.setAccountData).not.toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
it('saves client information after setting is enabled', async () => {
const watchSettingSpy = jest.spyOn(SettingsStore, 'watchSetting');
await createAndStart();
const [settingName, roomId, callback] = watchSettingSpy.mock.calls[0];
expect(settingName).toEqual('deviceClientInformationOptIn');
expect(roomId).toBeNull();
callback('deviceClientInformationOptIn', null, SettingLevel.DEVICE, SettingLevel.DEVICE, true);
await flushPromises();
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{ name: 'Element', url: 'localhost', version: '1.2.3' },
);
});
});
});
describe('recheck', () => {
it('does nothing when cross signing feature is not supported', async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);

85
test/Notifier-test.ts Normal file
View file

@ -0,0 +1,85 @@
/*
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/models/event";
import Notifier from "../src/Notifier";
import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications";
import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils";
describe("Notifier", () => {
let MockPlatform;
let accountDataStore = {};
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue("@bob:example.org"),
isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = new MatrixEvent({
type: eventType,
content,
});
}),
});
const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
const roomId = "!room1:server";
const testEvent = mkEvent({
event: true,
type: "m.room.message",
user: "@user1:server",
room: roomId,
content: {},
});
const testRoom = mkRoom(mockClient, roomId);
beforeEach(() => {
accountDataStore = {};
MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true),
maySendNotifications: jest.fn().mockReturnValue(true),
displayNotification: jest.fn(),
});
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
});
describe("_displayPopupNotification", () => {
it.each([
{ silenced: true, count: 0 },
{ silenced: false, count: 1 },
])("does not dispatch when notifications are silenced", ({ silenced, count }) => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced });
Notifier._displayPopupNotification(testEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count);
});
});
describe("_playAudioNotification", () => {
it.each([
{ silenced: true, count: 0 },
{ silenced: false, count: 1 },
])("does not dispatch when notifications are silenced", ({ silenced, count }) => {
// It's not ideal to only look at whether this function has been called
// but avoids starting to look into DOM stuff
Notifier.getSoundForRoom = jest.fn();
mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced });
Notifier._playAudioNotification(testEvent, testRoom);
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
});
});
});

View file

@ -0,0 +1,150 @@
/*
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 React from "react";
import { render, screen, act, cleanup, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { mocked, Mocked } from "jest-mock";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {
useMockedCalls,
MockedCall,
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
wrapInMatrixClientContext,
} from "../../../test-utils";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { CallEvent as UnwrappedCallEvent } from "../../../../src/components/views/messages/CallEvent";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { CallStore } from "../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../src/models/Call";
const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent);
describe("CallEvent", () => {
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", { value: { enumerateDevices: () => [] } });
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let call: MockedCall;
let widget: Widget;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
stubClient();
client = mocked(MatrixClientPeg.get());
client.getUserId.mockReturnValue("@alice:example.org");
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
jest.spyOn(room, "getMember").mockImplementation(
userId => [alice, bob].find(member => member.userId === userId) ?? null,
);
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(
store => setupAsyncStoreWithClient(store, client),
));
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.get(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(async () => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
});
const renderEvent = () => { render(<CallEvent mxEvent={call.event} />); };
it("shows a message and duration if the call was ended", () => {
jest.advanceTimersByTime(90000);
call.destroy();
renderEvent();
screen.getByText("Video call ended");
screen.getByText("1m 30s");
});
it("shows placeholder info if the call isn't loaded yet", () => {
jest.spyOn(CallStore.instance, "get").mockReturnValue(null);
jest.advanceTimersByTime(90000);
renderEvent();
screen.getByText("@alice:example.org started a video call");
expect(screen.getByRole("button", { name: "Join" })).toHaveAttribute("aria-disabled", "true");
});
it("shows call details and connection controls if the call is loaded", async () => {
jest.advanceTimersByTime(90000);
call.participants = new Set([alice, bob]);
renderEvent();
screen.getByText("@alice:example.org started a video call");
screen.getByLabelText("2 participants");
screen.getByText("1m 30s");
// Test that the join button works
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}));
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.connect());
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));
await waitFor(() => screen.getByRole("button", { name: "Join" }));
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
});

View file

@ -57,7 +57,7 @@ describe("<VoiceRecordComposerTile/>", () => {
durationSeconds: 1337,
contentType: "audio/ogg",
getPlayback: () => ({
thumbnailWaveform: [],
thumbnailWaveform: [1.4, 2.5, 3.6],
}),
} as unknown as VoiceRecording;
voiceRecordComposerTile = mount(<VoiceRecordComposerTile {...props} />);
@ -88,7 +88,11 @@ describe("<VoiceRecordComposerTile/>", () => {
"msgtype": MsgType.Audio,
"org.matrix.msc1767.audio": {
"duration": 1337000,
"waveform": [],
"waveform": [
1434,
2560,
3686,
],
},
"org.matrix.msc1767.file": {
"file": undefined,

View file

@ -15,7 +15,14 @@ limitations under the License.
import React from 'react';
// eslint-disable-next-line deprecate/import
import { mount, ReactWrapper } from 'enzyme';
import { IPushRule, IPushRules, RuleId, IPusher } from 'matrix-js-sdk/src/matrix';
import {
IPushRule,
IPushRules,
RuleId,
IPusher,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixEvent,
} from 'matrix-js-sdk/src/matrix';
import { IThreepid, ThreepidMedium } from 'matrix-js-sdk/src/@types/threepids';
import { act } from 'react-dom/test-utils';
@ -67,6 +74,17 @@ describe('<Notifications />', () => {
setPushRuleEnabled: jest.fn(),
setPushRuleActions: jest.fn(),
getRooms: jest.fn().mockReturnValue([]),
getAccountData: jest.fn().mockImplementation(eventType => {
if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
type: eventType,
content: {
is_silenced: false,
},
});
}
}),
setAccountData: jest.fn(),
});
mockClient.getPushRules.mockResolvedValue(pushRules);
@ -117,6 +135,7 @@ describe('<Notifications />', () => {
const component = await getComponentAndWait();
expect(findByTestId(component, 'notif-master-switch').length).toBeTruthy();
expect(findByTestId(component, 'notif-device-switch').length).toBeTruthy();
expect(findByTestId(component, 'notif-setting-notificationsEnabled').length).toBeTruthy();
expect(findByTestId(component, 'notif-setting-notificationBodyEnabled').length).toBeTruthy();
expect(findByTestId(component, 'notif-setting-audioNotificationsEnabled').length).toBeTruthy();

View file

@ -112,16 +112,16 @@ exports[`<DevicesPanel /> renders device panel with devices 1`] = `
data-testid="device-tile-device_1"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -214,6 +214,7 @@ exports[`<DevicesPanel /> renders device panel with devices 1`] = `
class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
data-testid="device-tile-checkbox-device_2"
id="device-tile-checkbox-device_2"
type="checkbox"
/>
@ -234,16 +235,16 @@ exports[`<DevicesPanel /> renders device panel with devices 1`] = `
data-testid="device-tile-device_2"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -295,6 +296,7 @@ exports[`<DevicesPanel /> renders device panel with devices 1`] = `
class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
data-testid="device-tile-checkbox-device_3"
id="device-tile-checkbox-device_3"
type="checkbox"
/>
@ -315,16 +317,16 @@ exports[`<DevicesPanel /> renders device panel with devices 1`] = `
data-testid="device-tile-device_3"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>

View file

@ -60,9 +60,10 @@ exports[`<Notifications /> main notification switches renders only enable notifi
className="mx_UserNotifSettings"
>
<LabelledToggleSwitch
caption="Turn off to disable notifications on all your devices and sessions"
data-test-id="notif-master-switch"
disabled={false}
label="Enable for this account"
label="Enable notifications for this account"
onChange={[Function]}
value={false}
>
@ -72,10 +73,18 @@ exports[`<Notifications /> main notification switches renders only enable notifi
<span
className="mx_SettingsFlag_label"
>
Enable for this account
Enable notifications for this account
<br />
<Caption>
<span
className="mx_Caption"
>
Turn off to disable notifications on all your devices and sessions
</span>
</Caption>
</span>
<_default
aria-label="Enable for this account"
aria-label="Enable notifications for this account"
checked={false}
disabled={false}
onChange={[Function]}
@ -83,7 +92,7 @@ exports[`<Notifications /> main notification switches renders only enable notifi
<AccessibleButton
aria-checked={false}
aria-disabled={false}
aria-label="Enable for this account"
aria-label="Enable notifications for this account"
className="mx_ToggleSwitch mx_ToggleSwitch_enabled"
element="div"
onClick={[Function]}
@ -93,7 +102,7 @@ exports[`<Notifications /> main notification switches renders only enable notifi
<div
aria-checked={false}
aria-disabled={false}
aria-label="Enable for this account"
aria-label="Enable notifications for this account"
className="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
onClick={[Function]}
onKeyDown={[Function]}

View file

@ -19,6 +19,7 @@ import { fireEvent, render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import CurrentDeviceSection from '../../../../../src/components/views/settings/devices/CurrentDeviceSection';
import { DeviceType } from '../../../../../src/utils/device/parseUserAgent';
describe('<CurrentDeviceSection />', () => {
const deviceId = 'alices_device';
@ -26,10 +27,12 @@ describe('<CurrentDeviceSection />', () => {
const alicesVerifiedDevice = {
device_id: deviceId,
isVerified: false,
deviceType: DeviceType.Unknown,
};
const alicesUnverifiedDevice = {
device_id: deviceId,
isVerified: false,
deviceType: DeviceType.Unknown,
};
const defaultProps = {

View file

@ -19,6 +19,7 @@ import { fireEvent, render, RenderResult } from '@testing-library/react';
import { DeviceDetailHeading } from '../../../../../src/components/views/settings/devices/DeviceDetailHeading';
import { flushPromisesWithFakeTimers } from '../../../../test-utils';
import { DeviceType } from '../../../../../src/utils/device/parseUserAgent';
jest.useFakeTimers();
@ -27,6 +28,7 @@ describe('<DeviceDetailHeading />', () => {
device_id: '123',
display_name: 'My device',
isVerified: true,
deviceType: DeviceType.Unknown,
};
const defaultProps = {
device,

View file

@ -20,11 +20,13 @@ import { PUSHER_ENABLED } from 'matrix-js-sdk/src/@types/event';
import DeviceDetails from '../../../../../src/components/views/settings/devices/DeviceDetails';
import { mkPusher } from '../../../../test-utils/test-utils';
import { DeviceType } from '../../../../../src/utils/device/parseUserAgent';
describe('<DeviceDetails />', () => {
const baseDevice = {
device_id: 'my-device',
isVerified: false,
deviceType: DeviceType.Unknown,
};
const defaultProps = {
device: baseDevice,
@ -33,7 +35,7 @@ describe('<DeviceDetails />', () => {
isLoading: false,
onSignOutDevice: jest.fn(),
saveDeviceName: jest.fn(),
setPusherEnabled: jest.fn(),
setPushNotifications: jest.fn(),
supportsMSC3881: true,
};
@ -58,6 +60,7 @@ describe('<DeviceDetails />', () => {
display_name: 'My Device',
last_seen_ip: '123.456.789',
last_seen_ts: now - 60000000,
appName: 'Element Web',
};
const { container } = render(getComponent({ device }));
expect(container).toMatchSnapshot();
@ -157,6 +160,27 @@ describe('<DeviceDetails />', () => {
fireEvent.click(checkbox);
expect(defaultProps.setPusherEnabled).toHaveBeenCalledWith(device.device_id, !enabled);
expect(defaultProps.setPushNotifications).toHaveBeenCalledWith(device.device_id, !enabled);
});
it('changes the local notifications settings status when clicked', () => {
const device = {
...baseDevice,
};
const enabled = false;
const { getByTestId } = render(getComponent({
device,
localNotificationSettings: {
is_silenced: !enabled,
},
isSigningOut: true,
}));
const checkbox = getByTestId('device-detail-push-notification-checkbox');
fireEvent.click(checkbox);
expect(defaultProps.setPushNotifications).toHaveBeenCalledWith(device.device_id, !enabled);
});
});

View file

@ -19,12 +19,14 @@ import { render } from '@testing-library/react';
import { IMyDevice } from 'matrix-js-sdk/src/matrix';
import DeviceTile from '../../../../../src/components/views/settings/devices/DeviceTile';
import { DeviceType } from '../../../../../src/utils/device/parseUserAgent';
describe('<DeviceTile />', () => {
const defaultProps = {
device: {
device_id: '123',
isVerified: false,
deviceType: DeviceType.Unknown,
},
};
const getComponent = (props = {}) => (

View file

@ -17,15 +17,15 @@ limitations under the License.
import { render } from '@testing-library/react';
import React from 'react';
import { DeviceType } from '../../../../../src/components/views/settings/devices/DeviceType';
import { DeviceTypeIcon } from '../../../../../src/components/views/settings/devices/DeviceTypeIcon';
describe('<DeviceType />', () => {
describe('<DeviceTypeIcon />', () => {
const defaultProps = {
isVerified: false,
isSelected: false,
};
const getComponent = (props = {}) =>
<DeviceType {...defaultProps} {...props} />;
<DeviceTypeIcon {...defaultProps} {...props} />;
it('renders an unverified device', () => {
const { container } = render(getComponent());

View file

@ -20,6 +20,7 @@ import { act, fireEvent, render } from '@testing-library/react';
import { FilteredDeviceList } from '../../../../../src/components/views/settings/devices/FilteredDeviceList';
import { DeviceSecurityVariation } from '../../../../../src/components/views/settings/devices/types';
import { flushPromises, mockPlatformPeg } from '../../../../test-utils';
import { DeviceType } from '../../../../../src/utils/device/parseUserAgent';
mockPlatformPeg();
@ -31,23 +32,39 @@ describe('<FilteredDeviceList />', () => {
last_seen_ip: '123.456.789',
display_name: 'My Device',
isVerified: true,
deviceType: DeviceType.Unknown,
};
const unverifiedNoMetadata = { device_id: 'unverified-no-metadata', isVerified: false };
const verifiedNoMetadata = { device_id: 'verified-no-metadata', isVerified: true };
const hundredDaysOld = { device_id: '100-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 100) };
const unverifiedNoMetadata = {
device_id: 'unverified-no-metadata',
isVerified: false,
deviceType: DeviceType.Unknown };
const verifiedNoMetadata = {
device_id: 'verified-no-metadata',
isVerified: true,
deviceType: DeviceType.Unknown };
const hundredDaysOld = {
device_id: '100-days-old',
isVerified: true,
last_seen_ts: Date.now() - (MS_DAY * 100),
deviceType: DeviceType.Unknown };
const hundredDaysOldUnverified = {
device_id: 'unverified-100-days-old',
isVerified: false,
last_seen_ts: Date.now() - (MS_DAY * 100),
deviceType: DeviceType.Unknown,
};
const defaultProps = {
onFilterChange: jest.fn(),
onDeviceExpandToggle: jest.fn(),
onSignOutDevices: jest.fn(),
saveDeviceName: jest.fn(),
setPushNotifications: jest.fn(),
setPusherEnabled: jest.fn(),
setSelectedDeviceIds: jest.fn(),
localNotificationSettings: new Map(),
expandedDeviceIds: [],
signingOutDeviceIds: [],
selectedDeviceIds: [],
devices: {
[unverifiedNoMetadata.device_id]: unverifiedNoMetadata,
[verifiedNoMetadata.device_id]: verifiedNoMetadata,

View file

@ -0,0 +1,54 @@
/*
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 } from '@testing-library/react';
import React from 'react';
import FilteredDeviceListHeader from '../../../../../src/components/views/settings/devices/FilteredDeviceListHeader';
describe('<FilteredDeviceListHeader />', () => {
const defaultProps = {
selectedDeviceCount: 0,
isAllSelected: false,
toggleSelectAll: jest.fn(),
children: <div>test</div>,
['data-testid']: 'test123',
};
const getComponent = (props = {}) => (<FilteredDeviceListHeader {...defaultProps} {...props} />);
it('renders correctly when no devices are selected', () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('renders correctly when all devices are selected', () => {
const { container } = render(getComponent({ isAllSelected: true }));
expect(container).toMatchSnapshot();
});
it('renders correctly when some devices are selected', () => {
const { getByText } = render(getComponent({ selectedDeviceCount: 2 }));
expect(getByText('2 sessions selected')).toBeTruthy();
});
it('clicking checkbox toggles selection', () => {
const toggleSelectAll = jest.fn();
const { getByTestId } = render(getComponent({ toggleSelectAll }));
fireEvent.click(getByTestId('device-select-all-checkbox'));
expect(toggleSelectAll).toHaveBeenCalled();
});
});

View file

@ -19,6 +19,7 @@ import React from 'react';
import { act } from 'react-dom/test-utils';
import SelectableDeviceTile from '../../../../../src/components/views/settings/devices/SelectableDeviceTile';
import { DeviceType } from '../../../../../src/utils/device/parseUserAgent';
describe('<SelectableDeviceTile />', () => {
const device = {
@ -26,6 +27,7 @@ describe('<SelectableDeviceTile />', () => {
device_id: 'my-device',
last_seen_ip: '123.456.789',
isVerified: false,
deviceType: DeviceType.Unknown,
};
const defaultProps = {
onClick: jest.fn(),

View file

@ -76,6 +76,7 @@ HTMLCollection [
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
@ -90,39 +91,6 @@ HTMLCollection [
alices_device
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Last activity
</td>
<td
class="mxDeviceDetails_metadataValue"
/>
</tr>
</tbody>
</table>
<table
class="mx_DeviceDetails_metadataTable"
>
<thead>
<tr>
<th>
Device
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
IP address
</td>
<td
class="mxDeviceDetails_metadataValue"
/>
</tr>
</tbody>
</table>
</section>
@ -183,16 +151,16 @@ exports[`<CurrentDeviceSection /> renders device and correct security card when
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -299,16 +267,16 @@ exports[`<CurrentDeviceSection /> renders device and correct security card when
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>

View file

@ -64,6 +64,7 @@ exports[`<DeviceDetails /> renders a verified device 1`] = `
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
@ -78,39 +79,6 @@ exports[`<DeviceDetails /> renders a verified device 1`] = `
my-device
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Last activity
</td>
<td
class="mxDeviceDetails_metadataValue"
/>
</tr>
</tbody>
</table>
<table
class="mx_DeviceDetails_metadataTable"
>
<thead>
<tr>
<th>
Device
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
IP address
</td>
<td
class="mxDeviceDetails_metadataValue"
/>
</tr>
</tbody>
</table>
</section>
@ -198,6 +166,7 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = `
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
@ -228,6 +197,33 @@ exports[`<DeviceDetails /> renders device with metadata 1`] = `
</table>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-application"
>
<thead>
<tr>
<th>
Application
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Name
</td>
<td
class="mxDeviceDetails_metadataValue"
>
Element Web
</td>
</tr>
</tbody>
</table>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-device"
>
<thead>
<tr>
@ -336,6 +332,7 @@ exports[`<DeviceDetails /> renders device without metadata 1`] = `
</p>
<table
class="mx_DeviceDetails_metadataTable"
data-testid="device-detail-metadata-session"
>
<tbody>
<tr>
@ -350,39 +347,6 @@ exports[`<DeviceDetails /> renders device without metadata 1`] = `
my-device
</td>
</tr>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
Last activity
</td>
<td
class="mxDeviceDetails_metadataValue"
/>
</tr>
</tbody>
</table>
<table
class="mx_DeviceDetails_metadataTable"
>
<thead>
<tr>
<th>
Device
</th>
</tr>
</thead>
<tbody>
<tr>
<td
class="mxDeviceDetails_metadataLabel"
>
IP address
</td>
<td
class="mxDeviceDetails_metadataValue"
/>
</tr>
</tbody>
</table>
</section>

View file

@ -7,16 +7,16 @@ exports[`<DeviceTile /> renders a device with no metadata 1`] = `
data-testid="device-tile-123"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -58,16 +58,16 @@ exports[`<DeviceTile /> renders a verified device with no metadata 1`] = `
data-testid="device-tile-123"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -109,16 +109,16 @@ exports[`<DeviceTile /> renders display name with a tooltip 1`] = `
data-testid="device-tile-123"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -160,16 +160,16 @@ exports[`<DeviceTile /> separates metadata with a dot 1`] = `
data-testid="device-tile-123"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>

View file

@ -1,58 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceType /> renders a verified device 1`] = `
<div>
<div
class="mx_DeviceType"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
role="img"
/>
<div
aria-label="Verified"
class="mx_DeviceType_verificationIcon verified"
role="img"
/>
</div>
</div>
`;
exports[`<DeviceType /> renders an unverified device 1`] = `
<div>
<div
class="mx_DeviceType"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
role="img"
/>
</div>
</div>
`;
exports[`<DeviceType /> renders correctly when selected 1`] = `
<div>
<div
class="mx_DeviceType mx_DeviceType_selected"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
role="img"
/>
</div>
</div>
`;

View file

@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<DeviceTypeIcon /> renders a verified device 1`] = `
<div>
<div
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Verified"
class="mx_DeviceTypeIcon_verificationIcon verified"
role="img"
/>
</div>
</div>
`;
exports[`<DeviceTypeIcon /> renders an unverified device 1`] = `
<div>
<div
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
</div>
`;
exports[`<DeviceTypeIcon /> renders correctly when selected 1`] = `
<div>
<div
class="mx_DeviceTypeIcon mx_DeviceTypeIcon_selected"
>
<div
aria-label="Unknown device type"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
</div>
`;

View file

@ -0,0 +1,88 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FilteredDeviceListHeader /> renders correctly when all devices are selected 1`] = `
<div>
<div
class="mx_FilteredDeviceListHeader"
data-testid="test123"
>
<div
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Deselect all"
checked=""
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<span
class="mx_FilteredDeviceListHeader_label"
>
Sessions
</span>
<div>
test
</div>
</div>
</div>
`;
exports[`<FilteredDeviceListHeader /> renders correctly when no devices are selected 1`] = `
<div>
<div
class="mx_FilteredDeviceListHeader"
data-testid="test123"
>
<div
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Select all"
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<span
class="mx_FilteredDeviceListHeader_label"
>
Sessions
</span>
<div>
test
</div>
</div>
</div>
`;

View file

@ -3,6 +3,7 @@
exports[`<SelectableDeviceTile /> renders selected tile 1`] = `
<input
checked=""
data-testid="device-tile-checkbox-my-device"
id="device-tile-checkbox-my-device"
type="checkbox"
/>
@ -17,6 +18,7 @@ exports[`<SelectableDeviceTile /> renders unselected device tile with checkbox 1
class="mx_Checkbox mx_SelectableDeviceTile_checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
data-testid="device-tile-checkbox-my-device"
id="device-tile-checkbox-my-device"
type="checkbox"
/>
@ -37,16 +39,16 @@ exports[`<SelectableDeviceTile /> renders unselected device tile with checkbox 1
data-testid="device-tile-my-device"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>

View file

@ -20,18 +20,38 @@ import {
import {
DeviceSecurityVariation,
} from "../../../../../src/components/views/settings/devices/types";
import { DeviceType } from "../../../../../src/utils/device/parseUserAgent";
const MS_DAY = 86400000;
describe('filterDevicesBySecurityRecommendation()', () => {
const unverifiedNoMetadata = { device_id: 'unverified-no-metadata', isVerified: false };
const verifiedNoMetadata = { device_id: 'verified-no-metadata', isVerified: true };
const hundredDaysOld = { device_id: '100-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 100) };
const unverifiedNoMetadata = {
device_id: 'unverified-no-metadata',
isVerified: false,
deviceType: DeviceType.Unknown,
};
const verifiedNoMetadata = {
device_id: 'verified-no-metadata',
isVerified: true,
deviceType: DeviceType.Unknown,
};
const hundredDaysOld = {
device_id: '100-days-old',
isVerified: true,
last_seen_ts: Date.now() - (MS_DAY * 100),
deviceType: DeviceType.Unknown,
};
const hundredDaysOldUnverified = {
device_id: 'unverified-100-days-old',
isVerified: false,
last_seen_ts: Date.now() - (MS_DAY * 100),
deviceType: DeviceType.Unknown,
};
const fiftyDaysOld = {
device_id: '50-days-old',
isVerified: true,
last_seen_ts: Date.now() - (MS_DAY * 50),
deviceType: DeviceType.Unknown,
};
const fiftyDaysOld = { device_id: '50-days-old', isVerified: true, last_seen_ts: Date.now() - (MS_DAY * 50) };
const devices = [
unverifiedNoMetadata,

View file

@ -22,7 +22,14 @@ import { logger } from 'matrix-js-sdk/src/logger';
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { VerificationRequest } from 'matrix-js-sdk/src/crypto/verification/request/VerificationRequest';
import { sleep } from 'matrix-js-sdk/src/utils';
import { IMyDevice, PUSHER_DEVICE_ID, PUSHER_ENABLED } from 'matrix-js-sdk/src/matrix';
import {
ClientEvent,
IMyDevice,
LOCAL_NOTIFICATION_SETTINGS_PREFIX,
MatrixEvent,
PUSHER_DEVICE_ID,
PUSHER_ENABLED,
} from 'matrix-js-sdk/src/matrix';
import SessionManagerTab from '../../../../../../src/components/views/settings/tabs/user/SessionManagerTab';
import MatrixClientContext from '../../../../../../src/contexts/MatrixClientContext';
@ -31,10 +38,17 @@ import {
getMockClientWithEventEmitter,
mkPusher,
mockClientMethodsUser,
mockPlatformPeg,
} from '../../../../../test-utils';
import Modal from '../../../../../../src/Modal';
import LogoutDialog from '../../../../../../src/components/views/dialogs/LogoutDialog';
import { DeviceWithVerification } from '../../../../../../src/components/views/settings/devices/types';
import {
DeviceSecurityVariation,
ExtendedDevice,
} from '../../../../../../src/components/views/settings/devices/types';
import { INACTIVE_DEVICE_AGE_MS } from '../../../../../../src/components/views/settings/devices/filter';
mockPlatformPeg();
describe('<SessionManagerTab />', () => {
const aliceId = '@alice:server.org';
@ -54,6 +68,11 @@ describe('<SessionManagerTab />', () => {
last_seen_ts: Date.now() - 600000,
};
const alicesInactiveDevice = {
device_id: 'alices_older_mobile_device',
last_seen_ts: Date.now() - (INACTIVE_DEVICE_AGE_MS + 1000),
};
const mockCrossSigningInfo = {
checkDeviceTrust: jest.fn(),
};
@ -68,9 +87,11 @@ describe('<SessionManagerTab />', () => {
deleteMultipleDevices: jest.fn(),
generateClientSecret: jest.fn(),
setDeviceDetails: jest.fn(),
getAccountData: jest.fn(),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true),
getPushers: jest.fn(),
setPusher: jest.fn(),
setLocalNotificationSettings: jest.fn(),
});
const defaultProps = {};
@ -83,7 +104,7 @@ describe('<SessionManagerTab />', () => {
const toggleDeviceDetails = (
getByTestId: ReturnType<typeof render>['getByTestId'],
deviceId: DeviceWithVerification['device_id'],
deviceId: ExtendedDevice['device_id'],
) => {
// open device detail
const tile = getByTestId(`device-tile-${deviceId}`);
@ -91,6 +112,36 @@ describe('<SessionManagerTab />', () => {
fireEvent.click(toggle);
};
const toggleDeviceSelection = (
getByTestId: ReturnType<typeof render>['getByTestId'],
deviceId: ExtendedDevice['device_id'],
) => {
const checkbox = getByTestId(`device-tile-checkbox-${deviceId}`);
fireEvent.click(checkbox);
};
const setFilter = async (
container: HTMLElement,
option: DeviceSecurityVariation | string,
) => await act(async () => {
const dropdown = container.querySelector('[aria-label="Filter devices"]');
fireEvent.click(dropdown as Element);
// tick to let dropdown render
await flushPromisesWithFakeTimers();
fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element);
});
const isDeviceSelected = (
getByTestId: ReturnType<typeof render>['getByTestId'],
deviceId: ExtendedDevice['device_id'],
): boolean => !!(getByTestId(`device-tile-checkbox-${deviceId}`) as HTMLInputElement).checked;
const isSelectAllChecked = (
getByTestId: ReturnType<typeof render>['getByTestId'],
): boolean => !!(getByTestId('device-select-all-checkbox') as HTMLInputElement).checked;
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(logger, 'error').mockRestore();
@ -114,6 +165,19 @@ describe('<SessionManagerTab />', () => {
[PUSHER_ENABLED.name]: true,
})],
});
mockClient.getAccountData
.mockReset()
.mockImplementation(eventType => {
if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
type: eventType,
content: {
is_silenced: false,
},
});
}
});
});
it('renders spinner while devices load', () => {
@ -179,6 +243,48 @@ describe('<SessionManagerTab />', () => {
expect(getByTestId(`device-tile-${alicesDevice.device_id}`)).toMatchSnapshot();
});
it('extends device with client information when available', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
mockClient.getAccountData.mockImplementation((eventType: string) => {
const content = {
name: 'Element Web',
version: '1.2.3',
url: 'test.com',
};
return new MatrixEvent({
type: eventType,
content,
});
});
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
// twice for each device
expect(mockClient.getAccountData).toHaveBeenCalledTimes(4);
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
// application metadata section rendered
expect(getByTestId('device-detail-metadata-application')).toBeTruthy();
});
it('renders devices without available client information without error', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
const { getByTestId, queryByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
// application metadata section not rendered
expect(queryByTestId('device-detail-metadata-application')).toBeFalsy();
});
it('renders current session section with an unverified session', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [alicesDevice, alicesMobileDevice] });
const { getByTestId } = render(getComponent());
@ -258,7 +364,7 @@ describe('<SessionManagerTab />', () => {
await flushPromisesWithFakeTimers();
// unverified filter is set
expect(container.querySelector('.mx_FilteredDeviceList_header')).toMatchSnapshot();
expect(container.querySelector('.mx_FilteredDeviceListHeader')).toMatchSnapshot();
});
describe('device detail expansion', () => {
@ -333,7 +439,6 @@ describe('<SessionManagerTab />', () => {
mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId));
mockCrossSigningInfo.checkDeviceTrust
.mockImplementation((_userId, { deviceId }) => {
console.log('hhh', deviceId);
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
@ -363,7 +468,6 @@ describe('<SessionManagerTab />', () => {
mockClient.getStoredDevice.mockImplementation((_userId, deviceId) => new DeviceInfo(deviceId));
mockCrossSigningInfo.checkDeviceTrust
.mockImplementation((_userId, { deviceId }) => {
console.log('hhh', deviceId);
if (deviceId === alicesDevice.device_id) {
return new DeviceTrustLevel(true, true, false, false);
}
@ -577,6 +681,33 @@ describe('<SessionManagerTab />', () => {
'[data-testid="device-detail-sign-out-cta"]',
) as Element).getAttribute('aria-disabled')).toEqual(null);
});
it('deletes multiple devices', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [
alicesDevice, alicesMobileDevice, alicesOlderMobileDevice,
] });
mockClient.deleteMultipleDevices.mockResolvedValue({});
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id);
fireEvent.click(getByTestId('sign-out-selection-cta'));
// delete called with both ids
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
[
alicesMobileDevice.device_id,
alicesOlderMobileDevice.device_id,
],
undefined,
);
});
});
});
@ -682,6 +813,167 @@ describe('<SessionManagerTab />', () => {
});
});
describe('Multiple selection', () => {
beforeEach(() => {
mockClient.getDevices.mockResolvedValue({ devices: [
alicesDevice, alicesMobileDevice, alicesOlderMobileDevice,
] });
});
it('toggles session selection', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id);
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
// unselected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeFalsy();
// still selected
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('cancel button clears selection', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
toggleDeviceSelection(getByTestId, alicesOlderMobileDevice.device_id);
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
fireEvent.click(getByTestId('cancel-selection-cta'));
// unselected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeFalsy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy();
});
it('changing the filter clears selection', async () => {
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
fireEvent.click(getByTestId('unverified-devices-cta'));
// our session manager waits a tick for rerender
await flushPromisesWithFakeTimers();
// unselected
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy();
});
describe('toggling select all', () => {
it('selects all sessions when there is not existing selection', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
fireEvent.click(getByTestId('device-select-all-checkbox'));
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// devices selected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('selects all sessions when some sessions are already selected', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
fireEvent.click(getByTestId('device-select-all-checkbox'));
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// devices selected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('deselects all sessions when all sessions are selected', async () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
fireEvent.click(getByTestId('device-select-all-checkbox'));
// header displayed correctly
expect(getByText('2 sessions selected')).toBeTruthy();
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// devices selected
expect(isDeviceSelected(getByTestId, alicesMobileDevice.device_id)).toBeTruthy();
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeTruthy();
});
it('selects only sessions that are part of the active filter', async () => {
mockClient.getDevices.mockResolvedValue({ devices: [
alicesDevice,
alicesMobileDevice,
alicesInactiveDevice,
] });
const { getByTestId, container } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
// filter for inactive sessions
await setFilter(container, DeviceSecurityVariation.Inactive);
// select all inactive sessions
fireEvent.click(getByTestId('device-select-all-checkbox'));
expect(isSelectAllChecked(getByTestId)).toBeTruthy();
// sign out of all selected sessions
fireEvent.click(getByTestId('sign-out-selection-cta'));
// only called with session from active filter
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
[
alicesInactiveDevice.device_id,
],
undefined,
);
});
});
});
it("lets you change the pusher state", async () => {
const { getByTestId } = render(getComponent());
@ -702,4 +994,61 @@ describe('<SessionManagerTab />', () => {
expect(mockClient.setPusher).toHaveBeenCalled();
});
it("lets you change the local notification settings state", async () => {
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
// device details are expanded
expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy();
expect(getByTestId('device-detail-push-notification')).toBeTruthy();
const checkbox = getByTestId('device-detail-push-notification-checkbox');
expect(checkbox).toBeTruthy();
fireEvent.click(checkbox);
expect(mockClient.setLocalNotificationSettings).toHaveBeenCalledWith(
alicesDevice.device_id,
{ is_silenced: true },
);
});
it("updates the UI when another session changes the local notifications", async () => {
const { getByTestId } = render(getComponent());
await act(async () => {
await flushPromisesWithFakeTimers();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
// device details are expanded
expect(getByTestId(`device-detail-${alicesDevice.device_id}`)).toBeTruthy();
expect(getByTestId('device-detail-push-notification')).toBeTruthy();
const checkbox = getByTestId('device-detail-push-notification-checkbox');
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute('aria-checked')).toEqual("true");
const evt = new MatrixEvent({
type: LOCAL_NOTIFICATION_SETTINGS_PREFIX.name + "." + alicesDevice.device_id,
content: {
is_silenced: true,
},
});
await act(async () => {
mockClient.emit(ClientEvent.AccountData, evt);
});
expect(checkbox.getAttribute('aria-checked')).toEqual("false");
});
});

View file

@ -17,10 +17,35 @@ exports[`<SessionManagerTab /> Sign out Signs out of current device 1`] = `
exports[`<SessionManagerTab /> goes to filtered list from security recommendations 1`] = `
<div
class="mx_FilteredDeviceList_header"
class="mx_FilteredDeviceListHeader"
>
<div
tabindex="0"
>
<span
class="mx_Checkbox mx_Checkbox_hasKind mx_Checkbox_kind_solid"
>
<input
aria-label="Select all"
data-testid="device-select-all-checkbox"
id="device-select-all-checkbox"
type="checkbox"
/>
<label
for="device-select-all-checkbox"
>
<div
class="mx_Checkbox_background"
>
<div
class="mx_Checkbox_checkmark"
/>
</div>
</label>
</span>
</div>
<span
class="mx_FilteredDeviceList_headerLabel"
class="mx_FilteredDeviceListHeader_label"
>
Sessions
</span>
@ -69,16 +94,16 @@ exports[`<SessionManagerTab /> renders current session section with a verified s
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Verified"
class="mx_DeviceType_verificationIcon verified"
class="mx_DeviceTypeIcon_verificationIcon verified"
role="img"
/>
</div>
@ -171,16 +196,16 @@ exports[`<SessionManagerTab /> renders current session section with an unverifie
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Unverified"
class="mx_DeviceType_verificationIcon unverified"
class="mx_DeviceTypeIcon_verificationIcon unverified"
role="img"
/>
</div>
@ -273,16 +298,16 @@ exports[`<SessionManagerTab /> sets device verification status correctly 1`] = `
data-testid="device-tile-alices_device"
>
<div
class="mx_DeviceType"
class="mx_DeviceTypeIcon"
>
<div
aria-label="Unknown device type"
class="mx_DeviceType_deviceIcon"
class="mx_DeviceTypeIcon_deviceIcon"
role="img"
/>
<div
aria-label="Verified"
class="mx_DeviceType_verificationIcon verified"
class="mx_DeviceTypeIcon_verificationIcon verified"
role="img"
/>
</div>

View file

@ -187,6 +187,35 @@ describe("CallLobby", () => {
});
describe("device buttons", () => {
const fakeVideoInput1: MediaDeviceInfo = {
deviceId: "v1",
groupId: "v1",
label: "Webcam",
kind: "videoinput",
toJSON: () => {},
};
const fakeVideoInput2: MediaDeviceInfo = {
deviceId: "v2",
groupId: "v2",
label: "Othercam",
kind: "videoinput",
toJSON: () => {},
};
const fakeAudioInput1: MediaDeviceInfo = {
deviceId: "v1",
groupId: "v1",
label: "Headphones",
kind: "audioinput",
toJSON: () => {},
};
const fakeAudioInput2: MediaDeviceInfo = {
deviceId: "v2",
groupId: "v2",
label: "Tailphones",
kind: "audioinput",
toJSON: () => {},
};
it("hide when no devices are available", async () => {
await renderView();
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
@ -194,13 +223,7 @@ describe("CallLobby", () => {
});
it("show without dropdown when only one device is available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([{
deviceId: "1",
groupId: "1",
label: "Webcam",
kind: "videoinput",
toJSON: () => {},
}]);
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([fakeVideoInput1]);
await renderView();
screen.getByRole("button", { name: /camera/ });
@ -209,27 +232,40 @@ describe("CallLobby", () => {
it("show with dropdown when multiple devices are available", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
{
deviceId: "1",
groupId: "1",
label: "Headphones",
kind: "audioinput",
toJSON: () => {},
},
{
deviceId: "2",
groupId: "1",
label: "", // Should fall back to "Audio input 2"
kind: "audioinput",
toJSON: () => {},
},
fakeAudioInput1, fakeAudioInput2,
]);
await renderView();
screen.getByRole("button", { name: /microphone/ });
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
screen.getByRole("menuitem", { name: "Headphones" });
screen.getByRole("menuitem", { name: "Audio input 2" });
screen.getByRole("menuitem", { name: "Tailphones" });
});
it("sets video device when selected", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
fakeVideoInput1, fakeVideoInput2,
]);
await renderView();
screen.getByRole("button", { name: /camera/ });
fireEvent.click(screen.getByRole("button", { name: "Video devices" }));
fireEvent.click(screen.getByRole("menuitem", { name: fakeVideoInput2.label }));
expect(client.getMediaHandler().setVideoInput).toHaveBeenCalledWith(fakeVideoInput2.deviceId);
});
it("sets audio device when selected", async () => {
mocked(navigator.mediaDevices.enumerateDevices).mockResolvedValue([
fakeAudioInput1, fakeAudioInput2,
]);
await renderView();
screen.getByRole("button", { name: /microphone/ });
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
fireEvent.click(screen.getByRole("menuitem", { name: fakeAudioInput2.label }));
expect(client.getMediaHandler().setAudioInput).toHaveBeenCalledWith(fakeAudioInput2.deviceId);
});
});
});

View file

@ -616,8 +616,8 @@ describe("ElementCall", () => {
await call.connect();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: "1",
videoInput: "2",
audioInput: "Headphones",
videoInput: "Built-in webcam",
});
});

View file

@ -209,7 +209,6 @@ describe("StopGapWidgetDriver", () => {
});
await expect(driver.readEventRelations('$event')).resolves.toEqual({
originalEvent: expect.objectContaining({ content: {} }),
chunk: [],
nextBatch: undefined,
prevBatch: undefined,
@ -218,24 +217,6 @@ describe("StopGapWidgetDriver", () => {
expect(client.relations).toBeCalledWith('!this-room-id', '$event', null, null, {});
});
it('reads related events if the original event is missing', async () => {
client.relations.mockResolvedValue({
// the relations function can return an undefined event, even
// though the typings don't permit an undefined value.
originalEvent: undefined as any,
events: [],
});
await expect(driver.readEventRelations('$event', '!room-id')).resolves.toEqual({
originalEvent: undefined,
chunk: [],
nextBatch: undefined,
prevBatch: undefined,
});
expect(client.relations).toBeCalledWith('!room-id', '$event', null, null, {});
});
it('reads related events from a selected room', async () => {
client.relations.mockResolvedValue({
originalEvent: new MatrixEvent(),
@ -244,7 +225,6 @@ describe("StopGapWidgetDriver", () => {
});
await expect(driver.readEventRelations('$event', '!room-id')).resolves.toEqual({
originalEvent: expect.objectContaining({ content: {} }),
chunk: [
expect.objectContaining({ content: {} }),
expect.objectContaining({ content: {} }),
@ -272,7 +252,6 @@ describe("StopGapWidgetDriver", () => {
25,
'f',
)).resolves.toEqual({
originalEvent: expect.objectContaining({ content: {} }),
chunk: [],
nextBatch: undefined,
prevBatch: undefined,

View file

@ -18,17 +18,18 @@ import { MatrixWidgetType } from "matrix-widget-api";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { mkEvent } from "./test-utils";
import { Call, ElementCall, JitsiCall } from "../../src/models/Call";
export class MockedCall extends Call {
private static EVENT_TYPE = "org.example.mocked_call";
public static readonly EVENT_TYPE = "org.example.mocked_call";
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private constructor(room: Room, id: string) {
private constructor(room: Room, public readonly event: MatrixEvent) {
super(
{
id,
id: event.getStateKey()!,
eventId: "$1:example.org",
roomId: room.roomId,
type: MatrixWidgetType.Custom,
@ -42,7 +43,9 @@ export class MockedCall extends Call {
public static get(room: Room): MockedCall | null {
const [event] = room.currentState.getStateEvents(this.EVENT_TYPE);
return event?.getContent().terminated ?? true ? null : new MockedCall(room, event.getStateKey()!);
return (event === undefined || "m.terminated" in event.getContent())
? null
: new MockedCall(room, event);
}
public static create(room: Room, id: string) {
@ -52,8 +55,9 @@ export class MockedCall extends Call {
type: this.EVENT_TYPE,
room: room.roomId,
user: "@alice:example.org",
content: { terminated: false },
content: { "m.type": "m.video", "m.intent": "m.prompt" },
skey: id,
ts: Date.now(),
})]);
}
@ -78,8 +82,9 @@ export class MockedCall extends Call {
type: MockedCall.EVENT_TYPE,
room: this.room.roomId,
user: "@alice:example.org",
content: { terminated: true },
content: { ...this.event.getContent(), "m.terminated": "Call ended" },
skey: this.widget.id,
ts: Date.now(),
})]);
super.destroy();

View file

@ -34,6 +34,7 @@ import {
} from 'matrix-js-sdk/src/matrix';
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
import { makeType } from "../../src/utils/TypeUtils";
@ -175,6 +176,11 @@ export function createTestClient(): MatrixClient {
sendToDevice: jest.fn().mockResolvedValue(undefined),
queueToDevice: jest.fn().mockResolvedValue(undefined),
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
getMediaHandler: jest.fn().mockReturnValue({
setVideoInput: jest.fn(),
setAudioInput: jest.fn(),
} as unknown as MediaHandler),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);

View file

@ -0,0 +1,32 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createVoiceMessageContent should create a voice message content 1`] = `
Object {
"body": "Voice message",
"file": Object {},
"info": Object {
"duration": 23000,
"mimetype": "ogg/opus",
"size": 42000,
},
"msgtype": "m.audio",
"org.matrix.msc1767.audio": Object {
"duration": 23000,
"waveform": Array [
1,
2,
3,
],
},
"org.matrix.msc1767.file": Object {
"file": Object {},
"mimetype": "ogg/opus",
"name": "Voice message.ogg",
"size": 42000,
"url": "mxc://example.com/file",
},
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc3245.voice": Object {},
"url": "mxc://example.com/file",
}
`;

View file

@ -0,0 +1,32 @@
/*
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 { IEncryptedFile } from "matrix-js-sdk/src/matrix";
import { createVoiceMessageContent } from "../../src/utils/createVoiceMessageContent";
describe("createVoiceMessageContent", () => {
it("should create a voice message content", () => {
expect(createVoiceMessageContent(
"mxc://example.com/file",
"ogg/opus",
23000,
42000,
{} as unknown as IEncryptedFile,
[1, 2, 3],
)).toMatchSnapshot();
});
});

View file

@ -0,0 +1,146 @@
/*
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 BasePlatform from "../../../src/BasePlatform";
import { IConfigOptions } from "../../../src/IConfigOptions";
import {
getDeviceClientInformation,
recordClientInformation,
} from "../../../src/utils/device/clientInformation";
import { getMockClientWithEventEmitter } from "../../test-utils";
describe('recordClientInformation()', () => {
const deviceId = 'my-device-id';
const version = '1.2.3';
const isElectron = window.electron;
const mockClient = getMockClientWithEventEmitter({
getDeviceId: jest.fn().mockReturnValue(deviceId),
setAccountData: jest.fn(),
});
const sdkConfig: IConfigOptions = {
brand: 'Test Brand',
element_call: { url: '', use_exclusively: false },
};
const platform = {
getAppVersion: jest.fn().mockResolvedValue(version),
} as unknown as BasePlatform;
beforeEach(() => {
jest.clearAllMocks();
window.electron = false;
});
afterAll(() => {
// restore global
window.electron = isElectron;
});
it('saves client information without url for electron clients', async () => {
window.electron = true;
await recordClientInformation(
mockClient,
sdkConfig,
platform,
);
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{
name: sdkConfig.brand,
version,
url: undefined,
},
);
});
it('saves client information with url for non-electron clients', async () => {
await recordClientInformation(
mockClient,
sdkConfig,
platform,
);
expect(mockClient.setAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
{
name: sdkConfig.brand,
version,
url: 'localhost',
},
);
});
});
describe('getDeviceClientInformation()', () => {
const deviceId = 'my-device-id';
const mockClient = getMockClientWithEventEmitter({
getAccountData: jest.fn(),
});
beforeEach(() => {
jest.resetAllMocks();
});
it('returns an empty object when no event exists for the device', () => {
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual({});
expect(mockClient.getAccountData).toHaveBeenCalledWith(
`io.element.matrix_client_information.${deviceId}`,
);
});
it('returns client information for the device', () => {
const eventContent = {
name: 'Element Web',
version: '1.2.3',
url: 'test.com',
};
const event = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: eventContent,
});
mockClient.getAccountData.mockReturnValue(event);
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual(eventContent);
});
it('excludes values with incorrect types', () => {
const eventContent = {
extraField: 'hello',
name: 'Element Web',
// wrong format
version: { value: '1.2.3' },
url: 'test.com',
};
const event = new MatrixEvent({
type: `io.element.matrix_client_information.${deviceId}`,
content: eventContent,
});
mockClient.getAccountData.mockReturnValue(event);
// invalid fields excluded
expect(getDeviceClientInformation(mockClient, deviceId)).toEqual({
name: eventContent.name,
url: eventContent.url,
});
});
});

View file

@ -0,0 +1,145 @@
/*
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 { DeviceType, ExtendedDeviceInformation, parseUserAgent } from "../../../src/utils/device/parseUserAgent";
const makeDeviceExtendedInfo = (
deviceType: DeviceType,
deviceModel?: string,
deviceOperatingSystem?: string,
clientName?: string,
clientVersion?: string,
): ExtendedDeviceInformation => ({
deviceType,
deviceModel,
deviceOperatingSystem,
client: clientName && [clientName, clientVersion].filter(Boolean).join(' '),
});
/* eslint-disable max-len */
const ANDROID_UA = [
// New User Agent Implementation
"Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) (5); Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
// Legacy User Agent Implementation
"Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)",
];
const ANDROID_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Xiaomi Mi 9T", "Android 11"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G960F", "Android 6.0.1"),
makeDeviceExtendedInfo(DeviceType.Mobile, "LG Nexus 5", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) 5", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Google (Nexus) (5)", "Android 7.0"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-A510F", "Android 6.0.1"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Samsung SM-G610M", "Android 7.0"),
];
const IOS_UA = [
"Element/1.8.21 (iPhone; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (11-inch); iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (12.9-inch) (3rd generation); iOS 15.2; Scale/3.00)",
];
const IOS_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple iPhone XS Max", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (11-inch)", "iOS 15.2"),
makeDeviceExtendedInfo(DeviceType.Mobile, "iPad Pro (12.9-inch) (3rd generation)", "iOS 15.2"),
];
const DESKTOP_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102" +
" Electron/20.1.1 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36",
];
const DESKTOP_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Desktop, undefined, "Mac OS 10.15.7", "Electron", "20"),
makeDeviceExtendedInfo(DeviceType.Desktop, undefined, "Windows 10", "Electron", "20"),
];
const WEB_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18",
"Mozilla/5.0 (Windows NT 6.0; rv:40.0) Gecko/20100101 Firefox/40.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
// using mobile browser
"Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
"Mozilla/5.0 (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
"Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36",
];
const WEB_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Mac OS 10.15.7", "Chrome", "104"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows 10", "Chrome", "104"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Mac OS 10.10", "Firefox", "39"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Mac OS 10.10.2", "Safari", "8"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows Vista", "Firefox", "40"),
makeDeviceExtendedInfo(DeviceType.Web, undefined, "Windows 10", "Edge", "12"),
// using mobile browser
makeDeviceExtendedInfo(DeviceType.Web, "Apple iPad", "iOS 8.4.1", "Mobile Safari", "8"),
makeDeviceExtendedInfo(DeviceType.Web, "Apple iPhone", "iOS 8.4.1", "Mobile Safari", "8"),
makeDeviceExtendedInfo(DeviceType.Web, "Samsung SM-G973U", "Android 9", "Chrome", "69"),
];
const MISC_UA = [
"AppleTV11,1/11.1",
"Curl Client/1.0",
"banana",
"",
];
const MISC_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Unknown, "Apple Apple TV", undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
makeDeviceExtendedInfo(DeviceType.Unknown, undefined, undefined, undefined, undefined),
];
/* eslint-disable max-len */
describe('parseUserAgent()', () => {
it('returns deviceType unknown when user agent is falsy', () => {
expect(parseUserAgent(undefined)).toEqual({
deviceType: DeviceType.Unknown,
});
});
type TestCase = [string, ExtendedDeviceInformation];
const testPlatform = (platform: string, userAgents: string[], results: ExtendedDeviceInformation[]): void => {
const testCases: TestCase[] = userAgents.map((userAgent, index) => [userAgent, results[index]]);
describe(platform, () => {
it.each(
testCases,
)('Parses user agent correctly - %s', (userAgent, expectedResult) => {
expect(parseUserAgent(userAgent)).toEqual(expectedResult);
});
});
};
testPlatform('Android', ANDROID_UA, ANDROID_EXPECTED_RESULT);
testPlatform('iOS', IOS_UA, IOS_EXPECTED_RESULT);
testPlatform('Desktop', DESKTOP_UA, DESKTOP_EXPECTED_RESULT);
testPlatform('Web', WEB_UA, WEB_EXPECTED_RESULT);
testPlatform('Misc', MISC_UA, MISC_EXPECTED_RESULT);
});

View file

@ -0,0 +1,61 @@
/*
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/models/event";
import { mocked } from "jest-mock";
import {
localNotificationsAreSilenced,
getLocalNotificationAccountDataEventType,
} from "../../src/utils/notifications";
import SettingsStore from "../../src/settings/SettingsStore";
import { getMockClientWithEventEmitter } from "../test-utils/client";
jest.mock("../../src/settings/SettingsStore");
describe('notifications', () => {
let accountDataStore = {};
const mockClient = getMockClientWithEventEmitter({
isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = new MatrixEvent({
type: eventType,
content,
});
}),
});
const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
beforeEach(() => {
accountDataStore = {};
mocked(SettingsStore).getValue.mockReturnValue(false);
});
describe('localNotificationsAreSilenced', () => {
it('defaults to true when no setting exists', () => {
expect(localNotificationsAreSilenced(mockClient)).toBeTruthy();
});
it('checks the persisted value', () => {
mockClient.setAccountData(accountDataEventKey, { is_silenced: true });
expect(localNotificationsAreSilenced(mockClient)).toBeTruthy();
mockClient.setAccountData(accountDataEventKey, { is_silenced: false });
expect(localNotificationsAreSilenced(mockClient)).toBeFalsy();
});
});
});