From 1b3f473c10af519a42e45d1300d744fcae82e9f0 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 29 Nov 2023 13:50:13 +0000
Subject: [PATCH] Migrate settings/* from Cypress to Playwright (#11949)
* Migrate location.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate location.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate appearance-user-settings-tab.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate device-management.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate general-room-settings-tab.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate general-user-settings-tab.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate preferences-user-settings-tab.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Migrate security-user-settings-tab.spec.ts from Cypress to Playwright
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Add screenshots
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Add screenshot
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Deflake
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Update screenshots
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Update screenshots
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
* Move settings into subclass
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---------
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
.../appearance-user-settings-tab.spec.ts | 304 ------------------
.../e2e/settings/device-management.spec.ts | 140 --------
.../general-room-settings-tab.spec.ts | 97 ------
.../general-user-settings-tab.spec.ts | 238 --------------
.../preferences-user-settings-tab.spec.ts | 50 ---
.../security-user-settings-tab.spec.ts | 72 -----
playwright/docker-entrypoint.sh | 2 +
playwright/e2e/editing/editing.spec.ts | 2 +-
.../appearance-user-settings-tab.spec.ts | 251 +++++++++++++++
.../e2e/settings/device-management.spec.ts | 105 ++++++
.../general-room-settings-tab.spec.ts | 63 ++++
.../general-user-settings-tab.spec.ts | 189 +++++++++++
.../preferences-user-settings-tab.spec.ts | 31 ++
.../security-user-settings-tab.spec.ts | 51 +++
.../user-onboarding-new.spec.ts | 2 +-
.../user-onboarding-old.spec.ts | 2 +-
playwright/element-web-test.ts | 3 +-
playwright/pages/ElementAppPage.ts | 88 ++---
playwright/pages/settings.ts | 102 ++++++
playwright/plugins/homeserver/index.ts | 7 +
.../plugins/homeserver/synapse/index.ts | 23 ++
playwright/sample-files/riot.png | Bin 0 -> 13818 bytes
...b-should-be-rendered-properly-1-darwin.png | Bin 0 -> 63134 bytes
...ab-should-be-rendered-properly-1-linux.png | Bin 0 -> 47350 bytes
.../font-slider-11-darwin.png | Bin 0 -> 2120 bytes
.../font-slider-11-linux.png | Bin 0 -> 2046 bytes
.../font-slider-21-darwin.png | Bin 0 -> 3840 bytes
.../font-slider-21-linux.png | Bin 0 -> 3720 bytes
...ab-should-be-rendered-properly-1-linux.png | Bin 0 -> 49821 bytes
.../general-linux.png | Bin 0 -> 47305 bytes
...ab-should-be-rendered-properly-1-linux.png | Bin 0 -> 59455 bytes
...og-should-be-rendered-properly-1-linux.png | Bin 0 -> 38683 bytes
32 files changed, 861 insertions(+), 961 deletions(-)
delete mode 100644 cypress/e2e/settings/appearance-user-settings-tab.spec.ts
delete mode 100644 cypress/e2e/settings/device-management.spec.ts
delete mode 100644 cypress/e2e/settings/general-room-settings-tab.spec.ts
delete mode 100644 cypress/e2e/settings/general-user-settings-tab.spec.ts
delete mode 100644 cypress/e2e/settings/preferences-user-settings-tab.spec.ts
delete mode 100644 cypress/e2e/settings/security-user-settings-tab.spec.ts
create mode 100644 playwright/e2e/settings/appearance-user-settings-tab.spec.ts
create mode 100644 playwright/e2e/settings/device-management.spec.ts
create mode 100644 playwright/e2e/settings/general-room-settings-tab.spec.ts
create mode 100644 playwright/e2e/settings/general-user-settings-tab.spec.ts
create mode 100644 playwright/e2e/settings/preferences-user-settings-tab.spec.ts
create mode 100644 playwright/e2e/settings/security-user-settings-tab.spec.ts
create mode 100644 playwright/pages/settings.ts
create mode 100644 playwright/sample-files/riot.png
create mode 100644 playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/Appearance-user-settings-tab-should-be-rendered-properly-1-darwin.png
create mode 100644 playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/Appearance-user-settings-tab-should-be-rendered-properly-1-linux.png
create mode 100644 playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-darwin.png
create mode 100644 playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-11-linux.png
create mode 100644 playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-darwin.png
create mode 100644 playwright/snapshots/settings/appearance-user-settings-tab.spec.ts/font-slider-21-linux.png
create mode 100644 playwright/snapshots/settings/general-room-settings-tab.spec.ts/General-room-settings-tab-should-be-rendered-properly-1-linux.png
create mode 100644 playwright/snapshots/settings/general-user-settings-tab.spec.ts/general-linux.png
create mode 100644 playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png
create mode 100644 playwright/snapshots/settings/security-user-settings-tab.spec.ts/Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1-linux.png
diff --git a/cypress/e2e/settings/appearance-user-settings-tab.spec.ts b/cypress/e2e/settings/appearance-user-settings-tab.spec.ts
deleted file mode 100644
index 46de23f591..0000000000
--- a/cypress/e2e/settings/appearance-user-settings-tab.spec.ts
+++ /dev/null
@@ -1,304 +0,0 @@
-/*
-Copyright 2023 Suguru Hirahara
-
-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 { SettingLevel } from "../../../src/settings/SettingLevel";
-
-describe("Appearance user settings tab", () => {
- let homeserver: HomeserverInstance;
-
- beforeEach(() => {
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
- cy.initTestUser(homeserver, "Hanako");
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- it("should be rendered properly", () => {
- cy.openUserSettings("Appearance");
-
- cy.findByTestId("mx_AppearanceUserSettingsTab").within(() => {
- cy.get("h2").should("have.text", "Customise your appearance").should("be.visible");
- });
-
- cy.findByTestId("mx_AppearanceUserSettingsTab").percySnapshotElement(
- "User settings tab - Appearance (advanced options collapsed)",
- {
- // Emulate TabbedView's actual min and max widths
- // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width
- // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right)
- widths: [580, 796],
- },
- );
-
- // Click "Show advanced" link button
- cy.findByRole("button", { name: "Show advanced" }).click();
-
- // Assert that "Hide advanced" link button is rendered
- cy.findByRole("button", { name: "Hide advanced" }).should("exist");
-
- cy.findByTestId("mx_AppearanceUserSettingsTab").percySnapshotElement(
- "User settings tab - Appearance (advanced options expanded)",
- {
- // Emulate TabbedView's actual min and max widths
- // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width
- // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right)
- widths: [580, 796],
- },
- );
- });
-
- it("should support switching layouts", () => {
- // Create and view a room first
- cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room");
-
- cy.openUserSettings("Appearance");
-
- cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => {
- // Assert that the layout selected by default is "Modern"
- cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => {
- cy.findByLabelText("Modern").should("exist");
- });
- });
-
- // Assert that the room layout is set to group (modern) layout
- cy.get(".mx_RoomView_body[data-layout='group']").should("exist");
-
- cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => {
- // Select the first layout
- cy.get(".mx_LayoutSwitcher_RadioButton").first().click();
-
- // Assert that the layout selected is "IRC (Experimental)"
- cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => {
- cy.findByLabelText("IRC (Experimental)").should("exist");
- });
- });
-
- // Assert that the room layout is set to IRC layout
- cy.get(".mx_RoomView_body[data-layout='irc']").should("exist");
-
- cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => {
- // Select the last layout
- cy.get(".mx_LayoutSwitcher_RadioButton").last().click();
-
- // Assert that the layout selected is "Message bubbles"
- cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => {
- cy.findByLabelText("Message bubbles").should("exist");
- });
- });
-
- // Assert that the room layout is set to bubble layout
- cy.get(".mx_RoomView_body[data-layout='bubble']").should("exist");
- });
-
- it("should support changing font size by clicking the font slider", () => {
- cy.openUserSettings("Appearance");
-
- cy.findByTestId("mx_AppearanceUserSettingsTab").within(() => {
- cy.get(".mx_FontScalingPanel_fontSlider").within(() => {
- cy.findByLabelText("Font size").should("exist");
- });
-
- cy.get(".mx_FontScalingPanel_fontSlider").within(() => {
- // Click the left position of the slider
- cy.get("input").realClick({ position: "left" });
-
- const MIN_FONT_SIZE = 11;
- // Assert that the smallest font size is selected
- cy.get(`input[value='${MIN_FONT_SIZE}']`).should("exist");
- cy.get("output .mx_Slider_selection_label").findByText(MIN_FONT_SIZE);
- });
-
- cy.get(".mx_FontScalingPanel_fontSlider").percySnapshotElement("Font size slider - smallest (13)", {
- widths: [486], // actual size (content-box, including inline padding)
- });
-
- cy.get(".mx_FontScalingPanel_fontSlider").within(() => {
- // Click the right position of the slider
- cy.get("input").realClick({ position: "right" });
-
- const MAX_FONT_SIZE = 21;
- // Assert that the largest font size is selected
- cy.get(`input[value='${MAX_FONT_SIZE}']`).should("exist");
- cy.get("output .mx_Slider_selection_label").findByText(MAX_FONT_SIZE);
- });
-
- cy.get(".mx_FontScalingPanel_fontSlider").percySnapshotElement("Font size slider - largest (21)", {
- widths: [486],
- });
- });
- });
-
- it("should disable font size slider when custom font size is used", () => {
- cy.openUserSettings("Appearance");
-
- cy.findByTestId("mx_FontScalingPanel").within(() => {
- cy.findByLabelText("Use custom size").click({ force: true }); // force click as checkbox size is zero
-
- // Assert that the font slider is disabled
- cy.get(".mx_FontScalingPanel_fontSlider input[disabled]").should("exist");
- });
- });
-
- it("should support enabling compact group (modern) layout", () => {
- // Create and view a room first
- cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room");
-
- cy.openUserSettings("Appearance");
-
- // Click "Show advanced" link button
- cy.findByRole("button", { name: "Show advanced" }).click();
-
- // force click as checkbox size is zero
- cy.findByLabelText("Use a more compact 'Modern' layout").click({ force: true });
-
- // Assert that the room layout is set to compact group (modern) layout
- cy.get("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout").should("exist");
- });
-
- it("should disable compact group (modern) layout option on IRC layout and bubble layout", () => {
- const checkDisabled = () => {
- cy.findByLabelText("Use a more compact 'Modern' layout").should("be.disabled");
- };
-
- cy.openUserSettings("Appearance");
-
- // Click "Show advanced" link button
- cy.findByRole("button", { name: "Show advanced" }).click();
-
- // Enable IRC layout
- cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => {
- // Select the first layout
- cy.get(".mx_LayoutSwitcher_RadioButton").first().click();
-
- // Assert that the layout selected is "IRC (Experimental)"
- cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => {
- cy.findByLabelText("IRC (Experimental)").should("exist");
- });
- });
-
- checkDisabled();
-
- // Enable bubble layout
- cy.get(".mx_LayoutSwitcher_RadioButtons").within(() => {
- // Select the first layout
- cy.get(".mx_LayoutSwitcher_RadioButton").last().click();
-
- // Assert that the layout selected is "IRC (Experimental)"
- cy.get(".mx_LayoutSwitcher_RadioButton_selected .mx_StyledRadioButton_enabled").within(() => {
- cy.findByLabelText("Message bubbles").should("exist");
- });
- });
-
- checkDisabled();
- });
-
- it("should support enabling system font", () => {
- cy.openUserSettings("Appearance");
-
- // Click "Show advanced" link button
- cy.findByRole("button", { name: "Show advanced" }).click();
-
- // force click as checkbox size is zero
- cy.findByLabelText("Use bundled emoji font").click({ force: true });
- cy.findByLabelText("Use a system font").click({ force: true });
-
- // Assert that the font-family value was removed
- cy.get("body").should("have.css", "font-family", '""');
- });
-
- describe("Theme Choice Panel", () => {
- beforeEach(() => {
- // Disable the default theme for consistency in case ThemeWatcher automatically chooses it
- cy.setSettingValue("use_system_theme", null, SettingLevel.DEVICE, false);
- });
-
- it("should be rendered with the light theme selected", () => {
- cy.openUserSettings("Appearance")
- .findByTestId("mx_ThemeChoicePanel")
- .within(() => {
- cy.findByTestId("checkbox-use-system-theme").within(() => {
- cy.findByText("Match system theme").should("be.visible");
-
- // Assert that 'Match system theme' is not checked
- // Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked
- cy.get(".mx_Checkbox_checkmark").should("not.be.visible");
- });
-
- cy.findByTestId("theme-choice-panel-selectors").within(() => {
- cy.get(".mx_ThemeSelector_light").should("exist");
- cy.get(".mx_ThemeSelector_dark").should("exist");
-
- // Assert that the light theme is selected
- cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled").should("exist");
-
- // Assert that the buttons for the light and dark theme are not enabled
- cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled").should("not.exist");
- cy.get(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled").should("not.exist");
- });
-
- // Assert that the checkbox for the high contrast theme is rendered
- cy.findByLabelText("Use high contrast").should("exist");
- });
- });
-
- it(
- "should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for " +
- "the system theme is clicked",
- () => {
- cy.openUserSettings("Appearance")
- .findByTestId("mx_ThemeChoicePanel")
- .findByLabelText("Match system theme")
- .click({ force: true }); // force click because the size of the checkbox is zero
-
- cy.findByTestId("mx_ThemeChoicePanel").within(() => {
- // Assert that the labels for the light theme and dark theme are disabled
- cy.get(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled").should("exist");
- cy.get(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled").should("exist");
-
- // Assert that there does not exist a label for an enabled theme
- cy.get("label.mx_StyledRadioButton_enabled").should("not.exist");
-
- // Assert that the checkbox and label to enable the the high contrast theme should not exist
- cy.findByLabelText("Use high contrast").should("not.exist");
- });
- },
- );
-
- it(
- "should not render the checkbox and the label for the high contrast theme " +
- "if the dark theme is selected",
- () => {
- cy.openUserSettings("Appearance");
-
- // Assert that the checkbox and the label to enable the high contrast theme should exist
- cy.findByLabelText("Use high contrast").should("exist");
-
- // Enable the dark theme
- cy.get(".mx_ThemeSelector_dark").click();
-
- // Assert that the checkbox and the label should not exist
- cy.findByLabelText("Use high contrast").should("not.exist");
- },
- );
- });
-});
diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts
deleted file mode 100644
index 06795b68be..0000000000
--- a/cypress/e2e/settings/device-management.spec.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-/*
-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 { HomeserverInstance } from "../../plugins/utils/homeserver";
-import type { UserCredentials } from "../../support/login";
-
-describe("Device manager", () => {
- let homeserver: HomeserverInstance | undefined;
- let user: UserCredentials | undefined;
-
- beforeEach(() => {
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
-
- cy.initTestUser(homeserver, "Alice")
- .then((credentials) => {
- user = credentials;
- })
- .then(() => {
- // create some extra sessions to manage
- return cy.loginUser(homeserver, user.username, user.password);
- })
- .then(() => {
- return cy.loginUser(homeserver, user.username, user.password);
- });
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver!);
- });
-
- it("should display sessions", () => {
- cy.openUserSettings("Sessions");
- cy.findByText("Current session").should("exist");
-
- cy.findByTestId("current-session-section").within(() => {
- cy.findByText("Unverified session").should("exist");
-
- // current session details opened
- cy.findByRole("button", { name: "Show details" }).click();
- cy.findByText("Session details").should("exist");
-
- // close current session details
- cy.findByRole("button", { name: "Hide details" }).click();
- cy.findByText("Session details").should("not.exist");
- });
-
- cy.findByTestId("security-recommendations-section").within(() => {
- cy.findByText("Security recommendations").should("exist");
- cy.findByRole("button", { name: "View all (3)" }).click();
- });
-
- /**
- * Other sessions section
- */
- cy.findByText("Other sessions").should("exist");
- // filter applied after clicking through from security recommendations
- cy.findByLabelText("Filter devices").should("have.text", "Show: Unverified");
- cy.get(".mx_FilteredDeviceList_list").within(() => {
- cy.get(".mx_FilteredDeviceList_listItem").should("have.length", 3);
-
- // select two sessions
- cy.get(".mx_FilteredDeviceList_listItem")
- .first()
- .within(() => {
- // force click as the input element itself is not visible (its size is zero)
- cy.findByRole("checkbox").click({ force: true });
- });
- cy.get(".mx_FilteredDeviceList_listItem")
- .last()
- .within(() => {
- // force click as the input element itself is not visible (its size is zero)
- cy.findByRole("checkbox").click({ force: true });
- });
- });
- // sign out from list selection action buttons
- cy.findByRole("button", { name: "Sign out" }).click();
- cy.get(".mx_Dialog .mx_QuestionDialog").within(() => {
- cy.findByRole("button", { name: "Sign out" }).click();
- });
- // list updated after sign out
- cy.get(".mx_FilteredDeviceList_list").find(".mx_FilteredDeviceList_listItem").should("have.length", 1);
- // security recommendation count updated
- cy.findByRole("button", { name: "View all (1)" });
-
- const sessionName = `Alice's device`;
- // open the first session
- cy.get(".mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem")
- .first()
- .within(() => {
- cy.findByRole("button", { name: "Show details" }).click();
-
- cy.findByText("Session details").should("exist");
-
- cy.findByRole("button", { name: "Rename" }).click();
- cy.findByTestId("device-rename-input").type(sessionName);
- cy.findByRole("button", { name: "Save" }).click();
- // there should be a spinner while device updates
- cy.get(".mx_Spinner").should("exist");
- // wait for spinner to complete
- cy.get(".mx_Spinner").should("not.exist");
-
- // session name updated in details
- cy.get(".mx_DeviceDetailHeading h4").within(() => {
- cy.findByText(sessionName);
- });
- // and main list item
- cy.get(".mx_DeviceTile h4").within(() => {
- cy.findByText(sessionName);
- });
-
- // sign out using the device details sign out
- cy.findByRole("button", { name: "Sign out of this session" }).click();
- });
- // confirm the signout
- cy.get(".mx_Dialog .mx_QuestionDialog").within(() => {
- cy.findByRole("button", { name: "Sign out" }).click();
- });
-
- // no other sessions or security recommendations sections when only one session
- cy.findByText("Other sessions").should("not.exist");
- cy.findByTestId("security-recommendations-section").should("not.exist");
- });
-});
diff --git a/cypress/e2e/settings/general-room-settings-tab.spec.ts b/cypress/e2e/settings/general-room-settings-tab.spec.ts
deleted file mode 100644
index 864b57edf6..0000000000
--- a/cypress/e2e/settings/general-room-settings-tab.spec.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/*
-Copyright 2023 Suguru Hirahara
-
-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";
-
-describe("General room settings tab", () => {
- let homeserver: HomeserverInstance;
- const roomName = "Test Room";
-
- beforeEach(() => {
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
- cy.initTestUser(homeserver, "Hanako");
-
- cy.createRoom({ name: roomName }).viewRoomByName(roomName);
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- it("should be rendered properly", () => {
- cy.openRoomSettings("General");
-
- // Assert that "Show less" details element is rendered
- cy.findByText("Show less").should("exist");
-
- cy.findByTestId("General").percySnapshotElement(
- "Room settings tab - General (Local addresses details area expanded)",
- {
- // Emulate TabbedView's actual min and max widths
- // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width
- // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right)
- widths: [580, 796],
- },
- );
-
- // Click the "Show less" details element
- cy.findByText("Show less").click();
-
- // Assert that "Show more" details element is rendered instead of "Show more"
- cy.findByText("Show less").should("not.exist");
- cy.findByText("Show more").should("exist");
-
- cy.findByTestId("General").percySnapshotElement(
- "Room settings tab - General (Local addresses details area collapsed)",
- {
- // Emulate TabbedView's actual min and max widths
- // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width
- // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right)
- widths: [580, 796],
- },
- );
- });
-
- it("long address should not cause dialog to overflow", () => {
- cy.openRoomSettings("General");
- // 1. Set the room-address to be a really long string
- const longString =
- "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksdnfjasdhfjh21jh3j12h3jashfcjbabbabasdbdasjh1j23hk1l2j3lamajshdjkltyiuwioeuqpirjdfmngsdnf8378234jskdfjkdfnbnsdfbasjbdjashdajshfgngnsdkfsdkkqwijeqiwjeiqhrkldfnaskldklasdn";
- cy.get("#roomAliases").within(() => {
- cy.get("input[label='Room address']").type(longString);
- cy.contains("Add").click();
- });
-
- // 2. wait for the new setting to apply ...
- cy.get("#canonicalAlias").should("have.value", `#${longString}:localhost`);
-
- // 3. Check if the dialog overflows
- cy.get(".mx_Dialog")
- .invoke("outerWidth")
- .then((dialogWidth) => {
- cy.get("#canonicalAlias")
- .invoke("outerWidth")
- .then((fieldWidth) => {
- // Assert that the width of the select element is less than that of .mx_Dialog div.
- expect(fieldWidth).to.be.lessThan(dialogWidth);
- });
- });
- });
-});
diff --git a/cypress/e2e/settings/general-user-settings-tab.spec.ts b/cypress/e2e/settings/general-user-settings-tab.spec.ts
deleted file mode 100644
index 725caf2038..0000000000
--- a/cypress/e2e/settings/general-user-settings-tab.spec.ts
+++ /dev/null
@@ -1,238 +0,0 @@
-/*
-Copyright 2023 Suguru Hirahara
-
-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";
-
-const USER_NAME = "Bob";
-const USER_NAME_NEW = "Alice";
-const IntegrationManager = "scalar.vector.im";
-
-describe("General user settings tab", () => {
- let homeserver: HomeserverInstance;
- let userId: string;
-
- beforeEach(() => {
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
- cy.initTestUser(homeserver, USER_NAME).then((user) => (userId = user.userId));
- cy.tweakConfig({ default_country_code: "US" }); // For checking the international country calling code
- });
- cy.openUserSettings("General");
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- it("should be rendered properly", () => {
- // Exclude userId from snapshots
- const percyCSS = ".mx_ProfileSettings_profile_controls_userId { visibility: hidden !important; }";
-
- cy.findByTestId("mx_GeneralUserSettingsTab").percySnapshotElement("User settings tab - General", {
- percyCSS,
- // Emulate TabbedView's actual min and max widths
- // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width
- // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right)
- widths: [580, 796],
- });
-
- cy.findByTestId("mx_GeneralUserSettingsTab").within(() => {
- // Assert that the top heading is rendered
- cy.findByText("General").should("be.visible");
-
- cy.get(".mx_ProfileSettings_profile")
- .scrollIntoView()
- .within(() => {
- // Assert USER_NAME is rendered
- cy.findByRole("textbox", { name: "Display Name" })
- .get(`input[value='${USER_NAME}']`)
- .should("be.visible");
-
- // Assert that a userId is rendered
- cy.get(".mx_ProfileSettings_profile_controls_userId").within(() => {
- cy.findByText(userId).should("exist");
- });
-
- // Check avatar setting
- cy.get(".mx_AvatarSetting_avatar")
- .should("exist")
- .realHover()
- .get(".mx_AvatarSetting_avatar_hovering")
- .within(() => {
- // Hover effect
- cy.get(".mx_AvatarSetting_hoverBg").should("exist");
- cy.get(".mx_AvatarSetting_hover span").within(() => {
- cy.findByText("Upload").should("exist");
- });
- });
- });
-
- // Wait until spinners disappear
- cy.findByTestId("accountSection").within(() => {
- cy.get(".mx_Spinner").should("not.exist");
- });
- cy.findByTestId("discoverySection").within(() => {
- cy.get(".mx_Spinner").should("not.exist");
- });
-
- cy.findByTestId("accountSection").within(() => {
- // Assert that input areas for changing a password exists
- cy.get("form.mx_GeneralUserSettingsTab_section--account_changePassword")
- .scrollIntoView()
- .within(() => {
- cy.findByLabelText("Current password").should("be.visible");
- cy.findByLabelText("New Password").should("be.visible");
- cy.findByLabelText("Confirm password").should("be.visible");
- });
- });
- // Check email addresses area
- cy.findByTestId("mx_AccountEmailAddresses")
- .scrollIntoView()
- .within(() => {
- // Assert that an input area for a new email address is rendered
- cy.findByRole("textbox", { name: "Email Address" }).should("be.visible");
-
- // Assert the add button is visible
- cy.findByRole("button", { name: "Add" }).should("be.visible");
- });
-
- // Check phone numbers area
- cy.findByTestId("mx_AccountPhoneNumbers")
- .scrollIntoView()
- .within(() => {
- // Assert that an input area for a new phone number is rendered
- cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible");
-
- // Assert that the add button is rendered
- cy.findByRole("button", { name: "Add" }).should("be.visible");
- });
-
- // Check language and region setting dropdown
- cy.get(".mx_GeneralUserSettingsTab_section_languageInput")
- .scrollIntoView()
- .within(() => {
- // Check the default value
- cy.findByText("English").should("be.visible");
-
- // Click the button to display the dropdown menu
- cy.findByRole("button", { name: "Language Dropdown" }).click();
-
- // Assert that the default option is rendered and highlighted
- cy.findByRole("option", { name: /Albanian/ })
- .should("be.visible")
- .should("have.class", "mx_Dropdown_option_highlight");
-
- cy.findByRole("option", { name: /Deutsch/ }).should("be.visible");
-
- // Click again to close the dropdown
- cy.findByRole("button", { name: "Language Dropdown" }).click();
-
- // Assert that the default value is rendered again
- cy.findByText("English").should("be.visible");
- });
-
- cy.get("form.mx_SetIdServer")
- .scrollIntoView()
- .within(() => {
- // Assert that an input area for identity server exists
- cy.findByRole("textbox", { name: "Enter a new identity server" }).should("be.visible");
- });
-
- cy.get(".mx_SetIntegrationManager")
- .scrollIntoView()
- .within(() => {
- cy.contains(".mx_SetIntegrationManager_heading_manager", IntegrationManager).should("be.visible");
-
- // Make sure integration manager's toggle switch is enabled
- cy.get(".mx_ToggleSwitch_enabled").should("be.visible");
-
- cy.get(".mx_SetIntegrationManager_heading_manager").should(
- "have.text",
- "Manage integrations(scalar.vector.im)",
- );
- });
-
- // Assert the account deactivation button is displayed
- cy.findByTestId("account-management-section")
- .scrollIntoView()
- .findByRole("button", { name: "Deactivate Account" })
- .should("be.visible")
- .should("have.class", "mx_AccessibleButton_kind_danger");
- });
- });
-
- it("should support adding and removing a profile picture", () => {
- cy.get(".mx_SettingsTab .mx_ProfileSettings").within(() => {
- // Upload a picture
- cy.get(".mx_ProfileSettings_avatarUpload").selectFile("cypress/fixtures/riot.png", { force: true });
-
- // Find and click "Remove" link button
- cy.get(".mx_ProfileSettings_profile").within(() => {
- cy.findByRole("button", { name: "Remove" }).click();
- });
-
- // Assert that the link button disappeared
- cy.get(".mx_AvatarSetting_avatar .mx_AccessibleButton_kind_link_sm").should("not.exist");
- });
- });
-
- it("should set a country calling code based on default_country_code", () => {
- // Check phone numbers area
- cy.findByTestId("mx_AccountPhoneNumbers")
- .scrollIntoView()
- .within(() => {
- // Assert that an input area for a new phone number is rendered
- cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible");
-
- // Check a new phone number dropdown menu
- cy.get(".mx_PhoneNumbers_country")
- .scrollIntoView()
- .within(() => {
- // Assert that the country calling code of United States is visible
- cy.findByText(/\+1/).should("be.visible");
-
- // Click the button to display the dropdown menu
- cy.findByRole("button", { name: "Country Dropdown" }).click();
-
- // Assert that the option for calling code of United Kingdom is visible
- cy.findByRole("option", { name: /United Kingdom/ }).should("be.visible");
-
- // Click again to close the dropdown
- cy.findByRole("button", { name: "Country Dropdown" }).click();
-
- // Assert that the default value is rendered again
- cy.findByText(/\+1/).should("be.visible");
- });
-
- cy.findByRole("button", { name: "Add" }).should("be.visible");
- });
- });
-
- it("should support changing a display name", () => {
- cy.get(".mx_SettingsTab .mx_ProfileSettings").within(() => {
- // Change the diaplay name to USER_NAME_NEW
- cy.findByRole("textbox", { name: "Display Name" }).type(`{selectAll}{del}${USER_NAME_NEW}{enter}`);
- });
-
- cy.closeDialog();
-
- // Assert the avatar's initial characters are set
- cy.get(".mx_UserMenu .mx_BaseAvatar").findByText("A").should("exist"); // Alice
- cy.get(".mx_RoomView_wrapper .mx_BaseAvatar").findByText("A").should("exist"); // Alice
- });
-});
diff --git a/cypress/e2e/settings/preferences-user-settings-tab.spec.ts b/cypress/e2e/settings/preferences-user-settings-tab.spec.ts
deleted file mode 100644
index 61f073e62c..0000000000
--- a/cypress/e2e/settings/preferences-user-settings-tab.spec.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
-Copyright 2023 Suguru Hirahara
-
-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";
-
-describe("Preferences user settings tab", () => {
- let homeserver: HomeserverInstance;
-
- beforeEach(() => {
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
- cy.initTestUser(homeserver, "Bob");
- });
- });
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- it("should be rendered properly", () => {
- cy.openUserSettings("Preferences");
-
- cy.findByTestId("mx_PreferencesUserSettingsTab").within(() => {
- // Assert that the top heading is rendered
- cy.contains("Preferences").should("be.visible");
- });
-
- cy.findByTestId("mx_PreferencesUserSettingsTab").percySnapshotElement("User settings tab - Preferences", {
- // Emulate TabbedView's actual min and max widths
- // 580: '.mx_UserSettingsDialog .mx_TabbedView' min-width
- // 796: 1036 (mx_TabbedView_tabsOnLeft actual width) - 240 (mx_TabbedView_tabPanel margin-right)
- widths: [580, 796],
- });
- });
-});
diff --git a/cypress/e2e/settings/security-user-settings-tab.spec.ts b/cypress/e2e/settings/security-user-settings-tab.spec.ts
deleted file mode 100644
index 341624dee3..0000000000
--- a/cypress/e2e/settings/security-user-settings-tab.spec.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
-Copyright 2023 Suguru Hirahara
-
-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";
-
-describe("Security user settings tab", () => {
- let homeserver: HomeserverInstance;
-
- afterEach(() => {
- cy.stopHomeserver(homeserver);
- });
-
- describe("with posthog enabled", () => {
- beforeEach(() => {
- // Enable posthog
- cy.intercept("/config.json?cachebuster=*", (req) => {
- req.continue((res) => {
- res.send(200, {
- ...res.body,
- posthog: {
- project_api_key: "foo",
- api_host: "bar",
- },
- privacy_policy_url: "example.tld", // Set privacy policy URL to enable privacyPolicyLink
- });
- });
- });
-
- cy.startHomeserver("default").then((data) => {
- homeserver = data;
- cy.initTestUser(homeserver, "Hanako");
- });
-
- // Hide "Notification" toast on Cypress Cloud
- cy.contains(".mx_Toast_toast h2", "Notifications")
- .should("exist")
- .closest(".mx_Toast_toast")
- .within(() => {
- cy.findByRole("button", { name: "Dismiss" }).click();
- });
-
- cy.get(".mx_Toast_buttons").within(() => {
- cy.findByRole("button", { name: "Yes" }).should("exist").click(); // Allow analytics
- });
-
- cy.openUserSettings("Security");
- });
-
- describe("AnalyticsLearnMoreDialog", () => {
- it("should be rendered properly", () => {
- cy.findByRole("button", { name: "Learn more" }).click();
-
- cy.get(".mx_AnalyticsLearnMoreDialog_wrapper").percySnapshotElement("AnalyticsLearnMoreDialog");
- });
- });
- });
-});
diff --git a/playwright/docker-entrypoint.sh b/playwright/docker-entrypoint.sh
index 55554a683a..4d2354dfa4 100644
--- a/playwright/docker-entrypoint.sh
+++ b/playwright/docker-entrypoint.sh
@@ -2,5 +2,7 @@
set -e
+yarn link
yarn --cwd ../element-web install
+yarn --cwd ../element-web link matrix-react-sdk
npx playwright test --update-snapshots --reporter line --project='Legacy Crypto' $1
diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts
index f05f6f3382..d8add58d81 100644
--- a/playwright/e2e/editing/editing.spec.ts
+++ b/playwright/e2e/editing/editing.spec.ts
@@ -216,7 +216,7 @@ test.describe("Editing", () => {
await app.closeDialog();
// Enable developer mode
- await app.setSettingValue("developerMode", null, SettingLevel.ACCOUNT, true);
+ await app.settings.setValue("developerMode", null, SettingLevel.ACCOUNT, true);
await clickEditedMessage(page, "Massage");
diff --git a/playwright/e2e/settings/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab.spec.ts
new file mode 100644
index 0000000000..dc834f00da
--- /dev/null
+++ b/playwright/e2e/settings/appearance-user-settings-tab.spec.ts
@@ -0,0 +1,251 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+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 { SettingLevel } from "../../../src/settings/SettingLevel";
+
+test.describe("Appearance user settings tab", () => {
+ test.use({
+ displayName: "Hanako",
+ });
+
+ test("should be rendered properly", async ({ page, user, app }) => {
+ const tab = await app.settings.openUserSettings("Appearance");
+
+ await expect(tab.getByRole("heading", { name: "Customise your appearance" })).toBeVisible();
+
+ // Click "Show advanced" link button
+ await tab.getByRole("button", { name: "Show advanced" }).click();
+
+ // Assert that "Hide advanced" link button is rendered
+ await expect(tab.getByRole("button", { name: "Hide advanced" })).toBeVisible();
+
+ await expect(tab).toHaveScreenshot();
+ });
+
+ test("should support switching layouts", async ({ page, user, app }) => {
+ // Create and view a room first
+ await app.createRoom({ name: "Test Room" });
+ await app.viewRoomByName("Test Room");
+
+ await app.settings.openUserSettings("Appearance");
+
+ const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
+
+ // Assert that the layout selected by default is "Modern"
+ await expect(
+ buttons.locator(".mx_StyledRadioButton_enabled", {
+ hasText: "Modern",
+ }),
+ ).toBeVisible();
+
+ // Assert that the room layout is set to group (modern) layout
+ await expect(page.locator(".mx_RoomView_body[data-layout='group']")).toBeVisible();
+
+ // Select the first layout
+ await buttons.first().click();
+ // Assert that the layout selected is "IRC (Experimental)"
+ await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
+
+ // Assert that the room layout is set to IRC layout
+ await expect(page.locator(".mx_RoomView_body[data-layout='irc']")).toBeVisible();
+
+ // Select the last layout
+ await buttons.last().click();
+
+ // Assert that the layout selected is "Message bubbles"
+ await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
+
+ // Assert that the room layout is set to bubble layout
+ await expect(page.locator(".mx_RoomView_body[data-layout='bubble']")).toBeVisible();
+ });
+
+ test("should support changing font size by clicking the font slider", async ({ page, app, user }) => {
+ await app.settings.openUserSettings("Appearance");
+
+ const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
+ const fontSliderSection = tab.locator(".mx_FontScalingPanel_fontSlider");
+
+ await expect(fontSliderSection.getByLabel("Font size")).toBeVisible();
+
+ const slider = fontSliderSection.getByRole("slider");
+ // Click the left position of the slider
+ await slider.click({ position: { x: 0, y: 10 } });
+
+ const MIN_FONT_SIZE = 11;
+ // Assert that the smallest font size is selected
+ await expect(fontSliderSection.locator(`input[value='${MIN_FONT_SIZE}']`)).toBeVisible();
+ await expect(
+ fontSliderSection.locator("output .mx_Slider_selection_label", { hasText: String(MIN_FONT_SIZE) }),
+ ).toBeVisible();
+
+ await expect(fontSliderSection).toHaveScreenshot(`font-slider-${MIN_FONT_SIZE}.png`);
+
+ // Click the right position of the slider
+ await slider.click({ position: { x: 572, y: 10 } });
+
+ const MAX_FONT_SIZE = 21;
+ // Assert that the largest font size is selected
+ await expect(fontSliderSection.locator(`input[value='${MAX_FONT_SIZE}']`)).toBeVisible();
+ await expect(
+ fontSliderSection.locator("output .mx_Slider_selection_label", { hasText: String(MAX_FONT_SIZE) }),
+ ).toBeVisible();
+
+ await expect(fontSliderSection).toHaveScreenshot(`font-slider-${MAX_FONT_SIZE}.png`);
+ });
+
+ test("should disable font size slider when custom font size is used", async ({ page, app, user }) => {
+ await app.settings.openUserSettings("Appearance");
+
+ const panel = page.getByTestId("mx_FontScalingPanel");
+ await panel.locator("label", { hasText: "Use custom size" }).click();
+
+ // Assert that the font slider is disabled
+ await expect(panel.locator(".mx_FontScalingPanel_fontSlider input[disabled]")).toBeVisible();
+ });
+
+ test("should support enabling compact group (modern) layout", async ({ page, app, user }) => {
+ // Create and view a room first
+ await app.createRoom({ name: "Test Room" });
+ await app.viewRoomByName("Test Room");
+
+ await app.settings.openUserSettings("Appearance");
+
+ // Click "Show advanced" link button
+ const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
+ await tab.getByRole("button", { name: "Show advanced" }).click();
+
+ await tab.locator("label", { hasText: "Use a more compact 'Modern' layout" }).click();
+
+ // Assert that the room layout is set to compact group (modern) layout
+ await expect(page.locator("#matrixchat .mx_MatrixChat_wrapper.mx_MatrixChat_useCompactLayout")).toBeVisible();
+ });
+
+ test("should disable compact group (modern) layout option on IRC layout and bubble layout", async ({
+ page,
+ app,
+ user,
+ }) => {
+ await app.settings.openUserSettings("Appearance");
+ const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
+
+ const checkDisabled = async () => {
+ await expect(tab.getByRole("checkbox", { name: "Use a more compact 'Modern' layout" })).toBeDisabled();
+ };
+
+ // Click "Show advanced" link button
+ await tab.getByRole("button", { name: "Show advanced" }).click();
+
+ const buttons = page.locator(".mx_LayoutSwitcher_RadioButton");
+
+ // Enable IRC layout
+ await buttons.first().click();
+
+ // Assert that the layout selected is "IRC (Experimental)"
+ await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "IRC (Experimental)" })).toBeVisible();
+
+ await checkDisabled();
+
+ // Enable bubble layout
+ await buttons.last().click();
+
+ // Assert that the layout selected is "IRC (Experimental)"
+ await expect(buttons.locator(".mx_StyledRadioButton_enabled", { hasText: "Message bubbles" })).toBeVisible();
+
+ await checkDisabled();
+ });
+
+ test("should support enabling system font", async ({ page, app, user }) => {
+ await app.settings.openUserSettings("Appearance");
+ const tab = page.getByTestId("mx_AppearanceUserSettingsTab");
+
+ // Click "Show advanced" link button
+ await tab.getByRole("button", { name: "Show advanced" }).click();
+
+ await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
+ await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
+
+ // Assert that the font-family value was removed
+ await expect(page.locator("body")).toHaveCSS("font-family", '""');
+ });
+
+ test.describe("Theme Choice Panel", () => {
+ test.beforeEach(async ({ app, user }) => {
+ // Disable the default theme for consistency in case ThemeWatcher automatically chooses it
+ await app.settings.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
+ });
+
+ test("should be rendered with the light theme selected", async ({ page, app }) => {
+ await app.settings.openUserSettings("Appearance");
+ const themePanel = page.getByTestId("mx_ThemeChoicePanel");
+
+ const useSystemTheme = themePanel.getByTestId("checkbox-use-system-theme");
+ await expect(useSystemTheme.getByText("Match system theme")).toBeVisible();
+ // Assert that 'Match system theme' is not checked
+ // Note that mx_Checkbox_checkmark exists and is hidden by CSS if it is not checked
+ await expect(useSystemTheme.locator(".mx_Checkbox_checkmark")).not.toBeVisible();
+
+ const selectors = themePanel.getByTestId("theme-choice-panel-selectors");
+ await expect(selectors.locator(".mx_ThemeSelector_light")).toBeVisible();
+ await expect(selectors.locator(".mx_ThemeSelector_dark")).toBeVisible();
+ // Assert that the light theme is selected
+ await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_enabled")).toBeVisible();
+ // Assert that the buttons for the light and dark theme are not enabled
+ await expect(selectors.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).not.toBeVisible();
+ await expect(selectors.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).not.toBeVisible();
+
+ // Assert that the checkbox for the high contrast theme is rendered
+ await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
+ });
+
+ test("should disable the labels for themes and the checkbox for the high contrast theme if the checkbox for the system theme is clicked", async ({
+ page,
+ app,
+ }) => {
+ await app.settings.openUserSettings("Appearance");
+ const themePanel = page.getByTestId("mx_ThemeChoicePanel");
+
+ await themePanel.locator(".mx_Checkbox", { hasText: "Match system theme" }).click();
+
+ // Assert that the labels for the light theme and dark theme are disabled
+ await expect(themePanel.locator(".mx_ThemeSelector_light.mx_StyledRadioButton_disabled")).toBeVisible();
+ await expect(themePanel.locator(".mx_ThemeSelector_dark.mx_StyledRadioButton_disabled")).toBeVisible();
+
+ // Assert that there does not exist a label for an enabled theme
+ await expect(themePanel.locator("label.mx_StyledRadioButton_enabled")).not.toBeVisible();
+
+ // Assert that the checkbox and label to enable the high contrast theme should not exist
+ await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
+ });
+
+ test("should not render the checkbox and the label for the high contrast theme if the dark theme is selected", async ({
+ page,
+ app,
+ }) => {
+ await app.settings.openUserSettings("Appearance");
+ const themePanel = page.getByTestId("mx_ThemeChoicePanel");
+
+ // Assert that the checkbox and the label to enable the high contrast theme should exist
+ await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).toBeVisible();
+
+ // Enable the dark theme
+ await themePanel.locator(".mx_ThemeSelector_dark").click();
+
+ // Assert that the checkbox and the label should not exist
+ await expect(themePanel.locator(".mx_Checkbox", { hasText: "Use high contrast" })).not.toBeVisible();
+ });
+ });
+});
diff --git a/playwright/e2e/settings/device-management.spec.ts b/playwright/e2e/settings/device-management.spec.ts
new file mode 100644
index 0000000000..b4595610b8
--- /dev/null
+++ b/playwright/e2e/settings/device-management.spec.ts
@@ -0,0 +1,105 @@
+/*
+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 { test, expect } from "../../element-web-test";
+
+test.describe("Device manager", () => {
+ test.use({
+ displayName: "Alice",
+ });
+
+ test.beforeEach(async ({ homeserver, user }) => {
+ // create 3 extra sessions to manage
+ for (let i = 0; i < 3; i++) {
+ await homeserver.loginUser(user.userId, user.password);
+ }
+ });
+
+ test("should display sessions", async ({ page, app }) => {
+ await app.settings.openUserSettings("Sessions");
+ const tab = page.locator(".mx_SettingsTab");
+
+ await expect(tab.getByText("Current session", { exact: true })).toBeVisible();
+
+ const currentSessionSection = tab.getByTestId("current-session-section");
+ await expect(currentSessionSection.getByText("Unverified session")).toBeVisible();
+
+ // current session details opened
+ await currentSessionSection.getByRole("button", { name: "Show details" }).click();
+ await expect(currentSessionSection.getByText("Session details")).toBeVisible();
+
+ // close current session details
+ await currentSessionSection.getByRole("button", { name: "Hide details" }).click();
+ await expect(currentSessionSection.getByText("Session details")).not.toBeVisible();
+
+ const securityRecommendationsSection = tab.getByTestId("security-recommendations-section");
+ await expect(securityRecommendationsSection.getByText("Security recommendations")).toBeVisible();
+ await securityRecommendationsSection.getByRole("button", { name: "View all (3)" }).click();
+
+ /**
+ * Other sessions section
+ */
+ await expect(tab.getByText("Other sessions")).toBeVisible();
+ // filter applied after clicking through from security recommendations
+ await expect(tab.getByLabel("Filter devices")).toHaveText("Show: Unverified");
+ const filteredDeviceListItems = tab.locator(".mx_FilteredDeviceList_listItem");
+ await expect(filteredDeviceListItems).toHaveCount(3);
+
+ // select two sessions
+ // force click as the input element itself is not visible (its size is zero)
+ await filteredDeviceListItems.first().click({ force: true });
+ await filteredDeviceListItems.last().click({ force: true });
+
+ // sign out from list selection action buttons
+ await tab.getByRole("button", { name: "Sign out", exact: true }).click();
+ await page.getByRole("dialog").getByTestId("dialog-primary-button").click();
+
+ // list updated after sign out
+ await expect(filteredDeviceListItems).toHaveCount(1);
+ // security recommendation count updated
+ await expect(tab.getByRole("button", { name: "View all (1)" })).toBeVisible();
+
+ const sessionName = `Alice's device`;
+ // open the first session
+ const firstSession = filteredDeviceListItems.first();
+ await firstSession.getByRole("button", { name: "Show details" }).click();
+
+ await expect(firstSession.getByText("Session details")).toBeVisible();
+
+ await firstSession.getByRole("button", { name: "Rename" }).click();
+ await firstSession.getByTestId("device-rename-input").type(sessionName);
+ await firstSession.getByRole("button", { name: "Save" }).click();
+ // there should be a spinner while device updates
+ await expect(firstSession.locator(".mx_Spinner")).toBeVisible();
+ // wait for spinner to complete
+ await expect(firstSession.locator(".mx_Spinner")).not.toBeVisible();
+
+ // session name updated in details
+ await expect(firstSession.locator(".mx_DeviceDetailHeading h4").getByText(sessionName)).toBeVisible();
+ // and main list item
+ await expect(firstSession.locator(".mx_DeviceTile h4").getByText(sessionName)).toBeVisible();
+
+ // sign out using the device details sign out
+ await firstSession.getByRole("button", { name: "Sign out of this session" }).click();
+
+ // confirm the signout
+ await page.getByRole("dialog").getByTestId("dialog-primary-button").click();
+
+ // no other sessions or security recommendations sections when only one session
+ await expect(tab.getByText("Other sessions")).not.toBeVisible();
+ await expect(tab.getByTestId("security-recommendations-section")).not.toBeVisible();
+ });
+});
diff --git a/playwright/e2e/settings/general-room-settings-tab.spec.ts b/playwright/e2e/settings/general-room-settings-tab.spec.ts
new file mode 100644
index 0000000000..6ba59bf22d
--- /dev/null
+++ b/playwright/e2e/settings/general-room-settings-tab.spec.ts
@@ -0,0 +1,63 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+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";
+
+test.describe("General room settings tab", () => {
+ const roomName = "Test Room";
+
+ test.use({
+ displayName: "Hanako",
+ });
+
+ test.beforeEach(async ({ user, app }) => {
+ await app.createRoom({ name: roomName });
+ await app.viewRoomByName(roomName);
+ });
+
+ test("should be rendered properly", async ({ page, app }) => {
+ const settings = await app.settings.openRoomSettings("General");
+
+ // Assert that "Show less" details element is rendered
+ await expect(settings.getByText("Show less")).toBeVisible();
+
+ await expect(settings).toHaveScreenshot();
+
+ // Click the "Show less" details element
+ await settings.getByText("Show less").click();
+
+ // Assert that "Show more" details element is rendered instead of "Show more"
+ await expect(settings.getByText("Show less")).not.toBeVisible();
+ await expect(settings.getByText("Show more")).toBeVisible();
+ });
+
+ test("long address should not cause dialog to overflow", async ({ page, app }) => {
+ const settings = await app.settings.openRoomSettings("General");
+ // 1. Set the room-address to be a really long string
+ const longString = "abcasdhjasjhdaj1jh1asdhasjdhajsdhjavhjksd".repeat(4);
+ await settings.locator("#roomAliases input[label='Room address']").fill(longString);
+ await settings.locator("#roomAliases").getByText("Add", { exact: true }).click();
+
+ // 2. wait for the new setting to apply ...
+ await expect(settings.locator("#canonicalAlias")).toHaveValue(`#${longString}:localhost`);
+
+ // 3. Check if the dialog overflows
+ const dialogBoundingBox = await page.locator(".mx_Dialog").boundingBox();
+ const inputBoundingBox = await settings.locator("#canonicalAlias").boundingBox();
+ // Assert that the width of the select element is less than that of .mx_Dialog div.
+ expect(inputBoundingBox.width).toBeLessThan(dialogBoundingBox.width);
+ });
+});
diff --git a/playwright/e2e/settings/general-user-settings-tab.spec.ts b/playwright/e2e/settings/general-user-settings-tab.spec.ts
new file mode 100644
index 0000000000..3f4d268533
--- /dev/null
+++ b/playwright/e2e/settings/general-user-settings-tab.spec.ts
@@ -0,0 +1,189 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+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";
+
+const USER_NAME = "Bob";
+const USER_NAME_NEW = "Alice";
+const IntegrationManager = "scalar.vector.im";
+
+test.describe("General user settings tab", () => {
+ let userId: string;
+
+ test.use({
+ displayName: USER_NAME,
+ config: {
+ default_country_code: "US", // For checking the international country calling code
+ },
+ uut: async ({ app, user }, use) => {
+ const locator = await app.settings.openUserSettings("General");
+ await use(locator);
+ },
+ });
+
+ test("should be rendered properly", async ({ uut }) => {
+ await expect(uut).toHaveScreenshot("general.png", {
+ // Exclude userId from snapshots
+ mask: [uut.locator(".mx_ProfileSettings_profile_controls > p")],
+ });
+
+ // Assert that the top heading is rendered
+ await expect(uut.getByRole("heading", { name: "General" })).toBeVisible();
+
+ const profile = uut.locator(".mx_ProfileSettings_profile");
+ await profile.scrollIntoViewIfNeeded();
+ await expect(profile.getByRole("textbox", { name: "Display Name" })).toHaveValue(USER_NAME);
+
+ // Assert that a userId is rendered
+ await expect(profile.locator(".mx_ProfileSettings_profile_controls_userId", { hasText: userId })).toBeVisible();
+
+ // Check avatar setting
+ const avatar = profile.locator(".mx_AvatarSetting_avatar");
+ await avatar.hover();
+
+ // Hover effect
+ await expect(avatar.locator(".mx_AvatarSetting_hoverBg")).toBeVisible();
+ await expect(avatar.locator(".mx_AvatarSetting_hover span").getByText("Upload")).toBeVisible();
+
+ // Wait until spinners disappear
+ await expect(uut.getByTestId("accountSection").locator(".mx_Spinner")).not.toBeVisible();
+ await expect(uut.getByTestId("discoverySection").locator(".mx_Spinner")).not.toBeVisible();
+
+ const accountSection = uut.getByTestId("accountSection");
+ // Assert that input areas for changing a password exists
+ const changePassword = accountSection.locator("form.mx_GeneralUserSettingsTab_section--account_changePassword");
+ await changePassword.scrollIntoViewIfNeeded();
+ await expect(changePassword.getByLabel("Current password")).toBeVisible();
+ await expect(changePassword.getByLabel("New Password")).toBeVisible();
+ await expect(changePassword.getByLabel("Confirm password")).toBeVisible();
+
+ // Check email addresses area
+ const emailAddresses = uut.getByTestId("mx_AccountEmailAddresses");
+ await emailAddresses.scrollIntoViewIfNeeded();
+ // Assert that an input area for a new email address is rendered
+ await expect(emailAddresses.getByRole("textbox", { name: "Email Address" })).toBeVisible();
+ // Assert the add button is visible
+ await expect(emailAddresses.getByRole("button", { name: "Add" })).toBeVisible();
+
+ // Check phone numbers area
+ const phoneNumbers = uut.getByTestId("mx_AccountPhoneNumbers");
+ await phoneNumbers.scrollIntoViewIfNeeded();
+ // Assert that an input area for a new phone number is rendered
+ await expect(phoneNumbers.getByRole("textbox", { name: "Phone Number" })).toBeVisible();
+ // Assert that the add button is rendered
+ await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
+
+ // Check language and region setting dropdown
+ const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
+ await languageInput.scrollIntoViewIfNeeded();
+ // Check the default value
+ await expect(languageInput.getByText("English")).toBeVisible();
+ // Click the button to display the dropdown menu
+ await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
+ // Assert that the default option is rendered and highlighted
+ languageInput.getByRole("option", { name: /Albanian/ });
+ await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
+ /mx_Dropdown_option_highlight/,
+ );
+ await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
+ // Click again to close the dropdown
+ await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
+ // Assert that the default value is rendered again
+ await expect(languageInput.getByText("English")).toBeVisible();
+
+ const setIdServer = uut.locator(".mx_SetIdServer");
+ await setIdServer.scrollIntoViewIfNeeded();
+ // Assert that an input area for identity server exists
+ await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
+
+ const setIntegrationManager = uut.locator(".mx_SetIntegrationManager");
+ await setIntegrationManager.scrollIntoViewIfNeeded();
+ await expect(
+ setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", { hasText: IntegrationManager }),
+ ).toBeVisible();
+ // Make sure integration manager's toggle switch is enabled
+ await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
+ await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
+ "Manage integrations(scalar.vector.im)",
+ );
+
+ // Assert the account deactivation button is displayed
+ const accountManagementSection = uut.getByTestId("account-management-section");
+ await accountManagementSection.scrollIntoViewIfNeeded();
+ await expect(accountManagementSection.getByRole("button", { name: "Deactivate Account" })).toHaveClass(
+ /mx_AccessibleButton_kind_danger/,
+ );
+ });
+
+ test("should support adding and removing a profile picture", async ({ uut }) => {
+ const profileSettings = uut.locator(".mx_ProfileSettings");
+ // Upload a picture
+ await profileSettings
+ .locator(".mx_ProfileSettings_avatarUpload")
+ .setInputFiles("playwright/sample-files/riot.png");
+
+ // Find and click "Remove" link button
+ await profileSettings.locator(".mx_ProfileSettings_profile").getByRole("button", { name: "Remove" }).click();
+
+ // Assert that the link button disappeared
+ await expect(
+ profileSettings.locator(".mx_AvatarSetting_avatar .mx_AccessibleButton_kind_link_sm"),
+ ).not.toBeVisible();
+ });
+
+ test("should set a country calling code based on default_country_code", async ({ uut }) => {
+ // Check phone numbers area
+ const accountPhoneNumbers = uut.getByTestId("mx_AccountPhoneNumbers");
+ await accountPhoneNumbers.scrollIntoViewIfNeeded();
+ // Assert that an input area for a new phone number is rendered
+ await expect(accountPhoneNumbers.getByRole("textbox", { name: "Phone Number" })).toBeVisible();
+
+ // Check a new phone number dropdown menu
+ const dropdown = accountPhoneNumbers.locator(".mx_PhoneNumbers_country");
+ await dropdown.scrollIntoViewIfNeeded();
+ // Assert that the country calling code of the United States is visible
+ await expect(dropdown.getByText(/\+1/)).toBeVisible();
+
+ // Click the button to display the dropdown menu
+ await dropdown.getByRole("button", { name: "Country Dropdown" }).click();
+
+ // Assert that the option for calling code of the United Kingdom is visible
+ await expect(dropdown.getByRole("option", { name: /United Kingdom/ })).toBeVisible();
+
+ // Click again to close the dropdown
+ await dropdown.getByRole("button", { name: "Country Dropdown" }).click();
+
+ // Assert that the default value is rendered again
+ await expect(dropdown.getByText(/\+1/)).toBeVisible();
+
+ await expect(accountPhoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
+ });
+
+ test("should support changing a display name", async ({ uut, page, app }) => {
+ // Change the diaplay name to USER_NAME_NEW
+ const displayNameInput = uut
+ .locator(".mx_SettingsTab .mx_ProfileSettings")
+ .getByRole("textbox", { name: "Display Name" });
+ await displayNameInput.fill(USER_NAME_NEW);
+ await displayNameInput.press("Enter");
+
+ await app.closeDialog();
+
+ // Assert the avatar's initial characters are set
+ await expect(page.locator(".mx_UserMenu .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice
+ await expect(page.locator(".mx_RoomView_wrapper .mx_BaseAvatar").getByText("A")).toBeVisible(); // Alice
+ });
+});
diff --git a/playwright/e2e/settings/preferences-user-settings-tab.spec.ts b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts
new file mode 100644
index 0000000000..884b62d0b1
--- /dev/null
+++ b/playwright/e2e/settings/preferences-user-settings-tab.spec.ts
@@ -0,0 +1,31 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+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";
+
+test.describe("Preferences user settings tab", () => {
+ test.use({
+ displayName: "Bob",
+ });
+
+ test("should be rendered properly", async ({ app, user }) => {
+ const tab = await app.settings.openUserSettings("Preferences");
+
+ // Assert that the top heading is rendered
+ await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
+ await expect(tab).toHaveScreenshot();
+ });
+});
diff --git a/playwright/e2e/settings/security-user-settings-tab.spec.ts b/playwright/e2e/settings/security-user-settings-tab.spec.ts
new file mode 100644
index 0000000000..5bb9131941
--- /dev/null
+++ b/playwright/e2e/settings/security-user-settings-tab.spec.ts
@@ -0,0 +1,51 @@
+/*
+Copyright 2023 Suguru Hirahara
+
+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";
+
+test.describe("Security user settings tab", () => {
+ test.describe("with posthog enabled", () => {
+ test.use({
+ displayName: "Hanako",
+ // Enable posthog
+ config: {
+ posthog: {
+ project_api_key: "foo",
+ api_host: "bar",
+ },
+ privacy_policy_url: "example.tld", // Set privacy policy URL to enable privacyPolicyLink
+ },
+ });
+
+ test.beforeEach(async ({ page, user }) => {
+ // Dismiss "Notification" toast
+ await page
+ .locator(".mx_Toast_toast", { hasText: "Notifications" })
+ .getByRole("button", { name: "Dismiss" })
+ .click();
+
+ await page.locator(".mx_Toast_buttons").getByRole("button", { name: "Yes" }).click(); // Allow analytics
+ });
+
+ test.describe("AnalyticsLearnMoreDialog", () => {
+ test("should be rendered properly", async ({ app, page }) => {
+ const tab = await app.settings.openUserSettings("Security");
+ await tab.getByRole("button", { name: "Learn more" }).click();
+ await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toHaveScreenshot();
+ });
+ });
+ });
+});
diff --git a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts
index 3c4db3f0cd..5d6570fcfe 100644
--- a/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts
+++ b/playwright/e2e/user-onboarding/user-onboarding-new.spec.ts
@@ -36,7 +36,7 @@ test.describe("User Onboarding (new user)", () => {
test("page is shown and preference exists", async ({ page, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).toHaveScreenshot();
- await app.openUserSettings("Preferences");
+ await app.settings.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible();
});
diff --git a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts b/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts
index 99bbcedd98..d9be78f349 100644
--- a/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts
+++ b/playwright/e2e/user-onboarding/user-onboarding-old.spec.ts
@@ -30,7 +30,7 @@ test.describe("User Onboarding (old user)", () => {
test("page and preference are hidden", async ({ page, user, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).not.toBeVisible();
await expect(page.locator(".mx_UserOnboardingButton")).not.toBeVisible();
- await app.openUserSettings("Preferences");
+ await app.settings.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).not.toBeVisible();
});
});
diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts
index 042af50a56..8777b84b41 100644
--- a/playwright/element-web-test.ts
+++ b/playwright/element-web-test.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { test as base, expect } from "@playwright/test";
+import { test as base, expect, Locator } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import _ from "lodash";
@@ -66,6 +66,7 @@ export const test = base.extend<
crypto: Crypto;
room?: { roomId: string };
toasts: Toasts;
+ uut?: Locator; // Unit Under Test, useful place to refer a prepared locator
}
>({
cryptoBackend: ["legacy", { option: true }],
diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts
index 359c0a54b8..de39a7bc32 100644
--- a/playwright/pages/ElementAppPage.ts
+++ b/playwright/pages/ElementAppPage.ts
@@ -17,70 +17,18 @@ limitations under the License.
import { type Locator, type Page } from "@playwright/test";
import type { IContent, ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/matrix";
-import type { SettingLevel } from "../../src/settings/SettingLevel";
+import { Settings } from "./settings";
export class ElementAppPage {
public constructor(private readonly page: Page) {}
- /**
- * Sets the value for a setting. The room ID is optional if the
- * setting is not being set for a particular room, otherwise it
- * should be supplied. The value may be null to indicate that the
- * level should no longer have an override.
- * @param {string} settingName The name of the setting to change.
- * @param {String} roomId The room ID to change the value in, may be
- * null.
- * @param {SettingLevel} level The level to change the value at.
- * @param {*} value The new value of the setting, may be null.
- * @return {Promise} Resolves when the setting has been changed.
- */
- public async setSettingValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise {
- return this.page.evaluate<
- Promise,
- {
- settingName: string;
- roomId: string | null;
- level: SettingLevel;
- value: any;
- }
- >(
- ({ settingName, roomId, level, value }) => {
- return window.mxSettingsStore.setValue(settingName, roomId, level, value);
- },
- { settingName, roomId, level, value },
- );
- }
+ public settings = new Settings(this.page);
/**
* Open the top left user menu, returning a Locator to the resulting context menu.
*/
public async openUserMenu(): Promise {
- await this.page.getByRole("button", { name: "User menu" }).click();
- const locator = this.page.locator(".mx_ContextualMenu");
- await locator.waitFor();
- return locator;
- }
-
- /**
- * Switch settings tab to the one by the given name
- * @param tab the name of the tab to switch to.
- */
- public async switchTab(tab: string): Promise {
- await this.page
- .locator(".mx_TabbedView_tabLabels")
- .locator(".mx_TabbedView_tabLabel", { hasText: tab })
- .click();
- }
-
- /**
- * Open user settings (via user menu), returns a locator to the dialog
- * @param tab the name of the tab to switch to after opening, optional.
- */
- public async openUserSettings(tab?: string): Promise {
- const locator = await this.openUserMenu();
- await locator.getByRole("menuitem", { name: "All settings", exact: true }).click();
- if (tab) await this.switchTab(tab);
- return this.page.locator(".mx_UserSettingsDialog");
+ return this.settings.openUserMenu();
}
/**
@@ -96,7 +44,7 @@ export class ElementAppPage {
* Close dialog currently open dialog
*/
public async closeDialog(): Promise {
- return this.page.getByRole("button", { name: "Close dialog", exact: true }).click();
+ return this.settings.closeDialog();
}
/**
@@ -113,6 +61,34 @@ export class ElementAppPage {
}, options);
}
+ /**
+ * Opens the given room by name. The room must be visible in the
+ * room list, but the room list may be folded horizontally, and the
+ * room may contain unread messages.
+ *
+ * @param name The exact room name to find and click on/open.
+ */
+ public async viewRoomByName(name: string): Promise {
+ // We look for the room inside the room list, which is a tree called Rooms.
+ //
+ // There are 3 cases:
+ // - the room list is folded:
+ // then the aria-label on the room tile is the name (with nothing extra)
+ // - the room list is unfolder and the room has messages:
+ // then the aria-label contains the unread count, but the title of the
+ // div inside the titleContainer equals the room name
+ // - the room list is unfolded and the room has no messages:
+ // then the aria-label is the name and so is the title of a div
+ //
+ // So by matching EITHER title=name OR aria-label=name we find this exact
+ // room in all three cases.
+ return this.page
+ .getByRole("tree", { name: "Rooms" })
+ .locator(`[title="${name}"],[aria-label="${name}"]`)
+ .first()
+ .click();
+ }
+
/**
* Get the composer element
* @param isRightPanel whether to select the right panel composer, otherwise the main timeline composer
diff --git a/playwright/pages/settings.ts b/playwright/pages/settings.ts
new file mode 100644
index 0000000000..347886a0ab
--- /dev/null
+++ b/playwright/pages/settings.ts
@@ -0,0 +1,102 @@
+/*
+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 { Locator, Page } from "@playwright/test";
+
+import type { SettingLevel } from "../../src/settings/SettingLevel";
+
+export class Settings {
+ public constructor(private readonly page: Page) {}
+
+ /**
+ * Open the top left user menu, returning a Locator to the resulting context menu.
+ */
+ public async openUserMenu(): Promise {
+ await this.page.getByRole("button", { name: "User menu" }).click();
+ const locator = this.page.locator(".mx_ContextualMenu");
+ await locator.waitFor();
+ return locator;
+ }
+
+ /**
+ * Close dialog currently open dialog
+ */
+ public async closeDialog(): Promise {
+ return this.page.getByRole("button", { name: "Close dialog", exact: true }).click();
+ }
+
+ /**
+ * Sets the value for a setting. The room ID is optional if the
+ * setting is not being set for a particular room, otherwise it
+ * should be supplied. The value may be null to indicate that the
+ * level should no longer have an override.
+ * @param {string} settingName The name of the setting to change.
+ * @param {String} roomId The room ID to change the value in, may be
+ * null.
+ * @param {SettingLevel} level The level to change the value at.
+ * @param {*} value The new value of the setting, may be null.
+ * @return {Promise} Resolves when the setting has been changed.
+ */
+ public async setValue(settingName: string, roomId: string, level: SettingLevel, value: any): Promise {
+ return this.page.evaluate<
+ Promise,
+ {
+ settingName: string;
+ roomId: string | null;
+ level: SettingLevel;
+ value: any;
+ }
+ >(
+ ({ settingName, roomId, level, value }) => {
+ return window.mxSettingsStore.setValue(settingName, roomId, level, value);
+ },
+ { settingName, roomId, level, value },
+ );
+ }
+
+ /**
+ * Switch settings tab to the one by the given name
+ * @param tab the name of the tab to switch to.
+ */
+ public async switchTab(tab: string): Promise {
+ await this.page
+ .locator(".mx_TabbedView_tabLabels")
+ .locator(".mx_TabbedView_tabLabel", { hasText: tab })
+ .click();
+ }
+
+ /**
+ * Open user settings (via user menu), returns a locator to the dialog
+ * @param tab the name of the tab to switch to after opening, optional.
+ */
+ public async openUserSettings(tab?: string): Promise {
+ const locator = await this.openUserMenu();
+ await locator.getByRole("menuitem", { name: "All settings", exact: true }).click();
+ if (tab) await this.switchTab(tab);
+ return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_UserSettingsDialog") });
+ }
+
+ /**
+ * Open room settings (via room menu), returns a locator to the dialog
+ * @param tab the name of the tab to switch to after opening, optional.
+ */
+ public async openRoomSettings(tab?: string): Promise {
+ await this.page.getByRole("main").getByRole("button", { name: "Room options", exact: true }).click();
+ await this.page.locator(".mx_RoomTile_contextMenu").getByRole("menuitem", { name: "Settings" }).click();
+ if (tab) await this.switchTab(tab);
+ return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_RoomSettingsDialog") });
+ }
+}
diff --git a/playwright/plugins/homeserver/index.ts b/playwright/plugins/homeserver/index.ts
index 39a6f639b8..bd01f0e555 100644
--- a/playwright/plugins/homeserver/index.ts
+++ b/playwright/plugins/homeserver/index.ts
@@ -31,6 +31,13 @@ export interface HomeserverInstance {
* @param displayName optional display name to set on the newly registered user
*/
registerUser(username: string, password: string, displayName?: string): Promise;
+
+ /**
+ * Logs into synapse with the given username/password
+ * @param userId login username
+ * @param password login password
+ */
+ loginUser(userId: string, password: string): Promise;
}
export interface StartHomeserverOpts {
diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts
index fc7eb5fb44..78a37d3a17 100644
--- a/playwright/plugins/homeserver/synapse/index.ts
+++ b/playwright/plugins/homeserver/synapse/index.ts
@@ -196,4 +196,27 @@ export class Synapse implements Homeserver, HomeserverInstance {
displayName,
};
}
+
+ public async loginUser(userId: string, password: string): Promise {
+ const url = `${this.config.baseUrl}/_matrix/client/v3/login`;
+ const res = await this.request.post(url, {
+ data: {
+ type: "m.login.password",
+ identifier: {
+ type: "m.id.user",
+ user: userId,
+ },
+ password: password,
+ },
+ });
+ const json = await res.json();
+
+ return {
+ password,
+ accessToken: json.access_token,
+ userId: json.user_id,
+ deviceId: json.device_id,
+ homeServer: json.home_server,
+ };
+ }
}
diff --git a/playwright/sample-files/riot.png b/playwright/sample-files/riot.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee42954c7826c78416f53af792ee022405d706df
GIT binary patch
literal 13818
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
T^B6||yQ}M
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