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