/* 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, IContent, ISendEventResponse, MsgType, Visibility } from "matrix-js-sdk/src/matrix"; import { expect, test } from "../../element-web-test"; import { ElementAppPage } from "../../pages/ElementAppPage"; import { SettingLevel } from "../../../src/settings/SettingLevel"; async function sendEvent(app: ElementAppPage, roomId: string): Promise { return app.client.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 { return { msgtype: "m.text" as MsgType, body: `padding ${n}`, format: "org.matrix.custom.html", formatted_body: `

Test event ${n}

\n`.repeat(10), }; } 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.client.createRoom({ name: "Test room" }); await use({ roomId }); }, botCreateOpts: { displayName: "Bob" }, }); 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).toMatchScreenshot("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.settings.setValue("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(); }); test("should correctly display events which are edited, where we lack the edit event", async ({ page, user, app, axe, checkA11y, bot: bob, }) => { // 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. // // In that scenario, we rely on the server to replace the content (pre-MSC3925), or do it ourselves based on // the bundled edit event (post-MSC3925). // // To test it, we need to have a room with lots of events in, so we can jump around the timeline without // paginating in the event itself. Hence, we create a bot user which creates the room and populates it before // we join. // "bob" now creates the room, and sends a load of events in it. Note that all of this happens via calls on // the js-sdk rather than Cypress commands, so uses regular async/await. const testRoomId = await bob.createRoom({ name: "TestRoom", visibility: "public" as Visibility }); const { event_id: originalEventId } = await bob.sendMessage(testRoomId, { body: "original", msgtype: "m.text", }); // send a load of padding events. We make them large, so that they fill the whole screen // and the client doesn't end up paginating into the event we want. let i = 0; while (i < 10) { await bob.sendMessage(testRoomId, mkPadding(i++)); } // ... then the edit ... const editEventId = ( await bob.sendMessage(testRoomId, { "m.new_content": { body: "Edited body", msgtype: "m.text" }, "m.relates_to": { rel_type: "m.replace", event_id: originalEventId, }, "body": "* edited", "msgtype": "m.text", }) ).event_id; // ... then a load more padding ... while (i < 20) { await bob.sendMessage(testRoomId, mkPadding(i++)); } // now have the cypress user join the room, jump to the original event, and wait for the event to be visible await app.client.joinRoom(testRoomId); await app.viewRoomByName("TestRoom"); await page.goto(`#/room/${testRoomId}/${originalEventId}`); const messageTile = page.locator(`[data-event-id="${originalEventId}"]`); // at this point, the edit event should still be unknown const timeline = await app.client.evaluate( (cli, { testRoomId, editEventId }) => cli.getRoom(testRoomId).getTimelineForEvent(editEventId), { testRoomId, editEventId }, ); expect(timeline).toBeNull(); // nevertheless, the event should be updated await expect(messageTile.locator(".mx_EventTile_body")).toHaveText("Edited body"); await expect(messageTile.locator(".mx_EventTile_edited")).toBeVisible(); }); });