diff --git a/cypress/e2e/dummy.spec.ts b/cypress/e2e/dummy.spec.ts new file mode 100644 index 0000000000..6940c642b5 --- /dev/null +++ b/cypress/e2e/dummy.spec.ts @@ -0,0 +1,19 @@ +/* +Copyright 2024 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. +*/ + +/// + +it("Dummy test to make CI pass", () => {}); diff --git a/cypress/e2e/polls/pollHistory.spec.ts b/cypress/e2e/polls/pollHistory.spec.ts deleted file mode 100644 index dec4fed5af..0000000000 --- a/cypress/e2e/polls/pollHistory.spec.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* -Copyright 2022 - 2023 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 { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; - -describe("Poll history", () => { - let homeserver: HomeserverInstance; - - type CreatePollOptions = { - title: string; - options: { - "id": string; - "org.matrix.msc1767.text": string; - }[]; - }; - const createPoll = async ({ title, options }: CreatePollOptions, roomId, client: MatrixClient) => { - return await client.sendEvent(roomId, "org.matrix.msc3381.poll.start", { - "org.matrix.msc3381.poll.start": { - question: { - "org.matrix.msc1767.text": title, - "body": title, - "msgtype": "m.text", - }, - kind: "org.matrix.msc3381.poll.disclosed", - max_selections: 1, - answers: options, - }, - "org.matrix.msc1767.text": "poll fallback text", - }); - }; - - const botVoteForOption = async ( - bot: MatrixClient, - roomId: string, - pollId: string, - optionId: string, - ): Promise => { - // We can't use the js-sdk types for this stuff directly, so manually construct the event. - await bot.sendEvent(roomId, "org.matrix.msc3381.poll.response", { - "m.relates_to": { - rel_type: "m.reference", - event_id: pollId, - }, - "org.matrix.msc3381.poll.response": { - answers: [optionId], - }, - }); - }; - - const endPoll = async (bot: MatrixClient, roomId: string, pollId: string): Promise => { - // We can't use the js-sdk types for this stuff directly, so manually construct the event. - await bot.sendEvent(roomId, "org.matrix.msc3381.poll.end", { - "m.relates_to": { - rel_type: "m.reference", - event_id: pollId, - }, - "org.matrix.msc1767.text": "The poll has ended", - }); - }; - - function openPollHistory(): void { - cy.findByRole("button", { name: "Room info" }).click(); - cy.get(".mx_RoomSummaryCard").within(() => { - cy.findByRole("menuitem", { name: "Poll history" }).click(); - }); - } - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Tom"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("Should display active and past polls", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - const pollParams1 = { - title: "Does the polls feature work?", - options: ["Yes", "No", "Maybe"].map((option) => ({ - "id": option, - "org.matrix.msc1767.text": option, - })), - }; - - const pollParams2 = { - title: "Which way", - options: ["Left", "Right"].map((option) => ({ - "id": option, - "org.matrix.msc1767.text": option, - })), - }; - - cy.createRoom({}).as("roomId"); - - cy.get("@roomId").then((roomId) => { - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - // wait until Bob joined - cy.findByText("BotBob joined the room").should("exist"); - }); - - // active poll - cy.get("@roomId") - .then(async (roomId) => { - const { event_id: pollId } = await createPoll(pollParams1, roomId, bot); - await botVoteForOption(bot, roomId, pollId, pollParams1.options[1].id); - return pollId; - }) - .as("pollId1"); - - // ended poll - cy.get("@roomId") - .then(async (roomId) => { - const { event_id: pollId } = await createPoll(pollParams2, roomId, bot); - await botVoteForOption(bot, roomId, pollId, pollParams1.options[1].id); - await endPoll(bot, roomId, pollId); - return pollId; - }) - .as("pollId2"); - - openPollHistory(); - - // these polls are also in the timeline - // focus on the poll history dialog - cy.get(".mx_Dialog").within(() => { - // active poll is in active polls list - // open poll detail - cy.findByText(pollParams1.title).click(); - - // vote in the poll - cy.findByText("Yes").click(); - cy.findByTestId("totalVotes").within(() => { - cy.findByText("Based on 2 votes"); - }); - - // navigate back to list - cy.get(".mx_PollHistory_header").within(() => { - cy.findByRole("button", { name: "Active polls" }).click(); - }); - - // go to past polls list - cy.findByText("Past polls").click(); - - cy.findByText(pollParams2.title).should("exist"); - }); - - // end poll1 while dialog is open - cy.all([cy.get("@roomId"), cy.get("@pollId1")]).then(async ([roomId, pollId]) => { - return endPoll(bot, roomId, pollId); - }); - - cy.get(".mx_Dialog").within(() => { - // both ended polls are in past polls list - cy.findByText(pollParams2.title).should("exist"); - cy.findByText(pollParams1.title).should("exist"); - - cy.findByText("Active polls").click(); - - // no more active polls - cy.findByText("There are no active polls in this room").should("exist"); - }); - }); -}); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts deleted file mode 100644 index 1a6682a642..0000000000 --- a/cypress/e2e/polls/polls.spec.ts +++ /dev/null @@ -1,372 +0,0 @@ -/* -Copyright 2022 - 2023 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 { HomeserverInstance } from "../../plugins/utils/homeserver"; -import { MatrixClient } from "../../global"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { Layout } from "../../../src/settings/enums/Layout"; -import Chainable = Cypress.Chainable; - -const hidePercyCSS = ".mx_MessageTimestamp, .mx_MessagePanel_myReadMarker { visibility: hidden !important; }"; - -describe("Polls", () => { - let homeserver: HomeserverInstance; - - 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.findByRole("textbox", { name: "Question or topic" }).type(title); - - options.forEach((option, index) => { - const optionId = `#pollcreate_option_${index}`; - - // click 'add option' button if needed - if (pollCreateDialog.find(optionId).length === 0) { - cy.findByRole("button", { name: "Add option" }).scrollIntoView().click(); - } - cy.get(optionId).scrollIntoView().type(option); - }); - }); - cy.get(".mx_Dialog").within(() => { - cy.findByRole("button", { name: "Create Poll" }).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_PollOption .mx_StyledRadioButton", optionText); - }; - - const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => { - getPollOption(pollId, optionText).within(() => { - cy.get(".mx_PollOption_optionVoteCount").should("contain", `${votes} vote`); - }); - }; - - const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => { - getPollOption(pollId, optionText).within((ref) => { - cy.findByRole("radio") - .invoke("attr", "value") - .then((optionId) => { - // We can't use the js-sdk types for this stuff directly, so manually construct the event. - bot.sendEvent(roomId, "org.matrix.msc3381.poll.response", { - "m.relates_to": { - rel_type: "m.reference", - event_id: pollId, - }, - "org.matrix.msc3381.poll.response": { - answers: [optionId], - }, - }); - }); - }); - }; - - beforeEach(() => { - cy.window().then((win) => { - win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests - }); - cy.startHomeserver("default").then((data) => { - homeserver = data; - - cy.initTestUser(homeserver, "Tom"); - }); - }); - - afterEach(() => { - cy.stopHomeserver(homeserver); - }); - - it("should be creatable and votable", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - bot = _bot; - }); - - let roomId: string; - cy.createRoom({}).then((_roomId) => { - roomId = _roomId; - cy.inviteUser(roomId, bot.getUserId()); - cy.visit("/#/room/" + roomId); - // wait until Bob joined - cy.findByText("BotBob joined the room").should("exist"); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "Poll" }).click(); - }); - - // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 - //cy.get(".mx_CompoundDialog").percySnapshotElement("Polls Composer"); - - const pollParams = { - title: "Does the polls feature work?", - // Since we're going to take a screenshot anyways, we include some - // non-ASCII characters here to stress test the app's font config - // while we're at it. - options: ["Yes", "Noo⃐o⃑o⃩o⃪o⃫o⃬o⃭o⃮o⃯", "のらねこ Maybe?"], - }; - createPoll(pollParams); - - // Wait for message to send, get its ID and save as @pollId - cy.contains(".mx_RoomView_body .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: hidePercyCSS }); - - // 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").within(() => { - cy.findByText("1 vote cast. Vote to see the results"); - }); - - // 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("should be editable from context menu if no votes have been cast", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { 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.findByRole("menuitem", { name: "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) => { - // Open context menu - getPollTile(pollId).rightclick(); - - // Select edit item - cy.findByRole("menuitem", { name: "Edit" }).click(); - - // Expect poll editing dialog - cy.get(".mx_PollCreateDialog"); - }); - }); - - it("should not be editable from context menu if votes have been cast", () => { - let bot: MatrixClient; - cy.getBot(homeserver, { 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.findByRole("menuitem", { name: "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) => { - // Bot votes 'Maybe' in the poll - botVoteForOption(bot, roomId, pollId, pollParams.options[2]); - - // wait for bot's vote to arrive - cy.get(".mx_MPollBody_totalVotes").should("contain", "1 vote cast"); - - // Open context menu - getPollTile(pollId).rightclick(); - - // Select edit item - cy.findByRole("menuitem", { name: "Edit" }).click(); - - // Expect error dialog - cy.get(".mx_ErrorDialog"); - }); - }); - - it("should be displayed correctly in thread panel", () => { - let botBob: MatrixClient; - let botCharlie: MatrixClient; - cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { - botBob = _bot; - }); - cy.getBot(homeserver, { 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); - // wait until the bots joined - cy.findByText("BotBob and one other were invited and joined", { timeout: 10000 }).should("exist"); - }); - - cy.openMessageComposerOptions().within(() => { - cy.findByRole("menuitem", { name: "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.contains(".mx_RoomView_body .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.findByRole("button", { name: "Open thread" }).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").within(() => { - cy.findByText("2 votes cast. Vote to see the results").should("exist"); - }); - // and thread view - cy.get(".mx_ThreadView .mx_MPollBody_totalVotes").within(() => { - cy.findByText("2 votes cast. Vote to see the results").should("exist"); - }); - - // Take snapshots of poll on ThreadView - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='bubble']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with a poll on bubble layout", { - percyCSS: hidePercyCSS, - }); - cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - cy.get(".mx_ThreadView .mx_EventTile[data-layout='group']").should("be.visible"); - cy.get(".mx_ThreadView").percySnapshotElement("ThreadView with a poll on group layout", { - percyCSS: hidePercyCSS, - }); - - 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/playwright/e2e/polls/pollHistory.spec.ts b/playwright/e2e/polls/pollHistory.spec.ts new file mode 100644 index 0000000000..458bb544c7 --- /dev/null +++ b/playwright/e2e/polls/pollHistory.spec.ts @@ -0,0 +1,149 @@ +/* +Copyright 2023 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 { test, expect } from "../../element-web-test"; +import type { Page } from "@playwright/test"; +import type { Bot } from "../../pages/bot"; +import type { Client } from "../../pages/client"; + +test.describe("Poll history", () => { + type CreatePollOptions = { + title: string; + options: { + "id": string; + "org.matrix.msc1767.text": string; + }[]; + }; + const createPoll = async (createOptions: CreatePollOptions, roomId: string, client: Client) => { + return client.sendEvent(roomId, null, "org.matrix.msc3381.poll.start", { + "org.matrix.msc3381.poll.start": { + question: { + "org.matrix.msc1767.text": createOptions.title, + "body": createOptions.title, + "msgtype": "m.text", + }, + kind: "org.matrix.msc3381.poll.disclosed", + max_selections: 1, + answers: createOptions.options, + }, + "org.matrix.msc1767.text": "poll fallback text", + }); + }; + + const botVoteForOption = async (bot: Bot, roomId: string, pollId: string, optionId: string): Promise => { + // We can't use the js-sdk types for this stuff directly, so manually construct the event. + await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.response", { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + "org.matrix.msc3381.poll.response": { + answers: [optionId], + }, + }); + }; + + const endPoll = async (bot: Bot, roomId: string, pollId: string): Promise => { + // We can't use the js-sdk types for this stuff directly, so manually construct the event. + await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.end", { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + "org.matrix.msc1767.text": "The poll has ended", + }); + }; + + async function openPollHistory(page: Page): Promise { + await page.getByRole("button", { name: "Room info" }).click(); + await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "Poll history" }).click(); + } + + test.use({ + displayName: "Tom", + botCreateOpts: { displayName: "BotBob" }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + // Collapse left panel for these tests + window.localStorage.setItem("mx_lhs_size", "0"); + }); + }); + + test("Should display active and past polls", async ({ page, app, user, bot }) => { + const pollParams1 = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"].map((option) => ({ + "id": option, + "org.matrix.msc1767.text": option, + })), + }; + + const pollParams2 = { + title: "Which way", + options: ["Left", "Right"].map((option) => ({ + "id": option, + "org.matrix.msc1767.text": option, + })), + }; + + const roomId = await app.client.createRoom({}); + + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + // wait until Bob joined + await expect(page.getByText("BotBob joined the room")).toBeAttached(); + + // active poll + const { event_id: pollId1 } = await createPoll(pollParams1, roomId, bot); + await botVoteForOption(bot, roomId, pollId1, pollParams1.options[1].id); + + // ended poll + const { event_id: pollId2 } = await createPoll(pollParams2, roomId, bot); + await botVoteForOption(bot, roomId, pollId2, pollParams1.options[1].id); + await endPoll(bot, roomId, pollId2); + + await openPollHistory(page); + + // these polls are also in the timeline + // focus on the poll history dialog + const dialog = page.locator(".mx_Dialog"); + + // active poll is in active polls list + // open poll detail + await dialog.getByText(pollParams1.title).click(); + await dialog.getByText("Yes").click(); + // vote in the poll + await expect(dialog.getByTestId("totalVotes").getByText("Based on 2 votes")).toBeAttached(); + // navigate back to list + await dialog.locator(".mx_PollHistory_header").getByRole("button", { name: "Active polls" }).click(); + + // go to past polls list + await dialog.getByText("Past polls").click(); + + await expect(dialog.getByText(pollParams2.title)).toBeAttached(); + + // end poll1 while dialog is open + await endPoll(bot, roomId, pollId1); + + await expect(dialog.getByText(pollParams2.title)).toBeAttached(); + await expect(dialog.getByText(pollParams1.title)).toBeAttached(); + dialog.getByText("Active polls").click(); + + // no more active polls + await expect(page.getByText("There are no active polls in this room")).toBeAttached(); + }); +}); diff --git a/playwright/e2e/polls/polls.spec.ts b/playwright/e2e/polls/polls.spec.ts new file mode 100644 index 0000000000..c4a8ae1bbe --- /dev/null +++ b/playwright/e2e/polls/polls.spec.ts @@ -0,0 +1,333 @@ +/* +Copyright 2023 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 { test, expect } from "../../element-web-test"; +import { Bot } from "../../pages/bot"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { Layout } from "../../../src/settings/enums/Layout"; +import type { Locator, Page } from "@playwright/test"; + +test.describe("Polls", () => { + type CreatePollOptions = { + title: string; + options: string[]; + }; + const createPoll = async (page: Page, { title, options }: CreatePollOptions) => { + if (options.length < 2) { + throw new Error("Poll must have at least two options"); + } + const dialog = page.locator(".mx_PollCreateDialog"); + await dialog.getByRole("textbox", { name: "Question or topic" }).fill(title); + for (const [index, value] of options.entries()) { + const optionIdLocator = dialog.locator(`#pollcreate_option_${index}`); + // click 'add option' button if needed + if ((await optionIdLocator.count()) === 0) { + const button = dialog.getByRole("button", { name: "Add option" }); + await button.scrollIntoViewIfNeeded(); + await button.click(); + } + await optionIdLocator.scrollIntoViewIfNeeded(); + await optionIdLocator.fill(value); + } + await page.locator(".mx_Dialog").getByRole("button", { name: "Create Poll" }).click(); + }; + + const getPollTile = (page: Page, pollId: string, optLocator?: Locator): Locator => { + return (optLocator ?? page).locator(`.mx_EventTile[data-scroll-tokens="${pollId}"]`); + }; + + const getPollOption = (page: Page, pollId: string, optionText: string, optLocator?: Locator): Locator => { + return getPollTile(page, pollId, optLocator) + .locator(".mx_PollOption .mx_StyledRadioButton") + .filter({ hasText: optionText }); + }; + + const expectPollOptionVoteCount = async ( + page: Page, + pollId: string, + optionText: string, + votes: number, + optLocator?: Locator, + ): Promise => { + await expect( + getPollOption(page, pollId, optionText, optLocator).locator(".mx_PollOption_optionVoteCount"), + ).toContainText(`${votes} vote`); + }; + + const botVoteForOption = async ( + page: Page, + bot: Bot, + roomId: string, + pollId: string, + optionText: string, + ): Promise => { + const locator = getPollOption(page, pollId, optionText); + const optionId = await locator.first().getByRole("radio").getAttribute("value"); + + // We can't use the js-sdk types for this stuff directly, so manually construct the event. + await bot.sendEvent(roomId, null, "org.matrix.msc3381.poll.response", { + "m.relates_to": { + rel_type: "m.reference", + event_id: pollId, + }, + "org.matrix.msc3381.poll.response": { + answers: [optionId], + }, + }); + }; + + test.use({ + displayName: "Tom", + botCreateOpts: { displayName: "BotBob" }, + }); + + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + // Collapse left panel for these tests + window.localStorage.setItem("mx_lhs_size", "0"); + }); + }); + + test("should be creatable and votable", async ({ page, app, bot, user }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + // wait until Bob joined + await expect(page.getByText("BotBob joined the room")).toBeAttached(); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 + //cy.get(".mx_CompoundDialog").percySnapshotElement("Polls Composer"); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe?"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + await expect(getPollTile(page, pollId)).toMatchScreenshot("Polls_Timeline_tile_no_votes.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + // Bot votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // no votes shown until I vote, check bots vote has arrived + await expect( + page.locator(".mx_MPollBody_totalVotes").getByText("1 vote cast. Vote to see the results"), + ).toBeAttached(); + + // vote 'Maybe' + await getPollOption(page, pollId, pollParams.options[2]).click(); + // both me and bot have voted Maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2); + + // change my vote to 'Yes' + await getPollOption(page, pollId, pollParams.options[0]).click(); + + // 1 vote for yes + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1); + // 1 vote for maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 1); + + // Bot updates vote to 'No' + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[1]); + + // 1 vote for yes + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1); + // 1 vote for no + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1); + // 0 for maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 0); + }); + + test("should be editable from context menu if no votes have been cast", async ({ page, app, user, bot }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + + // Open context menu + await getPollTile(page, pollId).click({ button: "right" }); + + // Select edit item + await page.getByRole("menuitem", { name: "Edit" }).click(); + + // Expect poll editing dialog + await expect(page.locator(".mx_PollCreateDialog")).toBeAttached(); + }); + + test("should not be editable from context menu if votes have been cast", async ({ page, app, user, bot }) => { + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await page.goto("/#/room/" + roomId); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + + // Bot votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // wait for bot's vote to arrive + await expect(page.locator(".mx_MPollBody_totalVotes")).toContainText("1 vote cast"); + + // Open context menu + await getPollTile(page, pollId).click({ button: "right" }); + + // Select edit item + await page.getByRole("menuitem", { name: "Edit" }).click(); + + // Expect poll editing dialog + await expect(page.locator(".mx_ErrorDialog")).toBeAttached(); + }); + + test("should be displayed correctly in thread panel", async ({ page, app, user, bot, homeserver }) => { + const botCharlie = new Bot(page, homeserver, { displayName: "BotCharlie" }); + await botCharlie.prepareClient(); + + const roomId: string = await app.client.createRoom({}); + await app.client.inviteUser(roomId, bot.credentials.userId); + await app.client.inviteUser(roomId, botCharlie.credentials.userId); + await page.goto("/#/room/" + roomId); + + // wait until the bots joined + await expect(page.getByText("BotBob and one other were invited and joined")).toBeAttached({ timeout: 10000 }); + + const locator = await app.openMessageComposerOptions(); + await locator.getByRole("menuitem", { name: "Poll" }).click(); + + const pollParams = { + title: "Does the polls feature work?", + options: ["Yes", "No", "Maybe"], + }; + await createPoll(page, pollParams); + + // Wait for message to send, get its ID and save as @pollId + const pollId = await page + .locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]") + .filter({ hasText: pollParams.title }) + .getAttribute("data-scroll-tokens"); + + // Bob starts thread on the poll + await bot.sendMessage( + roomId, + { + body: "Hello there", + msgtype: "m.text", + }, + pollId, + ); + + // open the thread summary + await page.getByRole("button", { name: "Open thread" }).click(); + + // Bob votes 'Maybe' in the poll + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[2]); + + // Charlie votes 'No' + await botVoteForOption(page, botCharlie, roomId, pollId, pollParams.options[1]); + + // no votes shown until I vote, check votes have arrived in main tl + await expect( + page + .locator(".mx_RoomView_body .mx_MPollBody_totalVotes") + .getByText("2 votes cast. Vote to see the results"), + ).toBeAttached(); + + // and thread view + await expect( + page.locator(".mx_ThreadView .mx_MPollBody_totalVotes").getByText("2 votes cast. Vote to see the results"), + ).toBeAttached(); + + // Take snapshots of poll on ThreadView + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='bubble']").first()).toBeVisible(); + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("ThreadView_with_a_poll_on_bubble_layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + await app.settings.setValue("layout", null, SettingLevel.DEVICE, Layout.Group); + await expect(page.locator(".mx_ThreadView .mx_EventTile[data-layout='group']").first()).toBeVisible(); + + await expect(page.locator(".mx_ThreadView")).toMatchScreenshot("ThreadView_with_a_poll_on_group_layout.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + const roomViewLocator = page.locator(".mx_RoomView_body"); + // vote 'Maybe' in the main timeline poll + await getPollOption(page, pollId, pollParams.options[2], roomViewLocator).click(); + // both me and bob have voted Maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2, roomViewLocator); + + const threadViewLocator = page.locator(".mx_ThreadView"); + // votes updated in thread view too + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 2, threadViewLocator); + // change my vote to 'Yes' + await getPollOption(page, pollId, pollParams.options[0], threadViewLocator).click(); + + // Bob updates vote to 'No' + await botVoteForOption(page, bot, roomId, pollId, pollParams.options[1]); + + // me: yes, bob: no, charlie: no + const expectVoteCounts = async (optLocator: Locator) => { + // I voted yes + await expectPollOptionVoteCount(page, pollId, pollParams.options[0], 1, optLocator); + // Bob and Charlie voted no + await expectPollOptionVoteCount(page, pollId, pollParams.options[1], 2, optLocator); + // 0 for maybe + await expectPollOptionVoteCount(page, pollId, pollParams.options[2], 0, optLocator); + }; + + // check counts are correct in main timeline tile + await expectVoteCounts(page.locator(".mx_RoomView_body")); + + // and in thread view tile + await expectVoteCounts(page.locator(".mx_ThreadView")); + }); +}); diff --git a/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png new file mode 100644 index 0000000000..a719425789 Binary files /dev/null and b/playwright/snapshots/polls/polls.spec.ts/Polls-Timeline-tile-no-votes-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png new file mode 100644 index 0000000000..73deb5b929 Binary files /dev/null and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png new file mode 100644 index 0000000000..d5348395c8 Binary files /dev/null and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ