diff --git a/code_style.md b/code_style.md index 5747540a76..8071cd264b 100644 --- a/code_style.md +++ b/code_style.md @@ -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/) \ No newline at end of file diff --git a/package.json b/package.json index fba76e4cec..08223ace12 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 8372ca14bf..6703c34198 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -670,6 +670,7 @@ export default class MessageContextMenu extends React.Component {...this.props} className="mx_MessageContextMenu" compact={true} + data-testid="mx_MessageContextMenu" > { nativeItemsList } { quickItemsList } diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index c53f5a64f9..4a0d15b729 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -124,6 +124,7 @@ class ReactionPicker extends React.Component { onChoose={this.onChoose} selectedEmojis={this.state.selectedEmojis} showQuickReactions={true} + data-testid='mx_ReactionPicker' />; } } diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx new file mode 100644 index 0000000000..3d624187b8 --- /dev/null +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -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('', () => { + 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 = {}) => + render( + + + ); + + 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'); + }); +}); diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts index ed4cabc501..b922048837 100644 --- a/test/test-utils/client.ts +++ b/test/test-utils/client.ts @@ -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(), }); diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index d7da56407b..666f7c68ea 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -38,6 +38,7 @@ export function untilDispatch(waitForAction: DispatcherAction): Promise (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) => diff --git a/yarn.lock b/yarn.lock index c212c63514..7c0d099633 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==