diff --git a/playwright/e2e/composer/CIDER.spec.ts b/playwright/e2e/composer/CIDER.spec.ts new file mode 100644 index 0000000000..779babdaf2 --- /dev/null +++ b/playwright/e2e/composer/CIDER.spec.ts @@ -0,0 +1,106 @@ +/* +Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { test, expect } from "../../element-web-test"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; + +const CtrlOrMeta = process.platform === "darwin" ? "Meta" : "Control"; + +test.describe("Composer", () => { + test.use({ + displayName: "Janet", + }); + + test.use({ + room: async ({ app, user }, use) => { + const roomId = await app.client.createRoom({ name: "Composing Room" }); + await app.viewRoomByName("Composing Room"); + await use({ roomId }); + }, + }); + + test.beforeEach(async ({ room }) => {}); // trigger room fixture + + test.describe("CIDER", () => { + test("sends a message when you click send or press Enter", async ({ page }) => { + const composer = page.getByRole("textbox", { name: "Send a message…" }); + + // Type a message + await composer.pressSequentially("my message 0"); + // It has not been sent yet + await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible(); + + // Click send + await page.getByRole("button", { name: "Send message" }).click(); + // It has been sent + await expect( + page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }), + ).toBeVisible(); + + // Type another and press Enter afterward + await composer.pressSequentially("my message 1"); + await composer.press("Enter"); + // It was sent + await expect( + page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }), + ).toBeVisible(); + }); + + test("can write formatted text", async ({ page }) => { + const composer = page.getByRole("textbox", { name: "Send a message…" }); + + await composer.pressSequentially("my bold"); + await composer.press(`${CtrlOrMeta}+KeyB`); + await composer.pressSequentially(" message"); + await page.getByRole("button", { name: "Send message" }).click(); + // Note: both "bold" and "message" are bold, which is probably surprising + await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible(); + }); + + test("should allow user to input emoji via graphical picker", async ({ page, app }) => { + await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); + + await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click(); + + await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker + await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message + + await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible(); + }); + + test.describe("when Control+Enter is required to send", () => { + test.beforeEach(async ({ app }) => { + await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); + }); + + test("only sends when you press Control+Enter", async ({ page }) => { + const composer = page.getByRole("textbox", { name: "Send a message…" }); + // Type a message and press Enter + await composer.pressSequentially("my message 3"); + await composer.press("Enter"); + // It has not been sent yet + await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible(); + + // Press Control+Enter + await composer.press(`${CtrlOrMeta}+Enter`); + // It was sent + await expect( + page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }), + ).toBeVisible(); + }); + }); + }); +}); diff --git a/playwright/e2e/composer/composer.spec.ts b/playwright/e2e/composer/RTE.spec.ts similarity index 80% rename from playwright/e2e/composer/composer.spec.ts rename to playwright/e2e/composer/RTE.spec.ts index e7be457f83..53599d5320 100644 --- a/playwright/e2e/composer/composer.spec.ts +++ b/playwright/e2e/composer/RTE.spec.ts @@ -34,76 +34,6 @@ test.describe("Composer", () => { test.beforeEach(async ({ room }) => {}); // trigger room fixture - test.describe("CIDER", () => { - test("sends a message when you click send or press Enter", async ({ page }) => { - const composer = page.getByRole("textbox", { name: "Send a message…" }); - - // Type a message - await composer.pressSequentially("my message 0"); - // It has not been sent yet - await expect(page.locator(".mx_EventTile_body", { hasText: "my message 0" })).not.toBeVisible(); - - // Click send - await page.getByRole("button", { name: "Send message" }).click(); - // It has been sent - await expect( - page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 0" }), - ).toBeVisible(); - - // Type another and press Enter afterward - await composer.pressSequentially("my message 1"); - await composer.press("Enter"); - // It was sent - await expect( - page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 1" }), - ).toBeVisible(); - }); - - test("can write formatted text", async ({ page }) => { - const composer = page.getByRole("textbox", { name: "Send a message…" }); - - await composer.pressSequentially("my bold"); - await composer.press(`${CtrlOrMeta}+KeyB`); - await composer.pressSequentially(" message"); - await page.getByRole("button", { name: "Send message" }).click(); - // Note: both "bold" and "message" are bold, which is probably surprising - await expect(page.locator(".mx_EventTile_body strong", { hasText: "bold message" })).toBeVisible(); - }); - - test("should allow user to input emoji via graphical picker", async ({ page, app }) => { - await app.getComposer(false).getByRole("button", { name: "Emoji" }).click(); - - await page.getByTestId("mx_EmojiPicker").locator(".mx_EmojiPicker_item", { hasText: "😇" }).click(); - - await page.locator(".mx_ContextualMenu_background").click(); // Close emoji picker - await page.getByRole("textbox", { name: "Send a message…" }).press("Enter"); // Send message - - await expect(page.locator(".mx_EventTile_body", { hasText: "😇" })).toBeVisible(); - }); - - test.describe("when Control+Enter is required to send", () => { - test.beforeEach(async ({ app }) => { - await app.settings.setValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true); - }); - - test("only sends when you press Control+Enter", async ({ page }) => { - const composer = page.getByRole("textbox", { name: "Send a message…" }); - // Type a message and press Enter - await composer.pressSequentially("my message 3"); - await composer.press("Enter"); - // It has not been sent yet - await expect(page.locator(".mx_EventTile_body", { hasText: "my message 3" })).not.toBeVisible(); - - // Press Control+Enter - await composer.press(`${CtrlOrMeta}+Enter`); - // It was sent - await expect( - page.locator(".mx_EventTile_last .mx_EventTile_body", { hasText: "my message 3" }), - ).toBeVisible(); - }); - }); - }); - test.describe("Rich text editor", () => { test.use({ labsFlags: ["feature_wysiwyg_composer"], diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts index 98f75d54e1..30301270ac 100644 --- a/playwright/e2e/crypto/crypto.spec.ts +++ b/playwright/e2e/crypto/crypto.spec.ts @@ -15,29 +15,10 @@ limitations under the License. */ import type { Page } from "@playwright/test"; -import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix"; import { expect, test } from "../../element-web-test"; -import { - copyAndContinue, - createRoom, - createSharedRoomWithUser, - doTwoWaySasVerification, - enableKeyBackup, - logIntoElement, - logOutOfElement, - sendMessageInCurrentRoom, - verifySession, - waitForVerificationRequest, -} from "./utils"; +import { autoJoin, copyAndContinue, createSharedRoomWithUser, enableKeyBackup, verify } from "./utils"; import { Bot } from "../../pages/bot"; import { ElementAppPage } from "../../pages/ElementAppPage"; -import { Client } from "../../pages/client"; -import { isDendrite } from "../../plugins/homeserver/dendrite"; - -const openRoomInfo = async (page: Page) => { - await page.getByRole("button", { name: "Room info" }).click(); - return page.locator(".mx_RightPanel"); -}; const checkDMRoom = async (page: Page) => { const body = page.locator(".mx_RoomView_body"); @@ -88,38 +69,6 @@ const bobJoin = async (page: Page, bob: Bot) => { return roomId; }; -/** configure the given MatrixClient to auto-accept any invites */ -async function autoJoin(client: Client) { - await client.evaluate((cli) => { - cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { - if (member.membership === "invite" && member.userId === cli.getUserId()) { - cli.joinRoom(member.roomId); - } - }); - }); -} - -const verify = async (page: Page, bob: Bot) => { - const bobsVerificationRequestPromise = waitForVerificationRequest(bob); - - const roomInfo = await openRoomInfo(page); - await page.locator(".mx_RightPanelTabs").getByText("People").click(); - await roomInfo.getByText("Bob").click(); - await roomInfo.getByRole("button", { name: "Verify" }).click(); - await roomInfo.getByRole("button", { name: "Start Verification" }).click(); - - // this requires creating a DM, so can take a while. Give it a longer timeout. - await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); - - const request = await bobsVerificationRequestPromise; - // the bot user races with the Element user to hit the "verify by emoji" button - const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1")); - await doTwoWaySasVerification(page, verifier); - await roomInfo.getByRole("button", { name: "They match" }).click(); - await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible(); - await roomInfo.getByRole("button", { name: "Got it" }).click(); -}; - test.describe("Cryptography", function () { test.use({ displayName: "Alice", @@ -299,509 +248,4 @@ test.describe("Cryptography", function () { await createSharedRoomWithUser(app, bob.credentials.userId); await verify(page, bob); }); - - test.describe("event shields", () => { - let testRoomId: string; - - test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => { - await app.client.bootstrapCrossSigning(aliceCredentials); - await autoJoin(bob); - - // create an encrypted room - testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { - name: "TestRoom", - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); - }); - - test("should show the correct shield on e2e events", async ({ page, app, bot: bob, homeserver }) => { - // Bob has a second, not cross-signed, device - const bobSecondDevice = new Bot(page, homeserver, { - bootstrapSecretStorage: false, - bootstrapCrossSigning: false, - }); - bobSecondDevice.setCredentials( - await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), - ); - await bobSecondDevice.prepareClient(); - - await bob.sendEvent(testRoomId, null, "m.room.encrypted", { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "the bird is in the hand", - }); - - const last = page.locator(".mx_EventTile_last"); - await expect(last).toContainText("Unable to decrypt message"); - const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); - await lastE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("This message could not be decrypted"); - - /* Should show a red padlock for an unencrypted message in an e2e room */ - await bob.evaluate( - (cli, testRoomId) => - cli.http.authedRequest( - window.matrixcs.Method.Put, - `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, - undefined, - { - msgtype: "m.text", - body: "test unencrypted", - }, - ), - testRoomId, - ); - - await expect(last).toContainText("test unencrypted"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("Not encrypted"); - - /* Should show no padlock for an unverified user */ - // bob sends a valid event - await bob.sendMessage(testRoomId, "test encrypted 1"); - - // the message should appear, decrypted, with no warning, but also no "verified" - const lastTile = page.locator(".mx_EventTile_last"); - const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); - await expect(lastTile).toContainText("test encrypted 1"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); - - /* Now verify Bob */ - await verify(page, bob); - - /* Existing message should be updated when user is verified. */ - await expect(last).toContainText("test encrypted 1"); - // still no e2e icon - await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); - - /* should show no padlock, and be verified, for a message from a verified device */ - await bob.sendMessage(testRoomId, "test encrypted 2"); - - await expect(lastTile).toContainText("test encrypted 2"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); - - /* should show red padlock for a message from an unverified device */ - await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); - await expect(lastTile).toContainText("test encrypted from unverified"); - await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastTileE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner."); - - /* Should show a grey padlock for a message from an unknown device */ - // bob deletes his second device - await bobSecondDevice.evaluate((cli) => cli.logout(true)); - - // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. - async function awaitOneDevice(iterations = 1) { - const rightPanel = page.locator(".mx_RightPanel"); - await rightPanel.getByRole("button", { name: "Room members" }).click(); - await rightPanel.getByText("Bob").click(); - const sessionCountText = await rightPanel - .locator(".mx_UserInfo_devices") - .getByText(" session", { exact: false }) - .textContent(); - // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here - if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { - if (iterations >= 10) { - throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); - } - await awaitOneDevice(iterations + 1); - } - } - - await awaitOneDevice(); - - // close and reopen the room, to get the shield to update. - await app.viewRoomByName("Bob"); - await app.viewRoomByName("TestRoom"); - - await expect(last).toContainText("test encrypted from unverified"); - await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); - await lastE2eIcon.focus(); - await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device."); - }); - - test("Should show a grey padlock for a key restored from backup", async ({ - page, - app, - bot: bob, - homeserver, - user: aliceCredentials, - }) => { - test.slow(); - const securityKey = await enableKeyBackup(app); - - // bob sends a valid event - await bob.sendMessage(testRoomId, "test encrypted 1"); - - const lastTile = page.locator(".mx_EventTile_last"); - const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); - await expect(lastTile).toContainText("test encrypted 1"); - // no e2e icon - await expect(lastTileE2eIcon).not.toBeVisible(); - - // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for - // the key to be backed up. - await page.waitForTimeout(10000); - - /* log out, and back in */ - await logOutOfElement(page); - // Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout - // https://github.com/element-hq/element-web/issues/25779 - await page.addInitScript(() => { - // When we reload, the initScript created by the `user`/`pageWithCredentials` fixtures - // will re-inject the original credentials into localStorage, which we don't want. - // To work around, we add a second initScript which will clear localStorage again. - window.localStorage.clear(); - }); - await page.reload(); - await logIntoElement(page, homeserver, aliceCredentials, securityKey); - - /* go back to the test room and find Bob's message again */ - await app.viewRoomById(testRoomId); - await expect(lastTile).toContainText("test encrypted 1"); - // The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning. - // No shield would have no div mx_EventTile_e2eIcon at all. - await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/); - await lastTileE2eIcon.hover(); - // The key is coming from backup, so it is not anymore possible to establish if the claimed device - // creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device." - // It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted. - await expect(page.getByRole("tooltip")).toContainText( - "The authenticity of this encrypted message can't be guaranteed on this device.", - ); - }); - - test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => { - // bob has a second, not cross-signed, device - const bobSecondDevice = new Bot(page, homeserver, { - bootstrapSecretStorage: false, - bootstrapCrossSigning: false, - }); - bobSecondDevice.setCredentials( - await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), - ); - await bobSecondDevice.prepareClient(); - - // verify Bob - await verify(page, bob); - - // bob sends a valid event - const testEvent = await bob.sendMessage(testRoomId, "Hoo!"); - - // the message should appear, decrypted, with no warning - await expect( - page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"), - ).not.toBeVisible(); - - // bob sends an edit to the first message with his unverified device - await bobSecondDevice.sendMessage(testRoomId, { - "m.new_content": { - msgtype: "m.text", - body: "Haa!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - - // the edit should have a warning - await expect( - page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"), - ).toBeVisible(); - - // a second edit from the verified device should be ok - await bob.sendMessage(testRoomId, { - "m.new_content": { - msgtype: "m.text", - body: "Hee!", - }, - "m.relates_to": { - rel_type: "m.replace", - event_id: testEvent.event_id, - }, - }); - - await expect( - page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"), - ).not.toBeVisible(); - }); - }); - - test.describe("decryption failure messages", () => { - test("should handle device-relative historical messages", async ({ - homeserver, - page, - app, - credentials, - user, - }) => { - test.setTimeout(60000); - - // Start with a logged-in session, without key backup, and send a message. - await createRoom(page, "Test room", true); - await sendMessageInCurrentRoom(page, "test test"); - - // Log out, discarding the key for the sent message. - await logOutOfElement(page, true); - - // Log in again, and see how the message looks. - await logIntoElement(page, homeserver, credentials); - await app.viewRoomByName("Test room"); - const lastTile = page.locator(".mx_EventTile").last(); - await expect(lastTile).toContainText("Historical messages are not available on this device"); - await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - - // Now, we set up key backup, and then send another message. - const secretStorageKey = await enableKeyBackup(app); - await app.viewRoomByName("Test room"); - await sendMessageInCurrentRoom(page, "test2 test2"); - - // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for - // the key to be backed up. - await page.waitForTimeout(10000); - - // Finally, log out again, and back in, skipping verification for now, and see what we see. - await logOutOfElement(page); - await logIntoElement(page, homeserver, credentials); - await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click(); - await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); - await app.viewRoomByName("Test room"); - - // There should be two historical events in the timeline - const tiles = await page.locator(".mx_EventTile").all(); - expect(tiles.length).toBeGreaterThanOrEqual(2); - // look at the last two tiles only - for (const tile of tiles.slice(-2)) { - await expect(tile).toContainText("You need to verify this device for access to historical messages"); - await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - } - - // Now verify our device (setting up key backup), and check what happens - await verifySession(app, secretStorageKey); - const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2); - - // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though. - await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message"); - await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - - // The second message should now be decrypted, with a grey shield - await expect(tilesAfterVerify[1]).toContainText("test2 test2"); - await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible(); - }); - - test.describe("non-joined historical messages", () => { - test.skip(isDendrite, "does not yet support membership on events"); - - test("should display undecryptable non-joined historical messages with a different message", async ({ - homeserver, - page, - app, - credentials: aliceCredentials, - user: alice, - bot: bob, - }) => { - // Bob creates an encrypted room and sends a message to it. He then invites Alice - const roomId = await bob.evaluate( - async (client, { alice }) => { - const encryptionStatePromise = new Promise((resolve) => { - client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => { - if (event.getType() === "m.room.encryption") { - resolve(); - } - }); - }); - - const { room_id: roomId } = await client.createRoom({ - initial_state: [ - { - type: "m.room.encryption", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - name: "Test room", - preset: "private_chat" as Preset, - }); - - // wait for m.room.encryption event, so that when we send a - // message, it will be encrypted - await encryptionStatePromise; - - await client.sendTextMessage(roomId, "This should be undecryptable"); - - await client.invite(roomId, alice.userId); - - return roomId; - }, - { alice }, - ); - - // Alice accepts the invite - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(1); - await page.getByRole("treeitem", { name: "Test room" }).click(); - await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); - - // Bob sends an encrypted event and an undecryptable event - await bob.evaluate( - async (client, { roomId }) => { - await client.sendTextMessage(roomId, "This should be decryptable"); - await client.sendEvent( - roomId, - "m.room.encrypted" as any, - { - algorithm: "m.megolm.v1.aes-sha2", - ciphertext: "this+message+will+be+undecryptable", - device_id: client.getDeviceId()!, - sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519, - session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - } as any, - ); - }, - { roomId }, - ); - - // We wait for the event tiles that we expect from the messages that - // Bob sent, in sequence. - await expect( - page.locator(`.mx_EventTile`).getByText("You don't have access to this message"), - ).toBeVisible(); - await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); - await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible(); - - // And then we ensure that they are where we expect them to be - // Alice should see these event tiles: - // - first message sent by Bob (undecryptable) - // - Bob invited Alice - // - Alice joined the room - // - second message sent by Bob (decryptable) - // - third message sent by Bob (undecryptable) - const tiles = await page.locator(".mx_EventTile").all(); - expect(tiles.length).toBeGreaterThanOrEqual(5); - - // The first message from Bob was sent before Alice was in the room, so should - // be different from the standard UTD message - await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message"); - await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - - // The second message from Bob should be decryptable - await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable"); - // this tile won't have an e2e icon since we got the key from the sender - - // The third message from Bob is undecryptable, but was sent while Alice was - // in the room and is expected to be decryptable, so this should have the - // standard UTD message - await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message"); - await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); - }); - - test("should be able to jump to a message sent before our last join event", async ({ - homeserver, - page, - app, - credentials: aliceCredentials, - user: alice, - bot: bob, - }) => { - // Bob: - // - creates an encrypted room, - // - invites Alice, - // - sends a message to it, - // - kicks Alice, - // - sends a bunch more events - // - invites Alice again - // In this way, there will be an event that Alice can decrypt, - // followed by a bunch of undecryptable events which Alice shouldn't - // expect to be able to decrypt. The old code would have hidden all - // the events, even the decryptable event (which it wouldn't have - // even tried to fetch, if it was far enough back). - const { roomId, eventId } = await bob.evaluate( - async (client, { alice }) => { - const { room_id: roomId } = await client.createRoom({ - initial_state: [ - { - type: "m.room.encryption", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - name: "Test room", - preset: "private_chat" as Preset, - }); - - // invite Alice - const inviteAlicePromise = new Promise((resolve) => { - client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { - if (member.userId === alice.userId && member.membership === "invite") { - resolve(); - } - }); - }); - await client.invite(roomId, alice.userId); - // wait for the invite to come back so that we encrypt to Alice - await inviteAlicePromise; - - // send a message that Alice should be able to decrypt - const { event_id: eventId } = await client.sendTextMessage( - roomId, - "This should be decryptable", - ); - - // kick Alice - const kickAlicePromise = new Promise((resolve) => { - client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { - if (member.userId === alice.userId && member.membership === "leave") { - resolve(); - } - }); - }); - await client.kick(roomId, alice.userId); - await kickAlicePromise; - - // send a bunch of messages that Alice won't be able to decrypt - for (let i = 0; i < 20; i++) { - await client.sendTextMessage(roomId, `${i}`); - } - - // invite Alice again - await client.invite(roomId, alice.userId); - - return { roomId, eventId }; - }, - { alice }, - ); - - // Alice accepts the invite - await expect( - page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), - ).toHaveCount(1); - await page.getByRole("treeitem", { name: "Test room" }).click(); - await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); - - // wait until we're joined and see the timeline - await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible(); - - // we should be able to jump to the decryptable message that Bob sent - await page.goto(`#/room/${roomId}/${eventId}`); - - await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); - }); - }); - }); }); diff --git a/playwright/e2e/crypto/decryption-failure-messages.spec.ts b/playwright/e2e/crypto/decryption-failure-messages.spec.ts new file mode 100644 index 0000000000..bcefa947ad --- /dev/null +++ b/playwright/e2e/crypto/decryption-failure-messages.spec.ts @@ -0,0 +1,302 @@ +/* +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { EmittedEvents, Preset } from "matrix-js-sdk/src/matrix"; +import { expect, test } from "../../element-web-test"; +import { + createRoom, + enableKeyBackup, + logIntoElement, + logOutOfElement, + sendMessageInCurrentRoom, + verifySession, +} from "./utils"; +import { isDendrite } from "../../plugins/homeserver/dendrite"; + +test.describe("Cryptography", function () { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + autoAcceptInvites: false, + }, + }); + + test.describe("decryption failure messages", () => { + test("should handle device-relative historical messages", async ({ + homeserver, + page, + app, + credentials, + user, + }) => { + test.setTimeout(60000); + + // Start with a logged-in session, without key backup, and send a message. + await createRoom(page, "Test room", true); + await sendMessageInCurrentRoom(page, "test test"); + + // Log out, discarding the key for the sent message. + await logOutOfElement(page, true); + + // Log in again, and see how the message looks. + await logIntoElement(page, homeserver, credentials); + await app.viewRoomByName("Test room"); + const lastTile = page.locator(".mx_EventTile").last(); + await expect(lastTile).toContainText("Historical messages are not available on this device"); + await expect(lastTile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // Now, we set up key backup, and then send another message. + const secretStorageKey = await enableKeyBackup(app); + await app.viewRoomByName("Test room"); + await sendMessageInCurrentRoom(page, "test2 test2"); + + // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for + // the key to be backed up. + await page.waitForTimeout(10000); + + // Finally, log out again, and back in, skipping verification for now, and see what we see. + await logOutOfElement(page); + await logIntoElement(page, homeserver, credentials); + await page.locator(".mx_AuthPage").getByRole("button", { name: "Skip verification for now" }).click(); + await page.locator(".mx_AuthPage").getByRole("button", { name: "I'll verify later" }).click(); + await app.viewRoomByName("Test room"); + + // There should be two historical events in the timeline + const tiles = await page.locator(".mx_EventTile").all(); + expect(tiles.length).toBeGreaterThanOrEqual(2); + // look at the last two tiles only + for (const tile of tiles.slice(-2)) { + await expect(tile).toContainText("You need to verify this device for access to historical messages"); + await expect(tile.locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + } + + // Now verify our device (setting up key backup), and check what happens + await verifySession(app, secretStorageKey); + const tilesAfterVerify = (await page.locator(".mx_EventTile").all()).slice(-2); + + // The first message still cannot be decrypted, because it was never backed up. It's now a regular UTD though. + await expect(tilesAfterVerify[0]).toContainText("Unable to decrypt message"); + await expect(tilesAfterVerify[0].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // The second message should now be decrypted, with a grey shield + await expect(tilesAfterVerify[1]).toContainText("test2 test2"); + await expect(tilesAfterVerify[1].locator(".mx_EventTile_e2eIcon_normal")).toBeVisible(); + }); + + test.describe("non-joined historical messages", () => { + test.skip(isDendrite, "does not yet support membership on events"); + + test("should display undecryptable non-joined historical messages with a different message", async ({ + homeserver, + page, + app, + credentials: aliceCredentials, + user: alice, + bot: bob, + }) => { + // Bob creates an encrypted room and sends a message to it. He then invites Alice + const roomId = await bob.evaluate( + async (client, { alice }) => { + const encryptionStatePromise = new Promise((resolve) => { + client.on("RoomState.events" as EmittedEvents, (event, _state, _lastStateEvent) => { + if (event.getType() === "m.room.encryption") { + resolve(); + } + }); + }); + + const { room_id: roomId } = await client.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + name: "Test room", + preset: "private_chat" as Preset, + }); + + // wait for m.room.encryption event, so that when we send a + // message, it will be encrypted + await encryptionStatePromise; + + await client.sendTextMessage(roomId, "This should be undecryptable"); + + await client.invite(roomId, alice.userId); + + return roomId; + }, + { alice }, + ); + + // Alice accepts the invite + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(1); + await page.getByRole("treeitem", { name: "Test room" }).click(); + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + // Bob sends an encrypted event and an undecryptable event + await bob.evaluate( + async (client, { roomId }) => { + await client.sendTextMessage(roomId, "This should be decryptable"); + await client.sendEvent( + roomId, + "m.room.encrypted" as any, + { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "this+message+will+be+undecryptable", + device_id: client.getDeviceId()!, + sender_key: (await client.getCrypto()!.getOwnDeviceKeys()).ed25519, + session_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } as any, + ); + }, + { roomId }, + ); + + // We wait for the event tiles that we expect from the messages that + // Bob sent, in sequence. + await expect( + page.locator(`.mx_EventTile`).getByText("You don't have access to this message"), + ).toBeVisible(); + await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); + await expect(page.locator(`.mx_EventTile`).getByText("Unable to decrypt message")).toBeVisible(); + + // And then we ensure that they are where we expect them to be + // Alice should see these event tiles: + // - first message sent by Bob (undecryptable) + // - Bob invited Alice + // - Alice joined the room + // - second message sent by Bob (decryptable) + // - third message sent by Bob (undecryptable) + const tiles = await page.locator(".mx_EventTile").all(); + expect(tiles.length).toBeGreaterThanOrEqual(5); + + // The first message from Bob was sent before Alice was in the room, so should + // be different from the standard UTD message + await expect(tiles[tiles.length - 5]).toContainText("You don't have access to this message"); + await expect(tiles[tiles.length - 5].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + + // The second message from Bob should be decryptable + await expect(tiles[tiles.length - 2]).toContainText("This should be decryptable"); + // this tile won't have an e2e icon since we got the key from the sender + + // The third message from Bob is undecryptable, but was sent while Alice was + // in the room and is expected to be decryptable, so this should have the + // standard UTD message + await expect(tiles[tiles.length - 1]).toContainText("Unable to decrypt message"); + await expect(tiles[tiles.length - 1].locator(".mx_EventTile_e2eIcon_decryption_failure")).toBeVisible(); + }); + + test("should be able to jump to a message sent before our last join event", async ({ + homeserver, + page, + app, + credentials: aliceCredentials, + user: alice, + bot: bob, + }) => { + // Bob: + // - creates an encrypted room, + // - invites Alice, + // - sends a message to it, + // - kicks Alice, + // - sends a bunch more events + // - invites Alice again + // In this way, there will be an event that Alice can decrypt, + // followed by a bunch of undecryptable events which Alice shouldn't + // expect to be able to decrypt. The old code would have hidden all + // the events, even the decryptable event (which it wouldn't have + // even tried to fetch, if it was far enough back). + const { roomId, eventId } = await bob.evaluate( + async (client, { alice }) => { + const { room_id: roomId } = await client.createRoom({ + initial_state: [ + { + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + name: "Test room", + preset: "private_chat" as Preset, + }); + + // invite Alice + const inviteAlicePromise = new Promise((resolve) => { + client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { + if (member.userId === alice.userId && member.membership === "invite") { + resolve(); + } + }); + }); + await client.invite(roomId, alice.userId); + // wait for the invite to come back so that we encrypt to Alice + await inviteAlicePromise; + + // send a message that Alice should be able to decrypt + const { event_id: eventId } = await client.sendTextMessage( + roomId, + "This should be decryptable", + ); + + // kick Alice + const kickAlicePromise = new Promise((resolve) => { + client.on("RoomMember.membership" as EmittedEvents, (_event, member, _oldMembership?) => { + if (member.userId === alice.userId && member.membership === "leave") { + resolve(); + } + }); + }); + await client.kick(roomId, alice.userId); + await kickAlicePromise; + + // send a bunch of messages that Alice won't be able to decrypt + for (let i = 0; i < 20; i++) { + await client.sendTextMessage(roomId, `${i}`); + } + + // invite Alice again + await client.invite(roomId, alice.userId); + + return { roomId, eventId }; + }, + { alice }, + ); + + // Alice accepts the invite + await expect( + page.getByRole("group", { name: "Invites" }).locator(".mx_RoomSublist_tiles").getByRole("treeitem"), + ).toHaveCount(1); + await page.getByRole("treeitem", { name: "Test room" }).click(); + await page.locator(".mx_RoomView").getByRole("button", { name: "Accept" }).click(); + + // wait until we're joined and see the timeline + await expect(page.locator(`.mx_EventTile`).getByText("Alice joined the room")).toBeVisible(); + + // we should be able to jump to the decryptable message that Bob sent + await page.goto(`#/room/${roomId}/${eventId}`); + + await expect(page.locator(`.mx_EventTile`).getByText("This should be decryptable")).toBeVisible(); + }); + }); + }); +}); diff --git a/playwright/e2e/crypto/verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts similarity index 67% rename from playwright/e2e/crypto/verification.spec.ts rename to playwright/e2e/crypto/device-verification.spec.ts index 167c302b47..929da09106 100644 --- a/playwright/e2e/crypto/verification.spec.ts +++ b/playwright/e2e/crypto/device-verification.spec.ts @@ -15,19 +15,18 @@ limitations under the License. */ import jsQR from "jsqr"; -import { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; import type { JSHandle, Locator, Page } from "@playwright/test"; -import type { VerificationRequest, Verifier } from "matrix-js-sdk/src/crypto-api"; +import type { VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { test, expect } from "../../element-web-test"; import { + awaitVerifier, checkDeviceIsConnectedKeyBackup, checkDeviceIsCrossSigned, doTwoWaySasVerification, logIntoElement, waitForVerificationRequest, } from "./utils"; -import { Client } from "../../pages/client"; import { Bot } from "../../pages/bot"; test.describe("Device verification", () => { @@ -235,112 +234,6 @@ test.describe("Device verification", () => { }); }); -test.describe("User verification", () => { - // note that there are other tests that check user verification works in `crypto.spec.ts`. - - test.use({ - displayName: "Alice", - botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, - room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { - await app.client.bootstrapCrossSigning(aliceCredentials); - - // the other user creates a DM - const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); - - // accept the DM - await app.viewRoomByName("Bob"); - await page.getByRole("button", { name: "Start chatting" }).click(); - await use({ roomId: dmRoomId }); - }, - }); - - test("can receive a verification request when there is no existing DM", async ({ - page, - bot: bob, - user: aliceCredentials, - toasts, - room: { roomId: dmRoomId }, - }) => { - // once Alice has joined, Bob starts the verification - const bobVerificationRequest = await bob.evaluateHandle( - async (client, { dmRoomId, aliceCredentials }) => { - const room = client.getRoom(dmRoomId); - while (room.getMember(aliceCredentials.userId)?.membership !== "join") { - await new Promise((resolve) => { - room.once(window.matrixcs.RoomStateEvent.Members, resolve); - }); - } - - return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); - }, - { dmRoomId, aliceCredentials }, - ); - - // there should also be a toast - const toast = await toasts.getToast("Verification requested"); - // it should contain the details of the requesting user - await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); - // Accept - await toast.getByRole("button", { name: "Verify User" }).click(); - - // request verification by emoji - await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); - - /* on the bot side, wait for the verifier to exist ... */ - const botVerifier = await awaitVerifier(bobVerificationRequest); - // ... confirm ... - botVerifier.evaluate((verifier) => verifier.verify()); - // ... and then check the emoji match - await doTwoWaySasVerification(page, botVerifier); - - await page.getByRole("button", { name: "They match" }).click(); - await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); - await page.getByRole("button", { name: "Got it" }).click(); - }); - - test("can abort emoji verification when emoji mismatch", async ({ - page, - bot: bob, - user: aliceCredentials, - toasts, - room: { roomId: dmRoomId }, - }) => { - // once Alice has joined, Bob starts the verification - const bobVerificationRequest = await bob.evaluateHandle( - async (client, { dmRoomId, aliceCredentials }) => { - const room = client.getRoom(dmRoomId); - while (room.getMember(aliceCredentials.userId)?.membership !== "join") { - await new Promise((resolve) => { - room.once(window.matrixcs.RoomStateEvent.Members, resolve); - }); - } - - return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); - }, - { dmRoomId, aliceCredentials }, - ); - - // Accept verification via toast - const toast = await toasts.getToast("Verification requested"); - await toast.getByRole("button", { name: "Verify User" }).click(); - - // request verification by emoji - await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); - - /* on the bot side, wait for the verifier to exist ... */ - const botVerifier = await awaitVerifier(bobVerificationRequest); - // ... confirm ... - botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {}); - // ... and abort the verification - await page.getByRole("button", { name: "They don't match" }).click(); - - const dialog = page.locator(".mx_Dialog"); - await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); - await dialog.getByRole("button", { name: "OK" }).click(); - await expect(dialog).not.toBeVisible(); - }); -}); - /** Extract the qrcode out of an on-screen html element */ async function readQrCode(base: Locator) { const qrCode = base.locator('[alt="QR Code"]'); @@ -372,35 +265,3 @@ async function readQrCode(base: Locator) { const result = jsQR(new Uint8ClampedArray(imageData.buffer), imageData.width, imageData.height); return new Uint8Array(result.binaryData); } - -async function createDMRoom(client: Client, userId: string): Promise { - return client.createRoom({ - preset: "trusted_private_chat" as Preset, - visibility: "private" as Visibility, - invite: [userId], - is_direct: true, - initial_state: [ - { - type: "m.room.encryption", - state_key: "", - content: { - algorithm: "m.megolm.v1.aes-sha2", - }, - }, - ], - }); -} - -/** - * Wait for a verifier to exist for a VerificationRequest - * - * @param botVerificationRequest - */ -async function awaitVerifier(botVerificationRequest: JSHandle): Promise> { - return botVerificationRequest.evaluateHandle(async (verificationRequest) => { - while (!verificationRequest.verifier) { - await new Promise((r) => verificationRequest.once("change" as any, r)); - } - return verificationRequest.verifier; - }); -} diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts new file mode 100644 index 0000000000..0de678c17c --- /dev/null +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -0,0 +1,269 @@ +/* +Copyright 2022-2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect, test } from "../../element-web-test"; +import { autoJoin, createSharedRoomWithUser, enableKeyBackup, logIntoElement, logOutOfElement, verify } from "./utils"; +import { Bot } from "../../pages/bot"; + +test.describe("Cryptography", function () { + test.use({ + displayName: "Alice", + botCreateOpts: { + displayName: "Bob", + autoAcceptInvites: false, + }, + }); + + test.describe("event shields", () => { + let testRoomId: string; + + test.beforeEach(async ({ page, bot: bob, user: aliceCredentials, app }) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + await autoJoin(bob); + + // create an encrypted room + testRoomId = await createSharedRoomWithUser(app, bob.credentials.userId, { + name: "TestRoom", + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); + }); + + test("should show the correct shield on e2e events", async ({ page, app, bot: bob, homeserver }) => { + // Bob has a second, not cross-signed, device + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: false, + bootstrapCrossSigning: false, + }); + bobSecondDevice.setCredentials( + await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), + ); + await bobSecondDevice.prepareClient(); + + await bob.sendEvent(testRoomId, null, "m.room.encrypted", { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "the bird is in the hand", + }); + + const last = page.locator(".mx_EventTile_last"); + await expect(last).toContainText("Unable to decrypt message"); + const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_decryption_failure/); + await lastE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("This message could not be decrypted"); + + /* Should show a red padlock for an unencrypted message in an e2e room */ + await bob.evaluate( + (cli, testRoomId) => + cli.http.authedRequest( + window.matrixcs.Method.Put, + `/rooms/${encodeURIComponent(testRoomId)}/send/m.room.message/test_txn_1`, + undefined, + { + msgtype: "m.text", + body: "test unencrypted", + }, + ), + testRoomId, + ); + + await expect(last).toContainText("test unencrypted"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("Not encrypted"); + + /* Should show no padlock for an unverified user */ + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); + + // the message should appear, decrypted, with no warning, but also no "verified" + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + /* Now verify Bob */ + await verify(page, bob); + + /* Existing message should be updated when user is verified. */ + await expect(last).toContainText("test encrypted 1"); + // still no e2e icon + await expect(last.locator(".mx_EventTile_e2eIcon")).not.toBeVisible(); + + /* should show no padlock, and be verified, for a message from a verified device */ + await bob.sendMessage(testRoomId, "test encrypted 2"); + + await expect(lastTile).toContainText("test encrypted 2"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + /* should show red padlock for a message from an unverified device */ + await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified"); + await expect(lastTile).toContainText("test encrypted from unverified"); + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastTileE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner."); + + /* Should show a grey padlock for a message from an unknown device */ + // bob deletes his second device + await bobSecondDevice.evaluate((cli) => cli.logout(true)); + + // wait for the logout to propagate. Workaround for https://github.com/vector-im/element-web/issues/26263 by repeatedly closing and reopening Bob's user info. + async function awaitOneDevice(iterations = 1) { + const rightPanel = page.locator(".mx_RightPanel"); + await rightPanel.getByRole("button", { name: "Room members" }).click(); + await rightPanel.getByText("Bob").click(); + const sessionCountText = await rightPanel + .locator(".mx_UserInfo_devices") + .getByText(" session", { exact: false }) + .textContent(); + // cf https://github.com/vector-im/element-web/issues/26279: Element-R uses the wrong text here + if (sessionCountText != "1 session" && sessionCountText != "1 verified session") { + if (iterations >= 10) { + throw new Error(`Bob still has ${sessionCountText} after 10 iterations`); + } + await awaitOneDevice(iterations + 1); + } + } + + await awaitOneDevice(); + + // close and reopen the room, to get the shield to update. + await app.viewRoomByName("Bob"); + await app.viewRoomByName("TestRoom"); + + await expect(last).toContainText("test encrypted from unverified"); + await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); + await lastE2eIcon.focus(); + await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device."); + }); + + test("Should show a grey padlock for a key restored from backup", async ({ + page, + app, + bot: bob, + homeserver, + user: aliceCredentials, + }) => { + test.slow(); + const securityKey = await enableKeyBackup(app); + + // bob sends a valid event + await bob.sendMessage(testRoomId, "test encrypted 1"); + + const lastTile = page.locator(".mx_EventTile_last"); + const lastTileE2eIcon = lastTile.locator(".mx_EventTile_e2eIcon"); + await expect(lastTile).toContainText("test encrypted 1"); + // no e2e icon + await expect(lastTileE2eIcon).not.toBeVisible(); + + // Workaround for https://github.com/element-hq/element-web/issues/27267. It can take up to 10 seconds for + // the key to be backed up. + await page.waitForTimeout(10000); + + /* log out, and back in */ + await logOutOfElement(page); + // Reload to work around a Rust crypto bug where it can hold onto the indexeddb even after logout + // https://github.com/element-hq/element-web/issues/25779 + await page.addInitScript(() => { + // When we reload, the initScript created by the `user`/`pageWithCredentials` fixtures + // will re-inject the original credentials into localStorage, which we don't want. + // To work around, we add a second initScript which will clear localStorage again. + window.localStorage.clear(); + }); + await page.reload(); + await logIntoElement(page, homeserver, aliceCredentials, securityKey); + + /* go back to the test room and find Bob's message again */ + await app.viewRoomById(testRoomId); + await expect(lastTile).toContainText("test encrypted 1"); + // The gray shield would be a mx_EventTile_e2eIcon_normal. The red shield would be a mx_EventTile_e2eIcon_warning. + // No shield would have no div mx_EventTile_e2eIcon at all. + await expect(lastTileE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_normal/); + await lastTileE2eIcon.hover(); + // The key is coming from backup, so it is not anymore possible to establish if the claimed device + // creator of this key is authentic. The tooltip should be "The authenticity of this encrypted message can't be guaranteed on this device." + // It is not "Encrypted by an unknown or deleted device." even if the claimed device is actually deleted. + await expect(page.getByRole("tooltip")).toContainText( + "The authenticity of this encrypted message can't be guaranteed on this device.", + ); + }); + + test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => { + // bob has a second, not cross-signed, device + const bobSecondDevice = new Bot(page, homeserver, { + bootstrapSecretStorage: false, + bootstrapCrossSigning: false, + }); + bobSecondDevice.setCredentials( + await homeserver.loginUser(bob.credentials.userId, bob.credentials.password), + ); + await bobSecondDevice.prepareClient(); + + // verify Bob + await verify(page, bob); + + // bob sends a valid event + const testEvent = await bob.sendMessage(testRoomId, "Hoo!"); + + // the message should appear, decrypted, with no warning + await expect( + page.locator(".mx_EventTile", { hasText: "Hoo!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + + // bob sends an edit to the first message with his unverified device + await bobSecondDevice.sendMessage(testRoomId, { + "m.new_content": { + msgtype: "m.text", + body: "Haa!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + + // the edit should have a warning + await expect( + page.locator(".mx_EventTile", { hasText: "Haa!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).toBeVisible(); + + // a second edit from the verified device should be ok + await bob.sendMessage(testRoomId, { + "m.new_content": { + msgtype: "m.text", + body: "Hee!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + + await expect( + page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"), + ).not.toBeVisible(); + }); + }); +}); diff --git a/playwright/e2e/crypto/user-verification.spec.ts b/playwright/e2e/crypto/user-verification.spec.ts new file mode 100644 index 0000000000..eac0fb639e --- /dev/null +++ b/playwright/e2e/crypto/user-verification.spec.ts @@ -0,0 +1,145 @@ +/* +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 { type Preset, type Visibility } from "matrix-js-sdk/src/matrix"; + +import { test, expect } from "../../element-web-test"; +import { doTwoWaySasVerification, awaitVerifier } from "./utils"; +import { Client } from "../../pages/client"; + +test.describe("User verification", () => { + // note that there are other tests that check user verification works in `crypto.spec.ts`. + + test.use({ + displayName: "Alice", + botCreateOpts: { displayName: "Bob", autoAcceptInvites: true, userIdPrefix: "bob_" }, + room: async ({ page, app, bot: bob, user: aliceCredentials }, use) => { + await app.client.bootstrapCrossSigning(aliceCredentials); + + // the other user creates a DM + const dmRoomId = await createDMRoom(bob, aliceCredentials.userId); + + // accept the DM + await app.viewRoomByName("Bob"); + await page.getByRole("button", { name: "Start chatting" }).click(); + await use({ roomId: dmRoomId }); + }, + }); + + test("can receive a verification request when there is no existing DM", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + }) => { + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // there should also be a toast + const toast = await toasts.getToast("Verification requested"); + // it should contain the details of the requesting user + await expect(toast.getByText(`Bob (${bob.credentials.userId})`)).toBeVisible(); + // Accept + await toast.getByRole("button", { name: "Verify User" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()); + // ... and then check the emoji match + await doTwoWaySasVerification(page, botVerifier); + + await page.getByRole("button", { name: "They match" }).click(); + await expect(page.getByText("You've successfully verified Bob!")).toBeVisible(); + await page.getByRole("button", { name: "Got it" }).click(); + }); + + test("can abort emoji verification when emoji mismatch", async ({ + page, + bot: bob, + user: aliceCredentials, + toasts, + room: { roomId: dmRoomId }, + }) => { + // once Alice has joined, Bob starts the verification + const bobVerificationRequest = await bob.evaluateHandle( + async (client, { dmRoomId, aliceCredentials }) => { + const room = client.getRoom(dmRoomId); + while (room.getMember(aliceCredentials.userId)?.membership !== "join") { + await new Promise((resolve) => { + room.once(window.matrixcs.RoomStateEvent.Members, resolve); + }); + } + + return client.getCrypto().requestVerificationDM(aliceCredentials.userId, dmRoomId); + }, + { dmRoomId, aliceCredentials }, + ); + + // Accept verification via toast + const toast = await toasts.getToast("Verification requested"); + await toast.getByRole("button", { name: "Verify User" }).click(); + + // request verification by emoji + await page.locator("#mx_RightPanel").getByRole("button", { name: "Verify by emoji" }).click(); + + /* on the bot side, wait for the verifier to exist ... */ + const botVerifier = await awaitVerifier(bobVerificationRequest); + // ... confirm ... + botVerifier.evaluate((verifier) => verifier.verify()).catch(() => {}); + // ... and abort the verification + await page.getByRole("button", { name: "They don't match" }).click(); + + const dialog = page.locator(".mx_Dialog"); + await expect(dialog.getByText("Your messages are not secure")).toBeVisible(); + await dialog.getByRole("button", { name: "OK" }).click(); + await expect(dialog).not.toBeVisible(); + }); +}); + +async function createDMRoom(client: Client, userId: string): Promise { + return client.createRoom({ + preset: "trusted_private_chat" as Preset, + visibility: "private" as Visibility, + invite: [userId], + is_direct: true, + initial_state: [ + { + type: "m.room.encryption", + state_key: "", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }, + ], + }); +} diff --git a/playwright/e2e/crypto/utils.ts b/playwright/e2e/crypto/utils.ts index b5109490a9..c4042d43e8 100644 --- a/playwright/e2e/crypto/utils.ts +++ b/playwright/e2e/crypto/utils.ts @@ -27,6 +27,7 @@ import type { import { Credentials, HomeserverInstance } from "../../plugins/homeserver"; import { Client } from "../../pages/client"; import { ElementAppPage } from "../../pages/ElementAppPage"; +import { Bot } from "../../pages/bot"; /** * wait for the given client to receive an incoming verification request, and automatically accept it @@ -327,3 +328,68 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool await expect(page.getByText("Encryption enabled")).toBeVisible(); } } + +/** + * Open the room info panel and return the panel element + * @param page - the page to use + */ +export const openRoomInfo = async (page: Page) => { + await page.getByRole("button", { name: "Room info" }).click(); + return page.locator(".mx_RightPanel"); +}; + +/** + * Configure the given MatrixClient to auto-accept any invites + * @param client - the client to configure + */ +export async function autoJoin(client: Client) { + await client.evaluate((cli) => { + cli.on(window.matrixcs.RoomMemberEvent.Membership, (event, member) => { + if (member.membership === "invite" && member.userId === cli.getUserId()) { + cli.joinRoom(member.roomId); + } + }); + }); +} + +/** + * Verify a user by emoji + * @param page - the page to use + * @param bob - the user to verify + */ +export const verify = async (page: Page, bob: Bot) => { + const bobsVerificationRequestPromise = waitForVerificationRequest(bob); + + const roomInfo = await openRoomInfo(page); + await page.locator(".mx_RightPanelTabs").getByText("People").click(); + await roomInfo.getByText("Bob").click(); + await roomInfo.getByRole("button", { name: "Verify" }).click(); + await roomInfo.getByRole("button", { name: "Start Verification" }).click(); + + // this requires creating a DM, so can take a while. Give it a longer timeout. + await roomInfo.getByRole("button", { name: "Verify by emoji" }).click({ timeout: 30000 }); + + const request = await bobsVerificationRequestPromise; + // the bot user races with the Element user to hit the "verify by emoji" button + const verifier = await request.evaluateHandle((request) => request.startVerification("m.sas.v1")); + await doTwoWaySasVerification(page, verifier); + await roomInfo.getByRole("button", { name: "They match" }).click(); + await expect(roomInfo.getByText("You've successfully verified Bob!")).toBeVisible(); + await roomInfo.getByRole("button", { name: "Got it" }).click(); +}; + +/** + * Wait for a verifier to exist for a VerificationRequest + * + * @param botVerificationRequest + */ +export async function awaitVerifier( + botVerificationRequest: JSHandle, +): Promise> { + return botVerificationRequest.evaluateHandle(async (verificationRequest) => { + while (!verificationRequest.verifier) { + await new Promise((r) => verificationRequest.once("change" as any, r)); + } + return verificationRequest.verifier; + }); +} diff --git a/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts new file mode 100644 index 0000000000..62394cccb5 --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages-in-threads.spec.ts @@ -0,0 +1,191 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("in threads", () => { + test("An edit of a threaded message makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given we have read the thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Resp1"); + await util.goTo(room1); + + // When a message inside it is edited + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + + // Then the room and thread are read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Msg1"); + }); + + test("Reading an edit of a threaded message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edited thread message appears after we read it + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Resp1"); + await util.goTo(room1); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertStillRead(room2); + + // When I read it + await util.goTo(room2); + await util.openThread("Msg1"); + + // Then the room and thread are still read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Marking a room as read after an edit in a thread makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edit in a thread is making the room unread + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.editOf("Resp1", "Edit1"), + ]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it is read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Editing a thread message after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room with an edited threaded message is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an edit in a thread is leaving a room read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.markAsRead(room2); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertStillRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then is it still read + await util.assertRead(room2); + }); + + test("A room where all threaded edits are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); + await util.assertUnread(room2, 1); + + await util.goTo(room2); + + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + await util.goTo(room1); // Make sure we are looking at room1 after reload + await util.assertStillRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("A room where all threaded edits are marked as read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.threadedOff("Msg1", "Resp1"), + msg.editOf("Resp1", "Edit1"), + ]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When I restart + await util.saveAndReload(); + + // It is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts new file mode 100644 index 0000000000..e03a011a4d --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages-main-timeline.spec.ts @@ -0,0 +1,180 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("in the main timeline", () => { + test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given I am not looking at the room + await util.goTo(room1); + + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When an edit appears in the room + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + }); + test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given an edit is making the room unread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + await util.assertStillRead(room2); + + // When I read it + await util.goTo(room2); + + // Then the room stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + test("Editing a message after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given the room is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("Editing a reply after reading it makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given the room is all read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When a message is edited + await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + }); + test("Editing a reply after marking as read makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a reply is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When the reply is edited + await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + test("A room with an edit is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When an edit appears in the room + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it remains read + await util.assertStillRead(room2); + + // And remains so after a reload + await util.saveAndReload(); + await util.assertStillRead(room2); + }); + test("An edited message becomes read if it happens while I am looking", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message is marked as read + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertRead(room2); + + // When I see an edit appear in the room I am looking at + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("A room where all edits are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message was edited and read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + + // When I reload + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts new file mode 100644 index 0000000000..279845f5d2 --- /dev/null +++ b/playwright/e2e/read-receipts/editing-messages-thread-roots.spec.ts @@ -0,0 +1,179 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("editing messages", () => { + test.describe("thread roots", () => { + test("An edit of a thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read a thread + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.backToThreadsList(); + await util.assertRead(room2); + await util.goTo(room1); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]); + + // Then the room is read + await util.assertStillRead(room2); + + // And the thread is read + await util.goTo(room2); + await util.assertStillRead(room2); + await util.assertReadThread("Edit1"); + }); + + test("Reading an edit of a thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // And I read that edit + await util.goTo(room2); + + // Then the room becomes read and stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + + test("Editing a thread root after reading leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + + // When the thread root is edited + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); + + // Then the room stays read + await util.assertStillRead(room2); + }); + + test("Marking a room as read after an edit of a thread root keeps it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a fully-read thread exists + await util.goTo(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When the thread root is edited (and I receive another message + // to allow Mark as read) + await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]); + + // And when I mark the room as read + await util.markAsRead(room2); + + // Then the room becomes read and stays read + await util.assertStillRead(room2); + await util.goTo(room1); + await util.assertStillRead(room2); + }); + + test("Editing a thread root that is a reply after marking as read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread based on a reply exists and is read because it is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg", + msg.replyTo("Msg", "Reply"), + msg.threadedOff("Reply", "InThread"), + ]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I edit the thread root + await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); + + // Then the room is read + await util.assertStillRead(room2); + + // And the thread is read + await util.goTo(room2); + await util.assertReadThread("Edited Reply"); + }); + + test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread based on a reply exists and the reply has been edited + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg", + msg.replyTo("Msg", "Reply"), + msg.threadedOff("Reply", "InThread"), + ]); + await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); + await util.assertUnread(room2, 2); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then the room and thread are read + await util.assertStillRead(room2); + await util.goTo(room2); + await util.assertReadThread("Edited Reply"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/editing-messages.spec.ts b/playwright/e2e/read-receipts/editing-messages.spec.ts deleted file mode 100644 index 5005ad62bf..0000000000 --- a/playwright/e2e/read-receipts/editing-messages.spec.ts +++ /dev/null @@ -1,504 +0,0 @@ -/* -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. -*/ - -/* See readme.md for tips on writing these tests. */ - -import { test } from "."; - -test.describe("Read receipts", () => { - test.describe("editing messages", () => { - test.describe("in the main timeline", () => { - test("Editing a message leaves a room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given I am not looking at the room - await util.goTo(room1); - - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When an edit appears in the room - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then it remains read - await util.assertStillRead(room2); - }); - test("Reading an edit leaves the room read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given an edit is making the room unread - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - await util.assertStillRead(room2); - - // When I read it - await util.goTo(room2); - - // Then the room stays read - await util.assertStillRead(room2); - await util.goTo(room1); - await util.assertStillRead(room2); - }); - test("Editing a message after marking as read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given the room is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When a message is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then the room remains read - await util.assertStillRead(room2); - }); - test("Editing a reply after reading it makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given the room is all read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); - await util.assertUnread(room2, 2); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When a message is edited - await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); - - // Then it remains read - await util.assertStillRead(room2); - }); - test("Editing a reply after marking as read makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a reply is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); - await util.assertUnread(room2, 2); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When the reply is edited - await util.receiveMessages(room2, [msg.editOf("Reply1", "Reply1 Edit1")]); - - // Then the room remains read - await util.assertStillRead(room2); - }); - test("A room with an edit is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When an edit appears in the room - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then it remains read - await util.assertStillRead(room2); - - // And remains so after a reload - await util.saveAndReload(); - await util.assertStillRead(room2); - }); - test("An edited message becomes read if it happens while I am looking", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message is marked as read - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertRead(room2); - - // When I see an edit appear in the room I am looking at - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then it becomes read - await util.assertStillRead(room2); - }); - test("A room where all edits are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message was edited and read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.editOf("Msg1", "Msg1 Edit1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - - // When I reload - await util.saveAndReload(); - - // Then the room is still read - await util.assertRead(room2); - }); - }); - - test.describe("in threads", () => { - test("An edit of a threaded message makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given we have read the thread - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Resp1"); - await util.goTo(room1); - - // When a message inside it is edited - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - - // Then the room and thread are read - await util.assertStillRead(room2); - await util.goTo(room2); - await util.assertReadThread("Msg1"); - }); - - test("Reading an edit of a threaded message makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an edited thread message appears after we read it - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Resp1"); - await util.goTo(room1); - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - await util.assertStillRead(room2); - - // When I read it - await util.goTo(room2); - await util.openThread("Msg1"); - - // Then the room and thread are still read - await util.assertStillRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("Marking a room as read after an edit in a thread makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an edit in a thread is making the room unread - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg1", - msg.threadedOff("Msg1", "Resp1"), - msg.editOf("Resp1", "Edit1"), - ]); - await util.assertUnread(room2, 1); - - // When I mark the room as read - await util.markAsRead(room2); - - // Then it is read - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("Editing a thread message after marking as read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a room is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When a message is edited - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - - // Then the room remains read - await util.assertStillRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("A room with an edited threaded message is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an edit in a thread is leaving a room read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.markAsRead(room2); - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - await util.assertStillRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then is it still read - await util.assertRead(room2); - }); - - test("A room where all threaded edits are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.receiveMessages(room2, [msg.editOf("Resp1", "Edit1")]); - await util.assertUnread(room2, 1); - - await util.goTo(room2); - - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - await util.goTo(room1); // Make sure we are looking at room1 after reload - await util.assertStillRead(room2); - - await util.saveAndReload(); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("A room where all threaded edits are marked as read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg1", - msg.threadedOff("Msg1", "Resp1"), - msg.editOf("Resp1", "Edit1"), - ]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When I restart - await util.saveAndReload(); - - // It is still read - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - }); - - test.describe("thread roots", () => { - test("An edit of a thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have read a thread - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.backToThreadsList(); - await util.assertRead(room2); - await util.goTo(room1); - - // When the thread root is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Edit1")]); - - // Then the room is read - await util.assertStillRead(room2); - - // And the thread is read - await util.goTo(room2); - await util.assertStillRead(room2); - await util.assertReadThread("Edit1"); - }); - - test("Reading an edit of a thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a fully-read thread exists - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - await util.assertRead(room2); - - // When the thread root is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // And I read that edit - await util.goTo(room2); - - // Then the room becomes read and stays read - await util.assertStillRead(room2); - await util.goTo(room1); - await util.assertStillRead(room2); - }); - - test("Editing a thread root after reading leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a fully-read thread exists - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - - // When the thread root is edited - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1")]); - - // Then the room stays read - await util.assertStillRead(room2); - }); - - test("Marking a room as read after an edit of a thread root keeps it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a fully-read thread exists - await util.goTo(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - await util.assertRead(room2); - - // When the thread root is edited (and I receive another message - // to allow Mark as read) - await util.receiveMessages(room2, [msg.editOf("Msg1", "Msg1 Edit1"), "Msg2"]); - - // And when I mark the room as read - await util.markAsRead(room2); - - // Then the room becomes read and stays read - await util.assertStillRead(room2); - await util.goTo(room1); - await util.assertStillRead(room2); - }); - - test("Editing a thread root that is a reply after marking as read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread based on a reply exists and is read because it is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg", - msg.replyTo("Msg", "Reply"), - msg.threadedOff("Reply", "InThread"), - ]); - await util.assertUnread(room2, 2); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When I edit the thread root - await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); - - // Then the room is read - await util.assertStillRead(room2); - - // And the thread is read - await util.goTo(room2); - await util.assertReadThread("Edited Reply"); - }); - - test("Marking a room as read after an edit of a thread root that is a reply leaves it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread based on a reply exists and the reply has been edited - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg", - msg.replyTo("Msg", "Reply"), - msg.threadedOff("Reply", "InThread"), - ]); - await util.receiveMessages(room2, [msg.editOf("Reply", "Edited Reply")]); - await util.assertUnread(room2, 2); - - // When I mark the room as read - await util.markAsRead(room2); - - // Then the room and thread are read - await util.assertStillRead(room2); - await util.goTo(room2); - await util.assertReadThread("Edited Reply"); - }); - }); - }); -}); diff --git a/playwright/e2e/read-receipts/high-level.spec.ts b/playwright/e2e/read-receipts/high-level.spec.ts index 30a3788e3e..a3c2c0de3d 100644 --- a/playwright/e2e/read-receipts/high-level.spec.ts +++ b/playwright/e2e/read-receipts/high-level.spec.ts @@ -19,77 +19,6 @@ limitations under the License. import { customEvent, many, test } from "."; test.describe("Read receipts", () => { - test.describe("Message ordering", () => { - test.describe("in the main timeline", () => { - test.fixme( - "A receipt for the last event in sync order (even with wrong ts) marks a room as read", - () => {}, - ); - test.fixme( - "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - }); - - test.describe("in threads", () => { - // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet - test.fixme( - "A receipt for the last event in sync order (even with wrong ts) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", - () => {}, - ); - - // These pass now and should not later - we should use order from MSC4033 instead of ts - // These are broken out - test.fixme( - "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - test.fixme( - "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - test.fixme( - "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", - () => {}, - ); - test.fixme( - "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", - () => {}, - ); - }); - - test.describe("thread roots", () => { - test.fixme( - "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", - () => {}, - ); - test.fixme( - "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - test.fixme( - "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", - () => {}, - ); - test.fixme( - "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", - () => {}, - ); - }); - }); - test.describe("Ignored events", () => { test("If all events after receipt are unimportant, the room is read", async ({ roomAlpha: room1, @@ -414,79 +343,4 @@ test.describe("Read receipts", () => { await util.assertReadThread("Root3"); }); }); - - test.describe("Room list order", () => { - test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - page, - }) => { - await util.goTo(room2); - - // Display the unread first room - await util.toggleRoomUnreadOrder(); - await util.receiveMessages(room1, ["Msg1"]); - await page.reload(); - - // Room 1 has an unread message and should be displayed first - await util.assertRoomListOrder([room1, room2]); - }); - - test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room2); - await util.receiveMessages(room1, ["Msg1"]); - await util.markAsRead(room1); - await util.assertRead(room1); - - // Display the unread first room - await util.toggleRoomUnreadOrder(); - await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); - await util.saveAndReload(); - - // Room 1 has an unread message and should be displayed first - await util.assertRoomListOrder([room1, room2]); - }); - }); - - test.describe("Notifications", () => { - test.describe("in the main timeline", () => { - test.fixme("A new message that mentions me shows a notification", () => {}); - test.fixme( - "Reading a notifying message reduces the notification count in the room list, space and tab", - () => {}, - ); - test.fixme( - "Reading the last notifying message removes the notification marker from room list, space and tab", - () => {}, - ); - test.fixme("Editing a message to mentions me shows a notification", () => {}); - test.fixme("Reading the last notifying edited message removes the notification marker", () => {}); - test.fixme("Redacting a notifying message removes the notification marker", () => {}); - }); - - test.describe("in threads", () => { - test.fixme("A new threaded message that mentions me shows a notification", () => {}); - test.fixme("Reading a notifying threaded message removes the notification count", () => {}); - test.fixme( - "Notification count remains steady when reading threads that contain seen notifications", - () => {}, - ); - test.fixme( - "Notification count remains steady when paging up thread view even when threads contain seen notifications", - () => {}, - ); - test.fixme( - "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", - () => {}, - ); - test.fixme("Redacting a notifying threaded message removes the notification marker", () => {}); - }); - }); }); diff --git a/playwright/e2e/read-receipts/message-ordering.spec.ts b/playwright/e2e/read-receipts/message-ordering.spec.ts new file mode 100644 index 0000000000..73c640d35a --- /dev/null +++ b/playwright/e2e/read-receipts/message-ordering.spec.ts @@ -0,0 +1,92 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("Message ordering", () => { + test.describe("in the main timeline", () => { + test.fixme( + "A receipt for the last event in sync order (even with wrong ts) marks a room as read", + () => {}, + ); + test.fixme( + "A receipt for a non-last event in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + }); + + test.describe("in threads", () => { + // These don't pass yet - we need MSC4033 - we don't even know the Sync order yet + test.fixme( + "A receipt for the last event in sync order (even with wrong ts) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for a non-last event in sync order (even when ts makes it last) leaves thread unread", + () => {}, + ); + + // These pass now and should not later - we should use order from MSC4033 instead of ts + // These are broken out + test.fixme( + "A receipt for last threaded event in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded event in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + test.fixme( + "A receipt for last threaded edit in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded edit in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + test.fixme( + "A receipt for last threaded reaction in ts order (even when it was received non-last) marks a thread as read", + () => {}, + ); + test.fixme( + "A receipt for non-last threaded reaction in ts order (even when it was received last) leaves thread unread", + () => {}, + ); + }); + + test.describe("thread roots", () => { + test.fixme( + "A receipt for last reaction to thread root in sync order (even when ts makes it last) marks room as read", + () => {}, + ); + test.fixme( + "A receipt for non-last reaction to thread root in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + test.fixme( + "A receipt for last edit to thread root in sync order (even when ts makes it last) marks room as read", + () => {}, + ); + test.fixme( + "A receipt for non-last edit to thread root in sync order (even when ts makes it last) leaves room unread", + () => {}, + ); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/new-messages.spec.ts b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts similarity index 55% rename from playwright/e2e/read-receipts/new-messages.spec.ts rename to playwright/e2e/read-receipts/new-messages-in-threads.spec.ts index 97308a4bb2..37b43bae1d 100644 --- a/playwright/e2e/read-receipts/new-messages.spec.ts +++ b/playwright/e2e/read-receipts/new-messages-in-threads.spec.ts @@ -20,151 +20,6 @@ import { many, test } from "."; test.describe("Read receipts", () => { test.describe("new messages", () => { - test.describe("in the main timeline", () => { - test("Receiving a message makes a room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I am in a different room - await util.goTo(room1); - await util.assertRead(room2); - - // When I receive some messages - await util.receiveMessages(room2, ["Msg1"]); - - // Then the room is marked as unread - await util.assertUnread(room2, 1); - }); - test("Reading latest message makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have some unread messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I read the main timeline - await util.goTo(room2); - - // Then the room becomes read - await util.assertRead(room2); - }); - test("Reading an older message leaves the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given there are lots of messages in a room - await util.goTo(room1); - await util.receiveMessages(room2, many("Msg", 30)); - await util.assertUnread(room2, 30); - - // When I jump to one of the older messages - await msg.jumpTo(room2.name, "Msg0001"); - - // Then the room is still unread, but some messages were read - await util.assertUnreadLessThan(room2, 30); - }); - test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { - // Given I have some unread messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I mark the room as read - await util.markAsRead(room2); - - // Then it is read - await util.assertRead(room2); - }); - test("Receiving a new message after marking as read makes it unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have marked my messages as read - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When I receive a new message - await util.receiveMessages(room2, ["Msg2"]); - - // Then the room is unread - await util.assertUnread(room2, 1); - }); - test("A room with a new message is still unread after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have an unread message - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - - // When I restart - await util.saveAndReload(); - - // Then I still have an unread message - await util.assertUnread(room2, 1); - }); - test("A room where all messages are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have read all messages - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then all messages are still read - await util.assertRead(room2); - }); - test("A room that was marked as read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have marked all messages as read - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1"]); - await util.assertUnread(room2, 1); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then all messages are still read - await util.assertRead(room2); - }); - }); - test.describe("in threads", () => { test("Receiving a message makes a room unread", async ({ roomAlpha: room1, @@ -450,100 +305,5 @@ test.describe("Read receipts", () => { await util.assertReadThread("Msg1"); }); }); - - test.describe("thread roots", () => { - test("Reading a thread root does not mark the thread as read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); - await util.assertUnread(room2, 1); // (Sanity) - - // When I read the main timeline - await util.goTo(room2); - - // Then room doesn't appear unread but the thread does - await util.assertRead(room2); - await util.assertUnreadThread("Msg1"); - }); - - test("Reading a thread root within the thread view marks it as read in the main timeline", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given lots of messages are on the main timeline, and one has a thread off it - await util.goTo(room1); - await util.receiveMessages(room2, [ - ...many("beforeThread", 30), - "ThreadRoot", - msg.threadedOff("ThreadRoot", "InThread"), - ...many("afterThread", 30), - ]); - await util.assertUnread(room2, 61); // Sanity - - // When I jump to an old message and read the thread - await msg.jumpTo(room2.name, "beforeThread0000"); - // When the thread is opened, the timeline is scrolled until the thread root reached the center - await util.openThread("ThreadRoot"); - - // Then the thread root is marked as read in the main timeline, - // 30 remaining messages are unread - 7 messages are displayed under the thread root - await util.assertUnread(room2, 30 - 7); - }); - - test("Creating a new thread based on a reply makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message and reply exist and are read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - await util.assertRead(room2); - - // When I receive a thread message created on the reply - await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]); - - // Then the thread is unread - await util.goTo(room2); - await util.assertUnreadThread("Reply1"); - }); - - test("Reading a thread whose root is a reply makes the thread read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread thread off a reply exists - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Msg1", - msg.replyTo("Msg1", "Reply1"), - msg.threadedOff("Reply1", "Resp1"), - ]); - await util.assertUnread(room2, 2); - await util.goTo(room2); - await util.assertRead(room2); - await util.assertUnreadThread("Reply1"); - - // When I read the thread - await util.openThread("Reply1"); - - // Then the room and thread are read - await util.assertRead(room2); - await util.assertReadThread("Reply1"); - }); - }); }); }); diff --git a/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts new file mode 100644 index 0000000000..eb528f2816 --- /dev/null +++ b/playwright/e2e/read-receipts/new-messages-main-timeline.spec.ts @@ -0,0 +1,168 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { many, test } from "."; + +test.describe("Read receipts", () => { + test.describe("new messages", () => { + test.describe("in the main timeline", () => { + test("Receiving a message makes a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I am in a different room + await util.goTo(room1); + await util.assertRead(room2); + + // When I receive some messages + await util.receiveMessages(room2, ["Msg1"]); + + // Then the room is marked as unread + await util.assertUnread(room2, 1); + }); + test("Reading latest message makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have some unread messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I read the main timeline + await util.goTo(room2); + + // Then the room becomes read + await util.assertRead(room2); + }); + test("Reading an older message leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given there are lots of messages in a room + await util.goTo(room1); + await util.receiveMessages(room2, many("Msg", 30)); + await util.assertUnread(room2, 30); + + // When I jump to one of the older messages + await msg.jumpTo(room2.name, "Msg0001"); + + // Then the room is still unread, but some messages were read + await util.assertUnreadLessThan(room2, 30); + }); + test("Marking a room as read makes it read", async ({ roomAlpha: room1, roomBeta: room2, util, msg }) => { + // Given I have some unread messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I mark the room as read + await util.markAsRead(room2); + + // Then it is read + await util.assertRead(room2); + }); + test("Receiving a new message after marking as read makes it unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have marked my messages as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I receive a new message + await util.receiveMessages(room2, ["Msg2"]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + test("A room with a new message is still unread after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have an unread message + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + + // When I restart + await util.saveAndReload(); + + // Then I still have an unread message + await util.assertUnread(room2, 1); + }); + test("A room where all messages are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read all messages + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then all messages are still read + await util.assertRead(room2); + }); + test("A room that was marked as read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have marked all messages as read + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1"]); + await util.assertUnread(room2, 1); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then all messages are still read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts new file mode 100644 index 0000000000..526bac4bff --- /dev/null +++ b/playwright/e2e/read-receipts/new-messages-thread-roots.spec.ts @@ -0,0 +1,118 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { many, test } from "."; + +test.describe("Read receipts", () => { + test.describe("new messages", () => { + test.describe("thread roots", () => { + test("Reading a thread root does not mark the thread as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + await util.assertUnread(room2, 1); // (Sanity) + + // When I read the main timeline + await util.goTo(room2); + + // Then room doesn't appear unread but the thread does + await util.assertRead(room2); + await util.assertUnreadThread("Msg1"); + }); + + test("Reading a thread root within the thread view marks it as read in the main timeline", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given lots of messages are on the main timeline, and one has a thread off it + await util.goTo(room1); + await util.receiveMessages(room2, [ + ...many("beforeThread", 30), + "ThreadRoot", + msg.threadedOff("ThreadRoot", "InThread"), + ...many("afterThread", 30), + ]); + await util.assertUnread(room2, 61); // Sanity + + // When I jump to an old message and read the thread + await msg.jumpTo(room2.name, "beforeThread0000"); + // When the thread is opened, the timeline is scrolled until the thread root reached the center + await util.openThread("ThreadRoot"); + + // Then the thread root is marked as read in the main timeline, + // 30 remaining messages are unread - 7 messages are displayed under the thread root + await util.assertUnread(room2, 30 - 7); + }); + + test("Creating a new thread based on a reply makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message and reply exist and are read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", msg.replyTo("Msg1", "Reply1")]); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + await util.assertRead(room2); + + // When I receive a thread message created on the reply + await util.receiveMessages(room2, [msg.threadedOff("Reply1", "Resp1")]); + + // Then the thread is unread + await util.goTo(room2); + await util.assertUnreadThread("Reply1"); + }); + + test("Reading a thread whose root is a reply makes the thread read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread off a reply exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Msg1", + msg.replyTo("Msg1", "Reply1"), + msg.threadedOff("Reply1", "Resp1"), + ]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.assertUnreadThread("Reply1"); + + // When I read the thread + await util.openThread("Reply1"); + + // Then the room and thread are read + await util.assertRead(room2); + await util.assertReadThread("Reply1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/notifications.spec.ts b/playwright/e2e/read-receipts/notifications.spec.ts new file mode 100644 index 0000000000..5d87de1bb6 --- /dev/null +++ b/playwright/e2e/read-receipts/notifications.spec.ts @@ -0,0 +1,56 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("Notifications", () => { + test.describe("in the main timeline", () => { + test.fixme("A new message that mentions me shows a notification", () => {}); + test.fixme( + "Reading a notifying message reduces the notification count in the room list, space and tab", + () => {}, + ); + test.fixme( + "Reading the last notifying message removes the notification marker from room list, space and tab", + () => {}, + ); + test.fixme("Editing a message to mentions me shows a notification", () => {}); + test.fixme("Reading the last notifying edited message removes the notification marker", () => {}); + test.fixme("Redacting a notifying message removes the notification marker", () => {}); + }); + + test.describe("in threads", () => { + test.fixme("A new threaded message that mentions me shows a notification", () => {}); + test.fixme("Reading a notifying threaded message removes the notification count", () => {}); + test.fixme( + "Notification count remains steady when reading threads that contain seen notifications", + () => {}, + ); + test.fixme( + "Notification count remains steady when paging up thread view even when threads contain seen notifications", + () => {}, + ); + test.fixme( + "Notification count remains steady when paging up thread view after mark as unread even if older threads contain notifications", + () => {}, + ); + test.fixme("Redacting a notifying threaded message removes the notification marker", () => {}); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/reactions.spec.ts b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts similarity index 56% rename from playwright/e2e/read-receipts/reactions.spec.ts rename to playwright/e2e/read-receipts/reactions-in-threads.spec.ts index 69208e5fc9..dcd97ac431 100644 --- a/playwright/e2e/read-receipts/reactions.spec.ts +++ b/playwright/e2e/read-receipts/reactions-in-threads.spec.ts @@ -20,82 +20,6 @@ import { test, expect } from "."; test.describe("Read receipts", () => { test.describe("reactions", () => { - test.describe("in the main timeline", () => { - test("Receiving a reaction to a message does not make a room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - // When I read the main timeline - await util.goTo(room2); - await util.assertRead(room2); - - await util.goTo(room1); - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - await util.assertRead(room2); - }); - test("Reacting to a message after marking as read does not make the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - await util.markAsRead(room2); - await util.assertRead(room2); - - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - await util.assertRead(room2); - }); - test("A room with an unread reaction is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - await util.markAsRead(room2); - await util.assertRead(room2); - - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - await util.assertRead(room2); - - await util.saveAndReload(); - await util.assertRead(room2); - }); - test("A room where all reactions are read is still read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]); - await util.assertUnread(room2, 2); - - await util.markAsRead(room2); - await util.assertRead(room2); - - await util.saveAndReload(); - await util.assertRead(room2); - }); - }); - test.describe("in threads", () => { test("A reaction to a threaded message does not make the room unread", async ({ roomAlpha: room1, @@ -281,97 +205,5 @@ test.describe("Read receipts", () => { await expect(await page.locator(".mx_ThreadPanel").getByLabel("Mae reacted with 😀")).not.toBeVisible(); }); }); - - test.describe("thread roots", () => { - test("A reaction to a thread root does not make the room unread", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a read thread root exists - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When someone reacts to it - await util.goTo(room1); - await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); - await page.waitForTimeout(200); - - // Then the room is still read - await util.assertRead(room2); - // as is the thread - await util.assertReadThread("Msg1"); - }); - - test("Reading a reaction to a thread root leaves the room read", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a read thread root exists - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - - // And the reaction to it does not make us unread - await util.goTo(room1); - await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When we read the reaction and go away again - await util.goTo(room2); - await util.openThread("Msg1"); - await util.assertRead(room2); - await util.goTo(room1); - await page.waitForTimeout(200); - - // Then the room is still read - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - }); - - test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread root exists - await util.goTo(room1); - await util.assertRead(room2); - await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); - await util.assertUnread(room2, 1); - - // And we have marked the room as read - await util.markAsRead(room2); - await util.assertRead(room2); - await util.assertReadThread("Msg1"); - - // When someone reacts to it - await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); - await page.waitForTimeout(200); - - // Then the room is still read - await util.assertRead(room2); - // as is the thread - await util.assertReadThread("Msg1"); - }); - }); }); }); diff --git a/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts new file mode 100644 index 0000000000..54f0c89afe --- /dev/null +++ b/playwright/e2e/read-receipts/reactions-main-timeline.spec.ts @@ -0,0 +1,99 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("reactions", () => { + test.describe("in the main timeline", () => { + test("Receiving a reaction to a message does not make a room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // When I read the main timeline + await util.goTo(room2); + await util.assertRead(room2); + + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + }); + test("Reacting to a message after marking as read does not make the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + }); + test("A room with an unread reaction is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + await util.assertRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + }); + test("A room where all reactions are read is still read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", "Msg2", msg.reactionTo("Msg2", "🪿")]); + await util.assertUnread(room2, 2); + + await util.markAsRead(room2); + await util.assertRead(room2); + + await util.saveAndReload(); + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts new file mode 100644 index 0000000000..9c1be63e5b --- /dev/null +++ b/playwright/e2e/read-receipts/reactions-thread-roots.spec.ts @@ -0,0 +1,115 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("reactions", () => { + test.describe("thread roots", () => { + test("A reaction to a thread root does not make the room unread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a read thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When someone reacts to it + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Msg1"); + }); + + test("Reading a reaction to a thread root leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a read thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + + // And the reaction to it does not make us unread + await util.goTo(room1); + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When we read the reaction and go away again + await util.goTo(room2); + await util.openThread("Msg1"); + await util.assertRead(room2); + await util.goTo(room1); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + }); + + test("Reacting to a thread root after marking as read makes the room unread but not the thread", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread root exists + await util.goTo(room1); + await util.assertRead(room2); + await util.receiveMessages(room2, ["Msg1", msg.threadedOff("Msg1", "Reply1")]); + await util.assertUnread(room2, 1); + + // And we have marked the room as read + await util.markAsRead(room2); + await util.assertRead(room2); + await util.assertReadThread("Msg1"); + + // When someone reacts to it + await util.receiveMessages(room2, [msg.reactionTo("Msg1", "🪿")]); + await page.waitForTimeout(200); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Msg1"); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/redactions.spec.ts b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts similarity index 52% rename from playwright/e2e/read-receipts/redactions.spec.ts rename to playwright/e2e/read-receipts/redactions-in-threads.spec.ts index f7affbed21..323748e7e0 100644 --- a/playwright/e2e/read-receipts/redactions.spec.ts +++ b/playwright/e2e/read-receipts/redactions-in-threads.spec.ts @@ -20,314 +20,6 @@ import { test } from "."; test.describe("Read receipts", () => { test.describe("redactions", () => { - test.describe("in the main timeline", () => { - test("Redacting the message pointed to by my receipt leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given I have read the messages in a room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When the latest message is redacted - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - - // Then the room remains read - await util.assertStillRead(room2); - }); - - test("Reading an unread room after a redaction of the latest message makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - // And the latest message has been redacted - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - - // When I read the room - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // Then it becomes read - await util.assertStillRead(room2); - }); - test("Reading an unread room after a redaction of an older message makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room with an earlier redaction - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - - // When I read the room - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // Then it becomes read - await util.assertStillRead(room2); - }); - test("Marking an unread room as read after a redaction makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room where latest message is redacted - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // When I mark it as read - await util.markAsRead(room2); - - // Then it becomes read - await util.assertRead(room2); - }); - test("Sending and redacting a message after marking the room as read makes it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a room that is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When a message is sent and then redacted - await util.receiveMessages(room2, ["Msg3"]); - await util.assertUnread(room2, 1); - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - - // Then the room is read - await util.assertRead(room2); - }); - test("Redacting a message after marking the room as read leaves it read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a room that is marked as read - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); - await util.assertUnread(room2, 3); - await util.markAsRead(room2); - await util.assertRead(room2); - - // When we redact some messages - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - - // Then it is still read - await util.assertStillRead(room2); - }); - test("Redacting one of the unread messages reduces the unread count", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); - await util.assertUnread(room2, 3); - - // When I redact a non-latest message - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - - // Then the unread count goes down - await util.assertUnread(room2, 2); - - // And when I redact the latest message - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - - // Then the unread count goes down again - await util.assertUnread(room2, 1); - }); - test("Redacting one of the unread messages reduces the unread count after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given unread count was reduced by redacting messages - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); - await util.assertUnread(room2, 3); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); - await util.assertUnread(room2, 1); - - // When I restart - await util.saveAndReload(); - - // Then the unread count is still reduced - await util.assertUnread(room2, 1); - }); - test("Redacting all unread messages makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread room - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - - // When I redact all the unread messages - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - - // Then the room is back to being read - await util.assertRead(room2); - }); - test("Redacting all unread messages makes the room read after restart", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given all unread messages were redacted - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); - await util.assertRead(room2); - - // When I restart - await util.saveAndReload(); - - // Then the room is still read - await util.assertRead(room2); - }); - test("Reacting to a redacted message leaves the room read", async ({ - page, - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a redacted message exists - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // And the room is read - await util.goTo(room2); - await util.assertRead(room2); - await page.waitForTimeout(200); - await util.goTo(room1); - - // When I react to the redacted message - await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); - - // Then the room is still read - await util.assertStillRead(room2); - }); - test("Editing a redacted message leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a redacted message exists - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // And the room is read - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When I attempt to edit the redacted message - await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]); - - // Then the room is still read - await util.assertStillRead(room2); - }); - test("A reply to a redacted message makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a message was redacted - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - - // And the room is read - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - - // When I receive a reply to the redacted message - await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); - - // Then the room is unread - await util.assertUnread(room2, 1); - }); - test("Reading a reply to a redacted message marks the room as read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given someone replied to a redacted message - await util.goTo(room1); - await util.receiveMessages(room2, ["Msg1", "Msg2"]); - await util.assertUnread(room2, 2); - await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.assertRead(room2); - await util.goTo(room1); - await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); - await util.assertUnread(room2, 1); - - // When I read the reply - await util.goTo(room2); - await util.assertRead(room2); - - // Then the room is unread - await util.goTo(room1); - await util.assertStillRead(room2); - }); - }); - test.describe("in threads", () => { test("Redacting the threaded message pointed to by my receipt leaves the room read", async ({ roomAlpha: room1, @@ -866,214 +558,5 @@ test.describe("Read receipts", () => { await util.assertReadThread("Root"); }); }); - - test.describe("thread roots", () => { - test("Redacting a thread root after it was read leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - test.slow(); - - // Given a thread exists and is read - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - - // When someone redacts the thread root - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // Then the room is still read - await util.assertStillRead(room2); - }); - - /* - * Disabled for the same reason as "A thread with a read redaction is still read after restart" - * above - */ - test.skip("Redacting a thread root still allows us to read the thread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given an unread thread exists - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - - // When someone redacts the thread root - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // Then the room is still unread - await util.assertUnread(room2, 1); - - // And I can open the thread and read it - await util.goTo(room2); - await util.assertRead(room2); - // The redacted message gets collapsed into, "foo was invited, joined and removed a message" - await util.openCollapsedMessage(1); - await util.openThread("Message deleted"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - }); - - test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and its root is redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When we receive a new message on it - await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]); - - // Then the room is read but the thread is unread - await util.assertRead(room2); - await util.goTo(room2); - await util.assertUnreadThread("Message deleted"); - }); - - test("Reacting to a redacted thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When I react to the old root - await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]); - - // Then the room is still read - await util.assertRead(room2); - await util.assertReadThread("Root"); - }); - - test("Editing a redacted thread root leaves the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When I edit the old root - await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]); - - // Then the room is still read - await util.assertRead(room2); - // as is the thread - await util.assertReadThread("Root"); - }); - - test("Replying to a redacted thread root makes the room unread", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - - // When I reply to the old root - await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); - - // Then the room is unread - await util.assertUnread(room2, 1); - }); - - test("Reading a reply to a redacted thread root makes the room read", async ({ - roomAlpha: room1, - roomBeta: room2, - util, - msg, - }) => { - // Given a thread exists, is read and the root was redacted, and - // someone replied to it - await util.goTo(room1); - await util.receiveMessages(room2, [ - "Root", - msg.threadedOff("Root", "Msg2"), - msg.threadedOff("Root", "Msg3"), - ]); - await util.assertUnread(room2, 1); - await util.goTo(room2); - await util.openThread("Root"); - await util.assertRead(room2); - await util.assertReadThread("Root"); - await util.receiveMessages(room2, [msg.redactionOf("Root")]); - await util.assertStillRead(room2); - await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); - await util.assertUnread(room2, 1); - - // When I read the room - await util.goTo(room2); - - // Then it becomes read - await util.assertRead(room2); - }); - }); }); }); diff --git a/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts new file mode 100644 index 0000000000..cb7393a63f --- /dev/null +++ b/playwright/e2e/read-receipts/redactions-main-timeline.spec.ts @@ -0,0 +1,331 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("redactions", () => { + test.describe("in the main timeline", () => { + test("Redacting the message pointed to by my receipt leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given I have read the messages in a room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When the latest message is redacted + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // Then the room remains read + await util.assertStillRead(room2); + }); + + test("Reading an unread room after a redaction of the latest message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // And the latest message has been redacted + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // When I read the room + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("Reading an unread room after a redaction of an older message makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room with an earlier redaction + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // When I read the room + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // Then it becomes read + await util.assertStillRead(room2); + }); + test("Marking an unread room as read after a redaction makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room where latest message is redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // When I mark it as read + await util.markAsRead(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + test("Sending and redacting a message after marking the room as read makes it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room that is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When a message is sent and then redacted + await util.receiveMessages(room2, ["Msg3"]); + await util.assertUnread(room2, 1); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the room is read + await util.assertRead(room2); + }); + test("Redacting a message after marking the room as read leaves it read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a room that is marked as read + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + await util.markAsRead(room2); + await util.assertRead(room2); + + // When we redact some messages + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // Then it is still read + await util.assertStillRead(room2); + }); + test("Redacting one of the unread messages reduces the unread count", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + + // When I redact a non-latest message + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + + // Then the unread count goes down + await util.assertUnread(room2, 2); + + // And when I redact the latest message + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + + // Then the unread count goes down again + await util.assertUnread(room2, 1); + }); + test("Redacting one of the unread messages reduces the unread count after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given unread count was reduced by redacting messages + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2", "Msg3"]); + await util.assertUnread(room2, 3); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg3")]); + await util.assertUnread(room2, 1); + + // When I restart + await util.saveAndReload(); + + // Then the unread count is still reduced + await util.assertUnread(room2, 1); + }); + test("Redacting all unread messages makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread room + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + + // When I redact all the unread messages + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + + // Then the room is back to being read + await util.assertRead(room2); + }); + test("Redacting all unread messages makes the room read after restart", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given all unread messages were redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.receiveMessages(room2, [msg.redactionOf("Msg1")]); + await util.assertRead(room2); + + // When I restart + await util.saveAndReload(); + + // Then the room is still read + await util.assertRead(room2); + }); + test("Reacting to a redacted message leaves the room read", async ({ + page, + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await page.waitForTimeout(200); + await util.goTo(room1); + + // When I react to the redacted message + await util.receiveMessages(room2, [msg.reactionTo("Msg2", "🪿")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + test("Editing a redacted message leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a redacted message exists + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I attempt to edit the redacted message + await util.receiveMessages(room2, [msg.editOf("Msg2", "Msg2 is BACK")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + test("A reply to a redacted message makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a message was redacted + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + + // And the room is read + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + + // When I receive a reply to the redacted message + await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + test("Reading a reply to a redacted message marks the room as read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given someone replied to a redacted message + await util.goTo(room1); + await util.receiveMessages(room2, ["Msg1", "Msg2"]); + await util.assertUnread(room2, 2); + await util.receiveMessages(room2, [msg.redactionOf("Msg2")]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.assertRead(room2); + await util.goTo(room1); + await util.receiveMessages(room2, [msg.replyTo("Msg2", "Reply to Msg2")]); + await util.assertUnread(room2, 1); + + // When I read the reply + await util.goTo(room2); + await util.assertRead(room2); + + // Then the room is unread + await util.goTo(room1); + await util.assertStillRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts new file mode 100644 index 0000000000..0ded3957fb --- /dev/null +++ b/playwright/e2e/read-receipts/redactions-thread-roots.spec.ts @@ -0,0 +1,232 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("redactions", () => { + test.describe("thread roots", () => { + test("Redacting a thread root after it was read leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + test.slow(); + + // Given a thread exists and is read + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + + // When someone redacts the thread root + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // Then the room is still read + await util.assertStillRead(room2); + }); + + /* + * Disabled for the same reason as "A thread with a read redaction is still read after restart" + * above + */ + test.skip("Redacting a thread root still allows us to read the thread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given an unread thread exists + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + + // When someone redacts the thread root + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // Then the room is still unread + await util.assertUnread(room2, 1); + + // And I can open the thread and read it + await util.goTo(room2); + await util.assertRead(room2); + // The redacted message gets collapsed into, "foo was invited, joined and removed a message" + await util.openCollapsedMessage(1); + await util.openThread("Message deleted"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Sending a threaded message onto a redacted thread root leaves the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and its root is redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When we receive a new message on it + await util.receiveMessages(room2, [msg.threadedOff("Root", "Msg4")]); + + // Then the room is read but the thread is unread + await util.assertRead(room2); + await util.goTo(room2); + await util.assertUnreadThread("Message deleted"); + }); + + test("Reacting to a redacted thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I react to the old root + await util.receiveMessages(room2, [msg.reactionTo("Root", "y")]); + + // Then the room is still read + await util.assertRead(room2); + await util.assertReadThread("Root"); + }); + + test("Editing a redacted thread root leaves the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I edit the old root + await util.receiveMessages(room2, [msg.editOf("Root", "New Root")]); + + // Then the room is still read + await util.assertRead(room2); + // as is the thread + await util.assertReadThread("Root"); + }); + + test("Replying to a redacted thread root makes the room unread", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + + // When I reply to the old root + await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); + + // Then the room is unread + await util.assertUnread(room2, 1); + }); + + test("Reading a reply to a redacted thread root makes the room read", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + // Given a thread exists, is read and the root was redacted, and + // someone replied to it + await util.goTo(room1); + await util.receiveMessages(room2, [ + "Root", + msg.threadedOff("Root", "Msg2"), + msg.threadedOff("Root", "Msg3"), + ]); + await util.assertUnread(room2, 1); + await util.goTo(room2); + await util.openThread("Root"); + await util.assertRead(room2); + await util.assertReadThread("Root"); + await util.receiveMessages(room2, [msg.redactionOf("Root")]); + await util.assertStillRead(room2); + await util.receiveMessages(room2, [msg.replyTo("Root", "Reply!")]); + await util.assertUnread(room2, 1); + + // When I read the room + await util.goTo(room2); + + // Then it becomes read + await util.assertRead(room2); + }); + }); + }); +}); diff --git a/playwright/e2e/read-receipts/room-list-order.spec.ts b/playwright/e2e/read-receipts/room-list-order.spec.ts new file mode 100644 index 0000000000..2b43022918 --- /dev/null +++ b/playwright/e2e/read-receipts/room-list-order.spec.ts @@ -0,0 +1,61 @@ +/* +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. +*/ + +/* See readme.md for tips on writing these tests. */ + +import { test } from "."; + +test.describe("Read receipts", () => { + test.describe("Room list order", () => { + test("Rooms with unread messages appear at the top of room list if 'unread first' is selected", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + page, + }) => { + await util.goTo(room2); + + // Display the unread first room + await util.toggleRoomUnreadOrder(); + await util.receiveMessages(room1, ["Msg1"]); + await page.reload(); + + // Room 1 has an unread message and should be displayed first + await util.assertRoomListOrder([room1, room2]); + }); + + test("Rooms with unread threads appear at the top of room list if 'unread first' is selected", async ({ + roomAlpha: room1, + roomBeta: room2, + util, + msg, + }) => { + await util.goTo(room2); + await util.receiveMessages(room1, ["Msg1"]); + await util.markAsRead(room1); + await util.assertRead(room1); + + // Display the unread first room + await util.toggleRoomUnreadOrder(); + await util.receiveMessages(room1, [msg.threadedOff("Msg1", "Resp1")]); + await util.saveAndReload(); + + // Room 1 has an unread message and should be displayed first + await util.assertRoomListOrder([room1, room2]); + }); + }); +}); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts index 79c78b71f7..aa00681f61 100644 --- a/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts +++ b/playwright/e2e/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts @@ -60,113 +60,4 @@ test.describe("Appearance user settings tab", () => { // Assert that the font-family value was removed await expect(page.locator("body")).toHaveCSS("font-family", '""'); }); - - test.describe("Message Layout Panel", () => { - test.beforeEach(async ({ app, user, util }) => { - await util.createAndDisplayRoom(); - await util.assertModernLayout(); - await util.openAppearanceTab(); - }); - - test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => { - await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png"); - - await util.getBubbleLayout().click(); - - // Assert that modern are irc layout are not selected - await expect(util.getBubbleLayout()).toBeChecked(); - await expect(util.getModernLayout()).not.toBeChecked(); - await expect(util.getIRCLayout()).not.toBeChecked(); - - // Assert that the room layout is set to bubble layout - await util.assertBubbleLayout(); - await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png"); - }); - - test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => { - await expect(util.getCompactLayoutCheckbox()).not.toBeChecked(); - - await util.getCompactLayoutCheckbox().click(); - await util.assertCompactLayout(); - }); - - test("should disable compact layout when the modern layout is not selected", async ({ - page, - app, - user, - util, - }) => { - await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled(); - - // Select the bubble layout, which should disable the compact layout checkbox - await util.getBubbleLayout().click(); - await expect(util.getCompactLayoutCheckbox()).toBeDisabled(); - }); - }); - - test.describe("Theme Choice Panel", () => { - test.beforeEach(async ({ app, user, util }) => { - // Disable the default theme for consistency in case ThemeWatcher automatically chooses it - await util.disableSystemTheme(); - await util.openAppearanceTab(); - }); - - test("should be rendered with the light theme selected", async ({ page, app, util }) => { - // Assert that 'Match system theme' is not checked - await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked(); - - // Assert that the light theme is selected - await expect(util.getLightTheme()).toBeChecked(); - // Assert that the dark and high contrast themes are not selected - await expect(util.getDarkTheme()).not.toBeChecked(); - await expect(util.getHighContrastTheme()).not.toBeChecked(); - - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png"); - }); - - test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => { - await util.getMatchSystemThemeCheckbox().click(); - - // Assert that the themes are disabled - await expect(util.getLightTheme()).toBeDisabled(); - await expect(util.getDarkTheme()).toBeDisabled(); - await expect(util.getHighContrastTheme()).toBeDisabled(); - - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png"); - }); - - test("should change the theme to dark", async ({ page, app, util }) => { - // Assert that the light theme is selected - await expect(util.getLightTheme()).toBeChecked(); - - await util.getDarkTheme().click(); - - // Assert that the light and high contrast themes are not selected - await expect(util.getLightTheme()).not.toBeChecked(); - await expect(util.getDarkTheme()).toBeChecked(); - await expect(util.getHighContrastTheme()).not.toBeChecked(); - - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png"); - }); - - test.describe("custom theme", () => { - test.use({ - labsFlags: ["feature_custom_themes"], - }); - - test("should render the custom theme section", async ({ page, app, util }) => { - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); - }); - - test("should be able to add and remove a custom theme", async ({ page, app, util }) => { - await util.addCustomTheme(); - - await expect(util.getCustomTheme()).not.toBeChecked(); - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png"); - - await util.removeCustomTheme(); - await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); - }); - }); - }); }); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts new file mode 100644 index 0000000000..1a22696da1 --- /dev/null +++ b/playwright/e2e/settings/appearance-user-settings-tab/message-layout-panel.ts @@ -0,0 +1,66 @@ +/* +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 { expect, test } from "."; + +test.describe("Appearance user settings tab", () => { + test.use({ + displayName: "Hanako", + }); + + test.describe("Message Layout Panel", () => { + test.beforeEach(async ({ app, user, util }) => { + await util.createAndDisplayRoom(); + await util.assertModernLayout(); + await util.openAppearanceTab(); + }); + + test("should change the message layout from modern to bubble", async ({ page, app, user, util }) => { + await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-modern.png"); + + await util.getBubbleLayout().click(); + + // Assert that modern are irc layout are not selected + await expect(util.getBubbleLayout()).toBeChecked(); + await expect(util.getModernLayout()).not.toBeChecked(); + await expect(util.getIRCLayout()).not.toBeChecked(); + + // Assert that the room layout is set to bubble layout + await util.assertBubbleLayout(); + await util.assertScreenshot(util.getMessageLayoutPanel(), "message-layout-panel-bubble.png"); + }); + + test("should enable compact layout when the modern layout is selected", async ({ page, app, user, util }) => { + await expect(util.getCompactLayoutCheckbox()).not.toBeChecked(); + + await util.getCompactLayoutCheckbox().click(); + await util.assertCompactLayout(); + }); + + test("should disable compact layout when the modern layout is not selected", async ({ + page, + app, + user, + util, + }) => { + await expect(util.getCompactLayoutCheckbox()).not.toBeDisabled(); + + // Select the bubble layout, which should disable the compact layout checkbox + await util.getBubbleLayout().click(); + await expect(util.getCompactLayoutCheckbox()).toBeDisabled(); + }); + }); +}); diff --git a/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts new file mode 100644 index 0000000000..2b1e8cc14d --- /dev/null +++ b/playwright/e2e/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts @@ -0,0 +1,89 @@ +/* +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 { expect, test } from "."; + +test.describe("Appearance user settings tab", () => { + test.use({ + displayName: "Hanako", + }); + + test.describe("Theme Choice Panel", () => { + test.beforeEach(async ({ app, user, util }) => { + // Disable the default theme for consistency in case ThemeWatcher automatically chooses it + await util.disableSystemTheme(); + await util.openAppearanceTab(); + }); + + test("should be rendered with the light theme selected", async ({ page, app, util }) => { + // Assert that 'Match system theme' is not checked + await expect(util.getMatchSystemThemeCheckbox()).not.toBeChecked(); + + // Assert that the light theme is selected + await expect(util.getLightTheme()).toBeChecked(); + // Assert that the dark and high contrast themes are not selected + await expect(util.getDarkTheme()).not.toBeChecked(); + await expect(util.getHighContrastTheme()).not.toBeChecked(); + + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-light.png"); + }); + + test("should disable the themes when the system theme is clicked", async ({ page, app, util }) => { + await util.getMatchSystemThemeCheckbox().click(); + + // Assert that the themes are disabled + await expect(util.getLightTheme()).toBeDisabled(); + await expect(util.getDarkTheme()).toBeDisabled(); + await expect(util.getHighContrastTheme()).toBeDisabled(); + + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-match-system-enabled.png"); + }); + + test("should change the theme to dark", async ({ page, app, util }) => { + // Assert that the light theme is selected + await expect(util.getLightTheme()).toBeChecked(); + + await util.getDarkTheme().click(); + + // Assert that the light and high contrast themes are not selected + await expect(util.getLightTheme()).not.toBeChecked(); + await expect(util.getDarkTheme()).toBeChecked(); + await expect(util.getHighContrastTheme()).not.toBeChecked(); + + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-dark.png"); + }); + + test.describe("custom theme", () => { + test.use({ + labsFlags: ["feature_custom_themes"], + }); + + test("should render the custom theme section", async ({ page, app, util }) => { + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); + }); + + test("should be able to add and remove a custom theme", async ({ page, app, util }) => { + await util.addCustomTheme(); + + await expect(util.getCustomTheme()).not.toBeChecked(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme-added.png"); + + await util.removeCustomTheme(); + await expect(util.getThemePanel()).toMatchScreenshot("theme-panel-custom-theme.png"); + }); + }); + }); +}); diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-added-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-added-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-custom-theme-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-custom-theme-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-dark-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-dark-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-light-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-light-linux.png diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png similarity index 100% rename from playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/theme-panel-match-system-enabled-linux.png rename to playwright/snapshots/settings/appearance-user-settings-tab/theme-choice-panel.spec.ts/theme-panel-match-system-enabled-linux.png