Unit test MessageActionBar (#8732)

* test most basic paths in messageactionbar

Signed-off-by: Kerry Archibald <kerrya@element.io>

* tidy

Signed-off-by: Kerry Archibald <kerrya@element.io>

* add rtl

* add code style note about using rtl

Signed-off-by: Kerry Archibald <kerrya@element.io>

* downgrade to rtl 12

* use rtl for MessageActionBar test

Signed-off-by: Kerry Archibald <kerrya@element.io>

* try mocking settingsstore for ci only failure

Signed-off-by: Kerry Archibald <kerrya@element.io>

* mock setValue too

Signed-off-by: Kerry Archibald <kerrya@element.io>

* uupdate lockfile

Signed-off-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
Kerry 2022-06-02 10:25:56 +02:00 committed by GitHub
parent 7c57680b93
commit 158e42f764
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 436 additions and 2 deletions

View file

@ -208,3 +208,7 @@ React
information in component state that could be derived from the model?
- Avoid things marked as Legacy or Deprecated in React 16 (e.g string refs and legacy contexts)
Unit tests
-----
- New tests should use [react testing library](https://testing-library.com/docs/react-testing-library/intro/)

View file

@ -58,6 +58,7 @@
"@babel/runtime": "^7.12.5",
"@sentry/browser": "^6.11.0",
"@sentry/tracing": "^6.11.0",
"@testing-library/react": "^12.1.5",
"@types/geojson": "^7946.0.8",
"await-lock": "^2.1.0",
"blurhash": "^1.1.3",

View file

@ -670,6 +670,7 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
{...this.props}
className="mx_MessageContextMenu"
compact={true}
data-testid="mx_MessageContextMenu"
>
{ nativeItemsList }
{ quickItemsList }

View file

@ -124,6 +124,7 @@ class ReactionPicker extends React.Component<IProps, IState> {
onChoose={this.onChoose}
selectedEmojis={this.state.selectedEmojis}
showQuickReactions={true}
data-testid='mx_ReactionPicker'
/>;
}
}

View file

@ -0,0 +1,363 @@
/*
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, fireEvent } from '@testing-library/react';
import { act } from 'react-test-renderer';
import {
EventType,
EventStatus,
MatrixEvent,
MatrixEventEvent,
MsgType,
Room,
} from 'matrix-js-sdk/src/matrix';
import MessageActionBar from '../../../../src/components/views/messages/MessageActionBar';
import {
getMockClientWithEventEmitter,
mockClientMethodsUser,
mockClientMethodsEvents,
} from '../../../test-utils';
import { RoomPermalinkCreator } from '../../../../src/utils/permalinks/Permalinks';
import RoomContext, { TimelineRenderingType } from '../../../../src/contexts/RoomContext';
import { IRoomState } from '../../../../src/components/structures/RoomView';
import dispatcher from '../../../../src/dispatcher/dispatcher';
import SettingsStore from '../../../../src/settings/SettingsStore';
jest.mock('../../../../src/dispatcher/dispatcher');
describe('<MessageActionBar />', () => {
const userId = '@alice:server.org';
const roomId = '!room:server.org';
const alicesMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: 'Hello',
},
});
const bobsMessageEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: '@bob:server.org',
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: 'I am bob',
},
});
const redactedEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
});
redactedEvent.makeRedacted(redactedEvent);
const client = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsEvents(),
getRoom: jest.fn(),
});
const room = new Room(roomId, client, userId);
jest.spyOn(room, 'getPendingEvents').mockReturnValue([]);
client.getRoom.mockReturnValue(room);
const defaultProps = {
getTile: jest.fn(),
getReplyChain: jest.fn(),
toggleThreadExpanded: jest.fn(),
mxEvent: alicesMessageEvent,
permalinkCreator: new RoomPermalinkCreator(room),
};
const defaultRoomContext = {
...RoomContext,
timelineRenderingType: TimelineRenderingType.Room,
canSendMessages: true,
canReact: true,
} as unknown as IRoomState;
const getComponent = (props = {}, roomContext: Partial<IRoomState> = {}) =>
render(
<RoomContext.Provider value={{ ...defaultRoomContext, ...roomContext }}>
<MessageActionBar {...defaultProps} {...props} />
</RoomContext.Provider>);
beforeEach(() => {
jest.clearAllMocks();
alicesMessageEvent.setStatus(EventStatus.SENT);
jest.spyOn(SettingsStore, 'getValue').mockReturnValue(false);
jest.spyOn(SettingsStore, 'setValue').mockResolvedValue(undefined);
});
afterAll(() => {
jest.spyOn(SettingsStore, 'getValue').mockRestore();
jest.spyOn(SettingsStore, 'setValue').mockRestore();
});
it('kills event listeners on unmount', () => {
const offSpy = jest.spyOn(alicesMessageEvent, 'off').mockClear();
const wrapper = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
wrapper.unmount();
});
expect(offSpy.mock.calls[0][0]).toEqual(MatrixEventEvent.Status);
expect(offSpy.mock.calls[1][0]).toEqual(MatrixEventEvent.Decrypted);
expect(offSpy.mock.calls[2][0]).toEqual(MatrixEventEvent.BeforeRedaction);
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
});
describe('decryption', () => {
it('decrypts event if needed', () => {
getComponent({ mxEvent: alicesMessageEvent });
expect(client.decryptEventIfNeeded).toHaveBeenCalled();
});
it('updates component on decrypted event', () => {
const decryptingEvent = new MatrixEvent({
type: EventType.RoomMessageEncrypted,
sender: userId,
room_id: roomId,
content: {},
});
jest.spyOn(decryptingEvent, 'isBeingDecrypted').mockReturnValue(true);
const { queryByLabelText } = getComponent({ mxEvent: decryptingEvent });
// still encrypted event is not actionable => no reply button
expect(queryByLabelText('Reply')).toBeFalsy();
act(() => {
// ''decrypt'' the event
decryptingEvent.event.type = alicesMessageEvent.getType();
decryptingEvent.event.content = alicesMessageEvent.getContent();
decryptingEvent.emit(MatrixEventEvent.Decrypted, decryptingEvent);
});
// new available actions after decryption
expect(queryByLabelText('Reply')).toBeTruthy();
});
});
describe('status', () => {
it('updates component when event status changes', () => {
alicesMessageEvent.setStatus(EventStatus.QUEUED);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
// pending event status, cancel action available
expect(queryByLabelText('Delete')).toBeTruthy();
act(() => {
alicesMessageEvent.setStatus(EventStatus.SENT);
});
// event is sent, no longer cancelable
expect(queryByLabelText('Delete')).toBeFalsy();
});
});
describe('redaction', () => {
// this doesn't do what it's supposed to
// because beforeRedaction event is fired... before redaction
// event is unchanged at point when this component updates
// TODO file bug
xit('updates component on before redaction event', () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: 'Hello',
},
});
const { queryByLabelText } = getComponent({ mxEvent: event });
// no pending redaction => no delete button
expect(queryByLabelText('Delete')).toBeFalsy();
act(() => {
const redactionEvent = new MatrixEvent({
type: EventType.RoomRedaction,
sender: userId,
room_id: roomId,
});
redactionEvent.setStatus(EventStatus.QUEUED);
event.markLocallyRedacted(redactionEvent);
});
// updated with local redaction event, delete now available
expect(queryByLabelText('Delete')).toBeTruthy();
});
});
describe('options button', () => {
it('renders options menu', () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText('Options')).toBeTruthy();
});
it('opens message context menu on click', () => {
const { findByTestId, queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
fireEvent.click(queryByLabelText('Options'));
});
expect(findByTestId('mx_MessageContextMenu')).toBeTruthy();
});
});
describe('reply button', () => {
it('renders reply button on own actionable event', () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText('Reply')).toBeTruthy();
});
it('renders reply button on others actionable event', () => {
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent }, { canSendMessages: true });
expect(queryByLabelText('Reply')).toBeTruthy();
});
it('does not render reply button on non-actionable event', () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
expect(queryByLabelText('Reply')).toBeFalsy();
});
it('does not render reply button when user cannot send messaged', () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canSendMessages: false });
expect(queryByLabelText('Reply')).toBeFalsy();
});
it('dispatches reply event on click', () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
fireEvent.click(queryByLabelText('Reply'));
});
expect(dispatcher.dispatch).toHaveBeenCalledWith({
action: 'reply_to_event',
event: alicesMessageEvent,
context: TimelineRenderingType.Room,
});
});
});
describe('react button', () => {
it('renders react button on own actionable event', () => {
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText('React')).toBeTruthy();
});
it('renders react button on others actionable event', () => {
const { queryByLabelText } = getComponent({ mxEvent: bobsMessageEvent });
expect(queryByLabelText('React')).toBeTruthy();
});
it('does not render react button on non-actionable event', () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent });
expect(queryByLabelText('React')).toBeFalsy();
});
it('does not render react button when user cannot react', () => {
// redacted event is not actionable
const { queryByLabelText } = getComponent({ mxEvent: redactedEvent }, { canReact: false });
expect(queryByLabelText('React')).toBeFalsy();
});
it('opens reaction picker on click', () => {
const { queryByLabelText, findByTestId } = getComponent({ mxEvent: alicesMessageEvent });
act(() => {
fireEvent.click(queryByLabelText('React'));
});
expect(findByTestId('mx_ReactionPicker')).toBeTruthy();
});
});
describe('cancel button', () => {
it('renders cancel button for an event with a cancelable status', () => {
alicesMessageEvent.setStatus(EventStatus.QUEUED);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText('Delete')).toBeTruthy();
});
it('renders cancel button for an event with a pending edit', () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: 'Hello',
},
});
event.setStatus(EventStatus.SENT);
const replacingEvent = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: 'replacing event body',
},
});
replacingEvent.setStatus(EventStatus.QUEUED);
event.makeReplaced(replacingEvent);
const { queryByLabelText } = getComponent({ mxEvent: event });
expect(queryByLabelText('Delete')).toBeTruthy();
});
it('renders cancel button for an event with a pending redaction', () => {
const event = new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
room_id: roomId,
content: {
msgtype: MsgType.Text,
body: 'Hello',
},
});
event.setStatus(EventStatus.SENT);
const redactionEvent = new MatrixEvent({
type: EventType.RoomRedaction,
sender: userId,
room_id: roomId,
});
redactionEvent.setStatus(EventStatus.QUEUED);
event.markLocallyRedacted(redactionEvent);
const { queryByLabelText } = getComponent({ mxEvent: event });
expect(queryByLabelText('Delete')).toBeTruthy();
});
it('renders cancel and retry button for an event with NOT_SENT status', () => {
alicesMessageEvent.setStatus(EventStatus.NOT_SENT);
const { queryByLabelText } = getComponent({ mxEvent: alicesMessageEvent });
expect(queryByLabelText('Retry')).toBeTruthy();
expect(queryByLabelText('Delete')).toBeTruthy();
});
it.todo('unsends event on cancel click');
it.todo('retrys event on retry click');
});
});

View file

@ -67,4 +67,17 @@ export const mockClientMethodsUser = (userId = '@alice:domain') => ({
getUserId: jest.fn().mockReturnValue(userId),
isGuest: jest.fn().mockReturnValue(false),
mxcUrlToHttp: jest.fn().mockReturnValue('mock-mxcUrlToHttp'),
credentials: { userId },
});
/**
* Returns basic mocked client methods related to rendering events
* ```
* const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser('@mytestuser:domain'),
});
* ```
*/
export const mockClientMethodsEvents = () => ({
decryptEventIfNeeded: jest.fn(),
});

View file

@ -38,6 +38,7 @@ export function untilDispatch(waitForAction: DispatcherAction): Promise<ActionPa
const findByAttr = (attr: string) => (component: ReactWrapper, value: string) => component.find(`[${attr}="${value}"]`);
export const findByTestId = findByAttr('data-test-id');
export const findById = findByAttr('id');
export const findByAriaLabel = findByAttr('aria-label');
const findByTagAndAttr = (attr: string) =>
(component: ReactWrapper, value: string, tag: string) =>

View file

@ -50,7 +50,7 @@
"@nicolo-ribaudo/chokidar-2" "2.1.8-no-fsevents.3"
chokidar "^3.4.0"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7":
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.7.tgz#44416b6bd7624b998f5b1af5d470856c40138789"
integrity sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==
@ -1852,11 +1852,39 @@
remark "^13.0.0"
unist-util-find-all-after "^3.0.2"
"@testing-library/dom@^8.0.0":
version "8.13.0"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5"
integrity sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.12.5"
"@types/aria-query" "^4.2.0"
aria-query "^5.0.0"
chalk "^4.1.0"
dom-accessibility-api "^0.5.9"
lz-string "^1.4.4"
pretty-format "^27.0.2"
"@testing-library/react@^12.1.5":
version "12.1.5"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b"
integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==
dependencies:
"@babel/runtime" "^7.12.5"
"@testing-library/dom" "^8.0.0"
"@types/react-dom" "<18.0.0"
"@tootallnate/once@1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
"@types/aria-query@^4.2.0":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc"
integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14", "@types/babel__core@^7.1.7":
version "7.1.19"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460"
@ -2107,6 +2135,13 @@
dependencies:
"@types/react" "*"
"@types/react-dom@<18.0.0":
version "17.0.17"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1"
integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==
dependencies:
"@types/react" "^17"
"@types/react-redux@^7.1.20":
version "7.1.24"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0"
@ -2484,6 +2519,11 @@ aria-query@^4.2.2:
"@babel/runtime" "^7.10.2"
"@babel/runtime-corejs3" "^7.10.2"
aria-query@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c"
integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==
arr-diff@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@ -3744,6 +3784,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-accessibility-api@^0.5.9:
version "0.5.14"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56"
integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg==
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
@ -6723,6 +6768,11 @@ lru-queue@^0.1.0:
dependencies:
es5-ext "~0.10.2"
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==
make-dir@^2.0.0, make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@ -7725,7 +7775,7 @@ pretty-format@^26.0.0, pretty-format@^26.6.2:
ansi-styles "^4.0.0"
react-is "^17.0.1"
pretty-format@^27.5.1:
pretty-format@^27.0.2, pretty-format@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e"
integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==