diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 8695531a60..113da3421a 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -16,17 +16,8 @@ limitations under the License. /// -import type { EventType, MsgType, ISendEventResponse, IContent } from "matrix-js-sdk/src/matrix"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; +import type { MsgType, IContent } from "matrix-js-sdk/src/matrix"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; -import Chainable = Cypress.Chainable; - -const sendEvent = (roomId: string): Chainable => { - return cy.sendEvent(roomId, null, "m.room.message" as EventType, { - msgtype: "m.text" as MsgType, - body: "Message", - }); -}; /** generate a message event which will take up some room on the page. */ function mkPadding(n: number): IContent { @@ -40,37 +31,13 @@ function mkPadding(n: number): IContent { describe("Editing", () => { let homeserver: HomeserverInstance; - let roomId: string; - - // Edit "Message" - const editLastMessage = (edit: string) => { - cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Edit" }).click(); - cy.findByRole("textbox", { name: "Edit message" }).type(`{selectAll}{del}${edit}{enter}`); - }; - - const clickEditedMessage = (edited: string) => { - // Assert that the message was edited - cy.contains(".mx_EventTile", edited) - .should("exist") - .within(() => { - // Click to display the message edit history dialog - cy.contains(".mx_EventTile_edited", "(edited)").click(); - }); - }; - - const clickButtonViewSource = () => { - // Assert that "View Source" button is rendered and click it - cy.get(".mx_EventTile .mx_EventTile_line").realHover().findByRole("button", { name: "View Source" }).click(); - }; beforeEach(() => { cy.startHomeserver("default").then((data) => { homeserver = data; cy.initTestUser(homeserver, "Edith").then(() => { - cy.createRoom({ name: "Test room" }).then((_room1Id) => { - roomId = _room1Id; - }), - cy.injectAxe(); + cy.createRoom({ name: "Test room" }); + cy.injectAxe(); }); }); }); @@ -79,224 +46,6 @@ describe("Editing", () => { cy.stopHomeserver(homeserver); }); - it("should render and interact with the message edit history dialog", () => { - // Click the "Remove" button on the message edit history dialog - const clickButtonRemove = () => { - cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Remove" }).click(); - }; - - cy.visit("/#/room/" + roomId); - - // Send "Message" - sendEvent(roomId); - - cy.get(".mx_RoomView_MessageList").within(() => { - // Edit "Message" to "Massage" - editLastMessage("Massage"); - - // Assert that the edit label is visible - cy.get(".mx_EventTile_edited").should("be.visible"); - - clickEditedMessage("Massage"); - }); - - cy.get(".mx_Dialog").within(() => { - // Assert that the message edit history dialog is rendered - cy.get(".mx_MessageEditHistoryDialog").within(() => { - // Assert CSS styles which are difficult or cannot be detected with snapshots are applied as expected - cy.get("li").should("have.css", "clear", "both"); - cy.get(".mx_EventTile .mx_MessageTimestamp") - .should("have.css", "position", "absolute") - .should("have.css", "inset-inline-start", "0px") - .should("have.css", "text-align", "center"); - // Assert that monospace characters can fill the content line as expected - cy.get(".mx_EventTile .mx_EventTile_content").should("have.css", "margin-inline-end", "0px"); - - // Assert that zero block start padding is applied to mx_EventTile as expected - // See: .mx_EventTile on _EventTile.pcss - cy.get(".mx_EventTile").should("have.css", "padding-block-start", "0px"); - - // Assert that the date separator is rendered at the top - cy.get("li:nth-child(1) .mx_TimelineSeparator").within(() => { - cy.get("h2").within(() => { - cy.findByText("today").should("have.css", "text-transform", "capitalize"); - }); - }); - - // Assert that the edited message is rendered under the date separator - cy.get("li:nth-child(2) .mx_EventTile").within(() => { - // Assert that the edited message body consists of both deleted character and inserted character - // Above the first "e" of "Message" was replaced with "a" - cy.get(".mx_EventTile_content .mx_EventTile_body").should("have.text", "Meassage"); - - cy.get(".mx_EventTile_content .mx_EventTile_body").within(() => { - cy.get(".mx_EditHistoryMessage_deletion").within(() => { - cy.findByText("e"); - }); - cy.get(".mx_EditHistoryMessage_insertion").within(() => { - cy.findByText("a"); - }); - }); - }); - - // Assert that the original message is rendered at the bottom - cy.get("li:nth-child(3) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").within(() => { - cy.findByText("Message"); - }); - }); - }); - }); - - // Exclude timestamps from a snapshot - const percyCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; - - // Take a snapshot of the dialog - cy.get(".mx_Dialog_wrapper").percySnapshotElement("Message edit history dialog", { percyCSS }); - - cy.get(".mx_Dialog").within(() => { - cy.get(".mx_MessageEditHistoryDialog li:nth-child(2) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").should("have.text", "Meassage"); - - // Click the "Remove" button again - clickButtonRemove(); - }); - - // Do nothing and close the dialog to confirm that the message edit history dialog is rendered - cy.get(".mx_TextInputDialog").closeDialog(); - - // Assert that the message edit history dialog is rendered again after it was closed - cy.get(".mx_MessageEditHistoryDialog li:nth-child(2) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").should("have.text", "Meassage"); - - // Click the "Remove" button again - clickButtonRemove(); - }); - - // This time remove the message really - cy.get(".mx_TextInputDialog").within(() => { - cy.findByRole("textbox", { name: "Reason (optional)" }).type("This is a test."); // Reason - cy.findByRole("button", { name: "Remove" }).click(); - }); - - // Assert that the message edit history dialog is rendered again - cy.get(".mx_MessageEditHistoryDialog").within(() => { - // Assert that the date is rendered - cy.get("li:nth-child(1) .mx_TimelineSeparator").within(() => { - cy.get("h2").within(() => { - cy.findByText("today").should("have.css", "text-transform", "capitalize"); - }); - }); - - // Assert that the original message is rendered under the date on the dialog - cy.get("li:nth-child(2) .mx_EventTile").within(() => { - cy.get(".mx_EventTile_content .mx_EventTile_body").within(() => { - cy.findByText("Message"); - }); - }); - - // Assert that the edited message is gone - cy.contains(".mx_EventTile_content .mx_EventTile_body", "Meassage").should("not.exist"); - - cy.closeDialog(); - }); - }); - - // Assert that the main timeline is rendered - cy.get(".mx_RoomView_MessageList").within(() => { - cy.get(".mx_EventTile_last .mx_RedactedBody").within(() => { - // Assert that the placeholder is rendered - cy.findByText("Message deleted"); - }); - }); - }); - - it("should render 'View Source' button in developer mode on the message edit history dialog", () => { - cy.visit("/#/room/" + roomId); - - // Send "Message" - sendEvent(roomId); - - cy.get(".mx_RoomView_MessageList").within(() => { - // Edit "Message" to "Massage" - editLastMessage("Massage"); - - // Assert that the edit label is visible - cy.get(".mx_EventTile_edited").should("be.visible"); - - clickEditedMessage("Massage"); - }); - - cy.get(".mx_Dialog").within(() => { - // Assert that the original message is rendered - cy.get(".mx_MessageEditHistoryDialog li:nth-child(3)").within(() => { - // Assert that "View Source" is not rendered - cy.get(".mx_EventTile .mx_EventTile_line") - .realHover() - .findByRole("button", { name: "View Source" }) - .should("not.exist"); - }); - - cy.closeDialog(); - }); - - // Enable developer mode - cy.setSettingValue("developerMode", null, SettingLevel.ACCOUNT, true); - - cy.get(".mx_RoomView_MessageList").within(() => { - clickEditedMessage("Massage"); - }); - - cy.get(".mx_Dialog").within(() => { - // Assert that the edited message is rendered - cy.get(".mx_MessageEditHistoryDialog li:nth-child(2)").within(() => { - // Assert that "Remove" button for the original message is rendered - cy.get(".mx_EventTile .mx_EventTile_line").realHover().findByRole("button", { name: "Remove" }); - - clickButtonViewSource(); - }); - - // Assert that view source dialog is rendered and close the dialog - cy.get(".mx_ViewSource").closeDialog(); - - // Assert that the original message is rendered - cy.get(".mx_MessageEditHistoryDialog li:nth-child(3)").within(() => { - // Assert that "Remove" button for the original message does not exist - cy.get(".mx_EventTile .mx_EventTile_line") - .realHover() - .findByRole("button", { name: "Remove" }) - .should("not.exist"); - - clickButtonViewSource(); - }); - - // Assert that view source dialog is rendered and close the dialog - cy.get(".mx_ViewSource").closeDialog(); - }); - }); - - it("should close the composer when clicking save after making a change and undoing it", () => { - cy.visit("/#/room/" + roomId); - - sendEvent(roomId); - - // Edit message - cy.get(".mx_RoomView_body .mx_EventTile").within(() => { - cy.findByText("Message"); - cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Edit" }).click().checkA11y(); - cy.get(".mx_EventTile_line") - .findByRole("textbox", { name: "Edit message" }) - .type("Foo{backspace}{backspace}{backspace}{enter}") - .checkA11y(); - }); - cy.get(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]").within(() => { - cy.findByText("Message"); - }); - - // Assert that the edit composer has gone away - cy.findByRole("textbox", { name: "Edit message" }).should("not.exist"); - }); - it("should correctly display events which are edited, where we lack the edit event", () => { // This tests the behaviour when a message has been edited some time after it has been sent, and we // jump back in room history to view the event, but do not have the actual edit event. diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts new file mode 100644 index 0000000000..f05f6f3382 --- /dev/null +++ b/playwright/e2e/editing/editing.spec.ts @@ -0,0 +1,292 @@ +/* +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 { Locator, Page } from "@playwright/test"; + +import type { EventType, MsgType, ISendEventResponse } from "matrix-js-sdk/src/matrix"; +import { test, expect } from "../../element-web-test"; +import { ElementAppPage } from "../../pages/ElementAppPage"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +const sendEvent = async (app: ElementAppPage, roomId: string): Promise => { + return app.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.text" as MsgType, + body: "Message", + }); +}; + +test.describe("Editing", () => { + // Edit "Message" + const editLastMessage = async (page: Page, edit: string) => { + const eventTile = page.locator(".mx_RoomView_MessageList .mx_EventTile_last"); + await eventTile.hover(); + await eventTile.getByRole("button", { name: "Edit" }).click(); + + const textbox = page.getByRole("textbox", { name: "Edit message" }); + await textbox.fill(edit); + await textbox.press("Enter"); + }; + + const clickEditedMessage = async (page: Page, edited: string) => { + // Assert that the message was edited + const eventTile = page.locator(".mx_EventTile", { hasText: edited }); + await expect(eventTile).toBeVisible(); + // Click to display the message edit history dialog + await eventTile.getByText("(edited)").click(); + }; + + const clickButtonViewSource = async (locator: Locator) => { + const eventTile = locator.locator(".mx_EventTile_line"); + await eventTile.hover(); + // Assert that "View Source" button is rendered and click it + await eventTile.getByRole("button", { name: "View Source" }).click(); + }; + + test.use({ + displayName: "Edith", + room: async ({ user, app }, use) => { + const roomId = await app.createRoom({ name: "Test room" }); + await use({ roomId }); + }, + }); + + test("should render and interact with the message edit history dialog", async ({ page, user, app, room }) => { + // Click the "Remove" button on the message edit history dialog + const clickButtonRemove = async (locator: Locator) => { + const eventTileLine = locator.locator(".mx_EventTile_line"); + await eventTileLine.hover(); + await eventTileLine.getByRole("button", { name: "Remove" }).click(); + }; + + await page.goto(`#/room/${room.roomId}`); + + // Send "Message" + await sendEvent(app, room.roomId); + + // Edit "Message" to "Massage" + await editLastMessage(page, "Massage"); + + // Assert that the edit label is visible + await expect(page.locator(".mx_EventTile_edited")).toBeVisible(); + + await clickEditedMessage(page, "Massage"); + + // Assert that the message edit history dialog is rendered + const dialog = page.getByRole("dialog"); + const li = dialog.getByRole("listitem").last(); + // Assert CSS styles which are difficult or cannot be detected with snapshots are applied as expected + await expect(li).toHaveCSS("clear", "both"); + + const timestamp = li.locator(".mx_EventTile .mx_MessageTimestamp"); + await expect(timestamp).toHaveCSS("position", "absolute"); + await expect(timestamp).toHaveCSS("inset-inline-start", "0px"); + await expect(timestamp).toHaveCSS("text-align", "center"); + + // Assert that monospace characters can fill the content line as expected + await expect(li.locator(".mx_EventTile .mx_EventTile_content")).toHaveCSS("margin-inline-end", "0px"); + + // Assert that zero block start padding is applied to mx_EventTile as expected + // See: .mx_EventTile on _EventTile.pcss + await expect(li.locator(".mx_EventTile")).toHaveCSS("padding-block-start", "0px"); + + // Assert that the date separator is rendered at the top + await expect(dialog.getByRole("listitem").first().locator("h2", { hasText: "today" })).toHaveCSS( + "text-transform", + "capitalize", + ); + + { + // Assert that the edited message is rendered under the date separator + const tile = dialog.locator("li:nth-child(2) .mx_EventTile"); + // Assert that the edited message body consists of both deleted character and inserted character + // Above the first "e" of "Message" was replaced with "a" + await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage"); + + const body = tile.locator(".mx_EventTile_content .mx_EventTile_body"); + await expect(body.locator(".mx_EditHistoryMessage_deletion").getByText("e")).toBeVisible(); + await expect(body.locator(".mx_EditHistoryMessage_insertion").getByText("a")).toBeVisible(); + } + + // Assert that the original message is rendered at the bottom + await expect( + dialog + .locator("li:nth-child(3) .mx_EventTile") + .locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }), + ).toBeVisible(); + + // Take a snapshot of the dialog + await expect(dialog).toHaveScreenshot("message-edit-history-dialog.png", { + mask: [page.locator(".mx_MessageTimestamp")], + }); + + { + const tile = dialog.locator("li:nth-child(2) .mx_EventTile"); + await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage"); + // Click the "Remove" button again + await clickButtonRemove(tile); + } + + // Do nothing and close the dialog to confirm that the message edit history dialog is rendered + await app.closeDialog(); + + { + // Assert that the message edit history dialog is rendered again after it was closed + const tile = dialog.locator("li:nth-child(2) .mx_EventTile"); + await expect(tile.locator(".mx_EventTile_body")).toHaveText("Meassage"); + // Click the "Remove" button again + await clickButtonRemove(tile); + } + + // This time remove the message really + const textInputDialog = page.locator(".mx_TextInputDialog"); + await textInputDialog.getByRole("textbox", { name: "Reason (optional)" }).fill("This is a test."); // Reason + await textInputDialog.getByRole("button", { name: "Remove" }).click(); + + // Assert that the message edit history dialog is rendered again + const messageEditHistoryDialog = page.locator(".mx_MessageEditHistoryDialog"); + // Assert that the date is rendered + await expect( + messageEditHistoryDialog.getByRole("listitem").first().locator("h2", { hasText: "today" }), + ).toHaveCSS("text-transform", "capitalize"); + + // Assert that the original message is rendered under the date on the dialog + await expect( + messageEditHistoryDialog + .locator("li:nth-child(2) .mx_EventTile") + .locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Message" }), + ).toBeVisible(); + + // Assert that the edited message is gone + await expect( + messageEditHistoryDialog.locator(".mx_EventTile_content .mx_EventTile_body", { hasText: "Meassage" }), + ).not.toBeVisible(); + + await app.closeDialog(); + + // Assert that the redaction placeholder is rendered + await expect( + page + .locator(".mx_RoomView_MessageList") + .locator(".mx_EventTile_last .mx_RedactedBody", { hasText: "Message deleted" }), + ).toBeVisible(); + }); + + test("should render 'View Source' button in developer mode on the message edit history dialog", async ({ + page, + user, + app, + room, + }) => { + await page.goto(`#/room/${room.roomId}`); + + // Send "Message" + await sendEvent(app, room.roomId); + + // Edit "Message" to "Massage" + await editLastMessage(page, "Massage"); + + // Assert that the edit label is visible + await expect(page.locator(".mx_EventTile_edited")).toBeVisible(); + + await clickEditedMessage(page, "Massage"); + + { + const dialog = page.getByRole("dialog"); + // Assert that the original message is rendered + const li = dialog.locator("li:nth-child(3)"); + // Assert that "View Source" is not rendered + const eventLine = li.locator(".mx_EventTile_line"); + await eventLine.hover(); + await expect(eventLine.getByRole("button", { name: "View Source" })).not.toBeVisible(); + } + + await app.closeDialog(); + + // Enable developer mode + await app.setSettingValue("developerMode", null, SettingLevel.ACCOUNT, true); + + await clickEditedMessage(page, "Massage"); + + { + const dialog = page.getByRole("dialog"); + { + // Assert that the edited message is rendered + const li = dialog.locator("li:nth-child(2)"); + // Assert that "Remove" button for the original message is rendered + const line = li.locator(".mx_EventTile_line"); + await line.hover(); + await expect(line.getByRole("button", { name: "Remove" })).toBeVisible(); + await clickButtonViewSource(li); + } + + // Assert that view source dialog is rendered and close the dialog + await app.closeDialog(); + + { + // Assert that the original message is rendered + const li = dialog.locator("li:nth-child(3)"); + // Assert that "Remove" button for the original message does not exist + const line = li.locator(".mx_EventTile_line"); + await line.hover(); + await expect(line.getByRole("button", { name: "Remove" })).not.toBeVisible(); + + await clickButtonViewSource(li); + } + + // Assert that view source dialog is rendered and close the dialog + await app.closeDialog(); + } + }); + + test("should close the composer when clicking save after making a change and undoing it", async ({ + page, + user, + app, + room, + axe, + checkA11y, + }) => { + axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here + axe.exclude(".mx_Tooltip_visible"); // XXX: this is fine but would be good to fix + + await page.goto(`#/room/${room.roomId}`); + + await sendEvent(app, room.roomId); + + { + // Edit message + const tile = page.locator(".mx_RoomView_body .mx_EventTile").last(); + await expect(tile.getByText("Message", { exact: true })).toBeVisible(); + const line = tile.locator(".mx_EventTile_line"); + await line.hover(); + await line.getByRole("button", { name: "Edit" }).click(); + await checkA11y(); + const editComposer = page.getByRole("textbox", { name: "Edit message" }); + await editComposer.pressSequentially("Foo"); + await editComposer.press("Backspace"); + await editComposer.press("Backspace"); + await editComposer.press("Backspace"); + await editComposer.press("Enter"); + await checkA11y(); + } + await expect( + page.locator(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", { hasText: "Message" }), + ).toBeVisible(); + + // Assert that the edit composer has gone away + await expect(page.getByRole("textbox", { name: "Edit message" })).not.toBeVisible(); + }); +}); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 28956919b0..042af50a56 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -64,6 +64,7 @@ export const test = base.extend< app: ElementAppPage; mailhog?: { api: mailhog.API; instance: Instance }; crypto: Crypto; + room?: { roomId: string }; toasts: Toasts; } >({ diff --git a/playwright/global.d.ts b/playwright/global.d.ts index 784c09cee4..8b4a280153 100644 --- a/playwright/global.d.ts +++ b/playwright/global.d.ts @@ -16,10 +16,15 @@ limitations under the License. import { type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type SettingLevel } from "../src/settings/SettingLevel"; + declare global { interface Window { mxMatrixClientPeg: { get(): MatrixClient; }; + mxSettingsStore: { + setValue(settingName: string, roomId: string | null, level: SettingLevel, value: any): Promise; + }; } } diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts index a984ba0d00..359c0a54b8 100644 --- a/playwright/pages/ElementAppPage.ts +++ b/playwright/pages/ElementAppPage.ts @@ -15,11 +15,42 @@ limitations under the License. */ import { type Locator, type Page } from "@playwright/test"; -import { type ICreateRoomOpts } from "matrix-js-sdk/src/matrix"; + +import type { IContent, ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/matrix"; +import type { SettingLevel } from "../../src/settings/SettingLevel"; 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 }, + ); + } + /** * Open the top left user menu, returning a Locator to the resulting context menu. */ @@ -100,4 +131,32 @@ export class ElementAppPage { await composer.getByRole("button", { name: "More options", exact: true }).click(); return this.page.getByRole("menu"); } + + /** + * @param {string} roomId + * @param {string} threadId + * @param {string} eventType + * @param {Object} content + */ + public async sendEvent( + roomId: string, + threadId: string | null, + eventType: string, + content: IContent, + ): Promise { + return this.page.evaluate< + Promise, + { + roomId: string; + threadId: string | null; + eventType: string; + content: IContent; + } + >( + async ({ roomId, threadId, eventType, content }) => { + return window.mxMatrixClientPeg.get().sendEvent(roomId, threadId, eventType, content); + }, + { roomId, threadId, eventType, content }, + ); + } } diff --git a/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png new file mode 100644 index 0000000000..0135b0a66e Binary files /dev/null and b/playwright/snapshots/editing/editing.spec.ts/message-edit-history-dialog-linux.png differ