diff --git a/cypress/e2e/15-polls/polls.spec.ts b/cypress/e2e/15-polls/polls.spec.ts new file mode 100644 index 0000000000..ecfd4af90e --- /dev/null +++ b/cypress/e2e/15-polls/polls.spec.ts @@ -0,0 +1,249 @@ +/* +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 { PollResponseEvent } from "matrix-events-sdk"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; +import { MatrixClient } from "../../global"; +import Chainable = Cypress.Chainable; + +const hideTimestampCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; + +describe("Polls", () => { + let synapse: SynapseInstance; + + type CreatePollOptions = { + title: string; + options: string[]; + }; + const createPoll = ({ title, options }: CreatePollOptions) => { + if (options.length < 2) { + throw new Error('Poll must have at least two options'); + } + cy.get('.mx_PollCreateDialog').within((pollCreateDialog) => { + cy.get('#poll-topic-input').type(title); + + options.forEach((option, index) => { + const optionId = `#pollcreate_option_${index}`; + + // click 'add option' button if needed + if (pollCreateDialog.find(optionId).length === 0) { + cy.get('.mx_PollCreateDialog_addOption').scrollIntoView().click(); + } + cy.get(optionId).scrollIntoView().type(option); + }); + }); + cy.get('.mx_Dialog button[type="submit"]').click(); + }; + + const getPollTile = (pollId: string): Chainable => { + return cy.get(`.mx_EventTile[data-scroll-tokens="${pollId}"]`); + }; + + const getPollOption = (pollId: string, optionText: string): Chainable => { + return getPollTile(pollId).contains('.mx_MPollBody_option .mx_StyledRadioButton', optionText); + }; + + const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => { + getPollOption(pollId, optionText).within(() => { + cy.get('.mx_MPollBody_optionVoteCount').should('contain', `${votes} vote`); + }); + }; + + const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => { + getPollOption(pollId, optionText).within(ref => { + cy.get('input[type="radio"]').invoke('attr', 'value').then(optionId => { + const pollVote = PollResponseEvent.from([optionId], pollId).serialize(); + bot.sendEvent( + roomId, + pollVote.type, + pollVote.content, + ); + }); + }); + }; + + beforeEach(() => { + cy.enableLabsFeature("feature_thread"); + cy.window().then(win => { + win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests + }); + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Tom"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("Open polls can be created and voted in", () => { + let bot: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + bot = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, bot.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + cy.get('.mx_CompoundDialog').percySnapshotElement('Polls Composer'); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + getPollTile(pollId).percySnapshotElement('Polls Timeline tile - no votes', { percyCSS: hideTimestampCSS }); + + // Bot votes 'Maybe' in the poll + botVoteForOption(bot, roomId, pollId, pollParams.options[2]); + + // no votes shown until I vote, check bots vote has arrived + cy.get('.mx_MPollBody_totalVotes').should('contain', '1 vote cast'); + + // vote 'Maybe' + getPollOption(pollId, pollParams.options[2]).click('topLeft'); + // both me and bot have voted Maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 2); + + // change my vote to 'Yes' + getPollOption(pollId, pollParams.options[0]).click('topLeft'); + + // 1 vote for yes + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // 1 vote for maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 1); + + // Bot updates vote to 'No' + botVoteForOption(bot, roomId, pollId, pollParams.options[1]); + + // 1 vote for yes + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // 1 vote for no + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // 0 for maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 0); + }); + }); + + it("displays polls correctly in thread panel", () => { + let botBob: MatrixClient; + let botCharlie: MatrixClient; + cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + botBob = _bot; + }); + cy.getBot(synapse, { displayName: "BotCharlie" }).then(_bot => { + botCharlie = _bot; + }); + + let roomId: string; + cy.createRoom({}).then(_roomId => { + roomId = _roomId; + cy.inviteUser(roomId, botBob.getUserId()); + cy.inviteUser(roomId, botCharlie.getUserId()); + cy.visit('/#/room/' + roomId); + }); + + cy.openMessageComposerOptions().within(() => { + cy.get('[aria-label="Poll"]').click(); + }); + + const pollParams = { + title: 'Does the polls feature work?', + options: ['Yes', 'No', 'Maybe'], + }; + createPoll(pollParams); + + // Wait for message to send, get its ID and save as @pollId + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", pollParams.title) + .invoke("attr", "data-scroll-tokens").as("pollId"); + + cy.get("@pollId").then(pollId => { + // Bob starts thread on the poll + botBob.sendMessage(roomId, pollId, { + body: "Hello there", + msgtype: "m.text", + }); + + // open the thread summary + cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); + + // Bob votes 'Maybe' in the poll + botVoteForOption(botBob, roomId, pollId, pollParams.options[2]); + // Charlie votes 'No' + botVoteForOption(botCharlie, roomId, pollId, pollParams.options[1]); + + // no votes shown until I vote, check votes have arrived in main tl + cy.get('.mx_RoomView_body .mx_MPollBody_totalVotes').should('contain', '2 votes cast'); + // and thread view + cy.get('.mx_ThreadView .mx_MPollBody_totalVotes').should('contain', '2 votes cast'); + + cy.get('.mx_RoomView_body').within(() => { + // vote 'Maybe' in the main timeline poll + getPollOption(pollId, pollParams.options[2]).click('topLeft'); + // both me and bob have voted Maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 2); + }); + + cy.get('.mx_ThreadView').within(() => { + // votes updated in thread view too + expectPollOptionVoteCount(pollId, pollParams.options[2], 2); + // change my vote to 'Yes' + getPollOption(pollId, pollParams.options[0]).click('topLeft'); + }); + + // Bob updates vote to 'No' + botVoteForOption(botBob, roomId, pollId, pollParams.options[1]); + + // me: yes, bob: no, charlie: no + const expectVoteCounts = () => { + // I voted yes + expectPollOptionVoteCount(pollId, pollParams.options[0], 1); + // Bob and Charlie voted no + expectPollOptionVoteCount(pollId, pollParams.options[1], 2); + // 0 for maybe + expectPollOptionVoteCount(pollId, pollParams.options[2], 0); + }; + + // check counts are correct in main timeline tile + cy.get('.mx_RoomView_body').within(() => { + expectVoteCounts(); + }); + // and in thread view tile + cy.get('.mx_ThreadView').within(() => { + expectVoteCounts(); + }); + }); + }); +}); diff --git a/cypress/support/composer.ts b/cypress/support/composer.ts new file mode 100644 index 0000000000..ae6c8bef87 --- /dev/null +++ b/cypress/support/composer.ts @@ -0,0 +1,48 @@ +/* +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 Chainable = Cypress.Chainable; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + // Get the composer element + // selects main timeline composer by default + // set `isRightPanel` true to select right panel composer + getComposer(isRightPanel?: boolean): Chainable; + // Open the message composer kebab menu + openMessageComposerOptions(isRightPanel?: boolean): Chainable; + } + } +} + +Cypress.Commands.add("getComposer", (isRightPanel?: boolean): Chainable => { + const panelClass = isRightPanel ? '.mx_RightPanel' : '.mx_RoomView_body'; + return cy.get(`${panelClass} .mx_MessageComposer`); +}); + +Cypress.Commands.add("openMessageComposerOptions", (isRightPanel?: boolean): Chainable => { + cy.getComposer(isRightPanel).within(() => { + cy.get('[aria-label="More options"]').click(); + }); + return cy.get('.mx_MessageComposer_Menu'); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 18445d8d04..faff9a8363 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -35,3 +35,4 @@ import "./views"; import "./iframes"; import "./timeline"; import "./network"; +import "./composer";