Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into staging

# Conflicts:
#	package.json
This commit is contained in:
Michael Telatynski 2024-07-23 12:52:57 +01:00
commit be846e6516
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
263 changed files with 6695 additions and 5290 deletions

View file

@ -190,13 +190,14 @@ jobs:
- name: Merge into HTML Report
if: inputs.skip != true
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts ./all-blob-reports
run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
env:
# Only pass creds to the flaky-reporter on main branch runs
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
# Upload the HTML report even if one of our reporters fails, this can happen when stale screenshots are detected
- name: Upload HTML report
if: inputs.skip != true
if: always() && inputs.skip != true
uses: actions/upload-artifact@v4
with:
name: html-report

View file

@ -73,7 +73,7 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.23.0",
"@matrix-org/analytics-events": "^0.24.0",
"@matrix-org/emojibase-bindings": "^1.1.2",
"@matrix-org/matrix-wysiwyg": "2.37.4",
"@matrix-org/react-sdk-module-api": "^2.4.0",
@ -81,7 +81,7 @@
"@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.2.0",
"@vector-im/compound-web": "^5.2.3",
"@vector-im/compound-web": "^5.4.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@ -91,12 +91,13 @@
"classnames": "^2.2.6",
"commonmark": "^0.31.0",
"counterpart": "^0.18.6",
"css-tree": "^2.3.1",
"diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5",
"emojibase-regex": "15.3.2",
"escape-html": "^1.0.3",
"file-saver": "^2.0.5",
"filesize": "10.1.2",
"filesize": "10.1.4",
"github-markdown-css": "^5.5.1",
"glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1",
@ -121,7 +122,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.141.3",
"posthog-js": "1.145.0",
"qrcode": "1.5.3",
"re-resizable": "^6.9.0",
"react": "17.0.2",
@ -167,6 +168,7 @@
"@types/commonmark": "^0.27.4",
"@types/content-type": "^1.1.5",
"@types/counterpart": "^0.18.1",
"@types/css-tree": "^2.3.8",
"@types/diff-match-patch": "^1.0.32",
"@types/escape-html": "^1.0.1",
"@types/express": "^4.17.21",
@ -211,6 +213,7 @@
"fake-indexeddb": "^6.0.0",
"fetch-mock-jest": "^1.5.1",
"fs-extra": "^11.0.0",
"glob": "^11.0.0",
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.6.2",
@ -221,15 +224,16 @@
"matrix-web-i18n": "^3.2.1",
"mocha-junit-reporter": "^2.2.0",
"node-fetch": "2",
"playwright-core": "^1.45.1",
"postcss-scss": "^4.0.4",
"prettier": "3.3.2",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.0",
"rimraf": "^6.0.0",
"stylelint": "^16.1.0",
"stylelint-config-standard": "^36.0.0",
"stylelint-scss": "^6.0.0",
"ts-node": "^10.9.1",
"typescript": "5.5.2",
"typescript": "5.5.3",
"web-streams-polyfill": "^4.0.0"
},
"peerDependencies": {

View file

@ -37,9 +37,10 @@ export default defineConfig({
},
testDir: "playwright/e2e",
outputDir: "playwright/test-results",
workers: 1,
workers: process.env.CI ? "50%" : 1,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["blob"], ["github"]] : [["html", { outputFolder: "playwright/html-report" }]],
snapshotDir: "playwright/snapshots",
snapshotPathTemplate: "{snapshotDir}/{testFilePath}/{arg}-{platform}{ext}",
forbidOnly: !!process.env.CI,
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_NotificationPanel_empty::before {
--maskImage: url("$(res)/img/element-icons/notifications.svg"); /* See: _RightPanel.pcss */
declare module "playwright-core/lib/utils" {
// This type is not public in playwright-core utils
export function sanitizeForFilePath(filePath: string): string;
}

View file

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.45.0-jammy
FROM mcr.microsoft.com/playwright:v1.45.1-jammy
WORKDIR /work/matrix-react-sdk
VOLUME ["/work/element-web/node_modules"]

View file

@ -0,0 +1,166 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";
test.describe("Landmark navigation tests", () => {
test.use({
displayName: "Alice",
});
test("without any rooms", async ({ page, homeserver, app, user }) => {
/**
* Without any rooms, there is no tile in the roomlist to be focused.
* So the next landmark in the list should be focused instead.
*/
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
test("with an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
// create dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
test("without an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();
// create a dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);
await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();
// Close the room
page.goto("/#/home");
// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();
// Pressing Control+F6 again will focus the home section
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
// Pressing Control+F6 will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
// Now go back in same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
});

View file

@ -0,0 +1,42 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
test(`shows error page if browser lacks Intl support`, async ({ page }) => {
await page.addInitScript({ content: `delete window.Intl;` });
await page.goto("/");
// Lack of Intl support causes the app bundle to fail to load, so we get the iframed
// static error page and need to explicitly look in the iframe becuse Playwright doesn't
// recurse into iframes when looking for elements
const header = await page.frameLocator("iframe").getByText("Unsupported browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser.png");
});
test(`shows error page if browser lacks WebAssembly support`, async ({ page }) => {
await page.addInitScript({ content: `delete window.WebAssembly;` });
await page.goto("/");
// Lack of WebAssembly support doesn't cause the bundle to fail loading, so we get
// CompatibilityView, ie. no iframes.
const header = await page.getByText("Unsupported browser");
await expect(header).toBeVisible();
await expect(page).toMatchScreenshot("unsupported-browser-CompatibilityView.png");
});

View file

@ -48,7 +48,7 @@ test("Shows the last known page on reload", async ({ pageWithCredentials: page }
// Check that the room reloaded
await expect(page).toHaveURL(/\/#\/room\//);
await expect(page.locator(".mx_LegacyRoomHeader")).toContainText("Test Room");
await expect(page.locator(".mx_RoomHeader")).toContainText("Test Room");
});
test("Room link correctly loads a room view", async ({ pageWithCredentials: page }) => {

View file

@ -0,0 +1,133 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import os from "node:os";
import path from "node:path";
import * as fsp from "node:fs/promises";
import * as fs from "node:fs";
import JSZip from "jszip";
import { test, expect } from "../../element-web-test";
// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912
async function extractZipFileToPath(file: string, outputPath: string): Promise<JSZip> {
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true });
}
const data = await fsp.readFile(file);
const zip = await JSZip.loadAsync(data, { createFolders: true });
await new Promise<void>((resolve, reject) => {
let entryCount = 0;
let errorOut = false;
zip.forEach(() => {
entryCount++;
}); // there is no other way to count the number of entries within the zip file.
zip.forEach((relativePath, zipEntry) => {
if (errorOut) {
return;
}
const outputEntryPath = path.join(outputPath, relativePath);
if (zipEntry.dir) {
if (!fs.existsSync(outputEntryPath)) {
fs.mkdirSync(outputEntryPath, { recursive: true });
}
entryCount--;
if (entryCount === 0) {
resolve();
}
} else {
void zipEntry
.async("blob")
.then(async (content) => Buffer.from(await content.arrayBuffer()))
.then((buffer) => {
const stream = fs.createWriteStream(outputEntryPath);
stream.write(buffer, (error) => {
if (error) {
reject(error);
errorOut = true;
}
});
stream.on("finish", () => {
entryCount--;
if (entryCount === 0) {
resolve();
}
});
stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that.
})
.catch((e) => {
errorOut = true;
reject(e);
});
}
});
});
return zip;
}
test.describe("HTML Export", () => {
test.use({
displayName: "Alice",
room: async ({ app, user }, use) => {
const roomId = await app.client.createRoom({ name: "Important Room" });
await app.viewRoomByName("Important Room");
await use({ roomId });
},
});
test("should export html successfully and match screenshot", async ({ page, app, room }) => {
// Send a bunch of messages to populate the room
for (let i = 1; i < 10; i++) {
await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" });
}
// Wait for all the messages to be displayed
await expect(
page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"),
).toBeVisible();
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Export Chat" }).click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export", exact: true }).click();
const download = await downloadPromise;
const dirPath = path.join(os.tmpdir(), "html-export-test");
const zipPath = `${dirPath}.zip`;
await download.saveAs(zipPath);
const zip = await extractZipFileToPath(zipPath, dirPath);
await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`);
await expect(page).toMatchScreenshot("html-export.png", {
mask: [
page.getByText("This is the start of export", { exact: false }),
// We need to mask the whole thing because the width of the time part changes
page.locator(".mx_TimelineSeparator"),
page.locator(".mx_MessageTimestamp"),
],
});
});
});

View file

@ -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();
});
});
});
});

View file

@ -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"],

View file

@ -36,7 +36,7 @@ test.describe("Create Room", () => {
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page).toHaveURL(/\/#\/room\/#test-room-1:localhost/);
const header = page.locator(".mx_LegacyRoomHeader");
const header = page.locator(".mx_RoomHeader");
await expect(header).toContainText(name);
await expect(header).toContainText(topic);
});

View file

@ -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",
@ -275,10 +224,10 @@ test.describe("Cryptography", function () {
await checkDMRoom(page);
const bobRoomId = await bobJoin(page, bob);
await testMessages(page, bob, bobRoomId);
await verify(page, bob);
await verify(app, bob);
// Assert that verified icon is rendered
await page.getByRole("button", { name: "Room members" }).click();
await page.getByTestId("base-card-back-button").click();
await page.locator(".mx_RightPanelTabs").getByText("Info").click();
await expect(page.locator('.mx_RoomSummaryCard_badges [data-kind="success"]')).toContainText("Encrypted");
@ -297,511 +246,6 @@ test.describe("Cryptography", function () {
// we need to have a room with the other user present, so we can open the verification panel
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<void>((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<void>((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<void>((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();
});
});
await verify(app, bob);
});
});

View file

@ -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<void>((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<void>((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<void>((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();
});
});
});
});

View file

@ -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<string> {
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<VerificationRequest>): Promise<JSHandle<Verifier>> {
return botVerificationRequest.evaluateHandle(async (verificationRequest) => {
while (!verificationRequest.verifier) {
await new Promise((r) => verificationRequest.once("change" as any, r));
}
return verificationRequest.verifier;
});
}

View file

@ -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(app, 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.getByTestId("base-card-back-button").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(app, 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();
});
});
});

View file

@ -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<string> {
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",
},
},
],
});
}

View file

@ -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,60 @@ export async function createRoom(page: Page, roomName: string, isEncrypted: bool
await expect(page.getByText("Encryption enabled")).toBeVisible();
}
}
/**
* 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 (app: ElementAppPage, bob: Bot) => {
const page = app.page;
const bobsVerificationRequestPromise = waitForVerificationRequest(bob);
const roomInfo = await app.toggleRoomInfoPanel();
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<VerificationRequest>,
): Promise<JSHandle<Verifier>> {
return botVerificationRequest.evaluateHandle(async (verificationRequest) => {
while (!verificationRequest.verifier) {
await new Promise((r) => verificationRequest.once("change" as any, r));
}
return verificationRequest.verifier;
});
}

View file

@ -38,8 +38,8 @@ test.describe("Image Upload", () => {
.locator(".mx_MessageComposer_actions input[type='file']")
.setInputFiles("playwright/sample-files/riot.png");
expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
expect(page).toMatchScreenshot("image-upload-preview.png");
await expect(page.getByRole("button", { name: "Upload" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Close dialog" })).toBeEnabled();
await expect(page).toMatchScreenshot("image-upload-preview.png");
});
});

View file

@ -118,8 +118,8 @@ test.describe("Integration Manager: Get OpenID Token", () => {
await app.viewRoomByName(ROOM_NAME);
});
test("should successfully obtain an openID token", async ({ page }) => {
await openIntegrationManager(page);
test("should successfully obtain an openID token", async ({ page, app }) => {
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl);
const iframe = page.frameLocator(`iframe[src*="${integrationManagerUrl}"]`);

View file

@ -167,7 +167,7 @@ test.describe("Integration Manager: Kick", () => {
await app.client.inviteUser(room.roomId, targetUser.credentials.userId);
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await openIntegrationManager(page);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, true);
@ -185,7 +185,7 @@ test.describe("Integration Manager: Kick", () => {
},
});
await openIntegrationManager(page);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
@ -197,7 +197,7 @@ test.describe("Integration Manager: Kick", () => {
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await targetUser.leave(room.roomId);
await openIntegrationManager(page);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
@ -209,7 +209,7 @@ test.describe("Integration Manager: Kick", () => {
await expect(page.getByText(`${BOT_DISPLAY_NAME} joined the room`)).toBeVisible();
await app.client.ban(room.roomId, targetUser.credentials.userId);
await openIntegrationManager(page);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);
@ -218,7 +218,7 @@ test.describe("Integration Manager: Kick", () => {
test("should no-op if the target was never a room member", async ({ page, app, bot: targetUser, room }) => {
await app.viewRoomByName(ROOM_NAME);
await openIntegrationManager(page);
await openIntegrationManager(app);
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, targetUser.credentials.userId);
await closeIntegrationManager(page, integrationManagerUrl);
await expectKickedMessage(page, false);

View file

@ -142,7 +142,7 @@ test.describe("Integration Manager: Read Events", () => {
// Send a state event
const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey);
await openIntegrationManager(page);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
@ -162,7 +162,7 @@ test.describe("Integration Manager: Read Events", () => {
// Send a state event
const sendEventResponse = await app.client.sendStateEvent(room.roomId, eventType, eventContent, stateKey);
await openIntegrationManager(page);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);
@ -196,7 +196,7 @@ test.describe("Integration Manager: Read Events", () => {
app.client.sendStateEvent(room.roomId, eventType, eventContent3, stateKey3),
]);
await openIntegrationManager(page);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(
@ -217,11 +217,11 @@ test.describe("Integration Manager: Read Events", () => {
await expect(iframe.locator("#message-response")).toContainText(`"content":${JSON.stringify(eventContent3)}`);
});
test("should fail to read an event type which is not allowed", async ({ page, room }) => {
test("should fail to read an event type which is not allowed", async ({ page, app, room }) => {
const eventType = "com.example.event";
const stateKey = "";
await openIntegrationManager(page);
await openIntegrationManager(app);
// Read state events
await sendActionFromIntegrationManager(page, integrationManagerUrl, room.roomId, eventType, stateKey);

View file

@ -137,7 +137,7 @@ test.describe("Integration Manager: Send Event", () => {
);
await app.viewRoomByName(ROOM_NAME);
await openIntegrationManager(page);
await openIntegrationManager(app);
});
test("should send a state event", async ({ page, app, room }) => {

View file

@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { Page } from "@playwright/test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
export async function openIntegrationManager(page: Page) {
await page.getByRole("button", { name: "Room info" }).click();
export async function openIntegrationManager(app: ElementAppPage) {
const { page } = app;
await app.toggleRoomInfoPanel();
await page
.locator(".mx_RoomSummaryCard_appsGroup")
.getByRole("button", { name: "Add widgets, bridges & bots" })

View file

@ -36,7 +36,7 @@ test.describe("Invite dialog", function () {
await expect(page.getByText("Hanako created and configured the room.")).toBeVisible();
// Open the room info panel
await page.getByRole("button", { name: "Room info" }).click();
await app.toggleRoomInfoPanel();
await page.locator(".mx_BaseCard").getByRole("menuitem", { name: "Invite" }).click();
@ -114,12 +114,9 @@ test.describe("Invite dialog", function () {
// Assert that the hovered user name on invitation UI does not have background color
// TODO: implement the test on room-header.spec.ts
const roomHeader = page.locator(".mx_LegacyRoomHeader");
await roomHeader.locator(".mx_LegacyRoomHeader_name--textonly").hover();
await expect(roomHeader.locator(".mx_LegacyRoomHeader_name--textonly")).toHaveCSS(
"background-color",
"rgba(0, 0, 0, 0)",
);
const roomHeader = page.locator(".mx_RoomHeader");
await roomHeader.locator(".mx_RoomHeader_heading").hover();
await expect(roomHeader.locator(".mx_RoomHeader_heading")).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
// Send a message to invite the bots
const composer = app.getComposer().locator("[contenteditable]");

View file

@ -31,7 +31,7 @@ test.describe("Create Knock Room", () => {
await dialog.getByRole("option", { name: "Ask to join" }).click();
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible();
await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
@ -48,7 +48,7 @@ test.describe("Create Knock Room", () => {
await dialog.getByRole("textbox", { name: "Name" }).fill("Cybersecurity");
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible();
await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");
@ -74,7 +74,7 @@ test.describe("Create Knock Room", () => {
await dialog.getByText("Make this room visible in the public room directory.").click();
await dialog.getByRole("button", { name: "Create room" }).click();
await expect(page.locator(".mx_LegacyRoomHeader").getByText("Cybersecurity")).toBeVisible();
await expect(page.locator(".mx_RoomHeader").getByText("Cybersecurity")).toBeVisible();
const urlHash = await page.evaluate(() => window.location.hash);
const roomId = urlHash.replace("#/room/", "");

View file

@ -78,8 +78,9 @@ test.describe("Lazy Loading", () => {
}
}
async function openMemberlist(page: Page): Promise<void> {
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Room info" }).click();
async function openMemberlist(app: ElementAppPage): Promise<void> {
await app.toggleRoomInfoPanel();
const { page } = app;
await page.locator(".mx_RightPanelTabs").getByText("People").click();
}
@ -123,7 +124,7 @@ test.describe("Lazy Loading", () => {
// Alice should see 2 messages from every charly with the correct display name
await checkPaginatedDisplayNames(app, charly1to5);
await openMemberlist(page);
await openMemberlist(app);
await checkMemberList(page, charly1to5);
await joinCharliesWhileAliceIsOffline(page, app, charly6to10);
await checkMemberList(page, charly6to10);

View file

@ -35,10 +35,10 @@ test.describe("1:1 chat room", () => {
await page.goto(`/#/user/${user2.userId}?action=chat`);
});
test("should open new 1:1 chat room after leaving the old one", async ({ page, user2 }) => {
test("should open new 1:1 chat room after leaving the old one", async ({ page, app, user2 }) => {
// leave 1:1 chat room
await page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName).click();
await page.getByRole("menuitem", { name: "Leave" }).click();
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Leave room" }).click();
await page.getByRole("button", { name: "Leave" }).click();
// wait till the room was left
@ -49,6 +49,6 @@ test.describe("1:1 chat room", () => {
// open new 1:1 chat room
await page.goto(`/#/user/${user2.userId}?action=chat`);
await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(user2.displayName)).toBeVisible();
await expect(page.locator(".mx_RoomHeader_heading").getByText(user2.displayName)).toBeVisible();
});
});

View file

@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { test, expect } from "../../element-web-test";
import type { Page } from "@playwright/test";
import type { Bot } from "../../pages/bot";
import type { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Poll history", () => {
type CreatePollOptions = {
@ -66,8 +66,9 @@ test.describe("Poll history", () => {
});
};
async function openPollHistory(page: Page): Promise<void> {
await page.getByRole("button", { name: "Room info" }).click();
async function openPollHistory(app: ElementAppPage): Promise<void> {
const { page } = app;
await app.toggleRoomInfoPanel();
await page.locator(".mx_RoomSummaryCard").getByRole("menuitem", { name: "Poll history" }).click();
}
@ -116,7 +117,7 @@ test.describe("Poll history", () => {
await botVoteForOption(bot, roomId, pollId2, pollParams1.options[1].id);
await endPoll(bot, roomId, pollId2);
await openPollHistory(page);
await openPollHistory(app);
// these polls are also in the timeline
// focus on the poll history dialog

View file

@ -59,7 +59,7 @@ test.describe("Presence tests", () => {
);
await app.client.createRoom({}); // trigger sync
await page.getByRole("button", { name: "Room info" }).click();
await app.toggleRoomInfoPanel();
await page.locator(".mx_RightPanel").getByText("People").click();
await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("Bob");
await expect(page.locator(".mx_EntityTile_unreachable")).toContainText("User's server unreachable");

View file

@ -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");
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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", () => {});
});
});
});

View file

@ -402,7 +402,7 @@ class Helpers {
* Close the threads panel.
*/
async closeThreadsPanel() {
await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click();
await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
await expect(this.page.locator(".mx_RightPanel")).not.toBeVisible();
}
@ -410,7 +410,7 @@ class Helpers {
* Return to the list of threads, given we are viewing a single thread.
*/
async backToThreadsList() {
await this.page.locator(".mx_LegacyRoomHeader").getByLabel("Threads").click();
await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
}
/**
@ -530,15 +530,14 @@ class Helpers {
// whether it's open or not - wait here to give it a chance to settle.
await this.page.waitForTimeout(200);
const ariaCurrent = await this.page.getByTestId("threadsButton").getAttribute("aria-current");
if (ariaCurrent !== "true") {
await this.page.getByTestId("threadsButton").click();
}
const threadPanel = this.page.locator(".mx_ThreadPanel");
const isThreadPanelOpen = (await threadPanel.count()) !== 0;
if (!isThreadPanelOpen) {
await this.page.locator(".mx_RoomHeader").getByLabel("Threads").click();
}
await expect(threadPanel).toBeVisible();
await threadPanel.evaluate(($panel) => {
const $button = $panel.querySelector<HTMLElement>('.mx_BaseCard_back[aria-label="Threads"]');
const $button = $panel.querySelector<HTMLElement>('[data-testid="base-card-back-button"]');
// If the Threads back button is present then click it - the
// threads button can open either threads list or thread panel
if ($button) {

View file

@ -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",
() => {},
);
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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", () => {});
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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);
});
});
});
});

View file

@ -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]);
});
});
});

View file

@ -50,7 +50,7 @@ test.describe("FilePanel", () => {
test.describe("render", () => {
test("should render empty state", async ({ page }) => {
// Wait until the information about the empty state is rendered
await expect(page.locator(".mx_FilePanel_empty")).toBeVisible();
await expect(page.locator(".mx_EmptyState")).toBeVisible();
// Take a snapshot of RightPanel - fix https://github.com/vector-im/element-web/issues/25332
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png");

View file

@ -35,7 +35,7 @@ test.describe("NotificationPanel", () => {
await page.getByRole("button", { name: "Notifications" }).click();
// Wait until the information about the empty state is rendered
await expect(page.locator(".mx_NotificationPanel_empty")).toBeVisible();
await expect(page.locator(".mx_EmptyState")).toBeVisible();
// Take a snapshot of RightPanel
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("empty.png");

View file

@ -63,9 +63,9 @@ test.describe("RightPanel", () => {
await app.closeDialog();
// Close and reopen the right panel to render the room address
await page.getByRole("button", { name: "Room info" }).click();
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).not.toBeVisible();
await page.getByRole("button", { name: "Room info" }).click();
await app.toggleRoomInfoPanel();
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("with-name-and-address.png");
});
@ -104,9 +104,9 @@ test.describe("RightPanel", () => {
await page.getByRole("menuitem", { name: "Files" }).click();
await expect(page.locator(".mx_FilePanel")).toBeVisible();
await expect(page.locator(".mx_FilePanel_empty")).toBeVisible();
await expect(page.locator(".mx_EmptyState")).toBeVisible();
await page.getByRole("button", { name: "Room information" }).click();
await page.getByTestId("base-card-back-button").click();
await checkRoomSummaryCard(page, ROOM_NAME);
});
@ -120,7 +120,7 @@ test.describe("RightPanel", () => {
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await page.getByRole("button", { name: "Room members" }).click();
await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await page.locator(".mx_RightPanelTabs").getByText("Info").click();
@ -138,14 +138,12 @@ test.describe("RightPanel", () => {
.getByRole("button", { name: /\d member/ })
.click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible();
await getMemberTileByName(page, NAME).click();
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(NAME)).toBeVisible();
await expect(page.locator(".mx_SpaceScopeHeader").getByText(SPACE_NAME)).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await page.getByTestId("base-card-back-button").click();
await expect(page.locator(".mx_MemberList")).toBeVisible();
});
});

View file

@ -20,7 +20,7 @@ import { ElementAppPage } from "../../pages/ElementAppPage";
export async function viewRoomSummaryByName(page: Page, app: ElementAppPage, name: string): Promise<void> {
await app.viewRoomByName(name);
await page.getByRole("button", { name: "Room info" }).click();
await app.toggleRoomInfoPanel();
return checkRoomSummaryCard(page, name);
}

View file

@ -18,7 +18,6 @@ import { Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
import { ElementAppPage } from "../../pages/ElementAppPage";
import type { Container } from "../../../src/stores/widgets/types";
test.describe("Room Header", () => {
test.use({
@ -33,24 +32,28 @@ test.describe("Room Header", () => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const header = page.locator(".mx_LegacyRoomHeader");
// Names (aria-label) of every button rendered on mx_LegacyRoomHeader by default
const expectedButtonNames = [
"Room options", // The room name button next to the room avatar, which renders dropdown menu on click
"Voice call",
"Video call",
"Search",
"Threads",
"Notifications",
"Room info",
];
const header = page.locator(".mx_RoomHeader");
// Assert they are found and visible
for (const name of expectedButtonNames) {
await expect(header.getByRole("button", { name })).toBeVisible();
}
// There's two room info button - the header itself and the i button
const infoButtons = header.getByRole("button", { name: "Room info" });
await expect(infoButtons).toHaveCount(2);
await expect(infoButtons.first()).toBeVisible();
await expect(infoButtons.last()).toBeVisible();
// Assert that just those seven buttons exist on mx_LegacyRoomHeader by default
// Memberlist button
await expect(header.locator(".mx_FacePile")).toBeVisible();
// There should be both a voice and a video call button
// but they'll be disabled
const callButtons = header.getByRole("button", { name: "There's no one here to call" });
await expect(callButtons).toHaveCount(2);
await expect(callButtons.first()).toBeVisible();
await expect(callButtons.last()).toBeVisible();
await expect(header.getByRole("button", { name: "Threads" })).toBeVisible();
await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible();
// Assert that there are six buttons in total
await expect(header.getByRole("button")).toHaveCount(7);
await expect(header).toMatchScreenshot("room-header.png");
@ -67,14 +70,15 @@ test.describe("Room Header", () => {
await app.client.createRoom({ name: LONG_ROOM_NAME });
await app.viewRoomByName(LONG_ROOM_NAME);
const header = page.locator(".mx_LegacyRoomHeader");
const header = page.locator(".mx_RoomHeader");
// Wait until the room name is set
await expect(page.locator(".mx_LegacyRoomHeader_nametext").getByText(LONG_ROOM_NAME)).toBeVisible();
await expect(page.locator(".mx_RoomHeader_heading").getByText(LONG_ROOM_NAME)).toBeVisible();
// Assert the size of buttons on RoomHeader are specified and the buttons are not compressed
// Note these assertions do not check the size of mx_LegacyRoomHeader_name button
const buttons = page.locator(".mx_LegacyRoomHeader_button");
await expect(buttons).toHaveCount(6);
const buttons = header.locator(".mx_Flex").getByRole("button");
await expect(buttons).toHaveCount(5);
for (const button of await buttons.all()) {
await expect(button).toBeVisible();
await expect(button).toHaveCSS("height", "32px");
@ -83,44 +87,6 @@ test.describe("Room Header", () => {
await expect(header).toMatchScreenshot("room-header-long-name.png");
});
test("should have buttons highlighted by being clicked", async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const header = page.locator(".mx_LegacyRoomHeader");
// Check these buttons
const buttonsHighlighted = ["Threads", "Notifications", "Room info"];
for (const name of buttonsHighlighted) {
await header.getByRole("button", { name: name }).click(); // Highlight the button
}
await expect(header).toMatchScreenshot("room-header-highlighted.png");
});
});
test.describe("with feature_pinning enabled", () => {
test.use({ labsFlags: ["feature_pinning"] });
test("should render the pin button for pinned messages card", async ({ page, app, user }) => {
await app.client.createRoom({ name: "Test Room" });
await app.viewRoomByName("Test Room");
const composer = app.getComposer().locator("[contenteditable]");
await composer.fill("Test message");
await composer.press("Enter");
const lastTile = page.locator(".mx_EventTile_last");
await lastTile.hover();
await lastTile.getByRole("button", { name: "Options" }).click();
await page.getByRole("menuitem", { name: "Pin" }).click();
await expect(
page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Pinned messages" }),
).toBeVisible();
});
});
test.describe("with a video room", () => {
@ -141,30 +107,27 @@ test.describe("Room Header", () => {
test.describe("and with feature_notifications enabled", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_notifications"] });
test("should render buttons for room options, beta pill, invite, chat, and room info", async ({
page,
app,
user,
}) => {
test("should render buttons for chat, room info, threads and facepile", async ({ page, app, user }) => {
await createVideoRoom(page, app);
const header = page.locator(".mx_LegacyRoomHeader");
// Names (aria-label) of the buttons on the video room header
const expectedButtonNames = [
"Room options",
"Video rooms are a beta feature Click for more info", // Beta pill
"Invite",
"Chat",
"Room info",
];
const header = page.locator(".mx_RoomHeader");
// Assert they are found and visible
for (const name of expectedButtonNames) {
await expect(header.getByRole("button", { name })).toBeVisible();
}
// There's two room info button - the header itself and the i button
const infoButtons = header.getByRole("button", { name: "Room info" });
await expect(infoButtons).toHaveCount(2);
await expect(infoButtons.first()).toBeVisible();
await expect(infoButtons.last()).toBeVisible();
// Facepile
await expect(header.locator(".mx_FacePile")).toBeVisible();
// Chat, Threads and Notification buttons
await expect(header.getByRole("button", { name: "Chat" })).toBeVisible();
await expect(header.getByRole("button", { name: "Threads" })).toBeVisible();
await expect(header.getByRole("button", { name: "Notifications" })).toBeVisible();
// Assert that there is not a button except those buttons
await expect(header.getByRole("button")).toHaveCount(7);
await expect(header.getByRole("button")).toHaveCount(6);
await expect(header).toMatchScreenshot("room-header-video-room.png");
});
@ -177,7 +140,7 @@ test.describe("Room Header", () => {
}) => {
await createVideoRoom(page, app);
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Chat" }).click();
await page.locator(".mx_RoomHeader").getByRole("button", { name: "Chat" }).click();
// Assert that the call view is still visible
await expect(page.locator(".mx_CallView")).toBeVisible();
@ -188,114 +151,4 @@ test.describe("Room Header", () => {
).toBeVisible();
});
});
test.describe("with a widget", () => {
const ROOM_NAME = "Test Room with a widget";
const WIDGET_ID = "fake-widget";
const WIDGET_HTML = `
<html lang="en">
<head>
<title>Fake Widget</title>
</head>
<body>
Hello World
</body>
</html>
`;
test.beforeEach(async ({ page, app, user, webserver }) => {
const widgetUrl = webserver.start(WIDGET_HTML);
const roomId = await app.client.createRoom({ name: ROOM_NAME });
// setup widget via state event
await app.client.evaluate(
async (matrixClient, { roomId, widgetUrl, id }) => {
await matrixClient.sendStateEvent(
roomId,
"im.vector.modular.widgets",
{
id,
creatorUserId: "somebody",
type: "widget",
name: "widget",
url: widgetUrl,
},
id,
);
await matrixClient.sendStateEvent(
roomId,
"io.element.widgets.layout",
{
widgets: {
[id]: {
container: "top" as Container,
index: 1,
width: 100,
height: 0,
},
},
},
"",
);
},
{
roomId,
widgetUrl,
id: WIDGET_ID,
},
);
// open the room
await app.viewRoomByName(ROOM_NAME);
});
test("should highlight the apps button", async ({ page, app, user }) => {
// Assert that AppsDrawer is rendered
await expect(page.locator(".mx_AppsDrawer")).toBeVisible();
const header = page.locator(".mx_LegacyRoomHeader");
// Assert that "Hide Widgets" button is rendered and aria-checked is set to true
await expect(header.getByRole("button", { name: "Hide Widgets" })).toHaveAttribute("aria-checked", "true");
await expect(header).toMatchScreenshot("room-header-with-apps-button-highlighted.png");
});
test("should support hiding a widget", async ({ page, app, user }) => {
await expect(page.locator(".mx_AppsDrawer")).toBeVisible();
const header = page.locator(".mx_LegacyRoomHeader");
// Click the apps button to hide AppsDrawer
await header.getByRole("button", { name: "Hide Widgets" }).click();
// Assert that "Show widgets" button is rendered and aria-checked is set to false
await expect(header.getByRole("button", { name: "Show Widgets" })).toHaveAttribute("aria-checked", "false");
// Assert that AppsDrawer is not rendered
await expect(page.locator(".mx_AppsDrawer")).not.toBeVisible();
await expect(header).toMatchScreenshot("room-header-with-apps-button-not-highlighted.png");
});
});
test.describe("with encryption", () => {
test("should render the E2E icon and the buttons", async ({ page, app, user }) => {
// Create an encrypted room
await app.client.createRoom({
name: "Test Encrypted Room",
initial_state: [
{
type: "m.room.encryption",
state_key: "",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
},
],
});
await app.viewRoomByName("Test Encrypted Room");
const header = page.locator(".mx_LegacyRoomHeader");
await expect(header).toMatchScreenshot("encrypted-room-header.png");
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -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();
});
});
});

View file

@ -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");
});
});
});
});

View file

@ -34,7 +34,7 @@ test.describe("General room settings tab", () => {
// Assert that "Show less" details element is rendered
await expect(settings.getByText("Show less")).toBeVisible();
await expect(settings).toMatchScreenshot();
await expect(settings).toMatchScreenshot("General-room-settings-tab-should-be-rendered-properly-1.png");
// Click the "Show less" details element
await settings.getByText("Show less").click();

View file

@ -18,7 +18,6 @@ import { test, expect } from "../../element-web-test";
const USER_NAME = "Bob";
const USER_NAME_NEW = "Alice";
const IntegrationManager = "scalar.vector.im";
test.describe("General user settings tab", () => {
test.use({
@ -73,17 +72,6 @@ test.describe("General user settings tab", () => {
// Assert that the add button is rendered
await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
const setIntegrationManager = uut.locator(".mx_SetIntegrationManager");
await setIntegrationManager.scrollIntoViewIfNeeded();
await expect(
setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", { hasText: IntegrationManager }),
).toBeVisible();
// Make sure integration manager's toggle switch is enabled
await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
"Manage integrations(scalar.vector.im)",
);
// Assert the account deactivation button is displayed
const accountManagementSection = uut.getByTestId("account-management-section");
await accountManagementSection.scrollIntoViewIfNeeded();

View file

@ -31,7 +31,7 @@ test.describe("Preferences user settings tab", () => {
// Assert that the top heading is rendered
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
await expect(tab).toMatchScreenshot();
await expect(tab).toMatchScreenshot("Preferences-user-settings-tab-should-be-rendered-properly-1.png");
});
test("should be able to change the app language", async ({ uut, user }) => {

View file

@ -1,5 +1,6 @@
/*
Copyright 2023 Suguru Hirahara
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -16,6 +17,8 @@ limitations under the License.
import { test, expect } from "../../element-web-test";
const IntegrationManager = "scalar.vector.im";
test.describe("Security user settings tab", () => {
test.describe("with posthog enabled", () => {
test.use({
@ -44,7 +47,9 @@ test.describe("Security user settings tab", () => {
test("should be rendered properly", async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
await tab.getByRole("button", { name: "Learn more" }).click();
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot();
await expect(page.locator(".mx_AnalyticsLearnMoreDialog_wrapper .mx_Dialog")).toMatchScreenshot(
"Security-user-settings-tab-with-posthog-enable-b5d89-csLearnMoreDialog-should-be-rendered-properly-1.png",
);
});
});
@ -56,5 +61,22 @@ test.describe("Security user settings tab", () => {
// Assert that an input area for identity server exists
await expect(setIdServer.getByRole("textbox", { name: "Enter a new identity server" })).toBeVisible();
});
test("should enable show integrations as enabled", async ({ app, page }) => {
const tab = await app.settings.openUserSettings("Security");
const setIntegrationManager = tab.locator(".mx_SetIntegrationManager");
await setIntegrationManager.scrollIntoViewIfNeeded();
await expect(
setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager", {
hasText: IntegrationManager,
}),
).toBeVisible();
// Make sure integration manager's toggle switch is enabled
await expect(setIntegrationManager.locator(".mx_ToggleSwitch_enabled")).toBeVisible();
await expect(setIntegrationManager.locator(".mx_SetIntegrationManager_heading_manager")).toHaveText(
"Manage integrations(scalar.vector.im)",
);
});
});
});

View file

@ -21,7 +21,7 @@ import type { Locator, Page } from "@playwright/test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
function roomHeaderName(page: Page): Locator {
return page.locator(".mx_LegacyRoomHeader_nametext");
return page.locator(".mx_RoomHeader_heading");
}
async function startDM(app: ElementAppPage, page: Page, name: string): Promise<void> {

View file

@ -252,7 +252,8 @@ test.describe("Threads", () => {
await expect(locator.locator(".mx_ThreadSummary_content").getByText("How are things?")).toBeAttached();
locator = page.getByRole("button", { name: "Threads" });
await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/); // User asserts thread list unread indicator
await expect(locator).toHaveAttribute("data-indicator", "default"); // User asserts thread list unread indicator
// await expect(locator).toHaveClass(/mx_LegacyRoomHeader_button--unread/);
await locator.click(); // User opens thread list
// User asserts thread with correct root & latest events & unread dot
@ -433,7 +434,7 @@ test.describe("Threads", () => {
await textbox.press("Enter");
await expect(locator.locator(".mx_EventTile_last").getByText("Hello Mr. User")).toBeAttached();
// Close thread
await locator.getByRole("button", { name: "Close" }).click();
await locator.getByTestId("base-card-close-button").click();
// Open existing thread
locator = page
@ -486,7 +487,7 @@ test.describe("Threads", () => {
await textbox.press("Enter");
await expect(threadPanel.locator(".mx_EventTile_last").getByText(threadMessage)).toBeVisible();
// Close thread
await threadPanel.getByRole("button", { name: "Close" }).click();
await threadPanel.getByTestId("base-card-close-button").click();
};
await sendMessage("Hello Mr. Bot");
@ -495,14 +496,12 @@ test.describe("Threads", () => {
await createThread("Hello again Mr. Bot", "Hello again Mr. User in a thread");
// Open thread panel
await page.getByTestId("threadsButton").click();
await page.locator(".mx_RoomHeader").getByRole("button", { name: "Threads" }).click();
const threadPanel = page.locator(".mx_ThreadPanel");
await expect(
threadPanel.locator(".mx_EventTile_last").getByText("Hello again Mr. User in a thread"),
).toBeVisible();
// Open threads list
await page.locator(".mx_BaseCard_back").click();
const rightPanel = page.locator(".mx_RightPanel");
// Check that the threads are listed
await expect(rightPanel.locator(".mx_EventTile").getByText("Hello Mr. User in a thread")).toBeVisible();

View file

@ -410,6 +410,7 @@ test.describe("Timeline", () => {
{
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
hideTooltips: true,
},
);
@ -427,6 +428,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-and-messages-irc-layout.png", {
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
hideTooltips: true,
});
// 3. Alignment of expanded GELS and placeholder of deleted message
@ -447,6 +449,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-redaction-placeholder.png", {
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
hideTooltips: true,
});
// 4. Alignment of expanded GELS, placeholder of deleted message, and emote
@ -469,6 +472,7 @@ test.describe("Timeline", () => {
await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("expanded-gels-emote-irc-layout.png", {
// Exclude timestamp from snapshot of mx_MainSplit
mask: [page.locator(".mx_MessageTimestamp")],
hideTooltips: true,
});
});
@ -481,6 +485,7 @@ test.describe("Timeline", () => {
display: none !important;
}
`,
hideTooltips: true,
};
await sendEvent(app.client, room.roomId);
@ -779,7 +784,7 @@ test.describe("Timeline", () => {
await sendEvent(app.client, room.roomId, true);
await page.goto(`/#/room/${room.roomId}`);
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
await app.toggleRoomInfoPanel();
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill("Message");
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");
@ -804,7 +809,7 @@ test.describe("Timeline", () => {
await page.goto(`/#/room/${room.roomId}`);
// Open a room setting dialog
await page.getByRole("button", { name: "Room options" }).click();
await app.toggleRoomInfoPanel();
await page.getByRole("menuitem", { name: "Settings" }).click();
// Set a room topic to render a TextualEvent
@ -818,9 +823,6 @@ test.describe("Timeline", () => {
page.getByText(`${OLD_NAME} changed the topic to "This is a room for ${stringToSearch}.".`),
).toHaveClass(/mx_TextualEvent/);
// Display the room search bar
await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click();
// Search the string to display both the message and TextualEvent on search results panel
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch);
await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter");

View file

@ -35,7 +35,9 @@ test.describe("User Onboarding (new user)", () => {
});
test("page is shown and preference exists", async ({ page, app }) => {
await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot();
await expect(page.locator(".mx_UserOnboardingPage")).toMatchScreenshot(
"User-Onboarding-new-user-page-is-shown-and-preference-exists-1.png",
);
await app.settings.openUserSettings("Preferences");
await expect(page.getByText("Show shortcut to welcome checklist above the room list")).toBeVisible();
});

View file

@ -15,9 +15,10 @@ limitations under the License.
*/
import { test as base, expect as baseExpect, Locator, Page, ExpectMatcherState, ElementHandle } from "@playwright/test";
import { sanitizeForFilePath } from "playwright-core/lib/utils";
import AxeBuilder from "@axe-core/playwright";
import _ from "lodash";
import { basename } from "node:path";
import { basename, extname } from "node:path";
import type mailhog from "mailhog";
import type { IConfigOptions } from "../src/IConfigOptions";
@ -298,20 +299,40 @@ export const test = base.extend<{
},
});
// Based on https://github.com/microsoft/playwright/blob/2b77ed4d7aafa85a600caa0b0d101b72c8437eeb/packages/playwright/src/util.ts#L206C8-L210C2
function sanitizeFilePathBeforeExtension(filePath: string): string {
const ext = extname(filePath);
const base = filePath.substring(0, filePath.length - ext.length);
return sanitizeForFilePath(base) + ext;
}
export const expect = baseExpect.extend({
async toMatchScreenshot(
this: ExpectMatcherState,
receiver: Page | Locator,
name?: `${string}.png`,
name: `${string}.png`,
options?: {
mask?: Array<Locator>;
omitBackground?: boolean;
hideTooltips?: boolean;
timeout?: number;
css?: string;
},
) {
const testInfo = test.info();
if (!testInfo) throw new Error(`toMatchScreenshot() must be called during the test`);
const page = "page" in receiver ? receiver.page() : receiver;
let hideTooltipsCss: string | undefined;
if (options?.hideTooltips) {
hideTooltipsCss = `
.mx_Tooltip_visible {
visibility: hidden !important;
}
`;
}
// We add a custom style tag before taking screenshots
const style = (await page.addStyleTag({
content: `
@ -339,13 +360,23 @@ export const expect = baseExpect.extend({
.mx_MessageTimestamp {
font-family: Inconsolata !important;
}
${hideTooltipsCss ?? ""}
${options?.css ?? ""}
`,
})) as ElementHandle<Element>;
await baseExpect(receiver).toHaveScreenshot(name, options);
const screenshotName = sanitizeFilePathBeforeExtension(name);
await baseExpect(receiver).toHaveScreenshot(screenshotName, options);
await style.evaluate((tag) => tag.remove());
testInfo.annotations.push({
// `_` prefix hides it from the HTML reporter
type: "_screenshot",
// include a path relative to `playwright/snapshots/`
description: testInfo.snapshotPath(screenshotName).split("/playwright/snapshots/", 2)[1],
});
return { pass: true, message: () => "", name: "toMatchScreenshot" };
},
});

View file

@ -171,4 +171,13 @@ export class ElementAppPage {
await spotlight.open();
return spotlight;
}
/**
* Opens/closes the room info panel
* @returns locator to the right panel
*/
public async toggleRoomInfoPanel(): Promise<Locator> {
await this.page.getByRole("button", { name: "Room info" }).first().click();
return this.page.locator(".mx_RightPanel");
}
}

View file

@ -91,12 +91,17 @@ export class Settings {
}
/**
* Open room settings (via room header menu), returns a locator to the dialog
* Open room settings (via room info panel), returns a locator to the dialog
* @param tab the name of the tab to switch to after opening, optional.
*/
public async openRoomSettings(tab?: string): Promise<Locator> {
await this.page.getByRole("banner").getByRole("button", { name: "Room options", exact: true }).click();
await this.page.locator(".mx_RoomTile_contextMenu").getByRole("menuitem", { name: "Settings" }).click();
// Open right panel if not open
const rightPanel = this.page.locator(".mx_RightPanel");
if ((await rightPanel.count()) === 0) {
await this.page.getByRole("button", { name: "Room info" }).first().click();
}
await rightPanel.getByRole("menuitem", { name: "Settings" }).click();
if (tab) await this.switchTab(tab);
return this.page.locator(".mx_Dialog").filter({ has: this.page.locator(".mx_RoomSettingsDialog") });
}

View file

@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand";
// Docker tag to use for `matrixdotorg/synapse` image.
// We target a specific digest as every now and then a Synapse update will break our CI.
// This digest is updated by the playwright-image-updates.yaml workflow periodically.
const DOCKER_TAG = "develop@sha256:db5f8e8ca4a903379ea18b010ac3360bd843c9ac7eb2e73ad89f5059d01f8104";
const DOCKER_TAG = "develop@sha256:9e193236098ae5ff66c9bf79252e318fd561ceb1322d5495780a11d9dbdcfb17";
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template);

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Some files were not shown because too many files have changed in this diff Show more